From 5f65cb5fdf0671e354b15fa8bd37a5af897be404 Mon Sep 17 00:00:00 2001 From: sarakha63 Date: Wed, 22 May 2013 14:56:48 +0300 Subject: [PATCH 001/492] Update COPYING.txt --- COPYING.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COPYING.txt b/COPYING.txt index 859d1c05b8..e7295c2ea3 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. From 88b26cf1129a8fbbcae0db7fc3c0f924b921bc90 Mon Sep 17 00:00:00 2001 From: sarakha63 Date: Wed, 22 May 2013 15:29:36 +0300 Subject: [PATCH 002/492] Update COPYING.txt --- COPYING.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COPYING.txt b/COPYING.txt index e7295c2ea3..859d1c05b8 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. From efaad220c9b0da379099639c03ed5ba69713a0cf Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Wed, 22 May 2013 14:39:07 +0200 Subject: [PATCH 003/492] attempt correction on updates --- sickbeard/versionChecker.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index c89073177d..9a21aee7b4 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -395,11 +395,6 @@ def update(self): (files, insertions, deletions) = match.groups() break - if None in (files, insertions, deletions): - logger.log(u"Didn't find indication of success in output, assuming git pull failed", logger.ERROR) - logger.log(u"Output: "+str(output)) - return False - return True From 9d402245acdb9a90848e61e76b231fb67a5a8996 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Wed, 22 May 2013 14:42:44 +0200 Subject: [PATCH 004/492] debuged --- sickbeard/versionChecker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 9a21aee7b4..54cfe24377 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -395,6 +395,11 @@ def update(self): (files, insertions, deletions) = match.groups() break + if None in (files, insertions, deletions): + logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) + logger.log(u"Output: "+str(output)) + return True + return True From 6d38ed9dbdd46d38267e47268a6620c0e7865d52 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Wed, 22 May 2013 15:32:27 +0200 Subject: [PATCH 005/492] uploaded ds station api --- sickbeard/clients/download_station.py | 31 ++++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/sickbeard/clients/download_station.py b/sickbeard/clients/download_station.py index 9b2c056a46..32e5634a1f 100644 --- a/sickbeard/clients/download_station.py +++ b/sickbeard/clients/download_station.py @@ -1,6 +1,5 @@ # Authors: # Pedro Jose Pereira Vieito (Twitter: @pvieito) -# Jens Timmerman & Mr_Orange # # URL: https://github.com/mr-orange/Sick-Beard # @@ -19,20 +18,13 @@ # You should have received a copy of the GNU General Public License # along with Sick Beard. If not, see . # -# Uses the Synology Download Station API v1: http://download.synology.com/download/other/Synology_Download_Station_Official_API_V3.pdf. +# Uses the Synology Download Station API: http://download.synology.com/download/other/Synology_Download_Station_Official_API_V3.pdf. -import json import requests - -import re +import json import time -from hashlib import sha1 import sickbeard -from sickbeard import logger -from sickbeard.exceptions import ex -from sickbeard.clients import http_error_code -from lib.bencode import bencode, bdecode from sickbeard.clients.generic import GenericClient class DownloadStationAPI(GenericClient): @@ -66,16 +58,25 @@ def _get_auth(self): def _add_torrent_uri(self, result): - data = {'api':'SYNO.DownloadStation.Task', 'version':'1', 'method':'create', 'session':'DownloadStation', '_sid':self.auth, 'uri':result.url} + data = {'api':'SYNO.DownloadStation.Task', + 'version':'1', 'method':'create', + 'session':'DownloadStation', + '_sid':self.auth, + 'uri':result.url + } self._request(method='post', data=data) return json.loads(self.response.text)['success'] def _add_torrent_file(self, result): - - # This should work, but it doesn't - data = {'api':'SYNO.DownloadStation.Task', 'version':'1', 'method':'create', 'session':'DownloadStation', '_sid':self.auth} - files = {'file':('tv.torrent', result.hash)} + + data = {'api':'SYNO.DownloadStation.Task', + 'version':'1', + 'method':'create', + 'session':'DownloadStation', + '_sid':self.auth + } + files = {'file':(result.name + '.torrent', result.content)} self._request(method='post', data=data, files=files) return json.loads(self.response.text)['success'] From 754981f961f7f2e1784553634c8f6887bb5f4b08 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Wed, 22 May 2013 15:48:17 +0200 Subject: [PATCH 006/492] changed version --- sickbeard/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/version.py b/sickbeard/version.py index 61612bfd24..0a80aa8aa2 100644 --- a/sickbeard/version.py +++ b/sickbeard/version.py @@ -1 +1 @@ -SICKBEARD_VERSION = "VO/VF VERSION alpha" +SICKBEARD_VERSION = "VO/VF" From 1ea11552e8fc56bfcd6b81cc056ac274c1e6aa4e Mon Sep 17 00:00:00 2001 From: sarakha63 Date: Thu, 23 May 2013 15:43:47 +0300 Subject: [PATCH 007/492] commented some lines until correction bug with all season searcg --- sickbeard/search.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sickbeard/search.py b/sickbeard/search.py index df77d41bcd..ab925081fa 100644 --- a/sickbeard/search.py +++ b/sickbeard/search.py @@ -308,9 +308,9 @@ def pickBestResult(results, quality_list=None, episode=None): eplink=bestResult.url else: eplink="" - count=myDB.select("SELECT count(*) from episode_links where episode_id=? and link=?",[epidr[0][0],eplink]) - if count[0][0]==0: - myDB.action("INSERT INTO episode_links (episode_id, link) VALUES (?,?)",[epidr[0][0],eplink]) + #count=myDB.select("SELECT count(*) from episode_links where episode_id=? and link=?",[epidr[0][0],eplink]) + #if count[0][0]==0: + #myDB.action("INSERT INTO episode_links (episode_id, link) VALUES (?,?)",[epidr[0][0],eplink]) else: logger.log(u"No result picked.", logger.DEBUG) From 54f33a35abf56006367e8ec042f1df687bc47d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Thu, 23 May 2013 14:54:32 +0200 Subject: [PATCH 008/492] Add Mail notification --- .pydevproject | 9 +- SickBeard.py | 2 +- .../default/config_notifications.tmpl | 94 ++++++++++++++++++- data/js/configNotifications.js | 14 +++ sickbeard/__init__.py | 32 +++++++ sickbeard/notifiers/__init__.py | 3 + sickbeard/webserve.py | 40 +++++++- 7 files changed, 188 insertions(+), 6 deletions(-) diff --git a/.pydevproject b/.pydevproject index 37a00cd49a..c84e5f1cbe 100644 --- a/.pydevproject +++ b/.pydevproject @@ -2,7 +2,14 @@ /Sick-Beard +/Sick-Beard/lib +/Sick-Beard/sickbeard python 2.7 -Default +Python 2.7 + +C:\Python27\imports\Cheetah\bin +C:\Python27\imports\Cheetah\lib\python\Cheetah +C:\Python27\imports\Cheetah-2.4.4\build\lib.win32-2.7 + diff --git a/SickBeard.py b/SickBeard.py index eec9f45951..4f3f07f52e 100755 --- a/SickBeard.py +++ b/SickBeard.py @@ -27,7 +27,7 @@ import Cheetah if Cheetah.Version[0] != '2': raise ValueError -except ValueError: +except ValueError: print "Sorry, requires Python module Cheetah 2.1.0 or newer." sys.exit(1) except: diff --git a/data/interfaces/default/config_notifications.tmpl b/data/interfaces/default/config_notifications.tmpl index f32b76589f..1252ade1b4 100755 --- a/data/interfaces/default/config_notifications.tmpl +++ b/data/interfaces/default/config_notifications.tmpl @@ -20,9 +20,6 @@

Home Theater


- - -
@@ -1082,6 +1079,97 @@
+ +
+
+ +

Mail

+

Send mail when Tv Snatch

+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
Click below to test.
+ + +
+ +
+
+ + +

diff --git a/data/js/configNotifications.js b/data/js/configNotifications.js index 363d231ec7..4b8813a1ab 100644 --- a/data/js/configNotifications.js +++ b/data/js/configNotifications.js @@ -188,4 +188,18 @@ $(document).ready(function () { $.get(sbRoot + "/home/testNMA", {'nma_api': nma_api, 'nma_priority': nma_priority}, function (data) { $('#testNMA-result').html(data); }); }); + + $('#testMail').click(function () { + $('#testMail-result').html(loading); + var mail_from = $("#mail_from").val(); + var mail_to = $("#mail_to").val(); + var mail_server = $("#mail_server").val(); + var mail_ssl = $("#mail_ssl").val(); + var mail_username = $("#mail_username").val(); + var mail_password = $("#mail_password").val(); + + $.get(sbRoot + "/home/testMail", {}, + function (data) { $('#testMail-result').html(data); }); + }); + }); diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 9905a81127..d8ea48fd13 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -351,6 +351,15 @@ NMA_API = None NMA_PRIORITY = 0 +USE_MAIL = False +MAIL_USERNAME = None +MAIL_PASSWORD = None +MAIL_SERVER = None +MAIL_SSL = False +MAIL_FROM = None +MAIL_TO = None +MAIL_NOTIFY_ONSNATCH = False + COMING_EPS_LAYOUT = None COMING_EPS_DISPLAY_PAUSED = None COMING_EPS_SORT = None @@ -408,6 +417,7 @@ def initialize(consoleLogging=True): USE_GROWL, GROWL_HOST, GROWL_PASSWORD, USE_PROWL, PROWL_NOTIFY_ONSNATCH, PROWL_NOTIFY_ONDOWNLOAD, PROWL_NOTIFY_ONSUBTITLEDOWNLOAD, PROWL_API, PROWL_PRIORITY, PROG_DIR, NZBMATRIX, NZBMATRIX_USERNAME, \ USE_PYTIVO, PYTIVO_NOTIFY_ONSNATCH, PYTIVO_NOTIFY_ONDOWNLOAD, PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD, PYTIVO_UPDATE_LIBRARY, PYTIVO_HOST, PYTIVO_SHARE_NAME, PYTIVO_TIVO_NAME, \ USE_NMA, NMA_NOTIFY_ONSNATCH, NMA_NOTIFY_ONDOWNLOAD, NMA_NOTIFY_ONSUBTITLEDOWNLOAD, NMA_API, NMA_PRIORITY, \ + USE_MAIL, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SERVER, MAIL_SSL, MAIL_FROM, MAIL_TO, MAIL_NOTIFY_ONSNATCH, \ NZBMATRIX_APIKEY, versionCheckScheduler, VERSION_NOTIFY, PROCESS_AUTOMATICALLY, PROCESS_AUTOMATICALLY_TORRENT, \ KEEP_PROCESSED_DIR, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ showQueueScheduler, searchQueueScheduler, ROOT_DIRS, CACHE_DIR, ACTUAL_CACHE_DIR, TVDB_API_PARMS, \ @@ -425,6 +435,7 @@ def initialize(consoleLogging=True): COMING_EPS_LAYOUT, COMING_EPS_SORT, COMING_EPS_DISPLAY_PAUSED, COMING_EPS_MISSED_RANGE, METADATA_WDTV, METADATA_TIVO, IGNORE_WORDS, CREATE_MISSING_SHOW_DIRS, \ ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler + if __INITIALIZED__: return False @@ -836,6 +847,17 @@ def initialize(consoleLogging=True): NMA_API = check_setting_str(CFG, 'NMA', 'nma_api', '') NMA_PRIORITY = check_setting_str(CFG, 'NMA', 'nma_priority', "0") + CheckSection(CFG, 'Mail') + USE_MAIL = bool(check_setting_int(CFG, 'Mail', 'use_mail', 0)) + MAIL_USERNAME = check_setting_str(CFG, 'Mail', 'mail_username', '') + MAIL_PASSWORD = check_setting_str(CFG, 'Mail', 'mail_password', '') + MAIL_SERVER = check_setting_str(CFG, 'Mail', 'mail_server', '') + MAIL_SSL = bool(check_setting_int(CFG, 'Mail', 'mail_ssl', 0)) + MAIL_FROM = check_setting_str(CFG, 'Mail', 'mail_from', '') + MAIL_TO = check_setting_str(CFG, 'Mail', 'mail_to', '') + MAIL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Mail', 'mail_notify_onsnatch', 0)) + + USE_SUBTITLES = bool(check_setting_int(CFG, 'Subtitles', 'use_subtitles', 0)) SUBTITLES_LANGUAGES = check_setting_str(CFG, 'Subtitles', 'subtitles_languages', '').split(',') if SUBTITLES_LANGUAGES[0] == '': @@ -1447,6 +1469,16 @@ def save_config(): new_config['NMA']['nma_api'] = NMA_API new_config['NMA']['nma_priority'] = NMA_PRIORITY + new_config['Mail'] = {} + new_config['Mail']['use_mail'] = int(USE_MAIL) + new_config['Mail']['mail_username'] = MAIL_USERNAME + new_config['Mail']['mail_password'] = MAIL_PASSWORD + new_config['Mail']['mail_server'] = MAIL_SERVER + new_config['Mail']['mail_ssl'] = int(MAIL_SSL) + new_config['Mail']['mail_from'] = MAIL_FROM + new_config['Mail']['mail_to'] = MAIL_TO + new_config['Mail']['mail_notify_onsnatch'] = int(MAIL_NOTIFY_ONSNATCH) + new_config['Newznab'] = {} new_config['Newznab']['newznab_data'] = '!!!'.join([x.configStr() for x in newznabProviderList]) diff --git a/sickbeard/notifiers/__init__.py b/sickbeard/notifiers/__init__.py index be6ff54861..fe0cd654f0 100755 --- a/sickbeard/notifiers/__init__.py +++ b/sickbeard/notifiers/__init__.py @@ -32,6 +32,7 @@ import pushover import boxcar import nma +import mail import tweet import trakt @@ -56,6 +57,7 @@ # online twitter_notifier = tweet.TwitterNotifier() trakt_notifier = trakt.TraktNotifier() +mail_notifier = mail.MailNotifier() notifiers = [ libnotify_notifier, # Libnotify notifier goes first because it doesn't involve blocking on network activity. @@ -73,6 +75,7 @@ nma_notifier, twitter_notifier, trakt_notifier, + mail_notifier, ] diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 55214a5bbe..31233ecdf7 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -1420,7 +1420,10 @@ def saveNotifications(self, use_xbmc=None, xbmc_notify_onsnatch=None, xbmc_notif use_trakt=None, trakt_username=None, trakt_password=None, trakt_api=None,trakt_remove_watchlist=None,trakt_use_watchlist=None,trakt_start_paused=None,trakt_method_add=None, use_pytivo=None, pytivo_notify_onsnatch=None, pytivo_notify_ondownload=None, pytivo_notify_onsubtitledownload=None, pytivo_update_library=None, pytivo_host=None, pytivo_share_name=None, pytivo_tivo_name=None, - use_nma=None, nma_notify_onsnatch=None, nma_notify_ondownload=None, nma_notify_onsubtitledownload=None, nma_api=None, nma_priority=0 ): + use_nma=None, nma_notify_onsnatch=None, nma_notify_ondownload=None, nma_notify_onsubtitledownload=None, nma_api=None, nma_priority=0, + use_mail=None, mail_username=None, mail_password=None, mail_server=None, mail_ssl=None, mail_from=None, mail_to=None, mail_notify_onsnatch=None ): + + results = [] @@ -1683,6 +1686,22 @@ def saveNotifications(self, use_xbmc=None, xbmc_notify_onsnatch=None, xbmc_notif else: nma_notify_onsubtitledownload = 0 + if use_mail == "on": + use_mail = 1 + else: + use_mail = 0 + + if mail_ssl == "on": + mail_ssl = 1 + else: + mail_ssl = 0 + + if mail_notify_onsnatch == "on": + mail_notify_onsnatch = 1 + else: + mail_notify_onsnatch = 0 + + sickbeard.USE_XBMC = use_xbmc sickbeard.XBMC_NOTIFY_ONSNATCH = xbmc_notify_onsnatch sickbeard.XBMC_NOTIFY_ONDOWNLOAD = xbmc_notify_ondownload @@ -1784,6 +1803,15 @@ def saveNotifications(self, use_xbmc=None, xbmc_notify_onsnatch=None, xbmc_notif sickbeard.NMA_API = nma_api sickbeard.NMA_PRIORITY = nma_priority + sickbeard.USE_MAIL = use_mail + sickbeard.MAIL_USERNAME = mail_username + sickbeard.MAIL_PASSWORD = mail_password + sickbeard.MAIL_SERVER = mail_server + sickbeard.MAIL_SSL = mail_ssl + sickbeard.MAIL_FROM = mail_from + sickbeard.MAIL_TO = mail_to + sickbeard.MAIL_NOTIFY_ONSNATCH = mail_notify_onsnatch + sickbeard.save_config() if len(results) > 0: @@ -2597,6 +2625,16 @@ def testTrakt(self, api=None, username=None, password=None): else: return "Test notice failed to Trakt" + @cherrypy.expose + def testMail(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_user=None, mail_password=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.mail_notifier.test_notify(mail_from, mail_to, mail_server, mail_ssl, mail_user, mail_password) + if result: + return "Mail sent" + else: + return "Can't sent mail." + @cherrypy.expose def testNMA(self, nma_api=None, nma_priority=0): cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" From e085b08dc886796ed83687be2e35ae94260f8d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Thu, 23 May 2013 14:56:58 +0200 Subject: [PATCH 009/492] Add mail Notifier --- sickbeard/notifiers/mail.py | 91 +++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 sickbeard/notifiers/mail.py diff --git a/sickbeard/notifiers/mail.py b/sickbeard/notifiers/mail.py new file mode 100644 index 0000000000..11203b01c6 --- /dev/null +++ b/sickbeard/notifiers/mail.py @@ -0,0 +1,91 @@ +# Author: Stephane CREMEL +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + + + +import os +import subprocess + +import sickbeard + +from sickbeard import logger +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex +from email.mime.text import MIMEText +import smtplib + +class MailNotifier: + + def test_notify(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): + return self._notifyMail("This is a test notification from SickBeard", "SickBeard message", mail_from, mail_to,mail_server,mail_ssl,mail_username,mail_password) + + def notify_snatch(self, ep_name): + logger.log("Notification MAIL SNATCH", logger.DEBUG) + if sickbeard.MAIL_NOTIFY_ONSNATCH: + message = str(ep_name) + return self._notifyMail("SickBeard Snatch", message, None, None, None, None, None, None) + else: + return + + def notify_download(self, ep_name): + logger.log("Notification MAIL SNATCH", logger.DEBUG) + message = str(ep_name) + return self._notifyMail("SickBeard Download", message, None, None, None, None, None, None) + + def notify_subtitle_download(self, ep_name, lang): + pass + + + def _notifyMail(self, title, message, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): + + if not sickbeard.USE_MAIL: + logger.log("Notification for Mail not enabled, skipping this notification", logger.DEBUG) + return False + + logger.log("Sending notification Mail", logger.DEBUG) + + if not mail_from: + mail_from = sickbeard.MAIL_FROM + if not mail_to: + mail_to = sickbeard.MAIL_TO + if not mail_ssl: + mail_ssl = sickbeard.MAIL_SSL + if not mail_server: + mail_server = sickbeard.MAIL_SERVER + if not mail_username: + mail_username = sickbeard.MAIL_USERNAME + if not mail_password: + mail_password = sickbeard.MAIL_PASSWORD + + if mail_ssl : + mailserver = smtplib.SMTP_SSL(mail_server) + else: + mailserver = smtplib.SMTP(mail_server) + + if len(mail_username) > 0: + mailserver.login(mail_username, mail_password) + + message = MIMEText(message) + message['Subject'] = title + message['From'] = mail_from + message['To'] = mail_to + + mailserver.sendmail(mail_from,mail_to,message.as_string()) + + return True + +notifier = MailNotifier From ebf3ef00d29f728bb545a9ecc9d66034270fd48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Thu, 23 May 2013 15:09:45 +0200 Subject: [PATCH 010/492] Add Mail notifier icon --- data/images/notifiers/mail.png | Bin 0 -> 4258 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 data/images/notifiers/mail.png diff --git a/data/images/notifiers/mail.png b/data/images/notifiers/mail.png new file mode 100644 index 0000000000000000000000000000000000000000..ee3fa70755bc66a9710193c44c35e5816b42d46e GIT binary patch literal 4258 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 Eg2QVXivR!s literal 0 HcmV?d00001 From 8cc4c0b668d770693300be1fb7cdb05628093e09 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 23 May 2013 20:53:57 +0200 Subject: [PATCH 011/492] corrected regex to avoid saison to be rejected --- sickbeard/show_name_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/show_name_helpers.py b/sickbeard/show_name_helpers.py index 5fd2850840..47c4f55f52 100644 --- a/sickbeard/show_name_helpers.py +++ b/sickbeard/show_name_helpers.py @@ -217,7 +217,7 @@ def isGoodResult(name, show, log=True): escaped_name = re.sub('\\\\[\\s.-]', '\W+', re.escape(curName)) if show.startyear: escaped_name += "(?:\W+"+str(show.startyear)+")?" - curRegex = '^' + escaped_name + '\W+(?:(?:S\d[\dE._ -])|(?:\d\d?x)|(?:\d{4}\W\d\d\W\d\d)|(?:(?:part|pt)[\._ -]?(\d|[ivx]))|Season\W+\d+\W+|E\d+\W+)' + curRegex = '^' + escaped_name + '\W+(?:(?:S\d[\dE._ -])|(?:\d\d?x)|(?:\d{4}\W\d\d\W\d\d)|(?:(?:part|pt)[\._ -]?(\d|[ivx]))|(Sea|sai)son\W+\d+\W+|E\d+\W+)' if log: logger.log(u"Checking if show "+name+" matches " + curRegex, logger.DEBUG) From 7ab868c7ab0e28e9b467642f7e79c9f7dfe37910 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 23 May 2013 21:01:22 +0200 Subject: [PATCH 012/492] corrected history links --- sickbeard/search.py | 151 ++++++++++++++++++++++++-------------------- 1 file changed, 82 insertions(+), 69 deletions(-) diff --git a/sickbeard/search.py b/sickbeard/search.py index ab925081fa..334564461e 100644 --- a/sickbeard/search.py +++ b/sickbeard/search.py @@ -229,90 +229,103 @@ def searchForNeededEpisodes(): return foundResults.values() -def pickBestResult(results, quality_list=None, episode=None): +def pickBestResult(results, quality_list=None, episode=None, season=None): logger.log(u"Picking the best result out of "+str([x.name for x in results]), logger.DEBUG) links=[] myDB = db.DBConnection() - for eps in episode.values(): - if hasattr(eps,'tvdbid'): - epidr=myDB.select("SELECT episode_id from tv_episodes where tvdbid=?",[eps.tvdbid]) - listlink=myDB.select("SELECT link from episode_links where episode_id=?",[epidr[0][0]]) + if season !=None: + epidr=myDB.select("SELECT episode_id from tv_episodes where showid=? and season=?",[episode,season]) + for epid in epidr: + listlink=myDB.select("SELECT link from episode_links where episode_id=?",[epid[0]]) for dlink in listlink: links.append(dlink[0]) + else: + for eps in episode.values(): + if hasattr(eps,'tvdbid'): + epidr=myDB.select("SELECT episode_id from tv_episodes where tvdbid=?",[eps.tvdbid]) + listlink=myDB.select("SELECT link from episode_links where episode_id=?",[epidr[0][0]]) + for dlink in listlink: + links.append(dlink[0]) # find the best result for the current episode - bestResult = None - for cur_result in results: - curmethod="nzb" - bestmethod="nzb" - if cur_result.resultType == "torrentdata" or cur_result.resultType == "torrent": - curmethod="torrent" - if bestResult: - if bestResult.resultType == "torrentdata" or bestResult.resultType == "torrent": - bestmethod="torrent" - if hasattr(cur_result,'item'): - if hasattr(cur_result.item,'nzburl'): - eplink=cur_result.item.nzburl - elif hasattr(cur_result.item,'url'): - eplink=cur_result.item.url - elif hasattr(cur_result,'nzburl'): - eplink=cur_result.nzburl - elif hasattr(cur_result,'url'): - eplink=cur_result.url - else: - eplink="" + bestResult = None + for cur_result in results: + curmethod="nzb" + bestmethod="nzb" + if cur_result.resultType == "torrentdata" or cur_result.resultType == "torrent": + curmethod="torrent" + if bestResult: + if bestResult.resultType == "torrentdata" or bestResult.resultType == "torrent": + bestmethod="torrent" + if hasattr(cur_result,'item'): + if hasattr(cur_result.item,'nzburl'): + eplink=cur_result.item.nzburl + elif hasattr(cur_result.item,'url'): + eplink=cur_result.item.url + elif hasattr(cur_result,'nzburl'): + eplink=cur_result.nzburl + elif hasattr(cur_result,'url'): + eplink=cur_result.url else: - if hasattr(cur_result,'nzburl'): - eplink=cur_result.nzburl - elif hasattr(cur_result,'url'): - eplink=cur_result.url - else: - eplink="" - logger.log("Quality of "+cur_result.name+" is "+Quality.qualityStrings[cur_result.quality]) + eplink="" + else: + if hasattr(cur_result,'nzburl'): + eplink=cur_result.nzburl + elif hasattr(cur_result,'url'): + eplink=cur_result.url + else: + eplink="" + logger.log("Quality of "+cur_result.name+" is "+Quality.qualityStrings[cur_result.quality]) - if quality_list and cur_result.quality not in quality_list: - logger.log(cur_result.name+" is a quality we know we don't want, rejecting it", logger.DEBUG) - continue + if quality_list and cur_result.quality not in quality_list: + logger.log(cur_result.name+" is a quality we know we don't want, rejecting it", logger.DEBUG) + continue - if eplink in links: - logger.log(eplink +" was already downloaded so let's skip it assuming the download failed, you can erase the downloaded links for that episode if you want", logger.DEBUG) - continue + if eplink in links: + logger.log(eplink +" was already downloaded so let's skip it assuming the download failed, you can erase the downloaded links for that episode if you want", logger.DEBUG) + continue - if ((not bestResult or bestResult.quality < cur_result.quality and cur_result.quality != Quality.UNKNOWN)) or (bestmethod != sickbeard.PREFERED_METHOD and curmethod == sickbeard.PREFERED_METHOD and cur_result.quality != Quality.UNKNOWN): - bestResult = cur_result + if ((not bestResult or bestResult.quality < cur_result.quality and cur_result.quality != Quality.UNKNOWN)) or (bestmethod != sickbeard.PREFERED_METHOD and curmethod == sickbeard.PREFERED_METHOD and cur_result.quality != Quality.UNKNOWN): + bestResult = cur_result - elif bestResult.quality == cur_result.quality: - if "proper" in cur_result.name.lower() or "repack" in cur_result.name.lower(): - bestResult = cur_result - elif "internal" in bestResult.name.lower() and "internal" not in cur_result.name.lower(): - bestResult = cur_result + elif bestResult.quality == cur_result.quality: + if "proper" in cur_result.name.lower() or "repack" in cur_result.name.lower(): + bestResult = cur_result + elif "internal" in bestResult.name.lower() and "internal" not in cur_result.name.lower(): + bestResult = cur_result - if bestResult: - logger.log(u"Picked "+bestResult.name+" as the best", logger.DEBUG) + if bestResult: + logger.log(u"Picked "+bestResult.name+" as the best", logger.DEBUG) - if hasattr(bestResult,'item'): - if hasattr(bestResult.item,'nzburl'): - eplink=bestResult.item.nzburl - elif hasattr(bestResult.item,'url'): - eplink=bestResult.item.url - elif hasattr(bestResult,'nzburl'): - eplink=bestResult.nzburl - elif hasattr(bestResult,'url'): - eplink=bestResult.url - else: - eplink="" + if hasattr(bestResult,'item'): + if hasattr(bestResult.item,'nzburl'): + eplink=bestResult.item.nzburl + elif hasattr(bestResult.item,'url'): + eplink=bestResult.item.url + elif hasattr(bestResult,'nzburl'): + eplink=bestResult.nzburl + elif hasattr(bestResult,'url'): + eplink=bestResult.url else: - if hasattr(bestResult,'nzburl'): - eplink=bestResult.nzburl - elif hasattr(bestResult,'url'): - eplink=bestResult.url - else: - eplink="" - #count=myDB.select("SELECT count(*) from episode_links where episode_id=? and link=?",[epidr[0][0],eplink]) - #if count[0][0]==0: - #myDB.action("INSERT INTO episode_links (episode_id, link) VALUES (?,?)",[epidr[0][0],eplink]) + eplink="" + else: + if hasattr(bestResult,'nzburl'): + eplink=bestResult.nzburl + elif hasattr(bestResult,'url'): + eplink=bestResult.url + else: + eplink="" + if season !=None: + for epid in epidr: + count=myDB.select("SELECT count(*) from episode_links where episode_id=? and link=?",[epid[0],eplink]) + if count[0][0]==0: + myDB.action("INSERT INTO episode_links (episode_id, link) VALUES (?,?)",[epid[0],eplink]) else: - logger.log(u"No result picked.", logger.DEBUG) + count=myDB.select("SELECT count(*) from episode_links where episode_id=? and link=?",[epidr[0][0],eplink]) + if count[0][0]==0: + myDB.action("INSERT INTO episode_links (episode_id, link) VALUES (?,?)",[epidr[0][0],eplink]) + else: + logger.log(u"No result picked.", logger.DEBUG) return bestResult @@ -503,7 +516,7 @@ def findSeason(show, season): # pick the best season NZB bestSeasonNZB = None if SEASON_RESULT in foundResults: - bestSeasonNZB = pickBestResult(foundResults[SEASON_RESULT], anyQualities+bestQualities,episode=show.episodes[min(show.episodes)]) + bestSeasonNZB = pickBestResult(foundResults[SEASON_RESULT], anyQualities+bestQualities,show.tvdbid,season) highest_quality_overall = 0 for cur_season in foundResults: From 29abcbcf3e18153c6efbb2d905c6de1e1d798689 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 23 May 2013 21:07:25 +0200 Subject: [PATCH 013/492] correction of a little mistake by brinbois lol --- data/interfaces/default/config_notifications.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/config_notifications.tmpl b/data/interfaces/default/config_notifications.tmpl index 1252ade1b4..54882b1a38 100755 --- a/data/interfaces/default/config_notifications.tmpl +++ b/data/interfaces/default/config_notifications.tmpl @@ -1157,7 +1157,7 @@
Click below to test.
From 5f78c2cb0b9c544056c009f45d183e1540dd4b39 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 23 May 2013 21:23:59 +0200 Subject: [PATCH 014/492] corrected binsearch to avoid empty nzbs --- .pydevproject | 2 +- sickbeard/providers/binnewz/__init__.py | 2 +- sickbeard/providers/binnewz/binsearch.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pydevproject b/.pydevproject index c84e5f1cbe..b41c812dd9 100644 --- a/.pydevproject +++ b/.pydevproject @@ -6,7 +6,7 @@ /Sick-Beard/sickbeard python 2.7 -Python 2.7 +Default C:\Python27\imports\Cheetah\bin C:\Python27\imports\Cheetah\lib\python\Cheetah diff --git a/sickbeard/providers/binnewz/__init__.py b/sickbeard/providers/binnewz/__init__.py index daaa50a48c..810e8a88b6 100644 --- a/sickbeard/providers/binnewz/__init__.py +++ b/sickbeard/providers/binnewz/__init__.py @@ -39,7 +39,7 @@ def __init__(self): self.supportsBacklog = True - self.nzbDownloaders = [ NZBIndex(), NZBClub(), BinSearch() ] + self.nzbDownloaders = [BinSearch(),NZBIndex(), NZBClub() ] self.url = "http://www.binnews.in/" diff --git a/sickbeard/providers/binnewz/binsearch.py b/sickbeard/providers/binnewz/binsearch.py index 216ffd4623..477f50eaa8 100644 --- a/sickbeard/providers/binnewz/binsearch.py +++ b/sickbeard/providers/binnewz/binsearch.py @@ -55,6 +55,6 @@ def search(self, filename, minSize, newsgroup=None): if foundName: postData = urllib.urlencode({foundName: 'on', 'action': 'nzb'}) - nzbURL = "http://binsearch.info/fcgi/nzb.fcgi?adv_age=&" + suffixURL + nzbURL = "https://binsearch.info/?adv_age=&" + suffixURL return NZBPostURLSearchResult( self, nzbURL, postData, sizeInMegs, binSearchURL ) \ No newline at end of file From dbae3160c1b32165b9027f7d332db77130e92343 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 00:57:01 +0200 Subject: [PATCH 015/492] attempt at making hist.log file --- sickbeard/versionChecker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 54cfe24377..9c9a086513 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -399,7 +399,11 @@ def update(self): logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) logger.log(u"Output: "+str(output)) return True - + log=self._run_git('log --pretty="%h - %s" --no-merges -100') + fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') + fp.write (log) + fp.close () + os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) return True From 0cc4324f53e446018d0f92d7b6995c2f251b7350 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:03:49 +0200 Subject: [PATCH 016/492] added logging message for commit history --- sickbeard/versionChecker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 9c9a086513..707bd4fc1f 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -399,6 +399,7 @@ def update(self): logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) logger.log(u"Output: "+str(output)) return True + logger.log(u"Writing commit History", logger.DEBUG) log=self._run_git('log --pretty="%h - %s" --no-merges -100') fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') fp.write (log) From 322d91c8757f339bf33e4ca325ab341607e2af47 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:12:32 +0200 Subject: [PATCH 017/492] change when hit.log is written --- sickbeard/versionChecker.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 707bd4fc1f..9980887b25 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -371,7 +371,12 @@ def update(self): self._run_git('config remote.origin.url git://github.com/sarakha63/Sick-Beard.git') self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable - + logger.log(u"Writing commit History", logger.DEBUG) + log=self._run_git('log --pretty="%ar %h - %s" --no-merges -200') + fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') + fp.write (log) + fp.close () + os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) if not output: return self._git_error() @@ -399,12 +404,7 @@ def update(self): logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) logger.log(u"Output: "+str(output)) return True - logger.log(u"Writing commit History", logger.DEBUG) - log=self._run_git('log --pretty="%h - %s" --no-merges -100') - fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (log) - fp.close () - os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) + return True From b18f657f824d1d080b99b9daaf6d303645c4cec3 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:17:07 +0200 Subject: [PATCH 018/492] correction fo hist.log --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 9980887b25..3d4f83620e 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -372,7 +372,7 @@ def update(self): self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) - log=self._run_git('log --pretty="%ar %h - %s" --no-merges -200') + log, err =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') fp.write (log) fp.close () From ddaec7fd642a9ce2182e7e5be7b38fac5e3927c2 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:18:53 +0200 Subject: [PATCH 019/492] correction for hist log --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 3d4f83620e..24a4dbd82e 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -374,7 +374,7 @@ def update(self): logger.log(u"Writing commit History", logger.DEBUG) log, err =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (log) + fp.write (str(log.split('\n'))) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) if not output: From 31df9294e2887e3166699198dbd2a33a29ef60f8 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:27:21 +0200 Subject: [PATCH 020/492] still triying --- sickbeard/versionChecker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 24a4dbd82e..7eee34f4da 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -374,7 +374,8 @@ def update(self): logger.log(u"Writing commit History", logger.DEBUG) log, err =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (str(log.split('\n'))) + for line in log.split('\n'): + fp.write (line) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) if not output: From a61c1f04ee4b709150ef5d3f42a8b05b2108fc24 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:34:55 +0200 Subject: [PATCH 021/492] see what's happening --- sickbeard/versionChecker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 7eee34f4da..955d3269a4 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -372,7 +372,8 @@ def update(self): self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) - log, err =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') + log, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') + print log fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') for line in log.split('\n'): fp.write (line) From 7773c5ef3679735e0bb03619a19331daf496d88a Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:36:15 +0200 Subject: [PATCH 022/492] more log --- sickbeard/versionChecker.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 955d3269a4..17a54b2bc5 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -372,10 +372,11 @@ def update(self): self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) - log, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') - print log + histlog, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') + print histlog + logger.log(histlog, logger.DEBUG) fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - for line in log.split('\n'): + for line in histlog.split('\n'): fp.write (line) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) From 004ae5912f1b1bd309b14a7e6b829c34b3cce396 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:42:49 +0200 Subject: [PATCH 023/492] still trying --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 17a54b2bc5..8d31aa8fd1 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -373,7 +373,7 @@ def update(self): output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) histlog, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') - print histlog + print histlog[1] logger.log(histlog, logger.DEBUG) fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') for line in histlog.split('\n'): From 46324d106b0c5a49978d9a350a03348ff29bd824 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:47:46 +0200 Subject: [PATCH 024/492] getting closer --- sickbeard/versionChecker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 8d31aa8fd1..c626944335 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -372,8 +372,8 @@ def update(self): self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) - histlog, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') - print histlog[1] + histlog2, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') + histlog=''.join( histlog2 ) logger.log(histlog, logger.DEBUG) fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') for line in histlog.split('\n'): From 7e7363da0b7d962506fb3088d5526e0881025705 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 01:53:03 +0200 Subject: [PATCH 025/492] triyng again and again --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index c626944335..fb608974b8 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -373,7 +373,7 @@ def update(self): output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) histlog2, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') - histlog=''.join( histlog2 ) + histlog= "\n".join(item[0] for item in histlog2) logger.log(histlog, logger.DEBUG) fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') for line in histlog.split('\n'): From 1f029897722e17bb1c75211621310d3bd002b6c1 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 02:01:42 +0200 Subject: [PATCH 026/492] triyng --- sickbeard/versionChecker.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index fb608974b8..6936be5137 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -372,14 +372,25 @@ def update(self): self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History", logger.DEBUG) - histlog2, err2 =self._run_git('log --pretty="%ar %h - %s" --no-merges -200') - histlog= "\n".join(item[0] for item in histlog2) - logger.log(histlog, logger.DEBUG) - fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - for line in histlog.split('\n'): - fp.write (line) - fp.close () - os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) + if sickbeard.GIT_PATH: + git_locations = ['"'+sickbeard.GIT_PATH+'"'] + else: + git_locations = ['git'] + for cur_git in git_locations: + cmd = cur_git +' log --pretty="%ar %h - %s" --no-merges -200' + + try: + logger.log(u"Executing "+cmd+" with your shell in "+sickbeard.PROG_DIR, logger.DEBUG) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) + output1, err1 = p.communicate() + fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') + fp.write (output1) + fp.close () + os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) + except OSError: + logger.log(u"Command "+cmd+" didn't work, couldn't find git.") + + if not output: return self._git_error() From 80c9ccb25d84da18fad670f18bafe390f200b28b Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 02:04:24 +0200 Subject: [PATCH 027/492] almost done --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 6936be5137..ab58e63db5 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -371,7 +371,7 @@ def update(self): self._run_git('config remote.origin.url git://github.com/sarakha63/Sick-Beard.git') self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable - logger.log(u"Writing commit History", logger.DEBUG) + logger.log(u"Writing commit History into the file", logger.DEBUG) if sickbeard.GIT_PATH: git_locations = ['"'+sickbeard.GIT_PATH+'"'] else: From 1210c98e3a71233b32cdf270fc76ffa788a69ac6 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 02:16:31 +0200 Subject: [PATCH 028/492] pfiouuu --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index ab58e63db5..881122ca19 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -384,7 +384,7 @@ def update(self): p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) output1, err1 = p.communicate() fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (output1) + fp.write (output1[0]) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) except OSError: From 29f4a8d79bedd222084c6b14c7a3eaf48f7b47d3 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 02:17:37 +0200 Subject: [PATCH 029/492] pfio --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 881122ca19..06c5c25e5a 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -384,7 +384,7 @@ def update(self): p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) output1, err1 = p.communicate() fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (output1[0]) + fp.write (output1[0][0]) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) except OSError: From 3361d1e8bf2a230917b640a881fa02bc62be1094 Mon Sep 17 00:00:00 2001 From: sarakha63 Date: Fri, 24 May 2013 03:25:14 +0300 Subject: [PATCH 030/492] Update COPYING.txt --- COPYING.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COPYING.txt b/COPYING.txt index 859d1c05b8..e7295c2ea3 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. From 8f919318f8487fac226c47873290eaa36e5d84a7 Mon Sep 17 00:00:00 2001 From: sarakha63 Date: Fri, 24 May 2013 03:28:44 +0300 Subject: [PATCH 031/492] Update COPYING.txt --- COPYING.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COPYING.txt b/COPYING.txt index e7295c2ea3..859d1c05b8 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ - GNU GENERAL PUBLIC LICENSE + GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. From fbd17361775d44ca0180e70b94a9436005f10041 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Fri, 24 May 2013 02:42:19 +0200 Subject: [PATCH 032/492] correction --- .project | 34 +- .pydevproject | 30 +- cherrypy/__init__.py | 1146 ++--- cherrypy/_cpdispatch.py | 1136 ++--- cherrypy/_cplogging.py | 500 +-- cherrypy/cherryd | 204 +- cherrypy/lib/covercp.py | 728 ++-- cherrypy/lib/httpauth.py | 722 ++-- cherrypy/process/win32.py | 348 +- data/interfaces/default/config_general.tmpl | 434 +- .../default/config_postProcessing.tmpl | 1154 ++--- data/interfaces/default/home_newShow.tmpl | 170 +- data/interfaces/default/inc_top.tmpl | 500 +-- .../default/manage_manageSearches.tmpl | 80 +- data/js/ajaxNotifications.js | 52 +- data/js/configNotifications.js | 410 +- data/js/configProviders.js | 420 +- lib/tvdb_api/__init__.py | 2 +- .../requests/packages/charade/cp949prober.py | 88 +- .../packages/charade/langbulgarianmodel.py | 458 +- .../requests/packages/charade/sjisprober.py | 182 +- .../packages/charade/universaldetector.py | 344 +- sickbeard/databases/cache_db.py | 100 +- sickbeard/encodingKludge.py | 138 +- sickbeard/generic_queue.py | 268 +- sickbeard/gh_api.py | 118 +- sickbeard/image_cache.py | 450 +- sickbeard/logger.py | 368 +- sickbeard/naming.py | 356 +- sickbeard/notifiers/nma.py | 110 +- sickbeard/notifiers/nmj.py | 366 +- sickbeard/providers/__init__.py | 258 +- sickbeard/providers/binnewz/nzbdownloader.py | 190 +- sickbeard/providers/cpasbien.py | 306 +- sickbeard/providers/newzbin.py | 768 ++-- sickbeard/providers/nzbmatrix.py | 362 +- sickbeard/providers/nzbsrus.py | 244 +- sickbeard/providers/piratebay/__init__.py | 806 ++-- sickbeard/scene_exceptions.py | 234 +- sickbeard/tv.py | 3824 ++++++++--------- sickbeard/versionChecker.py | 1045 +++-- 41 files changed, 9720 insertions(+), 9733 deletions(-) diff --git a/.project b/.project index b3bd377841..c0407428d3 100644 --- a/.project +++ b/.project @@ -1,17 +1,17 @@ - - - Sick-Beard - - - - - - org.python.pydev.PyDevBuilder - - - - - - org.python.pydev.pythonNature - - + + + SickBeard + + + + + + org.python.pydev.PyDevBuilder + + + + + + org.python.pydev.pythonNature + + diff --git a/.pydevproject b/.pydevproject index b41c812dd9..cb27afca5e 100644 --- a/.pydevproject +++ b/.pydevproject @@ -1,15 +1,15 @@ - - - -/Sick-Beard -/Sick-Beard/lib -/Sick-Beard/sickbeard - -python 2.7 -Default - -C:\Python27\imports\Cheetah\bin -C:\Python27\imports\Cheetah\lib\python\Cheetah -C:\Python27\imports\Cheetah-2.4.4\build\lib.win32-2.7 - - + + + +/SickBeard +/SickBeard/lib +/SickBeard/sickbeard + +python 2.7 +Default + +C:\Python27\imports\Cheetah\bin +C:\Python27\imports\Cheetah\lib\python\Cheetah +C:\Python27\imports\Cheetah-2.4.4\build\lib.win32-2.7 + + diff --git a/cherrypy/__init__.py b/cherrypy/__init__.py index 82e272993b..c9fc1f1d83 100644 --- a/cherrypy/__init__.py +++ b/cherrypy/__init__.py @@ -1,573 +1,573 @@ -"""CherryPy is a pythonic, object-oriented HTTP framework. - - -CherryPy consists of not one, but four separate API layers. - -The APPLICATION LAYER is the simplest. CherryPy applications are written as -a tree of classes and methods, where each branch in the tree corresponds to -a branch in the URL path. Each method is a 'page handler', which receives -GET and POST params as keyword arguments, and returns or yields the (HTML) -body of the response. The special method name 'index' is used for paths -that end in a slash, and the special method name 'default' is used to -handle multiple paths via a single handler. This layer also includes: - - * the 'exposed' attribute (and cherrypy.expose) - * cherrypy.quickstart() - * _cp_config attributes - * cherrypy.tools (including cherrypy.session) - * cherrypy.url() - -The ENVIRONMENT LAYER is used by developers at all levels. It provides -information about the current request and response, plus the application -and server environment, via a (default) set of top-level objects: - - * cherrypy.request - * cherrypy.response - * cherrypy.engine - * cherrypy.server - * cherrypy.tree - * cherrypy.config - * cherrypy.thread_data - * cherrypy.log - * cherrypy.HTTPError, NotFound, and HTTPRedirect - * cherrypy.lib - -The EXTENSION LAYER allows advanced users to construct and share their own -plugins. It consists of: - - * Hook API - * Tool API - * Toolbox API - * Dispatch API - * Config Namespace API - -Finally, there is the CORE LAYER, which uses the core API's to construct -the default components which are available at higher layers. You can think -of the default components as the 'reference implementation' for CherryPy. -Megaframeworks (and advanced users) may replace the default components -with customized or extended components. The core API's are: - - * Application API - * Engine API - * Request API - * Server API - * WSGI API - -These API's are described in the CherryPy specification: -http://www.cherrypy.org/wiki/CherryPySpec -""" - -__version__ = "3.2.0rc1" - -from urlparse import urljoin as _urljoin -from urllib import urlencode as _urlencode - - -class _AttributeDocstrings(type): - """Metaclass for declaring docstrings for class attributes.""" - # The full docstring for this type is down in the __init__ method so - # that it doesn't show up in help() for every consumer class. - - def __init__(cls, name, bases, dct): - '''Metaclass for declaring docstrings for class attributes. - - Base Python doesn't provide any syntax for setting docstrings on - 'data attributes' (non-callables). This metaclass allows class - definitions to follow the declaration of a data attribute with - a docstring for that attribute; the attribute docstring will be - popped from the class dict and folded into the class docstring. - - The naming convention for attribute docstrings is: - + "__doc". - For example: - - class Thing(object): - """A thing and its properties.""" - - __metaclass__ = cherrypy._AttributeDocstrings - - height = 50 - height__doc = """The height of the Thing in inches.""" - - In which case, help(Thing) starts like this: - - >>> help(mod.Thing) - Help on class Thing in module pkg.mod: - - class Thing(__builtin__.object) - | A thing and its properties. - | - | height [= 50]: - | The height of the Thing in inches. - | - - The benefits of this approach over hand-edited class docstrings: - 1. Places the docstring nearer to the attribute declaration. - 2. Makes attribute docs more uniform ("name (default): doc"). - 3. Reduces mismatches of attribute _names_ between - the declaration and the documentation. - 4. Reduces mismatches of attribute default _values_ between - the declaration and the documentation. - - The benefits of a metaclass approach over other approaches: - 1. Simpler ("less magic") than interface-based solutions. - 2. __metaclass__ can be specified at the module global level - for classic classes. - - For various formatting reasons, you should write multiline docs - with a leading newline and not a trailing one: - - response__doc = """ - The response object for the current thread. In the main thread, - and any threads which are not HTTP requests, this is None.""" - - The type of the attribute is intentionally not included, because - that's not How Python Works. Quack. - ''' - - newdoc = [cls.__doc__ or ""] - - dctkeys = dct.keys() - dctkeys.sort() - for name in dctkeys: - if name.endswith("__doc"): - # Remove the magic doc attribute. - if hasattr(cls, name): - delattr(cls, name) - - # Make a uniformly-indented docstring from it. - val = '\n'.join([' ' + line.strip() - for line in dct[name].split('\n')]) - - # Get the default value. - attrname = name[:-5] - try: - attrval = getattr(cls, attrname) - except AttributeError: - attrval = "missing" - - # Add the complete attribute docstring to our list. - newdoc.append("%s [= %r]:\n%s" % (attrname, attrval, val)) - - # Add our list of new docstrings to the class docstring. - cls.__doc__ = "\n\n".join(newdoc) - - -from cherrypy._cperror import HTTPError, HTTPRedirect, InternalRedirect -from cherrypy._cperror import NotFound, CherryPyException, TimeoutError - -from cherrypy import _cpdispatch as dispatch - -from cherrypy import _cptools -tools = _cptools.default_toolbox -Tool = _cptools.Tool - -from cherrypy import _cprequest -from cherrypy.lib import httputil as _httputil - -from cherrypy import _cptree -tree = _cptree.Tree() -from cherrypy._cptree import Application -from cherrypy import _cpwsgi as wsgi - -from cherrypy import process -try: - from cherrypy.process import win32 - engine = win32.Win32Bus() - engine.console_control_handler = win32.ConsoleCtrlHandler(engine) - del win32 -except ImportError: - engine = process.bus - - -# Timeout monitor -class _TimeoutMonitor(process.plugins.Monitor): - - def __init__(self, bus): - self.servings = [] - process.plugins.Monitor.__init__(self, bus, self.run) - - def acquire(self): - self.servings.append((serving.request, serving.response)) - - def release(self): - try: - self.servings.remove((serving.request, serving.response)) - except ValueError: - pass - - def run(self): - """Check timeout on all responses. (Internal)""" - for req, resp in self.servings: - resp.check_timeout() -engine.timeout_monitor = _TimeoutMonitor(engine) -engine.timeout_monitor.subscribe() - -engine.autoreload = process.plugins.Autoreloader(engine) -engine.autoreload.subscribe() - -engine.thread_manager = process.plugins.ThreadManager(engine) -engine.thread_manager.subscribe() - -engine.signal_handler = process.plugins.SignalHandler(engine) - - -from cherrypy import _cpserver -server = _cpserver.Server() -server.subscribe() - - -def quickstart(root=None, script_name="", config=None): - """Mount the given root, start the builtin server (and engine), then block. - - root: an instance of a "controller class" (a collection of page handler - methods) which represents the root of the application. - script_name: a string containing the "mount point" of the application. - This should start with a slash, and be the path portion of the URL - at which to mount the given root. For example, if root.index() will - handle requests to "http://www.example.com:8080/dept/app1/", then - the script_name argument would be "/dept/app1". - - It MUST NOT end in a slash. If the script_name refers to the root - of the URI, it MUST be an empty string (not "/"). - config: a file or dict containing application config. If this contains - a [global] section, those entries will be used in the global - (site-wide) config. - """ - if config: - _global_conf_alias.update(config) - - tree.mount(root, script_name, config) - - if hasattr(engine, "signal_handler"): - engine.signal_handler.subscribe() - if hasattr(engine, "console_control_handler"): - engine.console_control_handler.subscribe() - - engine.start() - engine.block() - - -try: - from threading import local as _local -except ImportError: - from cherrypy._cpthreadinglocal import local as _local - -class _Serving(_local): - """An interface for registering request and response objects. - - Rather than have a separate "thread local" object for the request and - the response, this class works as a single threadlocal container for - both objects (and any others which developers wish to define). In this - way, we can easily dump those objects when we stop/start a new HTTP - conversation, yet still refer to them as module-level globals in a - thread-safe way. - """ - - __metaclass__ = _AttributeDocstrings - - request = _cprequest.Request(_httputil.Host("127.0.0.1", 80), - _httputil.Host("127.0.0.1", 1111)) - request__doc = """ - The request object for the current thread. In the main thread, - and any threads which are not receiving HTTP requests, this is None.""" - - response = _cprequest.Response() - response__doc = """ - The response object for the current thread. In the main thread, - and any threads which are not receiving HTTP requests, this is None.""" - - def load(self, request, response): - self.request = request - self.response = response - - def clear(self): - """Remove all attributes of self.""" - self.__dict__.clear() - -serving = _Serving() - - -class _ThreadLocalProxy(object): - - __slots__ = ['__attrname__', '__dict__'] - - def __init__(self, attrname): - self.__attrname__ = attrname - - def __getattr__(self, name): - child = getattr(serving, self.__attrname__) - return getattr(child, name) - - def __setattr__(self, name, value): - if name in ("__attrname__",): - object.__setattr__(self, name, value) - else: - child = getattr(serving, self.__attrname__) - setattr(child, name, value) - - def __delattr__(self, name): - child = getattr(serving, self.__attrname__) - delattr(child, name) - - def _get_dict(self): - child = getattr(serving, self.__attrname__) - d = child.__class__.__dict__.copy() - d.update(child.__dict__) - return d - __dict__ = property(_get_dict) - - def __getitem__(self, key): - child = getattr(serving, self.__attrname__) - return child[key] - - def __setitem__(self, key, value): - child = getattr(serving, self.__attrname__) - child[key] = value - - def __delitem__(self, key): - child = getattr(serving, self.__attrname__) - del child[key] - - def __contains__(self, key): - child = getattr(serving, self.__attrname__) - return key in child - - def __len__(self): - child = getattr(serving, self.__attrname__) - return len(child) - - def __nonzero__(self): - child = getattr(serving, self.__attrname__) - return bool(child) - - -# Create request and response object (the same objects will be used -# throughout the entire life of the webserver, but will redirect -# to the "serving" object) -request = _ThreadLocalProxy('request') -response = _ThreadLocalProxy('response') - -# Create thread_data object as a thread-specific all-purpose storage -class _ThreadData(_local): - """A container for thread-specific data.""" -thread_data = _ThreadData() - - -# Monkeypatch pydoc to allow help() to go through the threadlocal proxy. -# Jan 2007: no Googleable examples of anyone else replacing pydoc.resolve. -# The only other way would be to change what is returned from type(request) -# and that's not possible in pure Python (you'd have to fake ob_type). -def _cherrypy_pydoc_resolve(thing, forceload=0): - """Given an object or a path to an object, get the object and its name.""" - if isinstance(thing, _ThreadLocalProxy): - thing = getattr(serving, thing.__attrname__) - return _pydoc._builtin_resolve(thing, forceload) - -try: - import pydoc as _pydoc - _pydoc._builtin_resolve = _pydoc.resolve - _pydoc.resolve = _cherrypy_pydoc_resolve -except ImportError: - pass - - -from cherrypy import _cplogging - -class _GlobalLogManager(_cplogging.LogManager): - - def __call__(self, *args, **kwargs): - # Do NOT use try/except here. See http://www.cherrypy.org/ticket/945 - if hasattr(request, 'app') and hasattr(request.app, 'log'): - log = request.app.log - else: - log = self - return log.error(*args, **kwargs) - - def access(self): - try: - return request.app.log.access() - except AttributeError: - return _cplogging.LogManager.access(self) - - -log = _GlobalLogManager() -# Set a default screen handler on the global log. -log.screen = True -log.error_file = '' -# Using an access file makes CP about 10% slower. Leave off by default. -log.access_file = '' - -def _buslog(msg, level): - log.error(msg, 'ENGINE', severity=level) -engine.subscribe('log', _buslog) - -# Helper functions for CP apps # - - -def expose(func=None, alias=None): - """Expose the function, optionally providing an alias or set of aliases.""" - def expose_(func): - func.exposed = True - if alias is not None: - if isinstance(alias, basestring): - parents[alias.replace(".", "_")] = func - else: - for a in alias: - parents[a.replace(".", "_")] = func - return func - - import sys, types - if isinstance(func, (types.FunctionType, types.MethodType)): - if alias is None: - # @expose - func.exposed = True - return func - else: - # func = expose(func, alias) - parents = sys._getframe(1).f_locals - return expose_(func) - elif func is None: - if alias is None: - # @expose() - parents = sys._getframe(1).f_locals - return expose_ - else: - # @expose(alias="alias") or - # @expose(alias=["alias1", "alias2"]) - parents = sys._getframe(1).f_locals - return expose_ - else: - # @expose("alias") or - # @expose(["alias1", "alias2"]) - parents = sys._getframe(1).f_locals - alias = func - return expose_ - - -def url(path="", qs="", script_name=None, base=None, relative=None): - """Create an absolute URL for the given path. - - If 'path' starts with a slash ('/'), this will return - (base + script_name + path + qs). - If it does not start with a slash, this returns - (base + script_name [+ request.path_info] + path + qs). - - If script_name is None, cherrypy.request will be used - to find a script_name, if available. - - If base is None, cherrypy.request.base will be used (if available). - Note that you can use cherrypy.tools.proxy to change this. - - Finally, note that this function can be used to obtain an absolute URL - for the current request path (minus the querystring) by passing no args. - If you call url(qs=cherrypy.request.query_string), you should get the - original browser URL (assuming no internal redirections). - - If relative is None or not provided, request.app.relative_urls will - be used (if available, else False). If False, the output will be an - absolute URL (including the scheme, host, vhost, and script_name). - If True, the output will instead be a URL that is relative to the - current request path, perhaps including '..' atoms. If relative is - the string 'server', the output will instead be a URL that is - relative to the server root; i.e., it will start with a slash. - """ - if isinstance(qs, (tuple, list, dict)): - qs = _urlencode(qs) - if qs: - qs = '?' + qs - - if request.app: - if not path.startswith("/"): - # Append/remove trailing slash from path_info as needed - # (this is to support mistyped URL's without redirecting; - # if you want to redirect, use tools.trailing_slash). - pi = request.path_info - if request.is_index is True: - if not pi.endswith('/'): - pi = pi + '/' - elif request.is_index is False: - if pi.endswith('/') and pi != '/': - pi = pi[:-1] - - if path == "": - path = pi - else: - path = _urljoin(pi, path) - - if script_name is None: - script_name = request.script_name - if base is None: - base = request.base - - newurl = base + script_name + path + qs - else: - # No request.app (we're being called outside a request). - # We'll have to guess the base from server.* attributes. - # This will produce very different results from the above - # if you're using vhosts or tools.proxy. - if base is None: - base = server.base() - - path = (script_name or "") + path - newurl = base + path + qs - - if './' in newurl: - # Normalize the URL by removing ./ and ../ - atoms = [] - for atom in newurl.split('/'): - if atom == '.': - pass - elif atom == '..': - atoms.pop() - else: - atoms.append(atom) - newurl = '/'.join(atoms) - - # At this point, we should have a fully-qualified absolute URL. - - if relative is None: - relative = getattr(request.app, "relative_urls", False) - - # See http://www.ietf.org/rfc/rfc2396.txt - if relative == 'server': - # "A relative reference beginning with a single slash character is - # termed an absolute-path reference, as defined by ..." - # This is also sometimes called "server-relative". - newurl = '/' + '/'.join(newurl.split('/', 3)[3:]) - elif relative: - # "A relative reference that does not begin with a scheme name - # or a slash character is termed a relative-path reference." - old = url().split('/')[:-1] - new = newurl.split('/') - while old and new: - a, b = old[0], new[0] - if a != b: - break - old.pop(0) - new.pop(0) - new = (['..'] * len(old)) + new - newurl = '/'.join(new) - - return newurl - - -# import _cpconfig last so it can reference other top-level objects -from cherrypy import _cpconfig -# Use _global_conf_alias so quickstart can use 'config' as an arg -# without shadowing cherrypy.config. -config = _global_conf_alias = _cpconfig.Config() -config.defaults = { - 'tools.log_tracebacks.on': True, - 'tools.log_headers.on': True, - 'tools.trailing_slash.on': True, - 'tools.encode.on': True - } -config.namespaces["log"] = lambda k, v: setattr(log, k, v) -config.namespaces["checker"] = lambda k, v: setattr(checker, k, v) -# Must reset to get our defaults applied. -config.reset() - -from cherrypy import _cpchecker -checker = _cpchecker.Checker() -engine.subscribe('start', checker) +"""CherryPy is a pythonic, object-oriented HTTP framework. + + +CherryPy consists of not one, but four separate API layers. + +The APPLICATION LAYER is the simplest. CherryPy applications are written as +a tree of classes and methods, where each branch in the tree corresponds to +a branch in the URL path. Each method is a 'page handler', which receives +GET and POST params as keyword arguments, and returns or yields the (HTML) +body of the response. The special method name 'index' is used for paths +that end in a slash, and the special method name 'default' is used to +handle multiple paths via a single handler. This layer also includes: + + * the 'exposed' attribute (and cherrypy.expose) + * cherrypy.quickstart() + * _cp_config attributes + * cherrypy.tools (including cherrypy.session) + * cherrypy.url() + +The ENVIRONMENT LAYER is used by developers at all levels. It provides +information about the current request and response, plus the application +and server environment, via a (default) set of top-level objects: + + * cherrypy.request + * cherrypy.response + * cherrypy.engine + * cherrypy.server + * cherrypy.tree + * cherrypy.config + * cherrypy.thread_data + * cherrypy.log + * cherrypy.HTTPError, NotFound, and HTTPRedirect + * cherrypy.lib + +The EXTENSION LAYER allows advanced users to construct and share their own +plugins. It consists of: + + * Hook API + * Tool API + * Toolbox API + * Dispatch API + * Config Namespace API + +Finally, there is the CORE LAYER, which uses the core API's to construct +the default components which are available at higher layers. You can think +of the default components as the 'reference implementation' for CherryPy. +Megaframeworks (and advanced users) may replace the default components +with customized or extended components. The core API's are: + + * Application API + * Engine API + * Request API + * Server API + * WSGI API + +These API's are described in the CherryPy specification: +http://www.cherrypy.org/wiki/CherryPySpec +""" + +__version__ = "3.2.0rc1" + +from urlparse import urljoin as _urljoin +from urllib import urlencode as _urlencode + + +class _AttributeDocstrings(type): + """Metaclass for declaring docstrings for class attributes.""" + # The full docstring for this type is down in the __init__ method so + # that it doesn't show up in help() for every consumer class. + + def __init__(cls, name, bases, dct): + '''Metaclass for declaring docstrings for class attributes. + + Base Python doesn't provide any syntax for setting docstrings on + 'data attributes' (non-callables). This metaclass allows class + definitions to follow the declaration of a data attribute with + a docstring for that attribute; the attribute docstring will be + popped from the class dict and folded into the class docstring. + + The naming convention for attribute docstrings is: + + "__doc". + For example: + + class Thing(object): + """A thing and its properties.""" + + __metaclass__ = cherrypy._AttributeDocstrings + + height = 50 + height__doc = """The height of the Thing in inches.""" + + In which case, help(Thing) starts like this: + + >>> help(mod.Thing) + Help on class Thing in module pkg.mod: + + class Thing(__builtin__.object) + | A thing and its properties. + | + | height [= 50]: + | The height of the Thing in inches. + | + + The benefits of this approach over hand-edited class docstrings: + 1. Places the docstring nearer to the attribute declaration. + 2. Makes attribute docs more uniform ("name (default): doc"). + 3. Reduces mismatches of attribute _names_ between + the declaration and the documentation. + 4. Reduces mismatches of attribute default _values_ between + the declaration and the documentation. + + The benefits of a metaclass approach over other approaches: + 1. Simpler ("less magic") than interface-based solutions. + 2. __metaclass__ can be specified at the module global level + for classic classes. + + For various formatting reasons, you should write multiline docs + with a leading newline and not a trailing one: + + response__doc = """ + The response object for the current thread. In the main thread, + and any threads which are not HTTP requests, this is None.""" + + The type of the attribute is intentionally not included, because + that's not How Python Works. Quack. + ''' + + newdoc = [cls.__doc__ or ""] + + dctkeys = dct.keys() + dctkeys.sort() + for name in dctkeys: + if name.endswith("__doc"): + # Remove the magic doc attribute. + if hasattr(cls, name): + delattr(cls, name) + + # Make a uniformly-indented docstring from it. + val = '\n'.join([' ' + line.strip() + for line in dct[name].split('\n')]) + + # Get the default value. + attrname = name[:-5] + try: + attrval = getattr(cls, attrname) + except AttributeError: + attrval = "missing" + + # Add the complete attribute docstring to our list. + newdoc.append("%s [= %r]:\n%s" % (attrname, attrval, val)) + + # Add our list of new docstrings to the class docstring. + cls.__doc__ = "\n\n".join(newdoc) + + +from cherrypy._cperror import HTTPError, HTTPRedirect, InternalRedirect +from cherrypy._cperror import NotFound, CherryPyException, TimeoutError + +from cherrypy import _cpdispatch as dispatch + +from cherrypy import _cptools +tools = _cptools.default_toolbox +Tool = _cptools.Tool + +from cherrypy import _cprequest +from cherrypy.lib import httputil as _httputil + +from cherrypy import _cptree +tree = _cptree.Tree() +from cherrypy._cptree import Application +from cherrypy import _cpwsgi as wsgi + +from cherrypy import process +try: + from cherrypy.process import win32 + engine = win32.Win32Bus() + engine.console_control_handler = win32.ConsoleCtrlHandler(engine) + del win32 +except ImportError: + engine = process.bus + + +# Timeout monitor +class _TimeoutMonitor(process.plugins.Monitor): + + def __init__(self, bus): + self.servings = [] + process.plugins.Monitor.__init__(self, bus, self.run) + + def acquire(self): + self.servings.append((serving.request, serving.response)) + + def release(self): + try: + self.servings.remove((serving.request, serving.response)) + except ValueError: + pass + + def run(self): + """Check timeout on all responses. (Internal)""" + for req, resp in self.servings: + resp.check_timeout() +engine.timeout_monitor = _TimeoutMonitor(engine) +engine.timeout_monitor.subscribe() + +engine.autoreload = process.plugins.Autoreloader(engine) +engine.autoreload.subscribe() + +engine.thread_manager = process.plugins.ThreadManager(engine) +engine.thread_manager.subscribe() + +engine.signal_handler = process.plugins.SignalHandler(engine) + + +from cherrypy import _cpserver +server = _cpserver.Server() +server.subscribe() + + +def quickstart(root=None, script_name="", config=None): + """Mount the given root, start the builtin server (and engine), then block. + + root: an instance of a "controller class" (a collection of page handler + methods) which represents the root of the application. + script_name: a string containing the "mount point" of the application. + This should start with a slash, and be the path portion of the URL + at which to mount the given root. For example, if root.index() will + handle requests to "http://www.example.com:8080/dept/app1/", then + the script_name argument would be "/dept/app1". + + It MUST NOT end in a slash. If the script_name refers to the root + of the URI, it MUST be an empty string (not "/"). + config: a file or dict containing application config. If this contains + a [global] section, those entries will be used in the global + (site-wide) config. + """ + if config: + _global_conf_alias.update(config) + + tree.mount(root, script_name, config) + + if hasattr(engine, "signal_handler"): + engine.signal_handler.subscribe() + if hasattr(engine, "console_control_handler"): + engine.console_control_handler.subscribe() + + engine.start() + engine.block() + + +try: + from threading import local as _local +except ImportError: + from cherrypy._cpthreadinglocal import local as _local + +class _Serving(_local): + """An interface for registering request and response objects. + + Rather than have a separate "thread local" object for the request and + the response, this class works as a single threadlocal container for + both objects (and any others which developers wish to define). In this + way, we can easily dump those objects when we stop/start a new HTTP + conversation, yet still refer to them as module-level globals in a + thread-safe way. + """ + + __metaclass__ = _AttributeDocstrings + + request = _cprequest.Request(_httputil.Host("127.0.0.1", 80), + _httputil.Host("127.0.0.1", 1111)) + request__doc = """ + The request object for the current thread. In the main thread, + and any threads which are not receiving HTTP requests, this is None.""" + + response = _cprequest.Response() + response__doc = """ + The response object for the current thread. In the main thread, + and any threads which are not receiving HTTP requests, this is None.""" + + def load(self, request, response): + self.request = request + self.response = response + + def clear(self): + """Remove all attributes of self.""" + self.__dict__.clear() + +serving = _Serving() + + +class _ThreadLocalProxy(object): + + __slots__ = ['__attrname__', '__dict__'] + + def __init__(self, attrname): + self.__attrname__ = attrname + + def __getattr__(self, name): + child = getattr(serving, self.__attrname__) + return getattr(child, name) + + def __setattr__(self, name, value): + if name in ("__attrname__",): + object.__setattr__(self, name, value) + else: + child = getattr(serving, self.__attrname__) + setattr(child, name, value) + + def __delattr__(self, name): + child = getattr(serving, self.__attrname__) + delattr(child, name) + + def _get_dict(self): + child = getattr(serving, self.__attrname__) + d = child.__class__.__dict__.copy() + d.update(child.__dict__) + return d + __dict__ = property(_get_dict) + + def __getitem__(self, key): + child = getattr(serving, self.__attrname__) + return child[key] + + def __setitem__(self, key, value): + child = getattr(serving, self.__attrname__) + child[key] = value + + def __delitem__(self, key): + child = getattr(serving, self.__attrname__) + del child[key] + + def __contains__(self, key): + child = getattr(serving, self.__attrname__) + return key in child + + def __len__(self): + child = getattr(serving, self.__attrname__) + return len(child) + + def __nonzero__(self): + child = getattr(serving, self.__attrname__) + return bool(child) + + +# Create request and response object (the same objects will be used +# throughout the entire life of the webserver, but will redirect +# to the "serving" object) +request = _ThreadLocalProxy('request') +response = _ThreadLocalProxy('response') + +# Create thread_data object as a thread-specific all-purpose storage +class _ThreadData(_local): + """A container for thread-specific data.""" +thread_data = _ThreadData() + + +# Monkeypatch pydoc to allow help() to go through the threadlocal proxy. +# Jan 2007: no Googleable examples of anyone else replacing pydoc.resolve. +# The only other way would be to change what is returned from type(request) +# and that's not possible in pure Python (you'd have to fake ob_type). +def _cherrypy_pydoc_resolve(thing, forceload=0): + """Given an object or a path to an object, get the object and its name.""" + if isinstance(thing, _ThreadLocalProxy): + thing = getattr(serving, thing.__attrname__) + return _pydoc._builtin_resolve(thing, forceload) + +try: + import pydoc as _pydoc + _pydoc._builtin_resolve = _pydoc.resolve + _pydoc.resolve = _cherrypy_pydoc_resolve +except ImportError: + pass + + +from cherrypy import _cplogging + +class _GlobalLogManager(_cplogging.LogManager): + + def __call__(self, *args, **kwargs): + # Do NOT use try/except here. See http://www.cherrypy.org/ticket/945 + if hasattr(request, 'app') and hasattr(request.app, 'log'): + log = request.app.log + else: + log = self + return log.error(*args, **kwargs) + + def access(self): + try: + return request.app.log.access() + except AttributeError: + return _cplogging.LogManager.access(self) + + +log = _GlobalLogManager() +# Set a default screen handler on the global log. +log.screen = True +log.error_file = '' +# Using an access file makes CP about 10% slower. Leave off by default. +log.access_file = '' + +def _buslog(msg, level): + log.error(msg, 'ENGINE', severity=level) +engine.subscribe('log', _buslog) + +# Helper functions for CP apps # + + +def expose(func=None, alias=None): + """Expose the function, optionally providing an alias or set of aliases.""" + def expose_(func): + func.exposed = True + if alias is not None: + if isinstance(alias, basestring): + parents[alias.replace(".", "_")] = func + else: + for a in alias: + parents[a.replace(".", "_")] = func + return func + + import sys, types + if isinstance(func, (types.FunctionType, types.MethodType)): + if alias is None: + # @expose + func.exposed = True + return func + else: + # func = expose(func, alias) + parents = sys._getframe(1).f_locals + return expose_(func) + elif func is None: + if alias is None: + # @expose() + parents = sys._getframe(1).f_locals + return expose_ + else: + # @expose(alias="alias") or + # @expose(alias=["alias1", "alias2"]) + parents = sys._getframe(1).f_locals + return expose_ + else: + # @expose("alias") or + # @expose(["alias1", "alias2"]) + parents = sys._getframe(1).f_locals + alias = func + return expose_ + + +def url(path="", qs="", script_name=None, base=None, relative=None): + """Create an absolute URL for the given path. + + If 'path' starts with a slash ('/'), this will return + (base + script_name + path + qs). + If it does not start with a slash, this returns + (base + script_name [+ request.path_info] + path + qs). + + If script_name is None, cherrypy.request will be used + to find a script_name, if available. + + If base is None, cherrypy.request.base will be used (if available). + Note that you can use cherrypy.tools.proxy to change this. + + Finally, note that this function can be used to obtain an absolute URL + for the current request path (minus the querystring) by passing no args. + If you call url(qs=cherrypy.request.query_string), you should get the + original browser URL (assuming no internal redirections). + + If relative is None or not provided, request.app.relative_urls will + be used (if available, else False). If False, the output will be an + absolute URL (including the scheme, host, vhost, and script_name). + If True, the output will instead be a URL that is relative to the + current request path, perhaps including '..' atoms. If relative is + the string 'server', the output will instead be a URL that is + relative to the server root; i.e., it will start with a slash. + """ + if isinstance(qs, (tuple, list, dict)): + qs = _urlencode(qs) + if qs: + qs = '?' + qs + + if request.app: + if not path.startswith("/"): + # Append/remove trailing slash from path_info as needed + # (this is to support mistyped URL's without redirecting; + # if you want to redirect, use tools.trailing_slash). + pi = request.path_info + if request.is_index is True: + if not pi.endswith('/'): + pi = pi + '/' + elif request.is_index is False: + if pi.endswith('/') and pi != '/': + pi = pi[:-1] + + if path == "": + path = pi + else: + path = _urljoin(pi, path) + + if script_name is None: + script_name = request.script_name + if base is None: + base = request.base + + newurl = base + script_name + path + qs + else: + # No request.app (we're being called outside a request). + # We'll have to guess the base from server.* attributes. + # This will produce very different results from the above + # if you're using vhosts or tools.proxy. + if base is None: + base = server.base() + + path = (script_name or "") + path + newurl = base + path + qs + + if './' in newurl: + # Normalize the URL by removing ./ and ../ + atoms = [] + for atom in newurl.split('/'): + if atom == '.': + pass + elif atom == '..': + atoms.pop() + else: + atoms.append(atom) + newurl = '/'.join(atoms) + + # At this point, we should have a fully-qualified absolute URL. + + if relative is None: + relative = getattr(request.app, "relative_urls", False) + + # See http://www.ietf.org/rfc/rfc2396.txt + if relative == 'server': + # "A relative reference beginning with a single slash character is + # termed an absolute-path reference, as defined by ..." + # This is also sometimes called "server-relative". + newurl = '/' + '/'.join(newurl.split('/', 3)[3:]) + elif relative: + # "A relative reference that does not begin with a scheme name + # or a slash character is termed a relative-path reference." + old = url().split('/')[:-1] + new = newurl.split('/') + while old and new: + a, b = old[0], new[0] + if a != b: + break + old.pop(0) + new.pop(0) + new = (['..'] * len(old)) + new + newurl = '/'.join(new) + + return newurl + + +# import _cpconfig last so it can reference other top-level objects +from cherrypy import _cpconfig +# Use _global_conf_alias so quickstart can use 'config' as an arg +# without shadowing cherrypy.config. +config = _global_conf_alias = _cpconfig.Config() +config.defaults = { + 'tools.log_tracebacks.on': True, + 'tools.log_headers.on': True, + 'tools.trailing_slash.on': True, + 'tools.encode.on': True + } +config.namespaces["log"] = lambda k, v: setattr(log, k, v) +config.namespaces["checker"] = lambda k, v: setattr(checker, k, v) +# Must reset to get our defaults applied. +config.reset() + +from cherrypy import _cpchecker +checker = _cpchecker.Checker() +engine.subscribe('start', checker) diff --git a/cherrypy/_cpdispatch.py b/cherrypy/_cpdispatch.py index 6020b7a42c..f1d10b8a4e 100644 --- a/cherrypy/_cpdispatch.py +++ b/cherrypy/_cpdispatch.py @@ -1,568 +1,568 @@ -"""CherryPy dispatchers. - -A 'dispatcher' is the object which looks up the 'page handler' callable -and collects config for the current request based on the path_info, other -request attributes, and the application architecture. The core calls the -dispatcher as early as possible, passing it a 'path_info' argument. - -The default dispatcher discovers the page handler by matching path_info -to a hierarchical arrangement of objects, starting at request.app.root. -""" - -import cherrypy - - -class PageHandler(object): - """Callable which sets response.body.""" - - def __init__(self, callable, *args, **kwargs): - self.callable = callable - self.args = args - self.kwargs = kwargs - - def __call__(self): - try: - return self.callable(*self.args, **self.kwargs) - except TypeError, x: - try: - test_callable_spec(self.callable, self.args, self.kwargs) - except cherrypy.HTTPError, error: - raise error - except: - raise x - raise - - -def test_callable_spec(callable, callable_args, callable_kwargs): - """ - Inspect callable and test to see if the given args are suitable for it. - - When an error occurs during the handler's invoking stage there are 2 - erroneous cases: - 1. Too many parameters passed to a function which doesn't define - one of *args or **kwargs. - 2. Too little parameters are passed to the function. - - There are 3 sources of parameters to a cherrypy handler. - 1. query string parameters are passed as keyword parameters to the handler. - 2. body parameters are also passed as keyword parameters. - 3. when partial matching occurs, the final path atoms are passed as - positional args. - Both the query string and path atoms are part of the URI. If they are - incorrect, then a 404 Not Found should be raised. Conversely the body - parameters are part of the request; if they are invalid a 400 Bad Request. - """ - show_mismatched_params = getattr( - cherrypy.serving.request, 'show_mismatched_params', False) - try: - (args, varargs, varkw, defaults) = inspect.getargspec(callable) - except TypeError: - if isinstance(callable, object) and hasattr(callable, '__call__'): - (args, varargs, varkw, defaults) = inspect.getargspec(callable.__call__) - else: - # If it wasn't one of our own types, re-raise - # the original error - raise - - if args and args[0] == 'self': - args = args[1:] - - arg_usage = dict([(arg, 0,) for arg in args]) - vararg_usage = 0 - varkw_usage = 0 - extra_kwargs = set() - - for i, value in enumerate(callable_args): - try: - arg_usage[args[i]] += 1 - except IndexError: - vararg_usage += 1 - - for key in callable_kwargs.keys(): - try: - arg_usage[key] += 1 - except KeyError: - varkw_usage += 1 - extra_kwargs.add(key) - - # figure out which args have defaults. - args_with_defaults = args[-len(defaults or []):] - for i, val in enumerate(defaults or []): - # Defaults take effect only when the arg hasn't been used yet. - if arg_usage[args_with_defaults[i]] == 0: - arg_usage[args_with_defaults[i]] += 1 - - missing_args = [] - multiple_args = [] - for key, usage in arg_usage.items(): - if usage == 0: - missing_args.append(key) - elif usage > 1: - multiple_args.append(key) - - if missing_args: - # In the case where the method allows body arguments - # there are 3 potential errors: - # 1. not enough query string parameters -> 404 - # 2. not enough body parameters -> 400 - # 3. not enough path parts (partial matches) -> 404 - # - # We can't actually tell which case it is, - # so I'm raising a 404 because that covers 2/3 of the - # possibilities - # - # In the case where the method does not allow body - # arguments it's definitely a 404. - message = None - if show_mismatched_params: - message = "Missing parameters: %s" % ",".join(missing_args) - raise cherrypy.HTTPError(404, message=message) - - # the extra positional arguments come from the path - 404 Not Found - if not varargs and vararg_usage > 0: - raise cherrypy.HTTPError(404) - - body_params = cherrypy.serving.request.body.params or {} - body_params = set(body_params.keys()) - qs_params = set(callable_kwargs.keys()) - body_params - - if multiple_args: - if qs_params.intersection(set(multiple_args)): - # If any of the multiple parameters came from the query string then - # it's a 404 Not Found - error = 404 - else: - # Otherwise it's a 400 Bad Request - error = 400 - - message = None - if show_mismatched_params: - message = "Multiple values for parameters: "\ - "%s" % ",".join(multiple_args) - raise cherrypy.HTTPError(error, message=message) - - if not varkw and varkw_usage > 0: - - # If there were extra query string parameters, it's a 404 Not Found - extra_qs_params = set(qs_params).intersection(extra_kwargs) - if extra_qs_params: - message = None - if show_mismatched_params: - message = "Unexpected query string "\ - "parameters: %s" % ", ".join(extra_qs_params) - raise cherrypy.HTTPError(404, message=message) - - # If there were any extra body parameters, it's a 400 Not Found - extra_body_params = set(body_params).intersection(extra_kwargs) - if extra_body_params: - message = None - if show_mismatched_params: - message = "Unexpected body parameters: "\ - "%s" % ", ".join(extra_body_params) - raise cherrypy.HTTPError(400, message=message) - - -try: - import inspect -except ImportError: - test_callable_spec = lambda callable, args, kwargs: None - - - -class LateParamPageHandler(PageHandler): - """When passing cherrypy.request.params to the page handler, we do not - want to capture that dict too early; we want to give tools like the - decoding tool a chance to modify the params dict in-between the lookup - of the handler and the actual calling of the handler. This subclass - takes that into account, and allows request.params to be 'bound late' - (it's more complicated than that, but that's the effect). - """ - - def _get_kwargs(self): - kwargs = cherrypy.serving.request.params.copy() - if self._kwargs: - kwargs.update(self._kwargs) - return kwargs - - def _set_kwargs(self, kwargs): - self._kwargs = kwargs - - kwargs = property(_get_kwargs, _set_kwargs, - doc='page handler kwargs (with ' - 'cherrypy.request.params copied in)') - - -class Dispatcher(object): - """CherryPy Dispatcher which walks a tree of objects to find a handler. - - The tree is rooted at cherrypy.request.app.root, and each hierarchical - component in the path_info argument is matched to a corresponding nested - attribute of the root object. Matching handlers must have an 'exposed' - attribute which evaluates to True. The special method name "index" - matches a URI which ends in a slash ("/"). The special method name - "default" may match a portion of the path_info (but only when no longer - substring of the path_info matches some other object). - - This is the default, built-in dispatcher for CherryPy. - """ - __metaclass__ = cherrypy._AttributeDocstrings - - dispatch_method_name = '_cp_dispatch' - dispatch_method_name__doc = """ - The name of the dispatch method that nodes may optionally implement - to provide their own dynamic dispatch algorithm. - """ - - def __init__(self, dispatch_method_name=None): - if dispatch_method_name: - self.dispatch_method_name = dispatch_method_name - - def __call__(self, path_info): - """Set handler and config for the current request.""" - request = cherrypy.serving.request - func, vpath = self.find_handler(path_info) - - if func: - # Decode any leftover %2F in the virtual_path atoms. - vpath = [x.replace("%2F", "/") for x in vpath] - request.handler = LateParamPageHandler(func, *vpath) - else: - request.handler = cherrypy.NotFound() - - def find_handler(self, path): - """Return the appropriate page handler, plus any virtual path. - - This will return two objects. The first will be a callable, - which can be used to generate page output. Any parameters from - the query string or request body will be sent to that callable - as keyword arguments. - - The callable is found by traversing the application's tree, - starting from cherrypy.request.app.root, and matching path - components to successive objects in the tree. For example, the - URL "/path/to/handler" might return root.path.to.handler. - - The second object returned will be a list of names which are - 'virtual path' components: parts of the URL which are dynamic, - and were not used when looking up the handler. - These virtual path components are passed to the handler as - positional arguments. - """ - request = cherrypy.serving.request - app = request.app - root = app.root - dispatch_name = self.dispatch_method_name - - # Get config for the root object/path. - curpath = "" - nodeconf = {} - if hasattr(root, "_cp_config"): - nodeconf.update(root._cp_config) - if "/" in app.config: - nodeconf.update(app.config["/"]) - object_trail = [['root', root, nodeconf, curpath]] - - node = root - names = [x for x in path.strip('/').split('/') if x] + ['index'] - iternames = names[:] - while iternames: - name = iternames[0] - # map to legal Python identifiers (replace '.' with '_') - objname = name.replace('.', '_') - - nodeconf = {} - subnode = getattr(node, objname, None) - if subnode is None: - dispatch = getattr(node, dispatch_name, None) - if dispatch and callable(dispatch) and not \ - getattr(dispatch, 'exposed', False): - subnode = dispatch(vpath=iternames) - name = iternames.pop(0) - node = subnode - - if node is not None: - # Get _cp_config attached to this node. - if hasattr(node, "_cp_config"): - nodeconf.update(node._cp_config) - - # Mix in values from app.config for this path. - curpath = "/".join((curpath, name)) - if curpath in app.config: - nodeconf.update(app.config[curpath]) - - object_trail.append([name, node, nodeconf, curpath]) - - def set_conf(): - """Collapse all object_trail config into cherrypy.request.config.""" - base = cherrypy.config.copy() - # Note that we merge the config from each node - # even if that node was None. - for name, obj, conf, curpath in object_trail: - base.update(conf) - if 'tools.staticdir.dir' in conf: - base['tools.staticdir.section'] = curpath - return base - - # Try successive objects (reverse order) - num_candidates = len(object_trail) - 1 - for i in range(num_candidates, -1, -1): - - name, candidate, nodeconf, curpath = object_trail[i] - if candidate is None: - continue - - # Try a "default" method on the current leaf. - if hasattr(candidate, "default"): - defhandler = candidate.default - if getattr(defhandler, 'exposed', False): - # Insert any extra _cp_config from the default handler. - conf = getattr(defhandler, "_cp_config", {}) - object_trail.insert(i + 1, ["default", defhandler, conf, curpath]) - request.config = set_conf() - # See http://www.cherrypy.org/ticket/613 - request.is_index = path.endswith("/") - return defhandler, names[i:-1] - - # Uncomment the next line to restrict positional params to "default". - # if i < num_candidates - 2: continue - - # Try the current leaf. - if getattr(candidate, 'exposed', False): - request.config = set_conf() - if i == num_candidates: - # We found the extra ".index". Mark request so tools - # can redirect if path_info has no trailing slash. - request.is_index = True - else: - # We're not at an 'index' handler. Mark request so tools - # can redirect if path_info has NO trailing slash. - # Note that this also includes handlers which take - # positional parameters (virtual paths). - request.is_index = False - return candidate, names[i:-1] - - # We didn't find anything - request.config = set_conf() - return None, [] - - -class MethodDispatcher(Dispatcher): - """Additional dispatch based on cherrypy.request.method.upper(). - - Methods named GET, POST, etc will be called on an exposed class. - The method names must be all caps; the appropriate Allow header - will be output showing all capitalized method names as allowable - HTTP verbs. - - Note that the containing class must be exposed, not the methods. - """ - - def __call__(self, path_info): - """Set handler and config for the current request.""" - request = cherrypy.serving.request - resource, vpath = self.find_handler(path_info) - - if resource: - # Set Allow header - avail = [m for m in dir(resource) if m.isupper()] - if "GET" in avail and "HEAD" not in avail: - avail.append("HEAD") - avail.sort() - cherrypy.serving.response.headers['Allow'] = ", ".join(avail) - - # Find the subhandler - meth = request.method.upper() - func = getattr(resource, meth, None) - if func is None and meth == "HEAD": - func = getattr(resource, "GET", None) - if func: - # Grab any _cp_config on the subhandler. - if hasattr(func, "_cp_config"): - request.config.update(func._cp_config) - - # Decode any leftover %2F in the virtual_path atoms. - vpath = [x.replace("%2F", "/") for x in vpath] - request.handler = LateParamPageHandler(func, *vpath) - else: - request.handler = cherrypy.HTTPError(405) - else: - request.handler = cherrypy.NotFound() - - -class RoutesDispatcher(object): - """A Routes based dispatcher for CherryPy.""" - - def __init__(self, full_result=False): - """ - Routes dispatcher - - Set full_result to True if you wish the controller - and the action to be passed on to the page handler - parameters. By default they won't be. - """ - import routes - self.full_result = full_result - self.controllers = {} - self.mapper = routes.Mapper() - self.mapper.controller_scan = self.controllers.keys - - def connect(self, name, route, controller, **kwargs): - self.controllers[name] = controller - self.mapper.connect(name, route, controller=name, **kwargs) - - def redirect(self, url): - raise cherrypy.HTTPRedirect(url) - - def __call__(self, path_info): - """Set handler and config for the current request.""" - func = self.find_handler(path_info) - if func: - cherrypy.serving.request.handler = LateParamPageHandler(func) - else: - cherrypy.serving.request.handler = cherrypy.NotFound() - - def find_handler(self, path_info): - """Find the right page handler, and set request.config.""" - import routes - - request = cherrypy.serving.request - - config = routes.request_config() - config.mapper = self.mapper - if hasattr(request, 'wsgi_environ'): - config.environ = request.wsgi_environ - config.host = request.headers.get('Host', None) - config.protocol = request.scheme - config.redirect = self.redirect - - result = self.mapper.match(path_info) - - config.mapper_dict = result - params = {} - if result: - params = result.copy() - if not self.full_result: - params.pop('controller', None) - params.pop('action', None) - request.params.update(params) - - # Get config for the root object/path. - request.config = base = cherrypy.config.copy() - curpath = "" - - def merge(nodeconf): - if 'tools.staticdir.dir' in nodeconf: - nodeconf['tools.staticdir.section'] = curpath or "/" - base.update(nodeconf) - - app = request.app - root = app.root - if hasattr(root, "_cp_config"): - merge(root._cp_config) - if "/" in app.config: - merge(app.config["/"]) - - # Mix in values from app.config. - atoms = [x for x in path_info.split("/") if x] - if atoms: - last = atoms.pop() - else: - last = None - for atom in atoms: - curpath = "/".join((curpath, atom)) - if curpath in app.config: - merge(app.config[curpath]) - - handler = None - if result: - controller = result.get('controller', None) - controller = self.controllers.get(controller) - if controller: - # Get config from the controller. - if hasattr(controller, "_cp_config"): - merge(controller._cp_config) - - action = result.get('action', None) - if action is not None: - handler = getattr(controller, action, None) - # Get config from the handler - if hasattr(handler, "_cp_config"): - merge(handler._cp_config) - - # Do the last path atom here so it can - # override the controller's _cp_config. - if last: - curpath = "/".join((curpath, last)) - if curpath in app.config: - merge(app.config[curpath]) - - return handler - - -def XMLRPCDispatcher(next_dispatcher=Dispatcher()): - from cherrypy.lib import xmlrpc - def xmlrpc_dispatch(path_info): - path_info = xmlrpc.patched_path(path_info) - return next_dispatcher(path_info) - return xmlrpc_dispatch - - -def VirtualHost(next_dispatcher=Dispatcher(), use_x_forwarded_host=True, **domains): - """Select a different handler based on the Host header. - - This can be useful when running multiple sites within one CP server. - It allows several domains to point to different parts of a single - website structure. For example: - - http://www.domain.example -> root - http://www.domain2.example -> root/domain2/ - http://www.domain2.example:443 -> root/secure - - can be accomplished via the following config: - - [/] - request.dispatch = cherrypy.dispatch.VirtualHost( - **{'www.domain2.example': '/domain2', - 'www.domain2.example:443': '/secure', - }) - - next_dispatcher: the next dispatcher object in the dispatch chain. - The VirtualHost dispatcher adds a prefix to the URL and calls - another dispatcher. Defaults to cherrypy.dispatch.Dispatcher(). - - use_x_forwarded_host: if True (the default), any "X-Forwarded-Host" - request header will be used instead of the "Host" header. This - is commonly added by HTTP servers (such as Apache) when proxying. - - **domains: a dict of {host header value: virtual prefix} pairs. - The incoming "Host" request header is looked up in this dict, - and, if a match is found, the corresponding "virtual prefix" - value will be prepended to the URL path before calling the - next dispatcher. Note that you often need separate entries - for "example.com" and "www.example.com". In addition, "Host" - headers may contain the port number. - """ - from cherrypy.lib import httputil - def vhost_dispatch(path_info): - request = cherrypy.serving.request - header = request.headers.get - - domain = header('Host', '') - if use_x_forwarded_host: - domain = header("X-Forwarded-Host", domain) - - prefix = domains.get(domain, "") - if prefix: - path_info = httputil.urljoin(prefix, path_info) - - result = next_dispatcher(path_info) - - # Touch up staticdir config. See http://www.cherrypy.org/ticket/614. - section = request.config.get('tools.staticdir.section') - if section: - section = section[len(prefix):] - request.config['tools.staticdir.section'] = section - - return result - return vhost_dispatch - +"""CherryPy dispatchers. + +A 'dispatcher' is the object which looks up the 'page handler' callable +and collects config for the current request based on the path_info, other +request attributes, and the application architecture. The core calls the +dispatcher as early as possible, passing it a 'path_info' argument. + +The default dispatcher discovers the page handler by matching path_info +to a hierarchical arrangement of objects, starting at request.app.root. +""" + +import cherrypy + + +class PageHandler(object): + """Callable which sets response.body.""" + + def __init__(self, callable, *args, **kwargs): + self.callable = callable + self.args = args + self.kwargs = kwargs + + def __call__(self): + try: + return self.callable(*self.args, **self.kwargs) + except TypeError, x: + try: + test_callable_spec(self.callable, self.args, self.kwargs) + except cherrypy.HTTPError, error: + raise error + except: + raise x + raise + + +def test_callable_spec(callable, callable_args, callable_kwargs): + """ + Inspect callable and test to see if the given args are suitable for it. + + When an error occurs during the handler's invoking stage there are 2 + erroneous cases: + 1. Too many parameters passed to a function which doesn't define + one of *args or **kwargs. + 2. Too little parameters are passed to the function. + + There are 3 sources of parameters to a cherrypy handler. + 1. query string parameters are passed as keyword parameters to the handler. + 2. body parameters are also passed as keyword parameters. + 3. when partial matching occurs, the final path atoms are passed as + positional args. + Both the query string and path atoms are part of the URI. If they are + incorrect, then a 404 Not Found should be raised. Conversely the body + parameters are part of the request; if they are invalid a 400 Bad Request. + """ + show_mismatched_params = getattr( + cherrypy.serving.request, 'show_mismatched_params', False) + try: + (args, varargs, varkw, defaults) = inspect.getargspec(callable) + except TypeError: + if isinstance(callable, object) and hasattr(callable, '__call__'): + (args, varargs, varkw, defaults) = inspect.getargspec(callable.__call__) + else: + # If it wasn't one of our own types, re-raise + # the original error + raise + + if args and args[0] == 'self': + args = args[1:] + + arg_usage = dict([(arg, 0,) for arg in args]) + vararg_usage = 0 + varkw_usage = 0 + extra_kwargs = set() + + for i, value in enumerate(callable_args): + try: + arg_usage[args[i]] += 1 + except IndexError: + vararg_usage += 1 + + for key in callable_kwargs.keys(): + try: + arg_usage[key] += 1 + except KeyError: + varkw_usage += 1 + extra_kwargs.add(key) + + # figure out which args have defaults. + args_with_defaults = args[-len(defaults or []):] + for i, val in enumerate(defaults or []): + # Defaults take effect only when the arg hasn't been used yet. + if arg_usage[args_with_defaults[i]] == 0: + arg_usage[args_with_defaults[i]] += 1 + + missing_args = [] + multiple_args = [] + for key, usage in arg_usage.items(): + if usage == 0: + missing_args.append(key) + elif usage > 1: + multiple_args.append(key) + + if missing_args: + # In the case where the method allows body arguments + # there are 3 potential errors: + # 1. not enough query string parameters -> 404 + # 2. not enough body parameters -> 400 + # 3. not enough path parts (partial matches) -> 404 + # + # We can't actually tell which case it is, + # so I'm raising a 404 because that covers 2/3 of the + # possibilities + # + # In the case where the method does not allow body + # arguments it's definitely a 404. + message = None + if show_mismatched_params: + message = "Missing parameters: %s" % ",".join(missing_args) + raise cherrypy.HTTPError(404, message=message) + + # the extra positional arguments come from the path - 404 Not Found + if not varargs and vararg_usage > 0: + raise cherrypy.HTTPError(404) + + body_params = cherrypy.serving.request.body.params or {} + body_params = set(body_params.keys()) + qs_params = set(callable_kwargs.keys()) - body_params + + if multiple_args: + if qs_params.intersection(set(multiple_args)): + # If any of the multiple parameters came from the query string then + # it's a 404 Not Found + error = 404 + else: + # Otherwise it's a 400 Bad Request + error = 400 + + message = None + if show_mismatched_params: + message = "Multiple values for parameters: "\ + "%s" % ",".join(multiple_args) + raise cherrypy.HTTPError(error, message=message) + + if not varkw and varkw_usage > 0: + + # If there were extra query string parameters, it's a 404 Not Found + extra_qs_params = set(qs_params).intersection(extra_kwargs) + if extra_qs_params: + message = None + if show_mismatched_params: + message = "Unexpected query string "\ + "parameters: %s" % ", ".join(extra_qs_params) + raise cherrypy.HTTPError(404, message=message) + + # If there were any extra body parameters, it's a 400 Not Found + extra_body_params = set(body_params).intersection(extra_kwargs) + if extra_body_params: + message = None + if show_mismatched_params: + message = "Unexpected body parameters: "\ + "%s" % ", ".join(extra_body_params) + raise cherrypy.HTTPError(400, message=message) + + +try: + import inspect +except ImportError: + test_callable_spec = lambda callable, args, kwargs: None + + + +class LateParamPageHandler(PageHandler): + """When passing cherrypy.request.params to the page handler, we do not + want to capture that dict too early; we want to give tools like the + decoding tool a chance to modify the params dict in-between the lookup + of the handler and the actual calling of the handler. This subclass + takes that into account, and allows request.params to be 'bound late' + (it's more complicated than that, but that's the effect). + """ + + def _get_kwargs(self): + kwargs = cherrypy.serving.request.params.copy() + if self._kwargs: + kwargs.update(self._kwargs) + return kwargs + + def _set_kwargs(self, kwargs): + self._kwargs = kwargs + + kwargs = property(_get_kwargs, _set_kwargs, + doc='page handler kwargs (with ' + 'cherrypy.request.params copied in)') + + +class Dispatcher(object): + """CherryPy Dispatcher which walks a tree of objects to find a handler. + + The tree is rooted at cherrypy.request.app.root, and each hierarchical + component in the path_info argument is matched to a corresponding nested + attribute of the root object. Matching handlers must have an 'exposed' + attribute which evaluates to True. The special method name "index" + matches a URI which ends in a slash ("/"). The special method name + "default" may match a portion of the path_info (but only when no longer + substring of the path_info matches some other object). + + This is the default, built-in dispatcher for CherryPy. + """ + __metaclass__ = cherrypy._AttributeDocstrings + + dispatch_method_name = '_cp_dispatch' + dispatch_method_name__doc = """ + The name of the dispatch method that nodes may optionally implement + to provide their own dynamic dispatch algorithm. + """ + + def __init__(self, dispatch_method_name=None): + if dispatch_method_name: + self.dispatch_method_name = dispatch_method_name + + def __call__(self, path_info): + """Set handler and config for the current request.""" + request = cherrypy.serving.request + func, vpath = self.find_handler(path_info) + + if func: + # Decode any leftover %2F in the virtual_path atoms. + vpath = [x.replace("%2F", "/") for x in vpath] + request.handler = LateParamPageHandler(func, *vpath) + else: + request.handler = cherrypy.NotFound() + + def find_handler(self, path): + """Return the appropriate page handler, plus any virtual path. + + This will return two objects. The first will be a callable, + which can be used to generate page output. Any parameters from + the query string or request body will be sent to that callable + as keyword arguments. + + The callable is found by traversing the application's tree, + starting from cherrypy.request.app.root, and matching path + components to successive objects in the tree. For example, the + URL "/path/to/handler" might return root.path.to.handler. + + The second object returned will be a list of names which are + 'virtual path' components: parts of the URL which are dynamic, + and were not used when looking up the handler. + These virtual path components are passed to the handler as + positional arguments. + """ + request = cherrypy.serving.request + app = request.app + root = app.root + dispatch_name = self.dispatch_method_name + + # Get config for the root object/path. + curpath = "" + nodeconf = {} + if hasattr(root, "_cp_config"): + nodeconf.update(root._cp_config) + if "/" in app.config: + nodeconf.update(app.config["/"]) + object_trail = [['root', root, nodeconf, curpath]] + + node = root + names = [x for x in path.strip('/').split('/') if x] + ['index'] + iternames = names[:] + while iternames: + name = iternames[0] + # map to legal Python identifiers (replace '.' with '_') + objname = name.replace('.', '_') + + nodeconf = {} + subnode = getattr(node, objname, None) + if subnode is None: + dispatch = getattr(node, dispatch_name, None) + if dispatch and callable(dispatch) and not \ + getattr(dispatch, 'exposed', False): + subnode = dispatch(vpath=iternames) + name = iternames.pop(0) + node = subnode + + if node is not None: + # Get _cp_config attached to this node. + if hasattr(node, "_cp_config"): + nodeconf.update(node._cp_config) + + # Mix in values from app.config for this path. + curpath = "/".join((curpath, name)) + if curpath in app.config: + nodeconf.update(app.config[curpath]) + + object_trail.append([name, node, nodeconf, curpath]) + + def set_conf(): + """Collapse all object_trail config into cherrypy.request.config.""" + base = cherrypy.config.copy() + # Note that we merge the config from each node + # even if that node was None. + for name, obj, conf, curpath in object_trail: + base.update(conf) + if 'tools.staticdir.dir' in conf: + base['tools.staticdir.section'] = curpath + return base + + # Try successive objects (reverse order) + num_candidates = len(object_trail) - 1 + for i in range(num_candidates, -1, -1): + + name, candidate, nodeconf, curpath = object_trail[i] + if candidate is None: + continue + + # Try a "default" method on the current leaf. + if hasattr(candidate, "default"): + defhandler = candidate.default + if getattr(defhandler, 'exposed', False): + # Insert any extra _cp_config from the default handler. + conf = getattr(defhandler, "_cp_config", {}) + object_trail.insert(i + 1, ["default", defhandler, conf, curpath]) + request.config = set_conf() + # See http://www.cherrypy.org/ticket/613 + request.is_index = path.endswith("/") + return defhandler, names[i:-1] + + # Uncomment the next line to restrict positional params to "default". + # if i < num_candidates - 2: continue + + # Try the current leaf. + if getattr(candidate, 'exposed', False): + request.config = set_conf() + if i == num_candidates: + # We found the extra ".index". Mark request so tools + # can redirect if path_info has no trailing slash. + request.is_index = True + else: + # We're not at an 'index' handler. Mark request so tools + # can redirect if path_info has NO trailing slash. + # Note that this also includes handlers which take + # positional parameters (virtual paths). + request.is_index = False + return candidate, names[i:-1] + + # We didn't find anything + request.config = set_conf() + return None, [] + + +class MethodDispatcher(Dispatcher): + """Additional dispatch based on cherrypy.request.method.upper(). + + Methods named GET, POST, etc will be called on an exposed class. + The method names must be all caps; the appropriate Allow header + will be output showing all capitalized method names as allowable + HTTP verbs. + + Note that the containing class must be exposed, not the methods. + """ + + def __call__(self, path_info): + """Set handler and config for the current request.""" + request = cherrypy.serving.request + resource, vpath = self.find_handler(path_info) + + if resource: + # Set Allow header + avail = [m for m in dir(resource) if m.isupper()] + if "GET" in avail and "HEAD" not in avail: + avail.append("HEAD") + avail.sort() + cherrypy.serving.response.headers['Allow'] = ", ".join(avail) + + # Find the subhandler + meth = request.method.upper() + func = getattr(resource, meth, None) + if func is None and meth == "HEAD": + func = getattr(resource, "GET", None) + if func: + # Grab any _cp_config on the subhandler. + if hasattr(func, "_cp_config"): + request.config.update(func._cp_config) + + # Decode any leftover %2F in the virtual_path atoms. + vpath = [x.replace("%2F", "/") for x in vpath] + request.handler = LateParamPageHandler(func, *vpath) + else: + request.handler = cherrypy.HTTPError(405) + else: + request.handler = cherrypy.NotFound() + + +class RoutesDispatcher(object): + """A Routes based dispatcher for CherryPy.""" + + def __init__(self, full_result=False): + """ + Routes dispatcher + + Set full_result to True if you wish the controller + and the action to be passed on to the page handler + parameters. By default they won't be. + """ + import routes + self.full_result = full_result + self.controllers = {} + self.mapper = routes.Mapper() + self.mapper.controller_scan = self.controllers.keys + + def connect(self, name, route, controller, **kwargs): + self.controllers[name] = controller + self.mapper.connect(name, route, controller=name, **kwargs) + + def redirect(self, url): + raise cherrypy.HTTPRedirect(url) + + def __call__(self, path_info): + """Set handler and config for the current request.""" + func = self.find_handler(path_info) + if func: + cherrypy.serving.request.handler = LateParamPageHandler(func) + else: + cherrypy.serving.request.handler = cherrypy.NotFound() + + def find_handler(self, path_info): + """Find the right page handler, and set request.config.""" + import routes + + request = cherrypy.serving.request + + config = routes.request_config() + config.mapper = self.mapper + if hasattr(request, 'wsgi_environ'): + config.environ = request.wsgi_environ + config.host = request.headers.get('Host', None) + config.protocol = request.scheme + config.redirect = self.redirect + + result = self.mapper.match(path_info) + + config.mapper_dict = result + params = {} + if result: + params = result.copy() + if not self.full_result: + params.pop('controller', None) + params.pop('action', None) + request.params.update(params) + + # Get config for the root object/path. + request.config = base = cherrypy.config.copy() + curpath = "" + + def merge(nodeconf): + if 'tools.staticdir.dir' in nodeconf: + nodeconf['tools.staticdir.section'] = curpath or "/" + base.update(nodeconf) + + app = request.app + root = app.root + if hasattr(root, "_cp_config"): + merge(root._cp_config) + if "/" in app.config: + merge(app.config["/"]) + + # Mix in values from app.config. + atoms = [x for x in path_info.split("/") if x] + if atoms: + last = atoms.pop() + else: + last = None + for atom in atoms: + curpath = "/".join((curpath, atom)) + if curpath in app.config: + merge(app.config[curpath]) + + handler = None + if result: + controller = result.get('controller', None) + controller = self.controllers.get(controller) + if controller: + # Get config from the controller. + if hasattr(controller, "_cp_config"): + merge(controller._cp_config) + + action = result.get('action', None) + if action is not None: + handler = getattr(controller, action, None) + # Get config from the handler + if hasattr(handler, "_cp_config"): + merge(handler._cp_config) + + # Do the last path atom here so it can + # override the controller's _cp_config. + if last: + curpath = "/".join((curpath, last)) + if curpath in app.config: + merge(app.config[curpath]) + + return handler + + +def XMLRPCDispatcher(next_dispatcher=Dispatcher()): + from cherrypy.lib import xmlrpc + def xmlrpc_dispatch(path_info): + path_info = xmlrpc.patched_path(path_info) + return next_dispatcher(path_info) + return xmlrpc_dispatch + + +def VirtualHost(next_dispatcher=Dispatcher(), use_x_forwarded_host=True, **domains): + """Select a different handler based on the Host header. + + This can be useful when running multiple sites within one CP server. + It allows several domains to point to different parts of a single + website structure. For example: + + http://www.domain.example -> root + http://www.domain2.example -> root/domain2/ + http://www.domain2.example:443 -> root/secure + + can be accomplished via the following config: + + [/] + request.dispatch = cherrypy.dispatch.VirtualHost( + **{'www.domain2.example': '/domain2', + 'www.domain2.example:443': '/secure', + }) + + next_dispatcher: the next dispatcher object in the dispatch chain. + The VirtualHost dispatcher adds a prefix to the URL and calls + another dispatcher. Defaults to cherrypy.dispatch.Dispatcher(). + + use_x_forwarded_host: if True (the default), any "X-Forwarded-Host" + request header will be used instead of the "Host" header. This + is commonly added by HTTP servers (such as Apache) when proxying. + + **domains: a dict of {host header value: virtual prefix} pairs. + The incoming "Host" request header is looked up in this dict, + and, if a match is found, the corresponding "virtual prefix" + value will be prepended to the URL path before calling the + next dispatcher. Note that you often need separate entries + for "example.com" and "www.example.com". In addition, "Host" + headers may contain the port number. + """ + from cherrypy.lib import httputil + def vhost_dispatch(path_info): + request = cherrypy.serving.request + header = request.headers.get + + domain = header('Host', '') + if use_x_forwarded_host: + domain = header("X-Forwarded-Host", domain) + + prefix = domains.get(domain, "") + if prefix: + path_info = httputil.urljoin(prefix, path_info) + + result = next_dispatcher(path_info) + + # Touch up staticdir config. See http://www.cherrypy.org/ticket/614. + section = request.config.get('tools.staticdir.section') + if section: + section = section[len(prefix):] + request.config['tools.staticdir.section'] = section + + return result + return vhost_dispatch + diff --git a/cherrypy/_cplogging.py b/cherrypy/_cplogging.py index 4c96391110..3935d24fc9 100644 --- a/cherrypy/_cplogging.py +++ b/cherrypy/_cplogging.py @@ -1,250 +1,250 @@ -"""CherryPy logging.""" - -import datetime -import logging -# Silence the no-handlers "warning" (stderr write!) in stdlib logging -logging.Logger.manager.emittedNoHandlerWarning = 1 -logfmt = logging.Formatter("%(message)s") -import os -import sys - -import cherrypy -from cherrypy import _cperror - - -class LogManager(object): - - appid = None - error_log = None - access_log = None - access_log_format = \ - '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' - - def __init__(self, appid=None, logger_root="cherrypy"): - self.logger_root = logger_root - self.appid = appid - if appid is None: - self.error_log = logging.getLogger("%s.error" % logger_root) - self.access_log = logging.getLogger("%s.access" % logger_root) - else: - self.error_log = logging.getLogger("%s.error.%s" % (logger_root, appid)) - self.access_log = logging.getLogger("%s.access.%s" % (logger_root, appid)) - self.error_log.setLevel(logging.INFO) - self.access_log.setLevel(logging.INFO) - cherrypy.engine.subscribe('graceful', self.reopen_files) - - def reopen_files(self): - """Close and reopen all file handlers.""" - for log in (self.error_log, self.access_log): - for h in log.handlers: - if isinstance(h, logging.FileHandler): - h.acquire() - h.stream.close() - h.stream = open(h.baseFilename, h.mode) - h.release() - - def error(self, msg='', context='', severity=logging.INFO, traceback=False): - """Write to the error log. - - This is not just for errors! Applications may call this at any time - to log application-specific information. - """ - if traceback: - msg += _cperror.format_exc() - self.error_log.log(severity, ' '.join((self.time(), context, msg))) - - def __call__(self, *args, **kwargs): - """Write to the error log. - - This is not just for errors! Applications may call this at any time - to log application-specific information. - """ - return self.error(*args, **kwargs) - - def access(self): - """Write to the access log (in Apache/NCSA Combined Log format). - - Like Apache started doing in 2.0.46, non-printable and other special - characters in %r (and we expand that to all parts) are escaped using - \\xhh sequences, where hh stands for the hexadecimal representation - of the raw byte. Exceptions from this rule are " and \\, which are - escaped by prepending a backslash, and all whitespace characters, - which are written in their C-style notation (\\n, \\t, etc). - """ - request = cherrypy.serving.request - remote = request.remote - response = cherrypy.serving.response - outheaders = response.headers - inheaders = request.headers - if response.output_status is None: - status = "-" - else: - status = response.output_status.split(" ", 1)[0] - - atoms = {'h': remote.name or remote.ip, - 'l': '-', - 'u': getattr(request, "login", None) or "-", - 't': self.time(), - 'r': request.request_line, - 's': status, - 'b': dict.get(outheaders, 'Content-Length', '') or "-", - 'f': dict.get(inheaders, 'Referer', ''), - 'a': dict.get(inheaders, 'User-Agent', ''), - } - for k, v in atoms.items(): - if isinstance(v, unicode): - v = v.encode('utf8') - elif not isinstance(v, str): - v = str(v) - # Fortunately, repr(str) escapes unprintable chars, \n, \t, etc - # and backslash for us. All we have to do is strip the quotes. - v = repr(v)[1:-1] - # Escape double-quote. - atoms[k] = v.replace('"', '\\"') - - try: - self.access_log.log(logging.INFO, self.access_log_format % atoms) - except: - self(traceback=True) - - def time(self): - """Return now() in Apache Common Log Format (no timezone).""" - now = datetime.datetime.now() - monthnames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', - 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] - month = monthnames[now.month - 1].capitalize() - return ('[%02d/%s/%04d:%02d:%02d:%02d]' % - (now.day, month, now.year, now.hour, now.minute, now.second)) - - def _get_builtin_handler(self, log, key): - for h in log.handlers: - if getattr(h, "_cpbuiltin", None) == key: - return h - - - # ------------------------- Screen handlers ------------------------- # - - def _set_screen_handler(self, log, enable, stream=None): - h = self._get_builtin_handler(log, "screen") - if enable: - if not h: - if stream is None: - stream = sys.stderr - h = logging.StreamHandler(stream) - h.setFormatter(logfmt) - h._cpbuiltin = "screen" - log.addHandler(h) - elif h: - log.handlers.remove(h) - - def _get_screen(self): - h = self._get_builtin_handler - has_h = h(self.error_log, "screen") or h(self.access_log, "screen") - return bool(has_h) - - def _set_screen(self, newvalue): - self._set_screen_handler(self.error_log, newvalue, stream=sys.stderr) - self._set_screen_handler(self.access_log, newvalue, stream=sys.stdout) - screen = property(_get_screen, _set_screen, - doc="If True, error and access will print to stderr.") - - - # -------------------------- File handlers -------------------------- # - - def _add_builtin_file_handler(self, log, fname): - h = logging.FileHandler(fname) - h.setFormatter(logfmt) - h._cpbuiltin = "file" - log.addHandler(h) - - def _set_file_handler(self, log, filename): - h = self._get_builtin_handler(log, "file") - if filename: - if h: - if h.baseFilename != os.path.abspath(filename): - h.close() - log.handlers.remove(h) - self._add_builtin_file_handler(log, filename) - else: - self._add_builtin_file_handler(log, filename) - else: - if h: - h.close() - log.handlers.remove(h) - - def _get_error_file(self): - h = self._get_builtin_handler(self.error_log, "file") - if h: - return h.baseFilename - return '' - def _set_error_file(self, newvalue): - self._set_file_handler(self.error_log, newvalue) - error_file = property(_get_error_file, _set_error_file, - doc="The filename for self.error_log.") - - def _get_access_file(self): - h = self._get_builtin_handler(self.access_log, "file") - if h: - return h.baseFilename - return '' - def _set_access_file(self, newvalue): - self._set_file_handler(self.access_log, newvalue) - access_file = property(_get_access_file, _set_access_file, - doc="The filename for self.access_log.") - - - # ------------------------- WSGI handlers ------------------------- # - - def _set_wsgi_handler(self, log, enable): - h = self._get_builtin_handler(log, "wsgi") - if enable: - if not h: - h = WSGIErrorHandler() - h.setFormatter(logfmt) - h._cpbuiltin = "wsgi" - log.addHandler(h) - elif h: - log.handlers.remove(h) - - def _get_wsgi(self): - return bool(self._get_builtin_handler(self.error_log, "wsgi")) - - def _set_wsgi(self, newvalue): - self._set_wsgi_handler(self.error_log, newvalue) - wsgi = property(_get_wsgi, _set_wsgi, - doc="If True, error messages will be sent to wsgi.errors.") - - -class WSGIErrorHandler(logging.Handler): - "A handler class which writes logging records to environ['wsgi.errors']." - - def flush(self): - """Flushes the stream.""" - try: - stream = cherrypy.serving.request.wsgi_environ.get('wsgi.errors') - except (AttributeError, KeyError): - pass - else: - stream.flush() - - def emit(self, record): - """Emit a record.""" - try: - stream = cherrypy.serving.request.wsgi_environ.get('wsgi.errors') - except (AttributeError, KeyError): - pass - else: - try: - msg = self.format(record) - fs = "%s\n" - import types - if not hasattr(types, "UnicodeType"): #if no unicode support... - stream.write(fs % msg) - else: - try: - stream.write(fs % msg) - except UnicodeError: - stream.write(fs % msg.encode("UTF-8")) - self.flush() - except: - self.handleError(record) +"""CherryPy logging.""" + +import datetime +import logging +# Silence the no-handlers "warning" (stderr write!) in stdlib logging +logging.Logger.manager.emittedNoHandlerWarning = 1 +logfmt = logging.Formatter("%(message)s") +import os +import sys + +import cherrypy +from cherrypy import _cperror + + +class LogManager(object): + + appid = None + error_log = None + access_log = None + access_log_format = \ + '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + + def __init__(self, appid=None, logger_root="cherrypy"): + self.logger_root = logger_root + self.appid = appid + if appid is None: + self.error_log = logging.getLogger("%s.error" % logger_root) + self.access_log = logging.getLogger("%s.access" % logger_root) + else: + self.error_log = logging.getLogger("%s.error.%s" % (logger_root, appid)) + self.access_log = logging.getLogger("%s.access.%s" % (logger_root, appid)) + self.error_log.setLevel(logging.INFO) + self.access_log.setLevel(logging.INFO) + cherrypy.engine.subscribe('graceful', self.reopen_files) + + def reopen_files(self): + """Close and reopen all file handlers.""" + for log in (self.error_log, self.access_log): + for h in log.handlers: + if isinstance(h, logging.FileHandler): + h.acquire() + h.stream.close() + h.stream = open(h.baseFilename, h.mode) + h.release() + + def error(self, msg='', context='', severity=logging.INFO, traceback=False): + """Write to the error log. + + This is not just for errors! Applications may call this at any time + to log application-specific information. + """ + if traceback: + msg += _cperror.format_exc() + self.error_log.log(severity, ' '.join((self.time(), context, msg))) + + def __call__(self, *args, **kwargs): + """Write to the error log. + + This is not just for errors! Applications may call this at any time + to log application-specific information. + """ + return self.error(*args, **kwargs) + + def access(self): + """Write to the access log (in Apache/NCSA Combined Log format). + + Like Apache started doing in 2.0.46, non-printable and other special + characters in %r (and we expand that to all parts) are escaped using + \\xhh sequences, where hh stands for the hexadecimal representation + of the raw byte. Exceptions from this rule are " and \\, which are + escaped by prepending a backslash, and all whitespace characters, + which are written in their C-style notation (\\n, \\t, etc). + """ + request = cherrypy.serving.request + remote = request.remote + response = cherrypy.serving.response + outheaders = response.headers + inheaders = request.headers + if response.output_status is None: + status = "-" + else: + status = response.output_status.split(" ", 1)[0] + + atoms = {'h': remote.name or remote.ip, + 'l': '-', + 'u': getattr(request, "login", None) or "-", + 't': self.time(), + 'r': request.request_line, + 's': status, + 'b': dict.get(outheaders, 'Content-Length', '') or "-", + 'f': dict.get(inheaders, 'Referer', ''), + 'a': dict.get(inheaders, 'User-Agent', ''), + } + for k, v in atoms.items(): + if isinstance(v, unicode): + v = v.encode('utf8') + elif not isinstance(v, str): + v = str(v) + # Fortunately, repr(str) escapes unprintable chars, \n, \t, etc + # and backslash for us. All we have to do is strip the quotes. + v = repr(v)[1:-1] + # Escape double-quote. + atoms[k] = v.replace('"', '\\"') + + try: + self.access_log.log(logging.INFO, self.access_log_format % atoms) + except: + self(traceback=True) + + def time(self): + """Return now() in Apache Common Log Format (no timezone).""" + now = datetime.datetime.now() + monthnames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', + 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] + month = monthnames[now.month - 1].capitalize() + return ('[%02d/%s/%04d:%02d:%02d:%02d]' % + (now.day, month, now.year, now.hour, now.minute, now.second)) + + def _get_builtin_handler(self, log, key): + for h in log.handlers: + if getattr(h, "_cpbuiltin", None) == key: + return h + + + # ------------------------- Screen handlers ------------------------- # + + def _set_screen_handler(self, log, enable, stream=None): + h = self._get_builtin_handler(log, "screen") + if enable: + if not h: + if stream is None: + stream = sys.stderr + h = logging.StreamHandler(stream) + h.setFormatter(logfmt) + h._cpbuiltin = "screen" + log.addHandler(h) + elif h: + log.handlers.remove(h) + + def _get_screen(self): + h = self._get_builtin_handler + has_h = h(self.error_log, "screen") or h(self.access_log, "screen") + return bool(has_h) + + def _set_screen(self, newvalue): + self._set_screen_handler(self.error_log, newvalue, stream=sys.stderr) + self._set_screen_handler(self.access_log, newvalue, stream=sys.stdout) + screen = property(_get_screen, _set_screen, + doc="If True, error and access will print to stderr.") + + + # -------------------------- File handlers -------------------------- # + + def _add_builtin_file_handler(self, log, fname): + h = logging.FileHandler(fname) + h.setFormatter(logfmt) + h._cpbuiltin = "file" + log.addHandler(h) + + def _set_file_handler(self, log, filename): + h = self._get_builtin_handler(log, "file") + if filename: + if h: + if h.baseFilename != os.path.abspath(filename): + h.close() + log.handlers.remove(h) + self._add_builtin_file_handler(log, filename) + else: + self._add_builtin_file_handler(log, filename) + else: + if h: + h.close() + log.handlers.remove(h) + + def _get_error_file(self): + h = self._get_builtin_handler(self.error_log, "file") + if h: + return h.baseFilename + return '' + def _set_error_file(self, newvalue): + self._set_file_handler(self.error_log, newvalue) + error_file = property(_get_error_file, _set_error_file, + doc="The filename for self.error_log.") + + def _get_access_file(self): + h = self._get_builtin_handler(self.access_log, "file") + if h: + return h.baseFilename + return '' + def _set_access_file(self, newvalue): + self._set_file_handler(self.access_log, newvalue) + access_file = property(_get_access_file, _set_access_file, + doc="The filename for self.access_log.") + + + # ------------------------- WSGI handlers ------------------------- # + + def _set_wsgi_handler(self, log, enable): + h = self._get_builtin_handler(log, "wsgi") + if enable: + if not h: + h = WSGIErrorHandler() + h.setFormatter(logfmt) + h._cpbuiltin = "wsgi" + log.addHandler(h) + elif h: + log.handlers.remove(h) + + def _get_wsgi(self): + return bool(self._get_builtin_handler(self.error_log, "wsgi")) + + def _set_wsgi(self, newvalue): + self._set_wsgi_handler(self.error_log, newvalue) + wsgi = property(_get_wsgi, _set_wsgi, + doc="If True, error messages will be sent to wsgi.errors.") + + +class WSGIErrorHandler(logging.Handler): + "A handler class which writes logging records to environ['wsgi.errors']." + + def flush(self): + """Flushes the stream.""" + try: + stream = cherrypy.serving.request.wsgi_environ.get('wsgi.errors') + except (AttributeError, KeyError): + pass + else: + stream.flush() + + def emit(self, record): + """Emit a record.""" + try: + stream = cherrypy.serving.request.wsgi_environ.get('wsgi.errors') + except (AttributeError, KeyError): + pass + else: + try: + msg = self.format(record) + fs = "%s\n" + import types + if not hasattr(types, "UnicodeType"): #if no unicode support... + stream.write(fs % msg) + else: + try: + stream.write(fs % msg) + except UnicodeError: + stream.write(fs % msg.encode("UTF-8")) + self.flush() + except: + self.handleError(record) diff --git a/cherrypy/cherryd b/cherrypy/cherryd index d4633b3b0e..6dcdcca3a4 100644 --- a/cherrypy/cherryd +++ b/cherrypy/cherryd @@ -1,102 +1,102 @@ -#! /usr/bin/env python -"""The CherryPy daemon.""" - -import sys - -import cherrypy -from cherrypy.process import plugins, servers - - -def start(configfiles=None, daemonize=False, environment=None, - fastcgi=False, scgi=False, pidfile=None, imports=None): - """Subscribe all engine plugins and start the engine.""" - sys.path = [''] + sys.path - for i in imports or []: - exec("import %s" % i) - - for c in configfiles or []: - cherrypy.config.update(c) - # If there's only one app mounted, merge config into it. - if len(cherrypy.tree.apps) == 1: - for app in cherrypy.tree.apps.values(): - app.merge(c) - - engine = cherrypy.engine - - if environment is not None: - cherrypy.config.update({'environment': environment}) - - # Only daemonize if asked to. - if daemonize: - # Don't print anything to stdout/sterr. - cherrypy.config.update({'log.screen': False}) - plugins.Daemonizer(engine).subscribe() - - if pidfile: - plugins.PIDFile(engine, pidfile).subscribe() - - if hasattr(engine, "signal_handler"): - engine.signal_handler.subscribe() - if hasattr(engine, "console_control_handler"): - engine.console_control_handler.subscribe() - - if fastcgi and scgi: - # fastcgi and scgi aren't allowed together. - cherrypy.log.error("fastcgi and scgi aren't allowed together.", 'ENGINE') - sys.exit(1) - elif fastcgi or scgi: - # Turn off autoreload when using fastcgi or scgi. - cherrypy.config.update({'engine.autoreload_on': False}) - # Turn off the default HTTP server (which is subscribed by default). - cherrypy.server.unsubscribe() - - addr = cherrypy.server.bind_addr - if fastcgi: - f = servers.FlupFCGIServer(application=cherrypy.tree, - bindAddress=addr) - else: - f = servers.FlupSCGIServer(application=cherrypy.tree, - bindAddress=addr) - s = servers.ServerAdapter(engine, httpserver=f, bind_addr=addr) - s.subscribe() - - # Always start the engine; this will start all other services - try: - engine.start() - except: - # Assume the error has been logged already via bus.log. - sys.exit(1) - else: - engine.block() - - -if __name__ == '__main__': - from optparse import OptionParser - - p = OptionParser() - p.add_option('-c', '--config', action="append", dest='config', - help="specify config file(s)") - p.add_option('-d', action="store_true", dest='daemonize', - help="run the server as a daemon") - p.add_option('-e', '--environment', dest='environment', default=None, - help="apply the given config environment") - p.add_option('-f', action="store_true", dest='fastcgi', - help="start a fastcgi server instead of the default HTTP server") - p.add_option('-s', action="store_true", dest='scgi', - help="start a scgi server instead of the default HTTP server") - p.add_option('-i', '--import', action="append", dest='imports', - help="specify modules to import") - p.add_option('-p', '--pidfile', dest='pidfile', default=None, - help="store the process id in the given file") - p.add_option('-P', '--Path', action="append", dest='Path', - help="add the given paths to sys.path") - options, args = p.parse_args() - - if options.Path: - for p in options.Path: - sys.path.insert(0, p) - - start(options.config, options.daemonize, - options.environment, options.fastcgi, options.scgi, options.pidfile, - options.imports) - +#! /usr/bin/env python +"""The CherryPy daemon.""" + +import sys + +import cherrypy +from cherrypy.process import plugins, servers + + +def start(configfiles=None, daemonize=False, environment=None, + fastcgi=False, scgi=False, pidfile=None, imports=None): + """Subscribe all engine plugins and start the engine.""" + sys.path = [''] + sys.path + for i in imports or []: + exec("import %s" % i) + + for c in configfiles or []: + cherrypy.config.update(c) + # If there's only one app mounted, merge config into it. + if len(cherrypy.tree.apps) == 1: + for app in cherrypy.tree.apps.values(): + app.merge(c) + + engine = cherrypy.engine + + if environment is not None: + cherrypy.config.update({'environment': environment}) + + # Only daemonize if asked to. + if daemonize: + # Don't print anything to stdout/sterr. + cherrypy.config.update({'log.screen': False}) + plugins.Daemonizer(engine).subscribe() + + if pidfile: + plugins.PIDFile(engine, pidfile).subscribe() + + if hasattr(engine, "signal_handler"): + engine.signal_handler.subscribe() + if hasattr(engine, "console_control_handler"): + engine.console_control_handler.subscribe() + + if fastcgi and scgi: + # fastcgi and scgi aren't allowed together. + cherrypy.log.error("fastcgi and scgi aren't allowed together.", 'ENGINE') + sys.exit(1) + elif fastcgi or scgi: + # Turn off autoreload when using fastcgi or scgi. + cherrypy.config.update({'engine.autoreload_on': False}) + # Turn off the default HTTP server (which is subscribed by default). + cherrypy.server.unsubscribe() + + addr = cherrypy.server.bind_addr + if fastcgi: + f = servers.FlupFCGIServer(application=cherrypy.tree, + bindAddress=addr) + else: + f = servers.FlupSCGIServer(application=cherrypy.tree, + bindAddress=addr) + s = servers.ServerAdapter(engine, httpserver=f, bind_addr=addr) + s.subscribe() + + # Always start the engine; this will start all other services + try: + engine.start() + except: + # Assume the error has been logged already via bus.log. + sys.exit(1) + else: + engine.block() + + +if __name__ == '__main__': + from optparse import OptionParser + + p = OptionParser() + p.add_option('-c', '--config', action="append", dest='config', + help="specify config file(s)") + p.add_option('-d', action="store_true", dest='daemonize', + help="run the server as a daemon") + p.add_option('-e', '--environment', dest='environment', default=None, + help="apply the given config environment") + p.add_option('-f', action="store_true", dest='fastcgi', + help="start a fastcgi server instead of the default HTTP server") + p.add_option('-s', action="store_true", dest='scgi', + help="start a scgi server instead of the default HTTP server") + p.add_option('-i', '--import', action="append", dest='imports', + help="specify modules to import") + p.add_option('-p', '--pidfile', dest='pidfile', default=None, + help="store the process id in the given file") + p.add_option('-P', '--Path', action="append", dest='Path', + help="add the given paths to sys.path") + options, args = p.parse_args() + + if options.Path: + for p in options.Path: + sys.path.insert(0, p) + + start(options.config, options.daemonize, + options.environment, options.fastcgi, options.scgi, options.pidfile, + options.imports) + diff --git a/cherrypy/lib/covercp.py b/cherrypy/lib/covercp.py index 4d9be29fdb..1d7bf1f463 100644 --- a/cherrypy/lib/covercp.py +++ b/cherrypy/lib/covercp.py @@ -1,364 +1,364 @@ -"""Code-coverage tools for CherryPy. - -To use this module, or the coverage tools in the test suite, -you need to download 'coverage.py', either Gareth Rees' original -implementation: -http://www.garethrees.org/2001/12/04/python-coverage/ - -or Ned Batchelder's enhanced version: -http://www.nedbatchelder.com/code/modules/coverage.html - -To turn on coverage tracing, use the following code: - - cherrypy.engine.subscribe('start', covercp.start) - -DO NOT subscribe anything on the 'start_thread' channel, as previously -recommended. Calling start once in the main thread should be sufficient -to start coverage on all threads. Calling start again in each thread -effectively clears any coverage data gathered up to that point. - -Run your code, then use the covercp.serve() function to browse the -results in a web browser. If you run this module from the command line, -it will call serve() for you. -""" - -import re -import sys -import cgi -from urllib import quote_plus -import os, os.path -localFile = os.path.join(os.path.dirname(__file__), "coverage.cache") - -try: - from coverage import the_coverage as coverage - def start(): - coverage.start() -except ImportError: - # Setting coverage to None will raise errors - # that need to be trapped downstream. - coverage = None - - import warnings - warnings.warn("No code coverage will be performed; coverage.py could not be imported.") - - def start(): - pass -start.priority = 20 - -TEMPLATE_MENU = """ - - CherryPy Coverage Menu - - - -

CherryPy Coverage

""" - -TEMPLATE_FORM = """ -
-
- - Show percentages
- Hide files over %%
- Exclude files matching
- -
- - -
-
""" - -TEMPLATE_FRAMESET = """ -CherryPy coverage data - - - - - -""" - -TEMPLATE_COVERAGE = """ - - Coverage for %(name)s - - - -

%(name)s

-

%(fullpath)s

-

Coverage: %(pc)s%%

""" - -TEMPLATE_LOC_COVERED = """ - %s  - %s -\n""" -TEMPLATE_LOC_NOT_COVERED = """ - %s  - %s -\n""" -TEMPLATE_LOC_EXCLUDED = """ - %s  - %s -\n""" - -TEMPLATE_ITEM = "%s%s%s\n" - -def _percent(statements, missing): - s = len(statements) - e = s - len(missing) - if s > 0: - return int(round(100.0 * e / s)) - return 0 - -def _show_branch(root, base, path, pct=0, showpct=False, exclude=""): - - # Show the directory name and any of our children - dirs = [k for k, v in root.items() if v] - dirs.sort() - for name in dirs: - newpath = os.path.join(path, name) - - if newpath.lower().startswith(base): - relpath = newpath[len(base):] - yield "| " * relpath.count(os.sep) - yield "%s\n" % \ - (newpath, quote_plus(exclude), name) - - for chunk in _show_branch(root[name], base, newpath, pct, showpct, exclude): - yield chunk - - # Now list the files - if path.lower().startswith(base): - relpath = path[len(base):] - files = [k for k, v in root.items() if not v] - files.sort() - for name in files: - newpath = os.path.join(path, name) - - pc_str = "" - if showpct: - try: - _, statements, _, missing, _ = coverage.analysis2(newpath) - except: - # Yes, we really want to pass on all errors. - pass - else: - pc = _percent(statements, missing) - pc_str = ("%3d%% " % pc).replace(' ', ' ') - if pc < float(pct) or pc == -1: - pc_str = "%s" % pc_str - else: - pc_str = "%s" % pc_str - - yield TEMPLATE_ITEM % ("| " * (relpath.count(os.sep) + 1), - pc_str, newpath, name) - -def _skip_file(path, exclude): - if exclude: - return bool(re.search(exclude, path)) - -def _graft(path, tree): - d = tree - - p = path - atoms = [] - while True: - p, tail = os.path.split(p) - if not tail: - break - atoms.append(tail) - atoms.append(p) - if p != "/": - atoms.append("/") - - atoms.reverse() - for node in atoms: - if node: - d = d.setdefault(node, {}) - -def get_tree(base, exclude): - """Return covered module names as a nested dict.""" - tree = {} - coverage.get_ready() - runs = list(coverage.cexecuted.keys()) - if runs: - for path in runs: - if not _skip_file(path, exclude) and not os.path.isdir(path): - _graft(path, tree) - return tree - -class CoverStats(object): - - def __init__(self, root=None): - if root is None: - # Guess initial depth. Files outside this path will not be - # reachable from the web interface. - import cherrypy - root = os.path.dirname(cherrypy.__file__) - self.root = root - - def index(self): - return TEMPLATE_FRAMESET % self.root.lower() - index.exposed = True - - def menu(self, base="/", pct="50", showpct="", - exclude=r'python\d\.\d|test|tut\d|tutorial'): - - # The coverage module uses all-lower-case names. - base = base.lower().rstrip(os.sep) - - yield TEMPLATE_MENU - yield TEMPLATE_FORM % locals() - - # Start by showing links for parent paths - yield "
" - path = "" - atoms = base.split(os.sep) - atoms.pop() - for atom in atoms: - path += atom + os.sep - yield ("%s %s" - % (path, quote_plus(exclude), atom, os.sep)) - yield "
" - - yield "
" - - # Then display the tree - tree = get_tree(base, exclude) - if not tree: - yield "

No modules covered.

" - else: - for chunk in _show_branch(tree, base, "/", pct, - showpct == 'checked', exclude): - yield chunk - - yield "
" - yield "" - menu.exposed = True - - def annotated_file(self, filename, statements, excluded, missing): - source = open(filename, 'r') - buffer = [] - for lineno, line in enumerate(source.readlines()): - lineno += 1 - line = line.strip("\n\r") - empty_the_buffer = True - if lineno in excluded: - template = TEMPLATE_LOC_EXCLUDED - elif lineno in missing: - template = TEMPLATE_LOC_NOT_COVERED - elif lineno in statements: - template = TEMPLATE_LOC_COVERED - else: - empty_the_buffer = False - buffer.append((lineno, line)) - if empty_the_buffer: - for lno, pastline in buffer: - yield template % (lno, cgi.escape(pastline)) - buffer = [] - yield template % (lineno, cgi.escape(line)) - - def report(self, name): - coverage.get_ready() - filename, statements, excluded, missing, _ = coverage.analysis2(name) - pc = _percent(statements, missing) - yield TEMPLATE_COVERAGE % dict(name=os.path.basename(name), - fullpath=name, - pc=pc) - yield '\n' - for line in self.annotated_file(filename, statements, excluded, - missing): - yield line - yield '
' - yield '' - yield '' - report.exposed = True - - -def serve(path=localFile, port=8080, root=None): - if coverage is None: - raise ImportError("The coverage module could not be imported.") - coverage.cache_default = path - - import cherrypy - cherrypy.config.update({'server.socket_port': int(port), - 'server.thread_pool': 10, - 'environment': "production", - }) - cherrypy.quickstart(CoverStats(root)) - -if __name__ == "__main__": - serve(*tuple(sys.argv[1:])) - +"""Code-coverage tools for CherryPy. + +To use this module, or the coverage tools in the test suite, +you need to download 'coverage.py', either Gareth Rees' original +implementation: +http://www.garethrees.org/2001/12/04/python-coverage/ + +or Ned Batchelder's enhanced version: +http://www.nedbatchelder.com/code/modules/coverage.html + +To turn on coverage tracing, use the following code: + + cherrypy.engine.subscribe('start', covercp.start) + +DO NOT subscribe anything on the 'start_thread' channel, as previously +recommended. Calling start once in the main thread should be sufficient +to start coverage on all threads. Calling start again in each thread +effectively clears any coverage data gathered up to that point. + +Run your code, then use the covercp.serve() function to browse the +results in a web browser. If you run this module from the command line, +it will call serve() for you. +""" + +import re +import sys +import cgi +from urllib import quote_plus +import os, os.path +localFile = os.path.join(os.path.dirname(__file__), "coverage.cache") + +try: + from coverage import the_coverage as coverage + def start(): + coverage.start() +except ImportError: + # Setting coverage to None will raise errors + # that need to be trapped downstream. + coverage = None + + import warnings + warnings.warn("No code coverage will be performed; coverage.py could not be imported.") + + def start(): + pass +start.priority = 20 + +TEMPLATE_MENU = """ + + CherryPy Coverage Menu + + + +

CherryPy Coverage

""" + +TEMPLATE_FORM = """ +
+
+ + Show percentages
+ Hide files over %%
+ Exclude files matching
+ +
+ + +
+
""" + +TEMPLATE_FRAMESET = """ +CherryPy coverage data + + + + + +""" + +TEMPLATE_COVERAGE = """ + + Coverage for %(name)s + + + +

%(name)s

+

%(fullpath)s

+

Coverage: %(pc)s%%

""" + +TEMPLATE_LOC_COVERED = """ + %s  + %s +\n""" +TEMPLATE_LOC_NOT_COVERED = """ + %s  + %s +\n""" +TEMPLATE_LOC_EXCLUDED = """ + %s  + %s +\n""" + +TEMPLATE_ITEM = "%s%s%s\n" + +def _percent(statements, missing): + s = len(statements) + e = s - len(missing) + if s > 0: + return int(round(100.0 * e / s)) + return 0 + +def _show_branch(root, base, path, pct=0, showpct=False, exclude=""): + + # Show the directory name and any of our children + dirs = [k for k, v in root.items() if v] + dirs.sort() + for name in dirs: + newpath = os.path.join(path, name) + + if newpath.lower().startswith(base): + relpath = newpath[len(base):] + yield "| " * relpath.count(os.sep) + yield "%s\n" % \ + (newpath, quote_plus(exclude), name) + + for chunk in _show_branch(root[name], base, newpath, pct, showpct, exclude): + yield chunk + + # Now list the files + if path.lower().startswith(base): + relpath = path[len(base):] + files = [k for k, v in root.items() if not v] + files.sort() + for name in files: + newpath = os.path.join(path, name) + + pc_str = "" + if showpct: + try: + _, statements, _, missing, _ = coverage.analysis2(newpath) + except: + # Yes, we really want to pass on all errors. + pass + else: + pc = _percent(statements, missing) + pc_str = ("%3d%% " % pc).replace(' ', ' ') + if pc < float(pct) or pc == -1: + pc_str = "%s" % pc_str + else: + pc_str = "%s" % pc_str + + yield TEMPLATE_ITEM % ("| " * (relpath.count(os.sep) + 1), + pc_str, newpath, name) + +def _skip_file(path, exclude): + if exclude: + return bool(re.search(exclude, path)) + +def _graft(path, tree): + d = tree + + p = path + atoms = [] + while True: + p, tail = os.path.split(p) + if not tail: + break + atoms.append(tail) + atoms.append(p) + if p != "/": + atoms.append("/") + + atoms.reverse() + for node in atoms: + if node: + d = d.setdefault(node, {}) + +def get_tree(base, exclude): + """Return covered module names as a nested dict.""" + tree = {} + coverage.get_ready() + runs = list(coverage.cexecuted.keys()) + if runs: + for path in runs: + if not _skip_file(path, exclude) and not os.path.isdir(path): + _graft(path, tree) + return tree + +class CoverStats(object): + + def __init__(self, root=None): + if root is None: + # Guess initial depth. Files outside this path will not be + # reachable from the web interface. + import cherrypy + root = os.path.dirname(cherrypy.__file__) + self.root = root + + def index(self): + return TEMPLATE_FRAMESET % self.root.lower() + index.exposed = True + + def menu(self, base="/", pct="50", showpct="", + exclude=r'python\d\.\d|test|tut\d|tutorial'): + + # The coverage module uses all-lower-case names. + base = base.lower().rstrip(os.sep) + + yield TEMPLATE_MENU + yield TEMPLATE_FORM % locals() + + # Start by showing links for parent paths + yield "
" + path = "" + atoms = base.split(os.sep) + atoms.pop() + for atom in atoms: + path += atom + os.sep + yield ("%s %s" + % (path, quote_plus(exclude), atom, os.sep)) + yield "
" + + yield "
" + + # Then display the tree + tree = get_tree(base, exclude) + if not tree: + yield "

No modules covered.

" + else: + for chunk in _show_branch(tree, base, "/", pct, + showpct == 'checked', exclude): + yield chunk + + yield "
" + yield "" + menu.exposed = True + + def annotated_file(self, filename, statements, excluded, missing): + source = open(filename, 'r') + buffer = [] + for lineno, line in enumerate(source.readlines()): + lineno += 1 + line = line.strip("\n\r") + empty_the_buffer = True + if lineno in excluded: + template = TEMPLATE_LOC_EXCLUDED + elif lineno in missing: + template = TEMPLATE_LOC_NOT_COVERED + elif lineno in statements: + template = TEMPLATE_LOC_COVERED + else: + empty_the_buffer = False + buffer.append((lineno, line)) + if empty_the_buffer: + for lno, pastline in buffer: + yield template % (lno, cgi.escape(pastline)) + buffer = [] + yield template % (lineno, cgi.escape(line)) + + def report(self, name): + coverage.get_ready() + filename, statements, excluded, missing, _ = coverage.analysis2(name) + pc = _percent(statements, missing) + yield TEMPLATE_COVERAGE % dict(name=os.path.basename(name), + fullpath=name, + pc=pc) + yield '\n' + for line in self.annotated_file(filename, statements, excluded, + missing): + yield line + yield '
' + yield '' + yield '' + report.exposed = True + + +def serve(path=localFile, port=8080, root=None): + if coverage is None: + raise ImportError("The coverage module could not be imported.") + coverage.cache_default = path + + import cherrypy + cherrypy.config.update({'server.socket_port': int(port), + 'server.thread_pool': 10, + 'environment': "production", + }) + cherrypy.quickstart(CoverStats(root)) + +if __name__ == "__main__": + serve(*tuple(sys.argv[1:])) + diff --git a/cherrypy/lib/httpauth.py b/cherrypy/lib/httpauth.py index c8616a4d4d..39e632c2be 100644 --- a/cherrypy/lib/httpauth.py +++ b/cherrypy/lib/httpauth.py @@ -1,361 +1,361 @@ -""" -httpauth modules defines functions to implement HTTP Digest Authentication (RFC 2617). -This has full compliance with 'Digest' and 'Basic' authentication methods. In -'Digest' it supports both MD5 and MD5-sess algorithms. - -Usage: - - First use 'doAuth' to request the client authentication for a - certain resource. You should send an httplib.UNAUTHORIZED response to the - client so he knows he has to authenticate itself. - - Then use 'parseAuthorization' to retrieve the 'auth_map' used in - 'checkResponse'. - - To use 'checkResponse' you must have already verified the password associated - with the 'username' key in 'auth_map' dict. Then you use the 'checkResponse' - function to verify if the password matches the one sent by the client. - -SUPPORTED_ALGORITHM - list of supported 'Digest' algorithms -SUPPORTED_QOP - list of supported 'Digest' 'qop'. -""" -__version__ = 1, 0, 1 -__author__ = "Tiago Cogumbreiro " -__credits__ = """ - Peter van Kampen for its recipe which implement most of Digest authentication: - http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/302378 -""" - -__license__ = """ -Copyright (c) 2005, Tiago Cogumbreiro -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - * Neither the name of Sylvain Hellegouarch nor the names of his contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -""" - -__all__ = ("digestAuth", "basicAuth", "doAuth", "checkResponse", - "parseAuthorization", "SUPPORTED_ALGORITHM", "md5SessionKey", - "calculateNonce", "SUPPORTED_QOP") - -################################################################################ -try: - # Python 2.5+ - from hashlib import md5 -except ImportError: - from md5 import new as md5 -import time -import base64 -from urllib2 import parse_http_list, parse_keqv_list - -MD5 = "MD5" -MD5_SESS = "MD5-sess" -AUTH = "auth" -AUTH_INT = "auth-int" - -SUPPORTED_ALGORITHM = (MD5, MD5_SESS) -SUPPORTED_QOP = (AUTH, AUTH_INT) - -################################################################################ -# doAuth -# -DIGEST_AUTH_ENCODERS = { - MD5: lambda val: md5(val).hexdigest(), - MD5_SESS: lambda val: md5(val).hexdigest(), -# SHA: lambda val: sha.new (val).hexdigest (), -} - -def calculateNonce (realm, algorithm=MD5): - """This is an auxaliary function that calculates 'nonce' value. It is used - to handle sessions.""" - - global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS - assert algorithm in SUPPORTED_ALGORITHM - - try: - encoder = DIGEST_AUTH_ENCODERS[algorithm] - except KeyError: - raise NotImplementedError ("The chosen algorithm (%s) does not have "\ - "an implementation yet" % algorithm) - - return encoder ("%d:%s" % (time.time(), realm)) - -def digestAuth (realm, algorithm=MD5, nonce=None, qop=AUTH): - """Challenges the client for a Digest authentication.""" - global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS, SUPPORTED_QOP - assert algorithm in SUPPORTED_ALGORITHM - assert qop in SUPPORTED_QOP - - if nonce is None: - nonce = calculateNonce (realm, algorithm) - - return 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % ( - realm, nonce, algorithm, qop - ) - -def basicAuth (realm): - """Challengenes the client for a Basic authentication.""" - assert '"' not in realm, "Realms cannot contain the \" (quote) character." - - return 'Basic realm="%s"' % realm - -def doAuth (realm): - """'doAuth' function returns the challenge string b giving priority over - Digest and fallback to Basic authentication when the browser doesn't - support the first one. - - This should be set in the HTTP header under the key 'WWW-Authenticate'.""" - - return digestAuth (realm) + " " + basicAuth (realm) - - -################################################################################ -# Parse authorization parameters -# -def _parseDigestAuthorization (auth_params): - # Convert the auth params to a dict - items = parse_http_list(auth_params) - params = parse_keqv_list(items) - - # Now validate the params - - # Check for required parameters - required = ["username", "realm", "nonce", "uri", "response"] - for k in required: - if k not in params: - return None - - # If qop is sent then cnonce and nc MUST be present - if "qop" in params and not ("cnonce" in params \ - and "nc" in params): - return None - - # If qop is not sent, neither cnonce nor nc can be present - if ("cnonce" in params or "nc" in params) and \ - "qop" not in params: - return None - - return params - - -def _parseBasicAuthorization (auth_params): - username, password = base64.decodestring (auth_params).split (":", 1) - return {"username": username, "password": password} - -AUTH_SCHEMES = { - "basic": _parseBasicAuthorization, - "digest": _parseDigestAuthorization, -} - -def parseAuthorization (credentials): - """parseAuthorization will convert the value of the 'Authorization' key in - the HTTP header to a map itself. If the parsing fails 'None' is returned. - """ - - global AUTH_SCHEMES - - auth_scheme, auth_params = credentials.split(" ", 1) - auth_scheme = auth_scheme.lower () - - parser = AUTH_SCHEMES[auth_scheme] - params = parser (auth_params) - - if params is None: - return - - assert "auth_scheme" not in params - params["auth_scheme"] = auth_scheme - return params - - -################################################################################ -# Check provided response for a valid password -# -def md5SessionKey (params, password): - """ - If the "algorithm" directive's value is "MD5-sess", then A1 - [the session key] is calculated only once - on the first request by the - client following receipt of a WWW-Authenticate challenge from the server. - - This creates a 'session key' for the authentication of subsequent - requests and responses which is different for each "authentication - session", thus limiting the amount of material hashed with any one - key. - - Because the server need only use the hash of the user - credentials in order to create the A1 value, this construction could - be used in conjunction with a third party authentication service so - that the web server would not need the actual password value. The - specification of such a protocol is beyond the scope of this - specification. -""" - - keys = ("username", "realm", "nonce", "cnonce") - params_copy = {} - for key in keys: - params_copy[key] = params[key] - - params_copy["algorithm"] = MD5_SESS - return _A1 (params_copy, password) - -def _A1(params, password): - algorithm = params.get ("algorithm", MD5) - H = DIGEST_AUTH_ENCODERS[algorithm] - - if algorithm == MD5: - # If the "algorithm" directive's value is "MD5" or is - # unspecified, then A1 is: - # A1 = unq(username-value) ":" unq(realm-value) ":" passwd - return "%s:%s:%s" % (params["username"], params["realm"], password) - - elif algorithm == MD5_SESS: - - # This is A1 if qop is set - # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd ) - # ":" unq(nonce-value) ":" unq(cnonce-value) - h_a1 = H ("%s:%s:%s" % (params["username"], params["realm"], password)) - return "%s:%s:%s" % (h_a1, params["nonce"], params["cnonce"]) - - -def _A2(params, method, kwargs): - # If the "qop" directive's value is "auth" or is unspecified, then A2 is: - # A2 = Method ":" digest-uri-value - - qop = params.get ("qop", "auth") - if qop == "auth": - return method + ":" + params["uri"] - elif qop == "auth-int": - # If the "qop" value is "auth-int", then A2 is: - # A2 = Method ":" digest-uri-value ":" H(entity-body) - entity_body = kwargs.get ("entity_body", "") - H = kwargs["H"] - - return "%s:%s:%s" % ( - method, - params["uri"], - H(entity_body) - ) - - else: - raise NotImplementedError ("The 'qop' method is unknown: %s" % qop) - -def _computeDigestResponse(auth_map, password, method="GET", A1=None, **kwargs): - """ - Generates a response respecting the algorithm defined in RFC 2617 - """ - params = auth_map - - algorithm = params.get ("algorithm", MD5) - - H = DIGEST_AUTH_ENCODERS[algorithm] - KD = lambda secret, data: H(secret + ":" + data) - - qop = params.get ("qop", None) - - H_A2 = H(_A2(params, method, kwargs)) - - if algorithm == MD5_SESS and A1 is not None: - H_A1 = H(A1) - else: - H_A1 = H(_A1(params, password)) - - if qop in ("auth", "auth-int"): - # If the "qop" value is "auth" or "auth-int": - # request-digest = <"> < KD ( H(A1), unq(nonce-value) - # ":" nc-value - # ":" unq(cnonce-value) - # ":" unq(qop-value) - # ":" H(A2) - # ) <"> - request = "%s:%s:%s:%s:%s" % ( - params["nonce"], - params["nc"], - params["cnonce"], - params["qop"], - H_A2, - ) - elif qop is None: - # If the "qop" directive is not present (this construction is - # for compatibility with RFC 2069): - # request-digest = - # <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) > <"> - request = "%s:%s" % (params["nonce"], H_A2) - - return KD(H_A1, request) - -def _checkDigestResponse(auth_map, password, method="GET", A1=None, **kwargs): - """This function is used to verify the response given by the client when - he tries to authenticate. - Optional arguments: - entity_body - when 'qop' is set to 'auth-int' you MUST provide the - raw data you are going to send to the client (usually the - HTML page. - request_uri - the uri from the request line compared with the 'uri' - directive of the authorization map. They must represent - the same resource (unused at this time). - """ - - if auth_map['realm'] != kwargs.get('realm', None): - return False - - response = _computeDigestResponse(auth_map, password, method, A1, **kwargs) - - return response == auth_map["response"] - -def _checkBasicResponse (auth_map, password, method='GET', encrypt=None, **kwargs): - # Note that the Basic response doesn't provide the realm value so we cannot - # test it - try: - return encrypt(auth_map["password"], auth_map["username"]) == password - except TypeError: - return encrypt(auth_map["password"]) == password - -AUTH_RESPONSES = { - "basic": _checkBasicResponse, - "digest": _checkDigestResponse, -} - -def checkResponse (auth_map, password, method="GET", encrypt=None, **kwargs): - """'checkResponse' compares the auth_map with the password and optionally - other arguments that each implementation might need. - - If the response is of type 'Basic' then the function has the following - signature: - - checkBasicResponse (auth_map, password) -> bool - - If the response is of type 'Digest' then the function has the following - signature: - - checkDigestResponse (auth_map, password, method = 'GET', A1 = None) -> bool - - The 'A1' argument is only used in MD5_SESS algorithm based responses. - Check md5SessionKey() for more info. - """ - global AUTH_RESPONSES - checker = AUTH_RESPONSES[auth_map["auth_scheme"]] - return checker (auth_map, password, method=method, encrypt=encrypt, **kwargs) - - - - +""" +httpauth modules defines functions to implement HTTP Digest Authentication (RFC 2617). +This has full compliance with 'Digest' and 'Basic' authentication methods. In +'Digest' it supports both MD5 and MD5-sess algorithms. + +Usage: + + First use 'doAuth' to request the client authentication for a + certain resource. You should send an httplib.UNAUTHORIZED response to the + client so he knows he has to authenticate itself. + + Then use 'parseAuthorization' to retrieve the 'auth_map' used in + 'checkResponse'. + + To use 'checkResponse' you must have already verified the password associated + with the 'username' key in 'auth_map' dict. Then you use the 'checkResponse' + function to verify if the password matches the one sent by the client. + +SUPPORTED_ALGORITHM - list of supported 'Digest' algorithms +SUPPORTED_QOP - list of supported 'Digest' 'qop'. +""" +__version__ = 1, 0, 1 +__author__ = "Tiago Cogumbreiro " +__credits__ = """ + Peter van Kampen for its recipe which implement most of Digest authentication: + http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/302378 +""" + +__license__ = """ +Copyright (c) 2005, Tiago Cogumbreiro +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of Sylvain Hellegouarch nor the names of his contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +""" + +__all__ = ("digestAuth", "basicAuth", "doAuth", "checkResponse", + "parseAuthorization", "SUPPORTED_ALGORITHM", "md5SessionKey", + "calculateNonce", "SUPPORTED_QOP") + +################################################################################ +try: + # Python 2.5+ + from hashlib import md5 +except ImportError: + from md5 import new as md5 +import time +import base64 +from urllib2 import parse_http_list, parse_keqv_list + +MD5 = "MD5" +MD5_SESS = "MD5-sess" +AUTH = "auth" +AUTH_INT = "auth-int" + +SUPPORTED_ALGORITHM = (MD5, MD5_SESS) +SUPPORTED_QOP = (AUTH, AUTH_INT) + +################################################################################ +# doAuth +# +DIGEST_AUTH_ENCODERS = { + MD5: lambda val: md5(val).hexdigest(), + MD5_SESS: lambda val: md5(val).hexdigest(), +# SHA: lambda val: sha.new (val).hexdigest (), +} + +def calculateNonce (realm, algorithm=MD5): + """This is an auxaliary function that calculates 'nonce' value. It is used + to handle sessions.""" + + global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS + assert algorithm in SUPPORTED_ALGORITHM + + try: + encoder = DIGEST_AUTH_ENCODERS[algorithm] + except KeyError: + raise NotImplementedError ("The chosen algorithm (%s) does not have "\ + "an implementation yet" % algorithm) + + return encoder ("%d:%s" % (time.time(), realm)) + +def digestAuth (realm, algorithm=MD5, nonce=None, qop=AUTH): + """Challenges the client for a Digest authentication.""" + global SUPPORTED_ALGORITHM, DIGEST_AUTH_ENCODERS, SUPPORTED_QOP + assert algorithm in SUPPORTED_ALGORITHM + assert qop in SUPPORTED_QOP + + if nonce is None: + nonce = calculateNonce (realm, algorithm) + + return 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % ( + realm, nonce, algorithm, qop + ) + +def basicAuth (realm): + """Challengenes the client for a Basic authentication.""" + assert '"' not in realm, "Realms cannot contain the \" (quote) character." + + return 'Basic realm="%s"' % realm + +def doAuth (realm): + """'doAuth' function returns the challenge string b giving priority over + Digest and fallback to Basic authentication when the browser doesn't + support the first one. + + This should be set in the HTTP header under the key 'WWW-Authenticate'.""" + + return digestAuth (realm) + " " + basicAuth (realm) + + +################################################################################ +# Parse authorization parameters +# +def _parseDigestAuthorization (auth_params): + # Convert the auth params to a dict + items = parse_http_list(auth_params) + params = parse_keqv_list(items) + + # Now validate the params + + # Check for required parameters + required = ["username", "realm", "nonce", "uri", "response"] + for k in required: + if k not in params: + return None + + # If qop is sent then cnonce and nc MUST be present + if "qop" in params and not ("cnonce" in params \ + and "nc" in params): + return None + + # If qop is not sent, neither cnonce nor nc can be present + if ("cnonce" in params or "nc" in params) and \ + "qop" not in params: + return None + + return params + + +def _parseBasicAuthorization (auth_params): + username, password = base64.decodestring (auth_params).split (":", 1) + return {"username": username, "password": password} + +AUTH_SCHEMES = { + "basic": _parseBasicAuthorization, + "digest": _parseDigestAuthorization, +} + +def parseAuthorization (credentials): + """parseAuthorization will convert the value of the 'Authorization' key in + the HTTP header to a map itself. If the parsing fails 'None' is returned. + """ + + global AUTH_SCHEMES + + auth_scheme, auth_params = credentials.split(" ", 1) + auth_scheme = auth_scheme.lower () + + parser = AUTH_SCHEMES[auth_scheme] + params = parser (auth_params) + + if params is None: + return + + assert "auth_scheme" not in params + params["auth_scheme"] = auth_scheme + return params + + +################################################################################ +# Check provided response for a valid password +# +def md5SessionKey (params, password): + """ + If the "algorithm" directive's value is "MD5-sess", then A1 + [the session key] is calculated only once - on the first request by the + client following receipt of a WWW-Authenticate challenge from the server. + + This creates a 'session key' for the authentication of subsequent + requests and responses which is different for each "authentication + session", thus limiting the amount of material hashed with any one + key. + + Because the server need only use the hash of the user + credentials in order to create the A1 value, this construction could + be used in conjunction with a third party authentication service so + that the web server would not need the actual password value. The + specification of such a protocol is beyond the scope of this + specification. +""" + + keys = ("username", "realm", "nonce", "cnonce") + params_copy = {} + for key in keys: + params_copy[key] = params[key] + + params_copy["algorithm"] = MD5_SESS + return _A1 (params_copy, password) + +def _A1(params, password): + algorithm = params.get ("algorithm", MD5) + H = DIGEST_AUTH_ENCODERS[algorithm] + + if algorithm == MD5: + # If the "algorithm" directive's value is "MD5" or is + # unspecified, then A1 is: + # A1 = unq(username-value) ":" unq(realm-value) ":" passwd + return "%s:%s:%s" % (params["username"], params["realm"], password) + + elif algorithm == MD5_SESS: + + # This is A1 if qop is set + # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd ) + # ":" unq(nonce-value) ":" unq(cnonce-value) + h_a1 = H ("%s:%s:%s" % (params["username"], params["realm"], password)) + return "%s:%s:%s" % (h_a1, params["nonce"], params["cnonce"]) + + +def _A2(params, method, kwargs): + # If the "qop" directive's value is "auth" or is unspecified, then A2 is: + # A2 = Method ":" digest-uri-value + + qop = params.get ("qop", "auth") + if qop == "auth": + return method + ":" + params["uri"] + elif qop == "auth-int": + # If the "qop" value is "auth-int", then A2 is: + # A2 = Method ":" digest-uri-value ":" H(entity-body) + entity_body = kwargs.get ("entity_body", "") + H = kwargs["H"] + + return "%s:%s:%s" % ( + method, + params["uri"], + H(entity_body) + ) + + else: + raise NotImplementedError ("The 'qop' method is unknown: %s" % qop) + +def _computeDigestResponse(auth_map, password, method="GET", A1=None, **kwargs): + """ + Generates a response respecting the algorithm defined in RFC 2617 + """ + params = auth_map + + algorithm = params.get ("algorithm", MD5) + + H = DIGEST_AUTH_ENCODERS[algorithm] + KD = lambda secret, data: H(secret + ":" + data) + + qop = params.get ("qop", None) + + H_A2 = H(_A2(params, method, kwargs)) + + if algorithm == MD5_SESS and A1 is not None: + H_A1 = H(A1) + else: + H_A1 = H(_A1(params, password)) + + if qop in ("auth", "auth-int"): + # If the "qop" value is "auth" or "auth-int": + # request-digest = <"> < KD ( H(A1), unq(nonce-value) + # ":" nc-value + # ":" unq(cnonce-value) + # ":" unq(qop-value) + # ":" H(A2) + # ) <"> + request = "%s:%s:%s:%s:%s" % ( + params["nonce"], + params["nc"], + params["cnonce"], + params["qop"], + H_A2, + ) + elif qop is None: + # If the "qop" directive is not present (this construction is + # for compatibility with RFC 2069): + # request-digest = + # <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) > <"> + request = "%s:%s" % (params["nonce"], H_A2) + + return KD(H_A1, request) + +def _checkDigestResponse(auth_map, password, method="GET", A1=None, **kwargs): + """This function is used to verify the response given by the client when + he tries to authenticate. + Optional arguments: + entity_body - when 'qop' is set to 'auth-int' you MUST provide the + raw data you are going to send to the client (usually the + HTML page. + request_uri - the uri from the request line compared with the 'uri' + directive of the authorization map. They must represent + the same resource (unused at this time). + """ + + if auth_map['realm'] != kwargs.get('realm', None): + return False + + response = _computeDigestResponse(auth_map, password, method, A1, **kwargs) + + return response == auth_map["response"] + +def _checkBasicResponse (auth_map, password, method='GET', encrypt=None, **kwargs): + # Note that the Basic response doesn't provide the realm value so we cannot + # test it + try: + return encrypt(auth_map["password"], auth_map["username"]) == password + except TypeError: + return encrypt(auth_map["password"]) == password + +AUTH_RESPONSES = { + "basic": _checkBasicResponse, + "digest": _checkDigestResponse, +} + +def checkResponse (auth_map, password, method="GET", encrypt=None, **kwargs): + """'checkResponse' compares the auth_map with the password and optionally + other arguments that each implementation might need. + + If the response is of type 'Basic' then the function has the following + signature: + + checkBasicResponse (auth_map, password) -> bool + + If the response is of type 'Digest' then the function has the following + signature: + + checkDigestResponse (auth_map, password, method = 'GET', A1 = None) -> bool + + The 'A1' argument is only used in MD5_SESS algorithm based responses. + Check md5SessionKey() for more info. + """ + global AUTH_RESPONSES + checker = AUTH_RESPONSES[auth_map["auth_scheme"]] + return checker (auth_map, password, method=method, encrypt=encrypt, **kwargs) + + + + diff --git a/cherrypy/process/win32.py b/cherrypy/process/win32.py index ad082def14..49a83d4024 100644 --- a/cherrypy/process/win32.py +++ b/cherrypy/process/win32.py @@ -1,174 +1,174 @@ -"""Windows service. Requires pywin32.""" - -import os -import win32api -import win32con -import win32event -import win32service -import win32serviceutil - -from cherrypy.process import wspbus, plugins - - -class ConsoleCtrlHandler(plugins.SimplePlugin): - """A WSPBus plugin for handling Win32 console events (like Ctrl-C).""" - - def __init__(self, bus): - self.is_set = False - plugins.SimplePlugin.__init__(self, bus) - - def start(self): - if self.is_set: - self.bus.log('Handler for console events already set.', level=40) - return - - result = win32api.SetConsoleCtrlHandler(self.handle, 1) - if result == 0: - self.bus.log('Could not SetConsoleCtrlHandler (error %r)' % - win32api.GetLastError(), level=40) - else: - self.bus.log('Set handler for console events.', level=40) - self.is_set = True - - def stop(self): - if not self.is_set: - self.bus.log('Handler for console events already off.', level=40) - return - - try: - result = win32api.SetConsoleCtrlHandler(self.handle, 0) - except ValueError: - # "ValueError: The object has not been registered" - result = 1 - - if result == 0: - self.bus.log('Could not remove SetConsoleCtrlHandler (error %r)' % - win32api.GetLastError(), level=40) - else: - self.bus.log('Removed handler for console events.', level=40) - self.is_set = False - - def handle(self, event): - """Handle console control events (like Ctrl-C).""" - if event in (win32con.CTRL_C_EVENT, win32con.CTRL_LOGOFF_EVENT, - win32con.CTRL_BREAK_EVENT, win32con.CTRL_SHUTDOWN_EVENT, - win32con.CTRL_CLOSE_EVENT): - self.bus.log('Console event %s: shutting down bus' % event) - - # Remove self immediately so repeated Ctrl-C doesn't re-call it. - try: - self.stop() - except ValueError: - pass - - self.bus.exit() - # 'First to return True stops the calls' - return 1 - return 0 - - -class Win32Bus(wspbus.Bus): - """A Web Site Process Bus implementation for Win32. - - Instead of time.sleep, this bus blocks using native win32event objects. - """ - - def __init__(self): - self.events = {} - wspbus.Bus.__init__(self) - - def _get_state_event(self, state): - """Return a win32event for the given state (creating it if needed).""" - try: - return self.events[state] - except KeyError: - event = win32event.CreateEvent(None, 0, 0, - "WSPBus %s Event (pid=%r)" % - (state.name, os.getpid())) - self.events[state] = event - return event - - def _get_state(self): - return self._state - def _set_state(self, value): - self._state = value - event = self._get_state_event(value) - win32event.PulseEvent(event) - state = property(_get_state, _set_state) - - def wait(self, state, interval=0.1, channel=None): - """Wait for the given state(s), KeyboardInterrupt or SystemExit. - - Since this class uses native win32event objects, the interval - argument is ignored. - """ - if isinstance(state, (tuple, list)): - # Don't wait for an event that beat us to the punch ;) - if self.state not in state: - events = tuple([self._get_state_event(s) for s in state]) - win32event.WaitForMultipleObjects(events, 0, win32event.INFINITE) - else: - # Don't wait for an event that beat us to the punch ;) - if self.state != state: - event = self._get_state_event(state) - win32event.WaitForSingleObject(event, win32event.INFINITE) - - -class _ControlCodes(dict): - """Control codes used to "signal" a service via ControlService. - - User-defined control codes are in the range 128-255. We generally use - the standard Python value for the Linux signal and add 128. Example: - - >>> signal.SIGUSR1 - 10 - control_codes['graceful'] = 128 + 10 - """ - - def key_for(self, obj): - """For the given value, return its corresponding key.""" - for key, val in self.items(): - if val is obj: - return key - raise ValueError("The given object could not be found: %r" % obj) - -control_codes = _ControlCodes({'graceful': 138}) - - -def signal_child(service, command): - if command == 'stop': - win32serviceutil.StopService(service) - elif command == 'restart': - win32serviceutil.RestartService(service) - else: - win32serviceutil.ControlService(service, control_codes[command]) - - -class PyWebService(win32serviceutil.ServiceFramework): - """Python Web Service.""" - - _svc_name_ = "Python Web Service" - _svc_display_name_ = "Python Web Service" - _svc_deps_ = None # sequence of service names on which this depends - _exe_name_ = "pywebsvc" - _exe_args_ = None # Default to no arguments - - # Only exists on Windows 2000 or later, ignored on windows NT - _svc_description_ = "Python Web Service" - - def SvcDoRun(self): - from cherrypy import process - process.bus.start() - process.bus.block() - - def SvcStop(self): - from cherrypy import process - self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) - process.bus.exit() - - def SvcOther(self, control): - process.bus.publish(control_codes.key_for(control)) - - -if __name__ == '__main__': - win32serviceutil.HandleCommandLine(PyWebService) +"""Windows service. Requires pywin32.""" + +import os +import win32api +import win32con +import win32event +import win32service +import win32serviceutil + +from cherrypy.process import wspbus, plugins + + +class ConsoleCtrlHandler(plugins.SimplePlugin): + """A WSPBus plugin for handling Win32 console events (like Ctrl-C).""" + + def __init__(self, bus): + self.is_set = False + plugins.SimplePlugin.__init__(self, bus) + + def start(self): + if self.is_set: + self.bus.log('Handler for console events already set.', level=40) + return + + result = win32api.SetConsoleCtrlHandler(self.handle, 1) + if result == 0: + self.bus.log('Could not SetConsoleCtrlHandler (error %r)' % + win32api.GetLastError(), level=40) + else: + self.bus.log('Set handler for console events.', level=40) + self.is_set = True + + def stop(self): + if not self.is_set: + self.bus.log('Handler for console events already off.', level=40) + return + + try: + result = win32api.SetConsoleCtrlHandler(self.handle, 0) + except ValueError: + # "ValueError: The object has not been registered" + result = 1 + + if result == 0: + self.bus.log('Could not remove SetConsoleCtrlHandler (error %r)' % + win32api.GetLastError(), level=40) + else: + self.bus.log('Removed handler for console events.', level=40) + self.is_set = False + + def handle(self, event): + """Handle console control events (like Ctrl-C).""" + if event in (win32con.CTRL_C_EVENT, win32con.CTRL_LOGOFF_EVENT, + win32con.CTRL_BREAK_EVENT, win32con.CTRL_SHUTDOWN_EVENT, + win32con.CTRL_CLOSE_EVENT): + self.bus.log('Console event %s: shutting down bus' % event) + + # Remove self immediately so repeated Ctrl-C doesn't re-call it. + try: + self.stop() + except ValueError: + pass + + self.bus.exit() + # 'First to return True stops the calls' + return 1 + return 0 + + +class Win32Bus(wspbus.Bus): + """A Web Site Process Bus implementation for Win32. + + Instead of time.sleep, this bus blocks using native win32event objects. + """ + + def __init__(self): + self.events = {} + wspbus.Bus.__init__(self) + + def _get_state_event(self, state): + """Return a win32event for the given state (creating it if needed).""" + try: + return self.events[state] + except KeyError: + event = win32event.CreateEvent(None, 0, 0, + "WSPBus %s Event (pid=%r)" % + (state.name, os.getpid())) + self.events[state] = event + return event + + def _get_state(self): + return self._state + def _set_state(self, value): + self._state = value + event = self._get_state_event(value) + win32event.PulseEvent(event) + state = property(_get_state, _set_state) + + def wait(self, state, interval=0.1, channel=None): + """Wait for the given state(s), KeyboardInterrupt or SystemExit. + + Since this class uses native win32event objects, the interval + argument is ignored. + """ + if isinstance(state, (tuple, list)): + # Don't wait for an event that beat us to the punch ;) + if self.state not in state: + events = tuple([self._get_state_event(s) for s in state]) + win32event.WaitForMultipleObjects(events, 0, win32event.INFINITE) + else: + # Don't wait for an event that beat us to the punch ;) + if self.state != state: + event = self._get_state_event(state) + win32event.WaitForSingleObject(event, win32event.INFINITE) + + +class _ControlCodes(dict): + """Control codes used to "signal" a service via ControlService. + + User-defined control codes are in the range 128-255. We generally use + the standard Python value for the Linux signal and add 128. Example: + + >>> signal.SIGUSR1 + 10 + control_codes['graceful'] = 128 + 10 + """ + + def key_for(self, obj): + """For the given value, return its corresponding key.""" + for key, val in self.items(): + if val is obj: + return key + raise ValueError("The given object could not be found: %r" % obj) + +control_codes = _ControlCodes({'graceful': 138}) + + +def signal_child(service, command): + if command == 'stop': + win32serviceutil.StopService(service) + elif command == 'restart': + win32serviceutil.RestartService(service) + else: + win32serviceutil.ControlService(service, control_codes[command]) + + +class PyWebService(win32serviceutil.ServiceFramework): + """Python Web Service.""" + + _svc_name_ = "Python Web Service" + _svc_display_name_ = "Python Web Service" + _svc_deps_ = None # sequence of service names on which this depends + _exe_name_ = "pywebsvc" + _exe_args_ = None # Default to no arguments + + # Only exists on Windows 2000 or later, ignored on windows NT + _svc_description_ = "Python Web Service" + + def SvcDoRun(self): + from cherrypy import process + process.bus.start() + process.bus.block() + + def SvcStop(self): + from cherrypy import process + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + process.bus.exit() + + def SvcOther(self, control): + process.bus.publish(control_codes.key_for(control)) + + +if __name__ == '__main__': + win32serviceutil.HandleCommandLine(PyWebService) diff --git a/data/interfaces/default/config_general.tmpl b/data/interfaces/default/config_general.tmpl index a6b28d7037..5ab25dbab5 100644 --- a/data/interfaces/default/config_general.tmpl +++ b/data/interfaces/default/config_general.tmpl @@ -1,217 +1,217 @@ -#import os.path -#import sickbeard -#from sickbeard.common import * -#from sickbeard import config -#from sickbeard import metadata -#from sickbeard.metadata.generic import GenericMetadata -#set global $title = "Config - General" -#set global $header = "General Configuration" - -#set global $sbPath="../.." - -#set global $topmenu="config"# -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - -
-
-
All non-absolute folder locations are relative to $sickbeard.DATA_DIR
- -
- -
- -
- -
-

Misc

-

Some options may require a manual restart to take effect.

-
- -
-
- - -
- -
- - - -
- -
- - -
- -
- -
- - -
-
- - -
- -
-

Web Interface

-

It is recommended that you enable a username and password to secure Sick Beard from being tampered with remotely.

-

These options require a manual restart to take effect.

-
- -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- -
-
- - -
- -
- - -
-
- - -
-
- -
- -
-

API

-

Allow 3rd party programs to interact with Sick-Beard.

-
- -
-
- - -
- -
-
- - -
-
- - -
-
- -
- -

- -
- -
-
- - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import os.path +#import sickbeard +#from sickbeard.common import * +#from sickbeard import config +#from sickbeard import metadata +#from sickbeard.metadata.generic import GenericMetadata +#set global $title = "Config - General" +#set global $header = "General Configuration" + +#set global $sbPath="../.." + +#set global $topmenu="config"# +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + +
+
+
All non-absolute folder locations are relative to $sickbeard.DATA_DIR
+ +
+ +
+ +
+ +
+

Misc

+

Some options may require a manual restart to take effect.

+
+ +
+
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+ +
+

Web Interface

+

It is recommended that you enable a username and password to secure Sick Beard from being tampered with remotely.

+

These options require a manual restart to take effect.

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+ +
+ +
+

API

+

Allow 3rd party programs to interact with Sick-Beard.

+
+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +

+ +
+ +
+
+ + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_postProcessing.tmpl b/data/interfaces/default/config_postProcessing.tmpl index ef60eef6a0..787c8b4959 100644 --- a/data/interfaces/default/config_postProcessing.tmpl +++ b/data/interfaces/default/config_postProcessing.tmpl @@ -1,577 +1,577 @@ -#import os.path -#import sickbeard -#from sickbeard.common import * -#from sickbeard import config -#from sickbeard import metadata -#from sickbeard.metadata.generic import GenericMetadata -#from sickbeard import naming - -#set global $title = "Config - Post Processing" -#set global $header = "Post Processing" - -#set global $sbPath="../.." - -#set global $topmenu="config"# -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - -
-
-
All non-absolute folder locations are relative to $sickbeard.DATA_DIR
- -
- -
- -
- -
-

NZB Post-Processing

-

Settings that dictate how Sick Beard should process completed NZB downloads.

-
- -
-
- - - - -
- -
- - - -
- -
-
- -
-
- -
- -
-

Torrent Post-Processing

-

Settings that dictate how Sick Beard should process completed torrent downloads.

-
- -
-
- - -
-
- - - -
- -
-
- -
-
- -
- -
-

Common Post-Processing options

-

Settings that dictate how Sick Beard should process completed downloads.

-
- -
- -
- - -
- -
- - - -
- -
- - -
- -
-
- -
-
- -
-
-

Naming

-

How Sick Beard will name and sort your episodes.

-
- -
- -
- -
- -
-
- -
- - -
- -
- -
- -
-

Sample:

-
-   -
-
-
- -
-

Multi-EP sample:

-
-   -
-

-
- -
- - -
- -
-
- -
- -
-
- -
- - -
- -
-

Sample:

-
-   -
-
-
- -
- -
-
- -
-
- -
- -
-

Metadata

-

The data associated to the data. These are files associated to a TV show in the form of images and text that, when supported, will enhance the viewing experience.

-
- -
-
- - Toggle the metadata options that you wish to be created. Multiple targets may be used. -
- -#for ($cur_name, $cur_generator) in $m_dict.items(): -#set $cur_metadata_inst = $sickbeard.metadata_provider_dict[$cur_generator.name] -#set $cur_id = $GenericMetadata.makeID($cur_name) -
- - - - -
-#end for - -
- - -
- -
- -
-
- -

-
- -
-
- - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import os.path +#import sickbeard +#from sickbeard.common import * +#from sickbeard import config +#from sickbeard import metadata +#from sickbeard.metadata.generic import GenericMetadata +#from sickbeard import naming + +#set global $title = "Config - Post Processing" +#set global $header = "Post Processing" + +#set global $sbPath="../.." + +#set global $topmenu="config"# +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + + +
+
+
All non-absolute folder locations are relative to $sickbeard.DATA_DIR
+ +
+ +
+ +
+ +
+

NZB Post-Processing

+

Settings that dictate how Sick Beard should process completed NZB downloads.

+
+ +
+
+ + + + +
+ +
+ + + +
+ +
+
+ +
+
+ +
+ +
+

Torrent Post-Processing

+

Settings that dictate how Sick Beard should process completed torrent downloads.

+
+ +
+
+ + +
+
+ + + +
+ +
+
+ +
+
+ +
+ +
+

Common Post-Processing options

+

Settings that dictate how Sick Beard should process completed downloads.

+
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+
+ +
+
+ +
+
+

Naming

+

How Sick Beard will name and sort your episodes.

+
+ +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+

Sample:

+
+   +
+
+
+ +
+

Multi-EP sample:

+
+   +
+

+
+ +
+ + +
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+

Sample:

+
+   +
+
+
+ +
+ +
+
+ +
+
+ +
+ +
+

Metadata

+

The data associated to the data. These are files associated to a TV show in the form of images and text that, when supported, will enhance the viewing experience.

+
+ +
+
+ + Toggle the metadata options that you wish to be created. Multiple targets may be used. +
+ +#for ($cur_name, $cur_generator) in $m_dict.items(): +#set $cur_metadata_inst = $sickbeard.metadata_provider_dict[$cur_generator.name] +#set $cur_id = $GenericMetadata.makeID($cur_name) +
+ + + + +
+#end for + +
+ + +
+ +
+ +
+
+ +

+
+ +
+
+ + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/home_newShow.tmpl b/data/interfaces/default/home_newShow.tmpl index 73f4c7631a..729a731a92 100644 --- a/data/interfaces/default/home_newShow.tmpl +++ b/data/interfaces/default/home_newShow.tmpl @@ -1,85 +1,85 @@ -#import os.path -#import sickbeard -#set global $title="New Show" - -#set global $sbPath="../.." - -#set global $statpath="../.."# -#set global $topmenu="home"# -#import os.path - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - - - -
aoeu
-
- -
- -
- Find a show on the TVDB - -
- #if $use_provided_info: - Show retrieved from existing metadata: $provided_tvdb_name - - - - #else: - - * -

- - * This will only affect the language of the retrieved metadata file contents and episode filenames.
- This DOES NOT allow Sick Beard to download non-english TV episodes!
-
-

- #end if -
-
- -
- Pick the parent folder - -
- #if $provided_show_dir: - Pre-chosen Destination Folder: $provided_show_dir
-
- #else - #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_rootDirs.tmpl") - #end if -
-
- -
- Customize options - -
- #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_addShowOptions.tmpl") -
-
- -#for $curNextDir in $other_shows: - -#end for - -
- -
- -
- -#if $provided_show_dir: - -#end if -
- - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import os.path +#import sickbeard +#set global $title="New Show" + +#set global $sbPath="../.." + +#set global $statpath="../.."# +#set global $topmenu="home"# +#import os.path + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + + + + +
aoeu
+
+ +
+ +
+ Find a show on the TVDB + +
+ #if $use_provided_info: + Show retrieved from existing metadata: $provided_tvdb_name + + + + #else: + + * +

+ + * This will only affect the language of the retrieved metadata file contents and episode filenames.
+ This DOES NOT allow Sick Beard to download non-english TV episodes!
+
+

+ #end if +
+
+ +
+ Pick the parent folder + +
+ #if $provided_show_dir: + Pre-chosen Destination Folder: $provided_show_dir
+
+ #else + #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_rootDirs.tmpl") + #end if +
+
+ +
+ Customize options + +
+ #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_addShowOptions.tmpl") +
+
+ +#for $curNextDir in $other_shows: + +#end for + +
+ +
+ +
+ +#if $provided_show_dir: + +#end if +
+ + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index 82fc9e19b7..5e10f9a224 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -1,250 +1,250 @@ -#import sickbeard.version - - - - - Sick Beard - $sickbeard.version.SICKBEARD_VERSION - $title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-#if $sickbeard.NEWEST_VERSION_STRING: -
-
- $sickbeard.NEWEST_VERSION_STRING -
-
-
-#end if - - - - -#if $varExists('submenu'): - -#end if -
- -
-
-

#if $varExists('header') then $header else $title#

+#import sickbeard.version + + + + + Sick Beard - $sickbeard.version.SICKBEARD_VERSION - $title + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+#if $sickbeard.NEWEST_VERSION_STRING: +
+
+ $sickbeard.NEWEST_VERSION_STRING +
+
+
+#end if + + + + +#if $varExists('submenu'): + +#end if +
+ +
+
+

#if $varExists('header') then $header else $title#

diff --git a/data/interfaces/default/manage_manageSearches.tmpl b/data/interfaces/default/manage_manageSearches.tmpl index 78c3b0b4cc..e8e19a0f4d 100644 --- a/data/interfaces/default/manage_manageSearches.tmpl +++ b/data/interfaces/default/manage_manageSearches.tmpl @@ -1,40 +1,40 @@ -#import sickbeard -#import datetime -#from sickbeard.common import * -#set global $title="Manage Searches" - -#set global $sbPath=".." - -#set global $topmenu="manage"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - -

Backlog Search:

- #if $backlogPaused then "Unpause" else "Pause"# -#if not $backlogRunning: -Not in progress
-#else: -#if $backlogPaused then "Paused: " else ""# -Currently running
-#end if - -
-

Daily Episode Search:

- Force -#if not $searchStatus: -Not in progress
-#else: -In Progress
-#end if -
- -

Version Check:

- Force Check -
- -
- -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#import datetime +#from sickbeard.common import * +#set global $title="Manage Searches" + +#set global $sbPath=".." + +#set global $topmenu="manage"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + + +

Backlog Search:

+ #if $backlogPaused then "Unpause" else "Pause"# +#if not $backlogRunning: +Not in progress
+#else: +#if $backlogPaused then "Paused: " else ""# +Currently running
+#end if + +
+

Daily Episode Search:

+ Force +#if not $searchStatus: +Not in progress
+#else: +In Progress
+#end if +
+ +

Version Check:

+ Force Check +
+ +
+ +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/js/ajaxNotifications.js b/data/js/ajaxNotifications.js index 186028da8e..66d07b0915 100644 --- a/data/js/ajaxNotifications.js +++ b/data/js/ajaxNotifications.js @@ -1,27 +1,27 @@ -var message_url = sbRoot + '/ui/get_messages'; -$.pnotify.defaults.pnotify_width = "340px"; -$.pnotify.defaults.styling = "jqueryui"; -$.pnotify.defaults.pnotify_history = false; -$.pnotify.defaults.pnotify_delay = 4000; - -function check_notifications() { - $.getJSON(message_url, function (data) { - $.each(data, function (name, data) { - $.pnotify({ - pnotify_type: data.type, - pnotify_hide: data.type == 'notice', - pnotify_title: data.title, - pnotify_text: data.message, - pnotify_shadow: false - }); - }); - }); - - setTimeout(check_notifications, 3000); -} - -$(document).ready(function () { - - check_notifications(); - +var message_url = sbRoot + '/ui/get_messages'; +$.pnotify.defaults.pnotify_width = "340px"; +$.pnotify.defaults.styling = "jqueryui"; +$.pnotify.defaults.pnotify_history = false; +$.pnotify.defaults.pnotify_delay = 4000; + +function check_notifications() { + $.getJSON(message_url, function (data) { + $.each(data, function (name, data) { + $.pnotify({ + pnotify_type: data.type, + pnotify_hide: data.type == 'notice', + pnotify_title: data.title, + pnotify_text: data.message, + pnotify_shadow: false + }); + }); + }); + + setTimeout(check_notifications, 3000); +} + +$(document).ready(function () { + + check_notifications(); + }); \ No newline at end of file diff --git a/data/js/configNotifications.js b/data/js/configNotifications.js index 4b8813a1ab..da3b289e40 100644 --- a/data/js/configNotifications.js +++ b/data/js/configNotifications.js @@ -1,205 +1,205 @@ -$(document).ready(function () { - var loading = ''; - - $('#testGrowl').click(function () { - $('#testGrowl-result').html(loading); - var growl_host = $("#growl_host").val(); - var growl_password = $("#growl_password").val(); - $.get(sbRoot + "/home/testGrowl", {'host': growl_host, 'password': growl_password}, - function (data) { $('#testGrowl-result').html(data); }); - }); - - $('#testProwl').click(function () { - $('#testProwl-result').html(loading); - var prowl_api = $("#prowl_api").val(); - var prowl_priority = $("#prowl_priority").val(); - $.get(sbRoot + "/home/testProwl", {'prowl_api': prowl_api, 'prowl_priority': prowl_priority}, - function (data) { $('#testProwl-result').html(data); }); - }); - - $('#testXBMC').click(function () { - $("#testXBMC").attr("disabled", true); - $('#testXBMC-result').html(loading); - var xbmc_host = $("#xbmc_host").val(); - var xbmc_username = $("#xbmc_username").val(); - var xbmc_password = $("#xbmc_password").val(); - $.get(sbRoot + "/home/testXBMC", {'host': xbmc_host, 'username': xbmc_username, 'password': xbmc_password}) - .done(function (data) { - $('#testXBMC-result').html(data); - $("#testXBMC").attr("disabled", false); - }); - }); - - $('#testPLEX').click(function () { - $('#testPLEX-result').html(loading); - var plex_host = $("#plex_host").val(); - var plex_username = $("#plex_username").val(); - var plex_password = $("#plex_password").val(); - $.get(sbRoot + "/home/testPLEX", {'host': plex_host, 'username': plex_username, 'password': plex_password}, - function (data) { $('#testPLEX-result').html(data); }); - }); - - $('#testNotifo').click(function () { - $('#testNotifo-result').html(loading); - var notifo_username = $("#notifo_username").val(); - var notifo_apisecret = $("#notifo_apisecret").val(); - $.get(sbRoot + "/home/testNotifo", {'username': notifo_username, 'apisecret': notifo_apisecret}, - function (data) { $('#testNotifo-result').html(data); }); - }); - - $('#testBoxcar').click(function () { - $('#testBoxcar-result').html(loading); - var boxcar_username = $("#boxcar_username").val(); - $.get(sbRoot + "/home/testBoxcar", {'username': boxcar_username}, - function (data) { $('#testBoxcar-result').html(data); }); - }); - - $('#testPushover').click(function () { - $('#testPushover-result').html(loading); - var pushover_userkey = $("#pushover_userkey").val(); - $.get(sbRoot + "/home/testPushover", {'userKey': pushover_userkey}, - function (data) { $('#testPushover-result').html(data); }); - }); - - $('#testLibnotify').click(function () { - $('#testLibnotify-result').html(loading); - $.get(sbRoot + "/home/testLibnotify", - function (data) { $('#testLibnotify-result').html(data); }); - }); - - $('#twitterStep1').click(function () { - $('#testTwitter-result').html(loading); - $.get(sbRoot + "/home/twitterStep1", function (data) {window.open(data); }) - .done(function () { $('#testTwitter-result').html('Step1: Confirm Authorization'); }); - }); - - $('#twitterStep2').click(function () { - $('#testTwitter-result').html(loading); - var twitter_key = $("#twitter_key").val(); - $.get(sbRoot + "/home/twitterStep2", {'key': twitter_key}, - function (data) { $('#testTwitter-result').html(data); }); - }); - - $('#testTwitter').click(function () { - $.get(sbRoot + "/home/testTwitter", - function (data) { $('#testTwitter-result').html(data); }); - }); - - $('#settingsNMJ').click(function () { - if (!$('#nmj_host').val()) { - alert('Please fill in the Popcorn IP address'); - $('#nmj_host').focus(); - return; - } - $('#testNMJ-result').html(loading); - var nmj_host = $('#nmj_host').val(); - - $.get(sbRoot + "/home/settingsNMJ", {'host': nmj_host}, - function (data) { - if (data === null) { - $('#nmj_database').removeAttr('readonly'); - $('#nmj_mount').removeAttr('readonly'); - } - var JSONData = $.parseJSON(data); - $('#testNMJ-result').html(JSONData.message); - $('#nmj_database').val(JSONData.database); - $('#nmj_mount').val(JSONData.mount); - - if (JSONData.database) { - $('#nmj_database').attr('readonly', true); - } else { - $('#nmj_database').removeAttr('readonly'); - } - if (JSONData.mount) { - $('#nmj_mount').attr('readonly', true); - } else { - $('#nmj_mount').removeAttr('readonly'); - } - }); - }); - - $('#testNMJ').click(function () { - $('#testNMJ-result').html(loading); - var nmj_host = $("#nmj_host").val(); - var nmj_database = $("#nmj_database").val(); - var nmj_mount = $("#nmj_mount").val(); - - $.get(sbRoot + "/home/testNMJ", {'host': nmj_host, 'database': nmj_database, 'mount': nmj_mount}, - function (data) { $('#testNMJ-result').html(data); }); - }); - - $('#settingsNMJv2').click(function () { - if (!$('#nmjv2_host').val()) { - alert('Please fill in the Popcorn IP address'); - $('#nmjv2_host').focus(); - return; - } - $('#testNMJv2-result').html(loading); - var nmjv2_host = $('#nmjv2_host').val(); - var nmjv2_dbloc; - var radios = document.getElementsByName("nmjv2_dbloc"); - for (var i = 0; i < radios.length; i++) { - if (radios[i].checked) { - nmjv2_dbloc=radios[i].value; - break; - } - } - - var nmjv2_dbinstance=$('#NMJv2db_instance').val(); - $.get(sbRoot + "/home/settingsNMJv2", {'host': nmjv2_host, 'dbloc': nmjv2_dbloc, 'instance': nmjv2_dbinstance}, - function (data){ - if (data == null) { - $('#nmjv2_database').removeAttr('readonly'); - } - var JSONData = $.parseJSON(data); - $('#testNMJv2-result').html(JSONData.message); - $('#nmjv2_database').val(JSONData.database); - - if (JSONData.database) { - $('#nmjv2_database').attr('readonly', true); - } else { - $('#nmjv2_database').removeAttr('readonly'); - } - }); - }); - - $('#testNMJv2').click(function () { - $('#testNMJv2-result').html(loading); - var nmjv2_host = $("#nmjv2_host").val(); - - $.get(sbRoot + "/home/testNMJv2", {'host': nmjv2_host}, - function (data){ $('#testNMJv2-result').html(data); }); - }); - - $('#testTrakt').click(function () { - $('#testTrakt-result').html(loading); - var trakt_api = $("#trakt_api").val(); - var trakt_username = $("#trakt_username").val(); - var trakt_password = $("#trakt_password").val(); - - $.get(sbRoot + "/home/testTrakt", {'api': trakt_api, 'username': trakt_username, 'password': trakt_password}, - function (data) { $('#testTrakt-result').html(data); }); - }); - - $('#testNMA').click(function () { - $('#testNMA-result').html(loading); - var nma_api = $("#nma_api").val(); - var nma_priority = $("#nma_priority").val(); - $.get(sbRoot + "/home/testNMA", {'nma_api': nma_api, 'nma_priority': nma_priority}, - function (data) { $('#testNMA-result').html(data); }); - }); - - $('#testMail').click(function () { - $('#testMail-result').html(loading); - var mail_from = $("#mail_from").val(); - var mail_to = $("#mail_to").val(); - var mail_server = $("#mail_server").val(); - var mail_ssl = $("#mail_ssl").val(); - var mail_username = $("#mail_username").val(); - var mail_password = $("#mail_password").val(); - - $.get(sbRoot + "/home/testMail", {}, - function (data) { $('#testMail-result').html(data); }); - }); - -}); +$(document).ready(function () { + var loading = ''; + + $('#testGrowl').click(function () { + $('#testGrowl-result').html(loading); + var growl_host = $("#growl_host").val(); + var growl_password = $("#growl_password").val(); + $.get(sbRoot + "/home/testGrowl", {'host': growl_host, 'password': growl_password}, + function (data) { $('#testGrowl-result').html(data); }); + }); + + $('#testProwl').click(function () { + $('#testProwl-result').html(loading); + var prowl_api = $("#prowl_api").val(); + var prowl_priority = $("#prowl_priority").val(); + $.get(sbRoot + "/home/testProwl", {'prowl_api': prowl_api, 'prowl_priority': prowl_priority}, + function (data) { $('#testProwl-result').html(data); }); + }); + + $('#testXBMC').click(function () { + $("#testXBMC").attr("disabled", true); + $('#testXBMC-result').html(loading); + var xbmc_host = $("#xbmc_host").val(); + var xbmc_username = $("#xbmc_username").val(); + var xbmc_password = $("#xbmc_password").val(); + $.get(sbRoot + "/home/testXBMC", {'host': xbmc_host, 'username': xbmc_username, 'password': xbmc_password}) + .done(function (data) { + $('#testXBMC-result').html(data); + $("#testXBMC").attr("disabled", false); + }); + }); + + $('#testPLEX').click(function () { + $('#testPLEX-result').html(loading); + var plex_host = $("#plex_host").val(); + var plex_username = $("#plex_username").val(); + var plex_password = $("#plex_password").val(); + $.get(sbRoot + "/home/testPLEX", {'host': plex_host, 'username': plex_username, 'password': plex_password}, + function (data) { $('#testPLEX-result').html(data); }); + }); + + $('#testNotifo').click(function () { + $('#testNotifo-result').html(loading); + var notifo_username = $("#notifo_username").val(); + var notifo_apisecret = $("#notifo_apisecret").val(); + $.get(sbRoot + "/home/testNotifo", {'username': notifo_username, 'apisecret': notifo_apisecret}, + function (data) { $('#testNotifo-result').html(data); }); + }); + + $('#testBoxcar').click(function () { + $('#testBoxcar-result').html(loading); + var boxcar_username = $("#boxcar_username").val(); + $.get(sbRoot + "/home/testBoxcar", {'username': boxcar_username}, + function (data) { $('#testBoxcar-result').html(data); }); + }); + + $('#testPushover').click(function () { + $('#testPushover-result').html(loading); + var pushover_userkey = $("#pushover_userkey").val(); + $.get(sbRoot + "/home/testPushover", {'userKey': pushover_userkey}, + function (data) { $('#testPushover-result').html(data); }); + }); + + $('#testLibnotify').click(function () { + $('#testLibnotify-result').html(loading); + $.get(sbRoot + "/home/testLibnotify", + function (data) { $('#testLibnotify-result').html(data); }); + }); + + $('#twitterStep1').click(function () { + $('#testTwitter-result').html(loading); + $.get(sbRoot + "/home/twitterStep1", function (data) {window.open(data); }) + .done(function () { $('#testTwitter-result').html('Step1: Confirm Authorization'); }); + }); + + $('#twitterStep2').click(function () { + $('#testTwitter-result').html(loading); + var twitter_key = $("#twitter_key").val(); + $.get(sbRoot + "/home/twitterStep2", {'key': twitter_key}, + function (data) { $('#testTwitter-result').html(data); }); + }); + + $('#testTwitter').click(function () { + $.get(sbRoot + "/home/testTwitter", + function (data) { $('#testTwitter-result').html(data); }); + }); + + $('#settingsNMJ').click(function () { + if (!$('#nmj_host').val()) { + alert('Please fill in the Popcorn IP address'); + $('#nmj_host').focus(); + return; + } + $('#testNMJ-result').html(loading); + var nmj_host = $('#nmj_host').val(); + + $.get(sbRoot + "/home/settingsNMJ", {'host': nmj_host}, + function (data) { + if (data === null) { + $('#nmj_database').removeAttr('readonly'); + $('#nmj_mount').removeAttr('readonly'); + } + var JSONData = $.parseJSON(data); + $('#testNMJ-result').html(JSONData.message); + $('#nmj_database').val(JSONData.database); + $('#nmj_mount').val(JSONData.mount); + + if (JSONData.database) { + $('#nmj_database').attr('readonly', true); + } else { + $('#nmj_database').removeAttr('readonly'); + } + if (JSONData.mount) { + $('#nmj_mount').attr('readonly', true); + } else { + $('#nmj_mount').removeAttr('readonly'); + } + }); + }); + + $('#testNMJ').click(function () { + $('#testNMJ-result').html(loading); + var nmj_host = $("#nmj_host").val(); + var nmj_database = $("#nmj_database").val(); + var nmj_mount = $("#nmj_mount").val(); + + $.get(sbRoot + "/home/testNMJ", {'host': nmj_host, 'database': nmj_database, 'mount': nmj_mount}, + function (data) { $('#testNMJ-result').html(data); }); + }); + + $('#settingsNMJv2').click(function () { + if (!$('#nmjv2_host').val()) { + alert('Please fill in the Popcorn IP address'); + $('#nmjv2_host').focus(); + return; + } + $('#testNMJv2-result').html(loading); + var nmjv2_host = $('#nmjv2_host').val(); + var nmjv2_dbloc; + var radios = document.getElementsByName("nmjv2_dbloc"); + for (var i = 0; i < radios.length; i++) { + if (radios[i].checked) { + nmjv2_dbloc=radios[i].value; + break; + } + } + + var nmjv2_dbinstance=$('#NMJv2db_instance').val(); + $.get(sbRoot + "/home/settingsNMJv2", {'host': nmjv2_host, 'dbloc': nmjv2_dbloc, 'instance': nmjv2_dbinstance}, + function (data){ + if (data == null) { + $('#nmjv2_database').removeAttr('readonly'); + } + var JSONData = $.parseJSON(data); + $('#testNMJv2-result').html(JSONData.message); + $('#nmjv2_database').val(JSONData.database); + + if (JSONData.database) { + $('#nmjv2_database').attr('readonly', true); + } else { + $('#nmjv2_database').removeAttr('readonly'); + } + }); + }); + + $('#testNMJv2').click(function () { + $('#testNMJv2-result').html(loading); + var nmjv2_host = $("#nmjv2_host").val(); + + $.get(sbRoot + "/home/testNMJv2", {'host': nmjv2_host}, + function (data){ $('#testNMJv2-result').html(data); }); + }); + + $('#testTrakt').click(function () { + $('#testTrakt-result').html(loading); + var trakt_api = $("#trakt_api").val(); + var trakt_username = $("#trakt_username").val(); + var trakt_password = $("#trakt_password").val(); + + $.get(sbRoot + "/home/testTrakt", {'api': trakt_api, 'username': trakt_username, 'password': trakt_password}, + function (data) { $('#testTrakt-result').html(data); }); + }); + + $('#testNMA').click(function () { + $('#testNMA-result').html(loading); + var nma_api = $("#nma_api").val(); + var nma_priority = $("#nma_priority").val(); + $.get(sbRoot + "/home/testNMA", {'nma_api': nma_api, 'nma_priority': nma_priority}, + function (data) { $('#testNMA-result').html(data); }); + }); + + $('#testMail').click(function () { + $('#testMail-result').html(loading); + var mail_from = $("#mail_from").val(); + var mail_to = $("#mail_to").val(); + var mail_server = $("#mail_server").val(); + var mail_ssl = $("#mail_ssl").val(); + var mail_username = $("#mail_username").val(); + var mail_password = $("#mail_password").val(); + + $.get(sbRoot + "/home/testMail", {}, + function (data) { $('#testMail-result').html(data); }); + }); + +}); diff --git a/data/js/configProviders.js b/data/js/configProviders.js index 07817fb1ca..2824378f7c 100644 --- a/data/js/configProviders.js +++ b/data/js/configProviders.js @@ -1,211 +1,211 @@ -$(document).ready(function(){ - - $.fn.showHideProviders = function() { - $('.providerDiv').each(function(){ - var providerName = $(this).attr('id'); - var selectedProvider = $('#editAProvider :selected').val(); - - if (selectedProvider+'Div' == providerName) - $(this).show(); - else - $(this).hide(); - - }); - } - - $.fn.addProvider = function (id, name, url, key, isDefault) { - - if (url.match('/$') == null) - url = url + '/' - - var newData = [isDefault, [name, url, key]]; - newznabProviders[id] = newData; - - if (!isDefault) - { - $('#editANewznabProvider').addOption(id, name); - $(this).populateNewznabSection(); - } - - if ($('#providerOrderList > #'+id).length == 0) { - var toAdd = '
  • '+name+' '+name+'
  • ' - - $('#providerOrderList').append(toAdd); - $('#providerOrderList').sortable("refresh"); - } - - $(this).makeNewznabProviderString(); - - } - - $.fn.updateProvider = function (id, url, key) { - - newznabProviders[id][1][1] = url; - newznabProviders[id][1][2] = key; - - $(this).populateNewznabSection(); - - $(this).makeNewznabProviderString(); - - } - - $.fn.deleteProvider = function (id) { - - $('#editANewznabProvider').removeOption(id); - delete newznabProviders[id]; - $(this).populateNewznabSection(); - - $('#providerOrderList > #'+id).remove(); - - $(this).makeNewznabProviderString(); - - } - - $.fn.populateNewznabSection = function() { - - var selectedProvider = $('#editANewznabProvider :selected').val(); - - if (selectedProvider == 'addNewznab') { - var data = ['','','']; - var isDefault = 0; - $('#newznab_add_div').show(); - $('#newznab_update_div').hide(); - } else { - var data = newznabProviders[selectedProvider][1]; - var isDefault = newznabProviders[selectedProvider][0]; - $('#newznab_add_div').hide(); - $('#newznab_update_div').show(); - } - - $('#newznab_name').val(data[0]); - $('#newznab_url').val(data[1]); - $('#newznab_key').val(data[2]); - - if (selectedProvider == 'addNewznab') { - $('#newznab_name').removeAttr("disabled"); - $('#newznab_url').removeAttr("disabled"); - } else { - - $('#newznab_name').attr("disabled", "disabled"); - - if (isDefault) { - $('#newznab_url').attr("disabled", "disabled"); - $('#newznab_delete').attr("disabled", "disabled"); - } else { - $('#newznab_url').removeAttr("disabled"); - $('#newznab_delete').removeAttr("disabled"); - } - } - - } - - $.fn.makeNewznabProviderString = function() { - - var provStrings = new Array(); - - for (var id in newznabProviders) { - provStrings.push(newznabProviders[id][1].join('|')); - } - - $('#newznab_string').val(provStrings.join('!!!')) - - } - - $.fn.refreshProviderList = function() { - var idArr = $("#providerOrderList").sortable('toArray'); - var finalArr = new Array(); - $.each(idArr, function(key, val) { - var checked = + $('#enable_'+val).prop('checked') ? '1' : '0'; - finalArr.push(val + ':' + checked); - }); - - $("#provider_order").val(finalArr.join(' ')); - } - - var newznabProviders = new Array(); - - $('.newznab_key').change(function(){ - - var provider_id = $(this).attr('id'); - provider_id = provider_id.substring(0, provider_id.length-'_hash'.length); - - var url = $('#'+provider_id+'_url').val(); - var key = $(this).val(); - - $(this).updateProvider(provider_id, url, key); - - }); - - $('#newznab_key,#newznab_url').change(function(){ - - var selectedProvider = $('#editANewznabProvider :selected').val(); - - if (selectedProvider == "addNewznab") - return; - - var url = $('#newznab_url').val(); - var key = $('#newznab_key').val(); - - $(this).updateProvider(selectedProvider, url, key); - - }); - - $('#editAProvider').change(function(){ - $(this).showHideProviders(); - }); - - $('#editANewznabProvider').change(function(){ - $(this).populateNewznabSection(); - }); - - $('.provider_enabler').live('click', function(){ - $(this).refreshProviderList(); - }); - - - $('#newznab_add').click(function(){ - - var selectedProvider = $('#editANewznabProvider :selected').val(); - - var name = $('#newznab_name').val(); - var url = $('#newznab_url').val(); - var key = $('#newznab_key').val(); - - var params = { name: name } - - // send to the form with ajax, get a return value - $.getJSON(sbRoot + '/config/providers/canAddNewznabProvider', params, - function(data){ - if (data.error != undefined) { - alert(data.error); - return; - } - - $(this).addProvider(data.success, name, url, key, 0); - }); - - - }); - - $('.newznab_delete').click(function(){ - - var selectedProvider = $('#editANewznabProvider :selected').val(); - - $(this).deleteProvider(selectedProvider); - - }); - - // initialization stuff - - $(this).showHideProviders(); - - $("#providerOrderList").sortable({ - placeholder: 'ui-state-highlight', - update: function (event, ui) { - $(this).refreshProviderList(); - } - }); - - $("#providerOrderList").disableSelection(); - +$(document).ready(function(){ + + $.fn.showHideProviders = function() { + $('.providerDiv').each(function(){ + var providerName = $(this).attr('id'); + var selectedProvider = $('#editAProvider :selected').val(); + + if (selectedProvider+'Div' == providerName) + $(this).show(); + else + $(this).hide(); + + }); + } + + $.fn.addProvider = function (id, name, url, key, isDefault) { + + if (url.match('/$') == null) + url = url + '/' + + var newData = [isDefault, [name, url, key]]; + newznabProviders[id] = newData; + + if (!isDefault) + { + $('#editANewznabProvider').addOption(id, name); + $(this).populateNewznabSection(); + } + + if ($('#providerOrderList > #'+id).length == 0) { + var toAdd = '
  • '+name+' '+name+'
  • ' + + $('#providerOrderList').append(toAdd); + $('#providerOrderList').sortable("refresh"); + } + + $(this).makeNewznabProviderString(); + + } + + $.fn.updateProvider = function (id, url, key) { + + newznabProviders[id][1][1] = url; + newznabProviders[id][1][2] = key; + + $(this).populateNewznabSection(); + + $(this).makeNewznabProviderString(); + + } + + $.fn.deleteProvider = function (id) { + + $('#editANewznabProvider').removeOption(id); + delete newznabProviders[id]; + $(this).populateNewznabSection(); + + $('#providerOrderList > #'+id).remove(); + + $(this).makeNewznabProviderString(); + + } + + $.fn.populateNewznabSection = function() { + + var selectedProvider = $('#editANewznabProvider :selected').val(); + + if (selectedProvider == 'addNewznab') { + var data = ['','','']; + var isDefault = 0; + $('#newznab_add_div').show(); + $('#newznab_update_div').hide(); + } else { + var data = newznabProviders[selectedProvider][1]; + var isDefault = newznabProviders[selectedProvider][0]; + $('#newznab_add_div').hide(); + $('#newznab_update_div').show(); + } + + $('#newznab_name').val(data[0]); + $('#newznab_url').val(data[1]); + $('#newznab_key').val(data[2]); + + if (selectedProvider == 'addNewznab') { + $('#newznab_name').removeAttr("disabled"); + $('#newznab_url').removeAttr("disabled"); + } else { + + $('#newznab_name').attr("disabled", "disabled"); + + if (isDefault) { + $('#newznab_url').attr("disabled", "disabled"); + $('#newznab_delete').attr("disabled", "disabled"); + } else { + $('#newznab_url').removeAttr("disabled"); + $('#newznab_delete').removeAttr("disabled"); + } + } + + } + + $.fn.makeNewznabProviderString = function() { + + var provStrings = new Array(); + + for (var id in newznabProviders) { + provStrings.push(newznabProviders[id][1].join('|')); + } + + $('#newznab_string').val(provStrings.join('!!!')) + + } + + $.fn.refreshProviderList = function() { + var idArr = $("#providerOrderList").sortable('toArray'); + var finalArr = new Array(); + $.each(idArr, function(key, val) { + var checked = + $('#enable_'+val).prop('checked') ? '1' : '0'; + finalArr.push(val + ':' + checked); + }); + + $("#provider_order").val(finalArr.join(' ')); + } + + var newznabProviders = new Array(); + + $('.newznab_key').change(function(){ + + var provider_id = $(this).attr('id'); + provider_id = provider_id.substring(0, provider_id.length-'_hash'.length); + + var url = $('#'+provider_id+'_url').val(); + var key = $(this).val(); + + $(this).updateProvider(provider_id, url, key); + + }); + + $('#newznab_key,#newznab_url').change(function(){ + + var selectedProvider = $('#editANewznabProvider :selected').val(); + + if (selectedProvider == "addNewznab") + return; + + var url = $('#newznab_url').val(); + var key = $('#newznab_key').val(); + + $(this).updateProvider(selectedProvider, url, key); + + }); + + $('#editAProvider').change(function(){ + $(this).showHideProviders(); + }); + + $('#editANewznabProvider').change(function(){ + $(this).populateNewznabSection(); + }); + + $('.provider_enabler').live('click', function(){ + $(this).refreshProviderList(); + }); + + + $('#newznab_add').click(function(){ + + var selectedProvider = $('#editANewznabProvider :selected').val(); + + var name = $('#newznab_name').val(); + var url = $('#newznab_url').val(); + var key = $('#newznab_key').val(); + + var params = { name: name } + + // send to the form with ajax, get a return value + $.getJSON(sbRoot + '/config/providers/canAddNewznabProvider', params, + function(data){ + if (data.error != undefined) { + alert(data.error); + return; + } + + $(this).addProvider(data.success, name, url, key, 0); + }); + + + }); + + $('.newznab_delete').click(function(){ + + var selectedProvider = $('#editANewznabProvider :selected').val(); + + $(this).deleteProvider(selectedProvider); + + }); + + // initialization stuff + + $(this).showHideProviders(); + + $("#providerOrderList").sortable({ + placeholder: 'ui-state-highlight', + update: function (event, ui) { + $(this).refreshProviderList(); + } + }); + + $("#providerOrderList").disableSelection(); + }); \ No newline at end of file diff --git a/lib/tvdb_api/__init__.py b/lib/tvdb_api/__init__.py index d3f5a12faa..8b13789179 100644 --- a/lib/tvdb_api/__init__.py +++ b/lib/tvdb_api/__init__.py @@ -1 +1 @@ - + diff --git a/sickbeard/clients/requests/packages/charade/cp949prober.py b/sickbeard/clients/requests/packages/charade/cp949prober.py index 543501fe09..ff4272f82a 100644 --- a/sickbeard/clients/requests/packages/charade/cp949prober.py +++ b/sickbeard/clients/requests/packages/charade/cp949prober.py @@ -1,44 +1,44 @@ -######################## BEGIN LICENSE BLOCK ######################## -# The Original Code is mozilla.org code. -# -# The Initial Developer of the Original Code is -# Netscape Communications Corporation. -# Portions created by the Initial Developer are Copyright (C) 1998 -# the Initial Developer. All Rights Reserved. -# -# Contributor(s): -# Mark Pilgrim - port to Python -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA -# 02110-1301 USA -######################### END LICENSE BLOCK ######################### - -from .mbcharsetprober import MultiByteCharSetProber -from .codingstatemachine import CodingStateMachine -from .chardistribution import EUCKRDistributionAnalysis -from .mbcssm import CP949SMModel - - -class CP949Prober(MultiByteCharSetProber): - def __init__(self): - MultiByteCharSetProber.__init__(self) - self._mCodingSM = CodingStateMachine(CP949SMModel) - # NOTE: CP949 is a superset of EUC-KR, so the distribution should be - # not different. - self._mDistributionAnalyzer = EUCKRDistributionAnalysis() - self.reset() - - def get_charset_name(self): - return "CP949" +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from .mbcharsetprober import MultiByteCharSetProber +from .codingstatemachine import CodingStateMachine +from .chardistribution import EUCKRDistributionAnalysis +from .mbcssm import CP949SMModel + + +class CP949Prober(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(CP949SMModel) + # NOTE: CP949 is a superset of EUC-KR, so the distribution should be + # not different. + self._mDistributionAnalyzer = EUCKRDistributionAnalysis() + self.reset() + + def get_charset_name(self): + return "CP949" diff --git a/sickbeard/clients/requests/packages/charade/langbulgarianmodel.py b/sickbeard/clients/requests/packages/charade/langbulgarianmodel.py index ea5a60ba04..e5788fc64a 100644 --- a/sickbeard/clients/requests/packages/charade/langbulgarianmodel.py +++ b/sickbeard/clients/requests/packages/charade/langbulgarianmodel.py @@ -1,229 +1,229 @@ -######################## BEGIN LICENSE BLOCK ######################## -# The Original Code is Mozilla Communicator client code. -# -# The Initial Developer of the Original Code is -# Netscape Communications Corporation. -# Portions created by the Initial Developer are Copyright (C) 1998 -# the Initial Developer. All Rights Reserved. -# -# Contributor(s): -# Mark Pilgrim - port to Python -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA -# 02110-1301 USA -######################### END LICENSE BLOCK ######################### - -# 255: Control characters that usually does not exist in any text -# 254: Carriage/Return -# 253: symbol (punctuation) that does not belong to word -# 252: 0 - 9 - -# Character Mapping Table: -# this table is modified base on win1251BulgarianCharToOrderMap, so -# only number <64 is sure valid - -Latin5_BulgarianCharToOrderMap = ( -255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 -255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 -253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 -252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 -253, 77, 90, 99,100, 72,109,107,101, 79,185, 81,102, 76, 94, 82, # 40 -110,186,108, 91, 74,119, 84, 96,111,187,115,253,253,253,253,253, # 50 -253, 65, 69, 70, 66, 63, 68,112,103, 92,194,104, 95, 86, 87, 71, # 60 -116,195, 85, 93, 97,113,196,197,198,199,200,253,253,253,253,253, # 70 -194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209, # 80 -210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225, # 90 - 81,226,227,228,229,230,105,231,232,233,234,235,236, 45,237,238, # a0 - 31, 32, 35, 43, 37, 44, 55, 47, 40, 59, 33, 46, 38, 36, 41, 30, # b0 - 39, 28, 34, 51, 48, 49, 53, 50, 54, 57, 61,239, 67,240, 60, 56, # c0 - 1, 18, 9, 20, 11, 3, 23, 15, 2, 26, 12, 10, 14, 6, 4, 13, # d0 - 7, 8, 5, 19, 29, 25, 22, 21, 27, 24, 17, 75, 52,241, 42, 16, # e0 - 62,242,243,244, 58,245, 98,246,247,248,249,250,251, 91,252,253, # f0 -) - -win1251BulgarianCharToOrderMap = ( -255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 -255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 -253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 -252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 -253, 77, 90, 99,100, 72,109,107,101, 79,185, 81,102, 76, 94, 82, # 40 -110,186,108, 91, 74,119, 84, 96,111,187,115,253,253,253,253,253, # 50 -253, 65, 69, 70, 66, 63, 68,112,103, 92,194,104, 95, 86, 87, 71, # 60 -116,195, 85, 93, 97,113,196,197,198,199,200,253,253,253,253,253, # 70 -206,207,208,209,210,211,212,213,120,214,215,216,217,218,219,220, # 80 -221, 78, 64, 83,121, 98,117,105,222,223,224,225,226,227,228,229, # 90 - 88,230,231,232,233,122, 89,106,234,235,236,237,238, 45,239,240, # a0 - 73, 80,118,114,241,242,243,244,245, 62, 58,246,247,248,249,250, # b0 - 31, 32, 35, 43, 37, 44, 55, 47, 40, 59, 33, 46, 38, 36, 41, 30, # c0 - 39, 28, 34, 51, 48, 49, 53, 50, 54, 57, 61,251, 67,252, 60, 56, # d0 - 1, 18, 9, 20, 11, 3, 23, 15, 2, 26, 12, 10, 14, 6, 4, 13, # e0 - 7, 8, 5, 19, 29, 25, 22, 21, 27, 24, 17, 75, 52,253, 42, 16, # f0 -) - -# Model Table: -# total sequences: 100% -# first 512 sequences: 96.9392% -# first 1024 sequences:3.0618% -# rest sequences: 0.2992% -# negative sequences: 0.0020% -BulgarianLangModel = ( -0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,2,3,3,3,3,3, -3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,3,3,3,2,2,3,2,2,1,2,2, -3,1,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,0,3,3,3,3,3,3,3,3,3,3,0,3,0,1, -0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,2,3,3,3,3,3,3,3,3,0,3,1,0, -0,1,0,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, -3,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,2,3,3,3,3,3,3,3,3,0,3,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -3,2,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,1,3,2,3,3,3,3,3,3,3,3,0,3,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -3,3,3,3,3,3,3,3,3,3,3,2,3,2,2,1,3,3,3,3,2,2,2,1,1,2,0,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,2,3,2,2,3,3,1,1,2,3,3,2,3,3,3,3,2,1,2,0,2,0,3,0,0, -0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,1,3,3,3,3,3,2,3,2,3,3,3,3,3,2,3,3,1,3,0,3,0,2,0,0, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,3,1,3,3,2,3,3,3,1,3,3,2,3,2,2,2,0,0,2,0,2,0,2,0,0, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,3,3,0,3,3,3,2,2,3,3,3,1,2,2,3,2,1,1,2,0,2,0,0,0,0, -1,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,2,3,3,1,2,3,2,2,2,3,3,3,3,3,2,2,3,1,2,0,2,1,2,0,0, -0,0,0,0,0,0,0,0,0,0,3,0,0,1,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,1,3,3,3,3,3,2,3,3,3,2,3,3,2,3,2,2,2,3,1,2,0,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,3,3,3,3,1,1,1,2,2,1,3,1,3,2,2,3,0,0,1,0,1,0,1,0,0, -0,0,0,1,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,2,2,3,2,2,3,1,2,1,1,1,2,3,1,3,1,2,2,0,1,1,1,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,1,3,2,2,3,3,1,2,3,1,1,3,3,3,3,1,2,2,1,1,1,0,2,0,2,0,1, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,3,3,3,2,2,1,1,2,0,2,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,0,1,2,1,3,3,2,3,3,3,3,3,2,3,2,1,0,3,1,2,1,2,1,2,3,2,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -1,1,1,2,3,3,3,3,3,3,3,3,3,3,3,3,0,0,3,1,3,3,2,3,3,2,2,2,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -2,3,3,3,3,0,3,3,3,3,3,2,1,1,2,1,3,3,0,3,1,1,1,1,3,2,0,1,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,2,2,2,3,3,3,3,3,3,3,3,3,3,3,1,1,3,1,3,3,2,3,2,2,2,3,0,2,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -3,3,3,3,3,2,3,3,2,2,3,2,1,1,1,1,1,3,1,3,1,1,0,0,0,1,0,0,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0, -3,3,3,3,3,2,3,2,0,3,2,0,3,0,2,0,0,2,1,3,1,0,0,1,0,0,0,1,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,2,1,1,1,1,2,1,1,2,1,1,1,2,2,1,2,1,1,1,0,1,1,0,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,2,1,3,1,1,2,1,3,2,1,1,0,1,2,3,2,1,1,1,0,0,0,0,0,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -2,3,3,3,3,2,2,1,0,1,0,0,1,0,0,0,2,1,0,3,0,0,1,0,0,0,0,0,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,2,3,2,3,3,1,3,2,1,1,1,2,1,1,2,1,3,0,1,0,0,0,1,0,0,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -3,1,1,2,2,3,3,2,3,2,2,2,3,1,2,2,1,1,2,1,1,2,2,0,1,1,0,1,0,2,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -3,3,3,3,2,1,3,1,0,2,2,1,3,2,1,0,0,2,0,2,0,1,0,0,0,0,0,0,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, -3,3,3,3,3,3,1,2,0,2,3,1,2,3,2,0,1,3,1,2,1,1,1,0,0,1,0,0,2,2,2,3, -2,2,2,2,1,2,1,1,2,2,1,1,2,0,1,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1, -3,3,3,3,3,2,1,2,2,1,2,0,2,0,1,0,1,2,1,2,1,1,0,0,0,1,0,1,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, -3,3,2,3,3,1,1,3,1,0,3,2,1,0,0,0,1,2,0,2,0,1,0,0,0,1,0,1,2,1,2,2, -1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,0,1,2,1,1,1,0,0,0,0,0,1,1,0,0, -3,1,0,1,0,2,3,2,2,2,3,2,2,2,2,2,1,0,2,1,2,1,1,1,0,1,2,1,2,2,2,1, -1,1,2,2,2,2,1,2,1,1,0,1,2,1,2,2,2,1,1,1,0,1,1,1,1,2,0,1,0,0,0,0, -2,3,2,3,3,0,0,2,1,0,2,1,0,0,0,0,2,3,0,2,0,0,0,0,0,1,0,0,2,0,1,2, -2,1,2,1,2,2,1,1,1,2,1,1,1,0,1,2,2,1,1,1,1,1,0,1,1,1,0,0,1,2,0,0, -3,3,2,2,3,0,2,3,1,1,2,0,0,0,1,0,0,2,0,2,0,0,0,1,0,1,0,1,2,0,2,2, -1,1,1,1,2,1,0,1,2,2,2,1,1,1,1,1,1,1,0,1,1,1,0,0,0,0,0,0,1,1,0,0, -2,3,2,3,3,0,0,3,0,1,1,0,1,0,0,0,2,2,1,2,0,0,0,0,0,0,0,0,2,0,1,2, -2,2,1,1,1,1,1,2,2,2,1,0,2,0,1,0,1,0,0,1,0,1,0,0,1,0,0,0,0,1,0,0, -3,3,3,3,2,2,2,2,2,0,2,1,1,1,1,2,1,2,1,1,0,2,0,1,0,1,0,0,2,0,1,2, -1,1,1,1,1,1,1,2,2,1,1,0,2,0,1,0,2,0,0,1,1,1,0,0,2,0,0,0,1,1,0,0, -2,3,3,3,3,1,0,0,0,0,0,0,0,0,0,0,2,0,0,1,1,0,0,0,0,0,0,1,2,0,1,2, -2,2,2,1,1,2,1,1,2,2,2,1,2,0,1,1,1,1,1,1,0,1,1,1,1,0,0,1,1,1,0,0, -2,3,3,3,3,0,2,2,0,2,1,0,0,0,1,1,1,2,0,2,0,0,0,3,0,0,0,0,2,0,2,2, -1,1,1,2,1,2,1,1,2,2,2,1,2,0,1,1,1,0,1,1,1,1,0,2,1,0,0,0,1,1,0,0, -2,3,3,3,3,0,2,1,0,0,2,0,0,0,0,0,1,2,0,2,0,0,0,0,0,0,0,0,2,0,1,2, -1,1,1,2,1,1,1,1,2,2,2,0,1,0,1,1,1,0,0,1,1,1,0,0,1,0,0,0,0,1,0,0, -3,3,2,2,3,0,1,0,1,0,0,0,0,0,0,0,1,1,0,3,0,0,0,0,0,0,0,0,1,0,2,2, -1,1,1,1,1,2,1,1,2,2,1,2,2,1,0,1,1,1,1,1,0,1,0,0,1,0,0,0,1,1,0,0, -3,1,0,1,0,2,2,2,2,3,2,1,1,1,2,3,0,0,1,0,2,1,1,0,1,1,1,1,2,1,1,1, -1,2,2,1,2,1,2,2,1,1,0,1,2,1,2,2,1,1,1,0,0,1,1,1,2,1,0,1,0,0,0,0, -2,1,0,1,0,3,1,2,2,2,2,1,2,2,1,1,1,0,2,1,2,2,1,1,2,1,1,0,2,1,1,1, -1,2,2,2,2,2,2,2,1,2,0,1,1,0,2,1,1,1,1,1,0,0,1,1,1,1,0,1,0,0,0,0, -2,1,1,1,1,2,2,2,2,1,2,2,2,1,2,2,1,1,2,1,2,3,2,2,1,1,1,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -2,2,2,3,2,0,1,2,0,1,2,1,1,0,1,0,1,2,1,2,0,0,0,1,1,0,0,0,1,0,0,2, -1,1,0,0,1,1,0,1,1,1,1,0,2,0,1,1,1,0,0,1,1,0,0,0,0,1,0,0,0,1,0,0, -2,0,0,0,0,1,2,2,2,2,2,2,2,1,2,1,1,1,1,1,1,1,0,1,1,1,1,1,2,1,1,1, -1,2,2,2,2,1,1,2,1,2,1,1,1,0,2,1,2,1,1,1,0,2,1,1,1,1,0,1,0,0,0,0, -3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0, -1,1,0,1,0,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -2,2,2,3,2,0,0,0,0,1,0,0,0,0,0,0,1,1,0,2,0,0,0,0,0,0,0,0,1,0,1,2, -1,1,1,1,1,1,0,0,2,2,2,2,2,0,1,1,0,1,1,1,1,1,0,0,1,0,0,0,1,1,0,1, -2,3,1,2,1,0,1,1,0,2,2,2,0,0,1,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,1,2, -1,1,1,1,2,1,1,1,1,1,1,1,1,0,1,1,0,1,0,1,0,1,0,0,1,0,0,0,0,1,0,0, -2,2,2,2,2,0,0,2,0,0,2,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,2,0,2,2, -1,1,1,1,1,0,0,1,2,1,1,0,1,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, -1,2,2,2,2,0,0,2,0,1,1,0,0,0,1,0,0,2,0,2,0,0,0,0,0,0,0,0,0,0,1,1, -0,0,0,1,1,1,1,1,1,1,1,1,1,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0, -1,2,2,3,2,0,0,1,0,0,1,0,0,0,0,0,0,1,0,2,0,0,0,1,0,0,0,0,0,0,0,2, -1,1,0,0,1,0,0,0,1,1,0,0,1,0,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, -2,1,2,2,2,1,2,1,2,2,1,1,2,1,1,1,0,1,1,1,1,2,0,1,0,1,1,1,1,0,1,1, -1,1,2,1,1,1,1,1,1,0,0,1,2,1,1,1,1,1,1,0,0,1,1,1,0,0,0,0,0,0,0,0, -1,0,0,1,3,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -2,2,2,2,1,0,0,1,0,2,0,0,0,0,0,1,1,1,0,1,0,0,0,0,0,0,0,0,2,0,0,1, -0,2,0,1,0,0,1,1,2,0,1,0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0, -1,2,2,2,2,0,1,1,0,2,1,0,1,1,1,0,0,1,0,2,0,1,0,0,0,0,0,0,0,0,0,1, -0,1,0,0,1,0,0,0,1,1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, -2,2,2,2,2,0,0,1,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1, -0,1,0,1,1,1,0,0,1,1,1,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, -2,0,1,0,0,1,2,1,1,1,1,1,1,2,2,1,0,0,1,0,1,0,0,0,0,1,1,1,1,0,0,0, -1,1,2,1,1,1,1,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -2,2,1,2,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1, -0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -1,0,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0, -0,1,1,0,1,1,1,0,0,1,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0, -1,0,1,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,1,0,2,0,0,2,0,1,0,0,1,0,0,1, -1,1,0,0,1,1,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, -0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0, -1,1,1,1,1,1,1,2,0,0,0,0,0,0,2,1,0,1,1,0,0,1,1,1,0,1,0,0,0,0,0,0, -2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, -1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,1,1,1,1,0,1,0,0, -0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, -) - -Latin5BulgarianModel = { - 'charToOrderMap': Latin5_BulgarianCharToOrderMap, - 'precedenceMatrix': BulgarianLangModel, - 'mTypicalPositiveRatio': 0.969392, - 'keepEnglishLetter': False, - 'charsetName': "ISO-8859-5" -} - -Win1251BulgarianModel = { - 'charToOrderMap': win1251BulgarianCharToOrderMap, - 'precedenceMatrix': BulgarianLangModel, - 'mTypicalPositiveRatio': 0.969392, - 'keepEnglishLetter': False, - 'charsetName': "windows-1251" -} - - -# flake8: noqa +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Communicator client code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +# 255: Control characters that usually does not exist in any text +# 254: Carriage/Return +# 253: symbol (punctuation) that does not belong to word +# 252: 0 - 9 + +# Character Mapping Table: +# this table is modified base on win1251BulgarianCharToOrderMap, so +# only number <64 is sure valid + +Latin5_BulgarianCharToOrderMap = ( +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 77, 90, 99,100, 72,109,107,101, 79,185, 81,102, 76, 94, 82, # 40 +110,186,108, 91, 74,119, 84, 96,111,187,115,253,253,253,253,253, # 50 +253, 65, 69, 70, 66, 63, 68,112,103, 92,194,104, 95, 86, 87, 71, # 60 +116,195, 85, 93, 97,113,196,197,198,199,200,253,253,253,253,253, # 70 +194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209, # 80 +210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225, # 90 + 81,226,227,228,229,230,105,231,232,233,234,235,236, 45,237,238, # a0 + 31, 32, 35, 43, 37, 44, 55, 47, 40, 59, 33, 46, 38, 36, 41, 30, # b0 + 39, 28, 34, 51, 48, 49, 53, 50, 54, 57, 61,239, 67,240, 60, 56, # c0 + 1, 18, 9, 20, 11, 3, 23, 15, 2, 26, 12, 10, 14, 6, 4, 13, # d0 + 7, 8, 5, 19, 29, 25, 22, 21, 27, 24, 17, 75, 52,241, 42, 16, # e0 + 62,242,243,244, 58,245, 98,246,247,248,249,250,251, 91,252,253, # f0 +) + +win1251BulgarianCharToOrderMap = ( +255,255,255,255,255,255,255,255,255,255,254,255,255,254,255,255, # 00 +255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255, # 10 +253,253,253,253,253,253,253,253,253,253,253,253,253,253,253,253, # 20 +252,252,252,252,252,252,252,252,252,252,253,253,253,253,253,253, # 30 +253, 77, 90, 99,100, 72,109,107,101, 79,185, 81,102, 76, 94, 82, # 40 +110,186,108, 91, 74,119, 84, 96,111,187,115,253,253,253,253,253, # 50 +253, 65, 69, 70, 66, 63, 68,112,103, 92,194,104, 95, 86, 87, 71, # 60 +116,195, 85, 93, 97,113,196,197,198,199,200,253,253,253,253,253, # 70 +206,207,208,209,210,211,212,213,120,214,215,216,217,218,219,220, # 80 +221, 78, 64, 83,121, 98,117,105,222,223,224,225,226,227,228,229, # 90 + 88,230,231,232,233,122, 89,106,234,235,236,237,238, 45,239,240, # a0 + 73, 80,118,114,241,242,243,244,245, 62, 58,246,247,248,249,250, # b0 + 31, 32, 35, 43, 37, 44, 55, 47, 40, 59, 33, 46, 38, 36, 41, 30, # c0 + 39, 28, 34, 51, 48, 49, 53, 50, 54, 57, 61,251, 67,252, 60, 56, # d0 + 1, 18, 9, 20, 11, 3, 23, 15, 2, 26, 12, 10, 14, 6, 4, 13, # e0 + 7, 8, 5, 19, 29, 25, 22, 21, 27, 24, 17, 75, 52,253, 42, 16, # f0 +) + +# Model Table: +# total sequences: 100% +# first 512 sequences: 96.9392% +# first 1024 sequences:3.0618% +# rest sequences: 0.2992% +# negative sequences: 0.0020% +BulgarianLangModel = ( +0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,3,3,3,3,3,3,3,2,3,3,3,3,3, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,3,3,3,2,2,3,2,2,1,2,2, +3,1,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,0,3,3,3,3,3,3,3,3,3,3,0,3,0,1, +0,0,0,0,0,0,0,0,0,0,1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,3,2,3,3,3,3,3,3,3,3,0,3,1,0, +0,1,0,0,0,0,0,0,0,0,1,1,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,2,3,3,3,3,3,3,3,3,0,3,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,2,3,3,2,3,3,3,3,3,3,3,3,3,3,3,3,1,3,2,3,3,3,3,3,3,3,3,0,3,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,3,3,3,3,3,3,2,3,2,2,1,3,3,3,3,2,2,2,1,1,2,0,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,2,3,2,2,3,3,1,1,2,3,3,2,3,3,3,3,2,1,2,0,2,0,3,0,0, +0,0,0,0,0,0,0,1,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,1,3,3,3,3,3,2,3,2,3,3,3,3,3,2,3,3,1,3,0,3,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,1,3,3,2,3,3,3,1,3,3,2,3,2,2,2,0,0,2,0,2,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,0,3,3,3,2,2,3,3,3,1,2,2,3,2,1,1,2,0,2,0,0,0,0, +1,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,2,3,3,1,2,3,2,2,2,3,3,3,3,3,2,2,3,1,2,0,2,1,2,0,0, +0,0,0,0,0,0,0,0,0,0,3,0,0,1,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,1,3,3,3,3,3,2,3,3,3,2,3,3,2,3,2,2,2,3,1,2,0,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,3,3,1,1,1,2,2,1,3,1,3,2,2,3,0,0,1,0,1,0,1,0,0, +0,0,0,1,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,2,2,3,2,2,3,1,2,1,1,1,2,3,1,3,1,2,2,0,1,1,1,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,1,3,2,2,3,3,1,2,3,1,1,3,3,3,3,1,2,2,1,1,1,0,2,0,2,0,1, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,2,2,3,3,3,2,2,1,1,2,0,2,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,0,1,2,1,3,3,2,3,3,3,3,3,2,3,2,1,0,3,1,2,1,2,1,2,3,2,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,1,1,2,3,3,3,3,3,3,3,3,3,3,3,3,0,0,3,1,3,3,2,3,3,2,2,2,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,0,3,3,3,3,3,2,1,1,2,1,3,3,0,3,1,1,1,1,3,2,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,2,2,2,3,3,3,3,3,3,3,3,3,3,3,1,1,3,1,3,3,2,3,2,2,2,3,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,2,3,3,2,2,3,2,1,1,1,1,1,3,1,3,1,1,0,0,0,1,0,0,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,3,2,3,2,0,3,2,0,3,0,2,0,0,2,1,3,1,0,0,1,0,0,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,2,1,1,1,1,2,1,1,2,1,1,1,2,2,1,2,1,1,1,0,1,1,0,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,2,1,3,1,1,2,1,3,2,1,1,0,1,2,3,2,1,1,1,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,3,3,3,3,2,2,1,0,1,0,0,1,0,0,0,2,1,0,3,0,0,1,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,2,3,2,3,3,1,3,2,1,1,1,2,1,1,2,1,3,0,1,0,0,0,1,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,1,1,2,2,3,3,2,3,2,2,2,3,1,2,2,1,1,2,1,1,2,2,0,1,1,0,1,0,2,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,3,3,3,2,1,3,1,0,2,2,1,3,2,1,0,0,2,0,2,0,1,0,0,0,0,0,0,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1, +3,3,3,3,3,3,1,2,0,2,3,1,2,3,2,0,1,3,1,2,1,1,1,0,0,1,0,0,2,2,2,3, +2,2,2,2,1,2,1,1,2,2,1,1,2,0,1,1,1,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1, +3,3,3,3,3,2,1,2,2,1,2,0,2,0,1,0,1,2,1,2,1,1,0,0,0,1,0,1,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,1, +3,3,2,3,3,1,1,3,1,0,3,2,1,0,0,0,1,2,0,2,0,1,0,0,0,1,0,1,2,1,2,2, +1,1,1,1,1,1,1,2,2,2,1,1,1,1,1,1,1,0,1,2,1,1,1,0,0,0,0,0,1,1,0,0, +3,1,0,1,0,2,3,2,2,2,3,2,2,2,2,2,1,0,2,1,2,1,1,1,0,1,2,1,2,2,2,1, +1,1,2,2,2,2,1,2,1,1,0,1,2,1,2,2,2,1,1,1,0,1,1,1,1,2,0,1,0,0,0,0, +2,3,2,3,3,0,0,2,1,0,2,1,0,0,0,0,2,3,0,2,0,0,0,0,0,1,0,0,2,0,1,2, +2,1,2,1,2,2,1,1,1,2,1,1,1,0,1,2,2,1,1,1,1,1,0,1,1,1,0,0,1,2,0,0, +3,3,2,2,3,0,2,3,1,1,2,0,0,0,1,0,0,2,0,2,0,0,0,1,0,1,0,1,2,0,2,2, +1,1,1,1,2,1,0,1,2,2,2,1,1,1,1,1,1,1,0,1,1,1,0,0,0,0,0,0,1,1,0,0, +2,3,2,3,3,0,0,3,0,1,1,0,1,0,0,0,2,2,1,2,0,0,0,0,0,0,0,0,2,0,1,2, +2,2,1,1,1,1,1,2,2,2,1,0,2,0,1,0,1,0,0,1,0,1,0,0,1,0,0,0,0,1,0,0, +3,3,3,3,2,2,2,2,2,0,2,1,1,1,1,2,1,2,1,1,0,2,0,1,0,1,0,0,2,0,1,2, +1,1,1,1,1,1,1,2,2,1,1,0,2,0,1,0,2,0,0,1,1,1,0,0,2,0,0,0,1,1,0,0, +2,3,3,3,3,1,0,0,0,0,0,0,0,0,0,0,2,0,0,1,1,0,0,0,0,0,0,1,2,0,1,2, +2,2,2,1,1,2,1,1,2,2,2,1,2,0,1,1,1,1,1,1,0,1,1,1,1,0,0,1,1,1,0,0, +2,3,3,3,3,0,2,2,0,2,1,0,0,0,1,1,1,2,0,2,0,0,0,3,0,0,0,0,2,0,2,2, +1,1,1,2,1,2,1,1,2,2,2,1,2,0,1,1,1,0,1,1,1,1,0,2,1,0,0,0,1,1,0,0, +2,3,3,3,3,0,2,1,0,0,2,0,0,0,0,0,1,2,0,2,0,0,0,0,0,0,0,0,2,0,1,2, +1,1,1,2,1,1,1,1,2,2,2,0,1,0,1,1,1,0,0,1,1,1,0,0,1,0,0,0,0,1,0,0, +3,3,2,2,3,0,1,0,1,0,0,0,0,0,0,0,1,1,0,3,0,0,0,0,0,0,0,0,1,0,2,2, +1,1,1,1,1,2,1,1,2,2,1,2,2,1,0,1,1,1,1,1,0,1,0,0,1,0,0,0,1,1,0,0, +3,1,0,1,0,2,2,2,2,3,2,1,1,1,2,3,0,0,1,0,2,1,1,0,1,1,1,1,2,1,1,1, +1,2,2,1,2,1,2,2,1,1,0,1,2,1,2,2,1,1,1,0,0,1,1,1,2,1,0,1,0,0,0,0, +2,1,0,1,0,3,1,2,2,2,2,1,2,2,1,1,1,0,2,1,2,2,1,1,2,1,1,0,2,1,1,1, +1,2,2,2,2,2,2,2,1,2,0,1,1,0,2,1,1,1,1,1,0,0,1,1,1,1,0,1,0,0,0,0, +2,1,1,1,1,2,2,2,2,1,2,2,2,1,2,2,1,1,2,1,2,3,2,2,1,1,1,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,3,2,0,1,2,0,1,2,1,1,0,1,0,1,2,1,2,0,0,0,1,1,0,0,0,1,0,0,2, +1,1,0,0,1,1,0,1,1,1,1,0,2,0,1,1,1,0,0,1,1,0,0,0,0,1,0,0,0,1,0,0, +2,0,0,0,0,1,2,2,2,2,2,2,2,1,2,1,1,1,1,1,1,1,0,1,1,1,1,1,2,1,1,1, +1,2,2,2,2,1,1,2,1,2,1,1,1,0,2,1,2,1,1,1,0,2,1,1,1,1,0,1,0,0,0,0, +3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0, +1,1,0,1,0,1,1,1,1,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,3,2,0,0,0,0,1,0,0,0,0,0,0,1,1,0,2,0,0,0,0,0,0,0,0,1,0,1,2, +1,1,1,1,1,1,0,0,2,2,2,2,2,0,1,1,0,1,1,1,1,1,0,0,1,0,0,0,1,1,0,1, +2,3,1,2,1,0,1,1,0,2,2,2,0,0,1,0,0,1,1,1,1,0,0,0,0,0,0,0,1,0,1,2, +1,1,1,1,2,1,1,1,1,1,1,1,1,0,1,1,0,1,0,1,0,1,0,0,1,0,0,0,0,1,0,0, +2,2,2,2,2,0,0,2,0,0,2,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,2,0,2,2, +1,1,1,1,1,0,0,1,2,1,1,0,1,0,1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,2,0,0,2,0,1,1,0,0,0,1,0,0,2,0,2,0,0,0,0,0,0,0,0,0,0,1,1, +0,0,0,1,1,1,1,1,1,1,1,1,1,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,3,2,0,0,1,0,0,1,0,0,0,0,0,0,1,0,2,0,0,0,1,0,0,0,0,0,0,0,2, +1,1,0,0,1,0,0,0,1,1,0,0,1,0,1,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +2,1,2,2,2,1,2,1,2,2,1,1,2,1,1,1,0,1,1,1,1,2,0,1,0,1,1,1,1,0,1,1, +1,1,2,1,1,1,1,1,1,0,0,1,2,1,1,1,1,1,1,0,0,1,1,1,0,0,0,0,0,0,0,0, +1,0,0,1,3,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,2,1,0,0,1,0,2,0,0,0,0,0,1,1,1,0,1,0,0,0,0,0,0,0,0,2,0,0,1, +0,2,0,1,0,0,1,1,2,0,1,0,1,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0, +1,2,2,2,2,0,1,1,0,2,1,0,1,1,1,0,0,1,0,2,0,1,0,0,0,0,0,0,0,0,0,1, +0,1,0,0,1,0,0,0,1,1,0,0,1,0,0,1,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +2,2,2,2,2,0,0,1,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1, +0,1,0,1,1,1,0,0,1,1,1,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0, +2,0,1,0,0,1,2,1,1,1,1,1,1,2,2,1,0,0,1,0,1,0,0,0,0,1,1,1,1,0,0,0, +1,1,2,1,1,1,1,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +2,2,1,2,1,0,0,1,0,0,0,0,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1, +0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0, +0,1,1,0,1,1,1,0,0,1,0,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0, +1,0,1,0,0,1,1,1,1,1,1,1,1,1,1,1,0,0,1,0,2,0,0,2,0,1,0,0,1,0,0,1, +1,1,0,0,1,1,0,1,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0, +1,1,1,1,1,1,1,2,0,0,0,0,0,0,2,1,0,1,1,0,0,1,1,1,0,1,0,0,0,0,0,0, +2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,1,1,0,1,1,1,1,1,0,1,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1, +) + +Latin5BulgarianModel = { + 'charToOrderMap': Latin5_BulgarianCharToOrderMap, + 'precedenceMatrix': BulgarianLangModel, + 'mTypicalPositiveRatio': 0.969392, + 'keepEnglishLetter': False, + 'charsetName': "ISO-8859-5" +} + +Win1251BulgarianModel = { + 'charToOrderMap': win1251BulgarianCharToOrderMap, + 'precedenceMatrix': BulgarianLangModel, + 'mTypicalPositiveRatio': 0.969392, + 'keepEnglishLetter': False, + 'charsetName': "windows-1251" +} + + +# flake8: noqa diff --git a/sickbeard/clients/requests/packages/charade/sjisprober.py b/sickbeard/clients/requests/packages/charade/sjisprober.py index 9bb0cdcf1f..b173614e68 100644 --- a/sickbeard/clients/requests/packages/charade/sjisprober.py +++ b/sickbeard/clients/requests/packages/charade/sjisprober.py @@ -1,91 +1,91 @@ -######################## BEGIN LICENSE BLOCK ######################## -# The Original Code is mozilla.org code. -# -# The Initial Developer of the Original Code is -# Netscape Communications Corporation. -# Portions created by the Initial Developer are Copyright (C) 1998 -# the Initial Developer. All Rights Reserved. -# -# Contributor(s): -# Mark Pilgrim - port to Python -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA -# 02110-1301 USA -######################### END LICENSE BLOCK ######################### - -import sys -from .mbcharsetprober import MultiByteCharSetProber -from .codingstatemachine import CodingStateMachine -from .chardistribution import SJISDistributionAnalysis -from .jpcntx import SJISContextAnalysis -from .mbcssm import SJISSMModel -from . import constants - - -class SJISProber(MultiByteCharSetProber): - def __init__(self): - MultiByteCharSetProber.__init__(self) - self._mCodingSM = CodingStateMachine(SJISSMModel) - self._mDistributionAnalyzer = SJISDistributionAnalysis() - self._mContextAnalyzer = SJISContextAnalysis() - self.reset() - - def reset(self): - MultiByteCharSetProber.reset(self) - self._mContextAnalyzer.reset() - - def get_charset_name(self): - return "SHIFT_JIS" - - def feed(self, aBuf): - aLen = len(aBuf) - for i in range(0, aLen): - codingState = self._mCodingSM.next_state(aBuf[i]) - if codingState == constants.eError: - if constants._debug: - sys.stderr.write(self.get_charset_name() - + ' prober hit error at byte ' + str(i) - + '\n') - self._mState = constants.eNotMe - break - elif codingState == constants.eItsMe: - self._mState = constants.eFoundIt - break - elif codingState == constants.eStart: - charLen = self._mCodingSM.get_current_charlen() - if i == 0: - self._mLastChar[1] = aBuf[0] - self._mContextAnalyzer.feed(self._mLastChar[2 - charLen:], - charLen) - self._mDistributionAnalyzer.feed(self._mLastChar, charLen) - else: - self._mContextAnalyzer.feed(aBuf[i + 1 - charLen:i + 3 - - charLen], charLen) - self._mDistributionAnalyzer.feed(aBuf[i - 1:i + 1], - charLen) - - self._mLastChar[0] = aBuf[aLen - 1] - - if self.get_state() == constants.eDetecting: - if (self._mContextAnalyzer.got_enough_data() and - (self.get_confidence() > constants.SHORTCUT_THRESHOLD)): - self._mState = constants.eFoundIt - - return self.get_state() - - def get_confidence(self): - contxtCf = self._mContextAnalyzer.get_confidence() - distribCf = self._mDistributionAnalyzer.get_confidence() - return max(contxtCf, distribCf) +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is mozilla.org code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 1998 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +import sys +from .mbcharsetprober import MultiByteCharSetProber +from .codingstatemachine import CodingStateMachine +from .chardistribution import SJISDistributionAnalysis +from .jpcntx import SJISContextAnalysis +from .mbcssm import SJISSMModel +from . import constants + + +class SJISProber(MultiByteCharSetProber): + def __init__(self): + MultiByteCharSetProber.__init__(self) + self._mCodingSM = CodingStateMachine(SJISSMModel) + self._mDistributionAnalyzer = SJISDistributionAnalysis() + self._mContextAnalyzer = SJISContextAnalysis() + self.reset() + + def reset(self): + MultiByteCharSetProber.reset(self) + self._mContextAnalyzer.reset() + + def get_charset_name(self): + return "SHIFT_JIS" + + def feed(self, aBuf): + aLen = len(aBuf) + for i in range(0, aLen): + codingState = self._mCodingSM.next_state(aBuf[i]) + if codingState == constants.eError: + if constants._debug: + sys.stderr.write(self.get_charset_name() + + ' prober hit error at byte ' + str(i) + + '\n') + self._mState = constants.eNotMe + break + elif codingState == constants.eItsMe: + self._mState = constants.eFoundIt + break + elif codingState == constants.eStart: + charLen = self._mCodingSM.get_current_charlen() + if i == 0: + self._mLastChar[1] = aBuf[0] + self._mContextAnalyzer.feed(self._mLastChar[2 - charLen:], + charLen) + self._mDistributionAnalyzer.feed(self._mLastChar, charLen) + else: + self._mContextAnalyzer.feed(aBuf[i + 1 - charLen:i + 3 + - charLen], charLen) + self._mDistributionAnalyzer.feed(aBuf[i - 1:i + 1], + charLen) + + self._mLastChar[0] = aBuf[aLen - 1] + + if self.get_state() == constants.eDetecting: + if (self._mContextAnalyzer.got_enough_data() and + (self.get_confidence() > constants.SHORTCUT_THRESHOLD)): + self._mState = constants.eFoundIt + + return self.get_state() + + def get_confidence(self): + contxtCf = self._mContextAnalyzer.get_confidence() + distribCf = self._mDistributionAnalyzer.get_confidence() + return max(contxtCf, distribCf) diff --git a/sickbeard/clients/requests/packages/charade/universaldetector.py b/sickbeard/clients/requests/packages/charade/universaldetector.py index 6175bfbc33..6307155d2a 100644 --- a/sickbeard/clients/requests/packages/charade/universaldetector.py +++ b/sickbeard/clients/requests/packages/charade/universaldetector.py @@ -1,172 +1,172 @@ -######################## BEGIN LICENSE BLOCK ######################## -# The Original Code is Mozilla Universal charset detector code. -# -# The Initial Developer of the Original Code is -# Netscape Communications Corporation. -# Portions created by the Initial Developer are Copyright (C) 2001 -# the Initial Developer. All Rights Reserved. -# -# Contributor(s): -# Mark Pilgrim - port to Python -# Shy Shalom - original C code -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA -# 02110-1301 USA -######################### END LICENSE BLOCK ######################### - -from . import constants -import sys -import codecs -from .latin1prober import Latin1Prober # windows-1252 -from .mbcsgroupprober import MBCSGroupProber # multi-byte character sets -from .sbcsgroupprober import SBCSGroupProber # single-byte character sets -from .escprober import EscCharSetProber # ISO-2122, etc. -import re - -MINIMUM_THRESHOLD = 0.20 -ePureAscii = 0 -eEscAscii = 1 -eHighbyte = 2 - - -class UniversalDetector: - def __init__(self): - self._highBitDetector = re.compile(b'[\x80-\xFF]') - self._escDetector = re.compile(b'(\033|~{)') - self._mEscCharSetProber = None - self._mCharSetProbers = [] - self.reset() - - def reset(self): - self.result = {'encoding': None, 'confidence': 0.0} - self.done = False - self._mStart = True - self._mGotData = False - self._mInputState = ePureAscii - self._mLastChar = b'' - if self._mEscCharSetProber: - self._mEscCharSetProber.reset() - for prober in self._mCharSetProbers: - prober.reset() - - def feed(self, aBuf): - if self.done: - return - - aLen = len(aBuf) - if not aLen: - return - - if not self._mGotData: - # If the data starts with BOM, we know it is UTF - if aBuf[:3] == codecs.BOM: - # EF BB BF UTF-8 with BOM - self.result = {'encoding': "UTF-8", 'confidence': 1.0} - elif aBuf[:4] == codecs.BOM_UTF32_LE: - # FF FE 00 00 UTF-32, little-endian BOM - self.result = {'encoding': "UTF-32LE", 'confidence': 1.0} - elif aBuf[:4] == codecs.BOM_UTF32_BE: - # 00 00 FE FF UTF-32, big-endian BOM - self.result = {'encoding': "UTF-32BE", 'confidence': 1.0} - elif aBuf[:4] == b'\xFE\xFF\x00\x00': - # FE FF 00 00 UCS-4, unusual octet order BOM (3412) - self.result = { - 'encoding': "X-ISO-10646-UCS-4-3412", - 'confidence': 1.0 - } - elif aBuf[:4] == b'\x00\x00\xFF\xFE': - # 00 00 FF FE UCS-4, unusual octet order BOM (2143) - self.result = { - 'encoding': "X-ISO-10646-UCS-4-2143", - 'confidence': 1.0 - } - elif aBuf[:2] == codecs.BOM_LE: - # FF FE UTF-16, little endian BOM - self.result = {'encoding': "UTF-16LE", 'confidence': 1.0} - elif aBuf[:2] == codecs.BOM_BE: - # FE FF UTF-16, big endian BOM - self.result = {'encoding': "UTF-16BE", 'confidence': 1.0} - - self._mGotData = True - if self.result['encoding'] and (self.result['confidence'] > 0.0): - self.done = True - return - - if self._mInputState == ePureAscii: - if self._highBitDetector.search(aBuf): - self._mInputState = eHighbyte - elif ((self._mInputState == ePureAscii) and - self._escDetector.search(self._mLastChar + aBuf)): - self._mInputState = eEscAscii - - self._mLastChar = aBuf[-1:] - - if self._mInputState == eEscAscii: - if not self._mEscCharSetProber: - self._mEscCharSetProber = EscCharSetProber() - if self._mEscCharSetProber.feed(aBuf) == constants.eFoundIt: - self.result = { - 'encoding': self._mEscCharSetProber.get_charset_name(), - 'confidence': self._mEscCharSetProber.get_confidence() - } - self.done = True - elif self._mInputState == eHighbyte: - if not self._mCharSetProbers: - self._mCharSetProbers = [MBCSGroupProber(), SBCSGroupProber(), - Latin1Prober()] - for prober in self._mCharSetProbers: - if prober.feed(aBuf) == constants.eFoundIt: - self.result = {'encoding': prober.get_charset_name(), - 'confidence': prober.get_confidence()} - self.done = True - break - - def close(self): - if self.done: - return - if not self._mGotData: - if constants._debug: - sys.stderr.write('no data received!\n') - return - self.done = True - - if self._mInputState == ePureAscii: - self.result = {'encoding': 'ascii', 'confidence': 1.0} - return self.result - - if self._mInputState == eHighbyte: - proberConfidence = None - maxProberConfidence = 0.0 - maxProber = None - for prober in self._mCharSetProbers: - if not prober: - continue - proberConfidence = prober.get_confidence() - if proberConfidence > maxProberConfidence: - maxProberConfidence = proberConfidence - maxProber = prober - if maxProber and (maxProberConfidence > MINIMUM_THRESHOLD): - self.result = {'encoding': maxProber.get_charset_name(), - 'confidence': maxProber.get_confidence()} - return self.result - - if constants._debug: - sys.stderr.write('no probers hit minimum threshhold\n') - for prober in self._mCharSetProbers[0].mProbers: - if not prober: - continue - sys.stderr.write('%s confidence = %s\n' % - (prober.get_charset_name(), - prober.get_confidence())) +######################## BEGIN LICENSE BLOCK ######################## +# The Original Code is Mozilla Universal charset detector code. +# +# The Initial Developer of the Original Code is +# Netscape Communications Corporation. +# Portions created by the Initial Developer are Copyright (C) 2001 +# the Initial Developer. All Rights Reserved. +# +# Contributor(s): +# Mark Pilgrim - port to Python +# Shy Shalom - original C code +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA +# 02110-1301 USA +######################### END LICENSE BLOCK ######################### + +from . import constants +import sys +import codecs +from .latin1prober import Latin1Prober # windows-1252 +from .mbcsgroupprober import MBCSGroupProber # multi-byte character sets +from .sbcsgroupprober import SBCSGroupProber # single-byte character sets +from .escprober import EscCharSetProber # ISO-2122, etc. +import re + +MINIMUM_THRESHOLD = 0.20 +ePureAscii = 0 +eEscAscii = 1 +eHighbyte = 2 + + +class UniversalDetector: + def __init__(self): + self._highBitDetector = re.compile(b'[\x80-\xFF]') + self._escDetector = re.compile(b'(\033|~{)') + self._mEscCharSetProber = None + self._mCharSetProbers = [] + self.reset() + + def reset(self): + self.result = {'encoding': None, 'confidence': 0.0} + self.done = False + self._mStart = True + self._mGotData = False + self._mInputState = ePureAscii + self._mLastChar = b'' + if self._mEscCharSetProber: + self._mEscCharSetProber.reset() + for prober in self._mCharSetProbers: + prober.reset() + + def feed(self, aBuf): + if self.done: + return + + aLen = len(aBuf) + if not aLen: + return + + if not self._mGotData: + # If the data starts with BOM, we know it is UTF + if aBuf[:3] == codecs.BOM: + # EF BB BF UTF-8 with BOM + self.result = {'encoding': "UTF-8", 'confidence': 1.0} + elif aBuf[:4] == codecs.BOM_UTF32_LE: + # FF FE 00 00 UTF-32, little-endian BOM + self.result = {'encoding': "UTF-32LE", 'confidence': 1.0} + elif aBuf[:4] == codecs.BOM_UTF32_BE: + # 00 00 FE FF UTF-32, big-endian BOM + self.result = {'encoding': "UTF-32BE", 'confidence': 1.0} + elif aBuf[:4] == b'\xFE\xFF\x00\x00': + # FE FF 00 00 UCS-4, unusual octet order BOM (3412) + self.result = { + 'encoding': "X-ISO-10646-UCS-4-3412", + 'confidence': 1.0 + } + elif aBuf[:4] == b'\x00\x00\xFF\xFE': + # 00 00 FF FE UCS-4, unusual octet order BOM (2143) + self.result = { + 'encoding': "X-ISO-10646-UCS-4-2143", + 'confidence': 1.0 + } + elif aBuf[:2] == codecs.BOM_LE: + # FF FE UTF-16, little endian BOM + self.result = {'encoding': "UTF-16LE", 'confidence': 1.0} + elif aBuf[:2] == codecs.BOM_BE: + # FE FF UTF-16, big endian BOM + self.result = {'encoding': "UTF-16BE", 'confidence': 1.0} + + self._mGotData = True + if self.result['encoding'] and (self.result['confidence'] > 0.0): + self.done = True + return + + if self._mInputState == ePureAscii: + if self._highBitDetector.search(aBuf): + self._mInputState = eHighbyte + elif ((self._mInputState == ePureAscii) and + self._escDetector.search(self._mLastChar + aBuf)): + self._mInputState = eEscAscii + + self._mLastChar = aBuf[-1:] + + if self._mInputState == eEscAscii: + if not self._mEscCharSetProber: + self._mEscCharSetProber = EscCharSetProber() + if self._mEscCharSetProber.feed(aBuf) == constants.eFoundIt: + self.result = { + 'encoding': self._mEscCharSetProber.get_charset_name(), + 'confidence': self._mEscCharSetProber.get_confidence() + } + self.done = True + elif self._mInputState == eHighbyte: + if not self._mCharSetProbers: + self._mCharSetProbers = [MBCSGroupProber(), SBCSGroupProber(), + Latin1Prober()] + for prober in self._mCharSetProbers: + if prober.feed(aBuf) == constants.eFoundIt: + self.result = {'encoding': prober.get_charset_name(), + 'confidence': prober.get_confidence()} + self.done = True + break + + def close(self): + if self.done: + return + if not self._mGotData: + if constants._debug: + sys.stderr.write('no data received!\n') + return + self.done = True + + if self._mInputState == ePureAscii: + self.result = {'encoding': 'ascii', 'confidence': 1.0} + return self.result + + if self._mInputState == eHighbyte: + proberConfidence = None + maxProberConfidence = 0.0 + maxProber = None + for prober in self._mCharSetProbers: + if not prober: + continue + proberConfidence = prober.get_confidence() + if proberConfidence > maxProberConfidence: + maxProberConfidence = proberConfidence + maxProber = prober + if maxProber and (maxProberConfidence > MINIMUM_THRESHOLD): + self.result = {'encoding': maxProber.get_charset_name(), + 'confidence': maxProber.get_confidence()} + return self.result + + if constants._debug: + sys.stderr.write('no probers hit minimum threshhold\n') + for prober in self._mCharSetProbers[0].mProbers: + if not prober: + continue + sys.stderr.write('%s confidence = %s\n' % + (prober.get_charset_name(), + prober.get_confidence())) diff --git a/sickbeard/databases/cache_db.py b/sickbeard/databases/cache_db.py index 997b09bf2e..635e8a0db9 100644 --- a/sickbeard/databases/cache_db.py +++ b/sickbeard/databases/cache_db.py @@ -1,51 +1,51 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from sickbeard import db - -# Add new migrations at the bottom of the list; subclass the previous migration. -class InitialSchema (db.SchemaUpgrade): - def test(self): - return self.hasTable("lastUpdate") - - def execute(self): - - queries = [ - ("CREATE TABLE lastUpdate (provider TEXT, time NUMERIC);",), - ("CREATE TABLE db_version (db_version INTEGER);",), - ("INSERT INTO db_version (db_version) VALUES (?)", 1), - ] - for query in queries: - if len(query) == 1: - self.connection.action(query[0]) - else: - self.connection.action(query[0], query[1:]) - -class AddSceneExceptions(InitialSchema): - def test(self): - return self.hasTable("scene_exceptions") - - def execute(self): - self.connection.action("CREATE TABLE scene_exceptions (exception_id INTEGER PRIMARY KEY, tvdb_id INTEGER KEY, show_name TEXT)") - -class AddSceneNameCache(AddSceneExceptions): - def test(self): - return self.hasTable("scene_names") - - def execute(self): +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from sickbeard import db + +# Add new migrations at the bottom of the list; subclass the previous migration. +class InitialSchema (db.SchemaUpgrade): + def test(self): + return self.hasTable("lastUpdate") + + def execute(self): + + queries = [ + ("CREATE TABLE lastUpdate (provider TEXT, time NUMERIC);",), + ("CREATE TABLE db_version (db_version INTEGER);",), + ("INSERT INTO db_version (db_version) VALUES (?)", 1), + ] + for query in queries: + if len(query) == 1: + self.connection.action(query[0]) + else: + self.connection.action(query[0], query[1:]) + +class AddSceneExceptions(InitialSchema): + def test(self): + return self.hasTable("scene_exceptions") + + def execute(self): + self.connection.action("CREATE TABLE scene_exceptions (exception_id INTEGER PRIMARY KEY, tvdb_id INTEGER KEY, show_name TEXT)") + +class AddSceneNameCache(AddSceneExceptions): + def test(self): + return self.hasTable("scene_names") + + def execute(self): self.connection.action("CREATE TABLE scene_names (tvdb_id INTEGER, name TEXT)") \ No newline at end of file diff --git a/sickbeard/encodingKludge.py b/sickbeard/encodingKludge.py index cdd95f29dc..9f24fe328d 100644 --- a/sickbeard/encodingKludge.py +++ b/sickbeard/encodingKludge.py @@ -1,69 +1,69 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import os - -from sickbeard import logger -import sickbeard - -# This module tries to deal with the apparently random behavior of python when dealing with unicode <-> utf-8 -# encodings. It tries to just use unicode, but if that fails then it tries forcing it to utf-8. Any functions -# which return something should always return unicode. - -def fixStupidEncodings(x, silent=False): - if type(x) == str: - try: - return x.decode(sickbeard.SYS_ENCODING) - except UnicodeDecodeError: - logger.log(u"Unable to decode value: "+repr(x), logger.ERROR) - return None - elif type(x) == unicode: - return x - else: - logger.log(u"Unknown value passed in, ignoring it: "+str(type(x))+" ("+repr(x)+":"+repr(type(x))+")", logger.DEBUG if silent else logger.ERROR) - return None - - return None - -def fixListEncodings(x): - if type(x) != list and type(x) != tuple: - return x - else: - return filter(lambda x: x != None, map(fixStupidEncodings, x)) - -def callPeopleStupid(x): - try: - return x.encode(sickbeard.SYS_ENCODING) - except UnicodeEncodeError: - logger.log(u"YOUR COMPUTER SUCKS! Your data is being corrupted by a bad locale/encoding setting. Report this error on the forums or IRC please: "+repr(x)+", "+sickbeard.SYS_ENCODING, logger.ERROR) - return x.encode(sickbeard.SYS_ENCODING, 'ignore') - -def ek(func, *args): - result = None - - if os.name == 'nt': - result = func(*args) - else: - result = func(*[callPeopleStupid(x) if type(x) in (str, unicode) else x for x in args]) - - if type(result) in (list, tuple): - return fixListEncodings(result) - elif type(result) == str: - return fixStupidEncodings(result) - else: - return result +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import os + +from sickbeard import logger +import sickbeard + +# This module tries to deal with the apparently random behavior of python when dealing with unicode <-> utf-8 +# encodings. It tries to just use unicode, but if that fails then it tries forcing it to utf-8. Any functions +# which return something should always return unicode. + +def fixStupidEncodings(x, silent=False): + if type(x) == str: + try: + return x.decode(sickbeard.SYS_ENCODING) + except UnicodeDecodeError: + logger.log(u"Unable to decode value: "+repr(x), logger.ERROR) + return None + elif type(x) == unicode: + return x + else: + logger.log(u"Unknown value passed in, ignoring it: "+str(type(x))+" ("+repr(x)+":"+repr(type(x))+")", logger.DEBUG if silent else logger.ERROR) + return None + + return None + +def fixListEncodings(x): + if type(x) != list and type(x) != tuple: + return x + else: + return filter(lambda x: x != None, map(fixStupidEncodings, x)) + +def callPeopleStupid(x): + try: + return x.encode(sickbeard.SYS_ENCODING) + except UnicodeEncodeError: + logger.log(u"YOUR COMPUTER SUCKS! Your data is being corrupted by a bad locale/encoding setting. Report this error on the forums or IRC please: "+repr(x)+", "+sickbeard.SYS_ENCODING, logger.ERROR) + return x.encode(sickbeard.SYS_ENCODING, 'ignore') + +def ek(func, *args): + result = None + + if os.name == 'nt': + result = func(*args) + else: + result = func(*[callPeopleStupid(x) if type(x) in (str, unicode) else x for x in args]) + + if type(result) in (list, tuple): + return fixListEncodings(result) + elif type(result) == str: + return fixStupidEncodings(result) + else: + return result diff --git a/sickbeard/generic_queue.py b/sickbeard/generic_queue.py index eb1d3801c1..fd72911eb6 100644 --- a/sickbeard/generic_queue.py +++ b/sickbeard/generic_queue.py @@ -1,134 +1,134 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import datetime -import threading - -from sickbeard import logger - -class QueuePriorities: - LOW = 10 - NORMAL = 20 - HIGH = 30 - -class GenericQueue(object): - - def __init__(self): - - self.currentItem = None - self.queue = [] - - self.thread = None - - self.queue_name = "QUEUE" - - self.min_priority = 0 - - self.currentItem = None - - def pause(self): - logger.log(u"Pausing queue") - self.min_priority = 999999999999 - - def unpause(self): - logger.log(u"Unpausing queue") - self.min_priority = 0 - - def add_item(self, item): - item.added = datetime.datetime.now() - self.queue.append(item) - - return item - - def run(self): - - # only start a new task if one isn't already going - if self.thread == None or self.thread.isAlive() == False: - - # if the thread is dead then the current item should be finished - if self.currentItem != None: - self.currentItem.finish() - self.currentItem = None - - # if there's something in the queue then run it in a thread and take it out of the queue - if len(self.queue) > 0: - - # sort by priority - def sorter(x,y): - """ - Sorts by priority descending then time ascending - """ - if x.priority == y.priority: - if y.added == x.added: - return 0 - elif y.added < x.added: - return 1 - elif y.added > x.added: - return -1 - else: - return y.priority-x.priority - - self.queue.sort(cmp=sorter) - - queueItem = self.queue[0] - - if queueItem.priority < self.min_priority: - return - - # launch the queue item in a thread - # TODO: improve thread name - threadName = self.queue_name + '-' + queueItem.get_thread_name() - self.thread = threading.Thread(None, queueItem.execute, threadName) - self.thread.start() - - self.currentItem = queueItem - - # take it out of the queue - del self.queue[0] - -class QueueItem: - def __init__(self, name, action_id = 0): - self.name = name - - self.inProgress = False - - self.priority = QueuePriorities.NORMAL - - self.thread_name = None - - self.action_id = action_id - - self.added = None - - def get_thread_name(self): - if self.thread_name: - return self.thread_name - else: - return self.name.replace(" ","-").upper() - - def execute(self): - """Implementing classes should call this""" - - self.inProgress = True - - def finish(self): - """Implementing Classes should call this""" - - self.inProgress = False - - +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import datetime +import threading + +from sickbeard import logger + +class QueuePriorities: + LOW = 10 + NORMAL = 20 + HIGH = 30 + +class GenericQueue(object): + + def __init__(self): + + self.currentItem = None + self.queue = [] + + self.thread = None + + self.queue_name = "QUEUE" + + self.min_priority = 0 + + self.currentItem = None + + def pause(self): + logger.log(u"Pausing queue") + self.min_priority = 999999999999 + + def unpause(self): + logger.log(u"Unpausing queue") + self.min_priority = 0 + + def add_item(self, item): + item.added = datetime.datetime.now() + self.queue.append(item) + + return item + + def run(self): + + # only start a new task if one isn't already going + if self.thread == None or self.thread.isAlive() == False: + + # if the thread is dead then the current item should be finished + if self.currentItem != None: + self.currentItem.finish() + self.currentItem = None + + # if there's something in the queue then run it in a thread and take it out of the queue + if len(self.queue) > 0: + + # sort by priority + def sorter(x,y): + """ + Sorts by priority descending then time ascending + """ + if x.priority == y.priority: + if y.added == x.added: + return 0 + elif y.added < x.added: + return 1 + elif y.added > x.added: + return -1 + else: + return y.priority-x.priority + + self.queue.sort(cmp=sorter) + + queueItem = self.queue[0] + + if queueItem.priority < self.min_priority: + return + + # launch the queue item in a thread + # TODO: improve thread name + threadName = self.queue_name + '-' + queueItem.get_thread_name() + self.thread = threading.Thread(None, queueItem.execute, threadName) + self.thread.start() + + self.currentItem = queueItem + + # take it out of the queue + del self.queue[0] + +class QueueItem: + def __init__(self, name, action_id = 0): + self.name = name + + self.inProgress = False + + self.priority = QueuePriorities.NORMAL + + self.thread_name = None + + self.action_id = action_id + + self.added = None + + def get_thread_name(self): + if self.thread_name: + return self.thread_name + else: + return self.name.replace(" ","-").upper() + + def execute(self): + """Implementing classes should call this""" + + self.inProgress = True + + def finish(self): + """Implementing Classes should call this""" + + self.inProgress = False + + diff --git a/sickbeard/gh_api.py b/sickbeard/gh_api.py index 3ed9762885..481bbc019b 100644 --- a/sickbeard/gh_api.py +++ b/sickbeard/gh_api.py @@ -1,59 +1,59 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -try: - import json -except ImportError: - from lib import simplejson as json - -import urllib - -class GitHub(object): - """ - Simple api wrapper for the Github API v3. Currently only supports the small thing that SB - needs it for - list of cimmots. - """ - - def _access_API(self, path, params=None): - """ - Access the API at the path given and with the optional params given. - - path: A list of the path elements to use (eg. ['repos', 'midgetspy', 'Sick-Beard', 'commits']) - params: Optional dict of name/value pairs for extra params to send. (eg. {'per_page': 10}) - - Returns a deserialized json object of the result. Doesn't do any error checking (hope it works). - """ - - url = 'https://api.github.com/' + '/'.join(path) - - if params and type(params) is dict: - url += '?' + '&'.join([str(x) + '=' + str(params[x]) for x in params.keys()]) - - return json.load(urllib.urlopen(url)) - - def commits(self, user, repo, branch='master'): - """ - Uses the API to get a list of the 100 most recent commits from the specified user/repo/branch, starting from HEAD. - - user: The github username of the person whose repo you're querying - repo: The repo name to query - branch: Optional, the branch name to show commits from - - Returns a deserialized json object containing the commit info. See http://developer.github.com/v3/repos/commits/ - """ - return self._access_API(['repos', user, repo, 'commits'], {'per_page': 100, 'sha': branch}) +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +try: + import json +except ImportError: + from lib import simplejson as json + +import urllib + +class GitHub(object): + """ + Simple api wrapper for the Github API v3. Currently only supports the small thing that SB + needs it for - list of cimmots. + """ + + def _access_API(self, path, params=None): + """ + Access the API at the path given and with the optional params given. + + path: A list of the path elements to use (eg. ['repos', 'midgetspy', 'Sick-Beard', 'commits']) + params: Optional dict of name/value pairs for extra params to send. (eg. {'per_page': 10}) + + Returns a deserialized json object of the result. Doesn't do any error checking (hope it works). + """ + + url = 'https://api.github.com/' + '/'.join(path) + + if params and type(params) is dict: + url += '?' + '&'.join([str(x) + '=' + str(params[x]) for x in params.keys()]) + + return json.load(urllib.urlopen(url)) + + def commits(self, user, repo, branch='master'): + """ + Uses the API to get a list of the 100 most recent commits from the specified user/repo/branch, starting from HEAD. + + user: The github username of the person whose repo you're querying + repo: The repo name to query + branch: Optional, the branch name to show commits from + + Returns a deserialized json object containing the commit info. See http://developer.github.com/v3/repos/commits/ + """ + return self._access_API(['repos', user, repo, 'commits'], {'per_page': 100, 'sha': branch}) diff --git a/sickbeard/image_cache.py b/sickbeard/image_cache.py index 0a485a5a21..fecc41de4f 100644 --- a/sickbeard/image_cache.py +++ b/sickbeard/image_cache.py @@ -1,225 +1,225 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import os.path - -import sickbeard - -from sickbeard import helpers, logger, exceptions -from sickbeard import encodingKludge as ek - -from sickbeard.metadata.generic import GenericMetadata - -from lib.hachoir_parser import createParser -from lib.hachoir_metadata import extractMetadata - -class ImageCache: - - def __init__(self): - pass - - def _cache_dir(self): - """ - Builds up the full path to the image cache directory - """ - return ek.ek(os.path.abspath, ek.ek(os.path.join, sickbeard.CACHE_DIR, 'images')) - - def poster_path(self, tvdb_id): - """ - Builds up the path to a poster cache for a given tvdb id - - returns: a full path to the cached poster file for the given tvdb id - - tvdb_id: ID of the show to use in the file name - """ - poster_file_name = str(tvdb_id) + '.poster.jpg' - return ek.ek(os.path.join, self._cache_dir(), poster_file_name) - - def banner_path(self, tvdb_id): - """ - Builds up the path to a banner cache for a given tvdb id - - returns: a full path to the cached banner file for the given tvdb id - - tvdb_id: ID of the show to use in the file name - """ - banner_file_name = str(tvdb_id) + '.banner.jpg' - return ek.ek(os.path.join, self._cache_dir(), banner_file_name) - - def has_poster(self, tvdb_id): - """ - Returns true if a cached poster exists for the given tvdb id - """ - poster_path = self.poster_path(tvdb_id) - logger.log(u"Checking if file "+str(poster_path)+" exists", logger.DEBUG) - return ek.ek(os.path.isfile, poster_path) - - def has_banner(self, tvdb_id): - """ - Returns true if a cached banner exists for the given tvdb id - """ - banner_path = self.banner_path(tvdb_id) - logger.log(u"Checking if file "+str(banner_path)+" exists", logger.DEBUG) - return ek.ek(os.path.isfile, banner_path) - - BANNER = 1 - POSTER = 2 - - def which_type(self, path): - """ - Analyzes the image provided and attempts to determine whether it is a poster or banner. - - returns: BANNER, POSTER if it concluded one or the other, or None if the image was neither (or didn't exist) - - path: full path to the image - """ - - if not ek.ek(os.path.isfile, path): - logger.log(u"Couldn't check the type of "+str(path)+" cause it doesn't exist", logger.WARNING) - return None - - # use hachoir to parse the image for us - img_parser = createParser(path) - img_metadata = extractMetadata(img_parser) - - if not img_metadata: - logger.log(u"Unable to get metadata from "+str(path)+", not using your existing image", logger.DEBUG) - return None - - img_ratio = float(img_metadata.get('width'))/float(img_metadata.get('height')) - - img_parser.stream._input.close() - - # most posters are around 0.68 width/height ratio (eg. 680/1000) - if 0.55 < img_ratio < 0.8: - return self.POSTER - - # most banners are around 5.4 width/height ratio (eg. 758/140) - elif 5 < img_ratio < 6: - return self.BANNER - else: - logger.log(u"Image has size ratio of "+str(img_ratio)+", unknown type", logger.WARNING) - return None - - def _cache_image_from_file(self, image_path, img_type, tvdb_id): - """ - Takes the image provided and copies it to the cache folder - - returns: bool representing success - - image_path: path to the image we're caching - img_type: BANNER or POSTER - tvdb_id: id of the show this image belongs to - """ - - # generate the path based on the type & tvdb_id - if img_type == self.POSTER: - dest_path = self.poster_path(tvdb_id) - elif img_type == self.BANNER: - dest_path = self.banner_path(tvdb_id) - else: - logger.log(u"Invalid cache image type: "+str(img_type), logger.ERROR) - return False - - # make sure the cache folder exists before we try copying to it - if not ek.ek(os.path.isdir, self._cache_dir()): - logger.log(u"Image cache dir didn't exist, creating it at "+str(self._cache_dir())) - ek.ek(os.makedirs, self._cache_dir()) - - logger.log(u"Copying from "+image_path+" to "+dest_path) - helpers.copyFile(image_path, dest_path) - - return True - - def _cache_image_from_tvdb(self, show_obj, img_type): - """ - Retrieves an image of the type specified from TVDB and saves it to the cache folder - - returns: bool representing success - - show_obj: TVShow object that we want to cache an image for - img_type: BANNER or POSTER - """ - - # generate the path based on the type & tvdb_id - if img_type == self.POSTER: - img_type_name = 'poster' - dest_path = self.poster_path(show_obj.tvdbid) - elif img_type == self.BANNER: - img_type_name = 'banner' - dest_path = self.banner_path(show_obj.tvdbid) - else: - logger.log(u"Invalid cache image type: "+str(img_type), logger.ERROR) - return False - - # retrieve the image from TVDB using the generic metadata class - #TODO: refactor - metadata_generator = GenericMetadata() - img_data = metadata_generator._retrieve_show_image(img_type_name, show_obj) - result = metadata_generator._write_image(img_data, dest_path) - - return result - - def fill_cache(self, show_obj): - """ - Caches all images for the given show. Copies them from the show dir if possible, or - downloads them from TVDB if they aren't in the show dir. - - show_obj: TVShow object to cache images for - """ - - logger.log(u"Checking if we need any cache images for show "+str(show_obj.tvdbid), logger.DEBUG) - - # check if the images are already cached or not - need_images = {self.POSTER: not self.has_poster(show_obj.tvdbid), - self.BANNER: not self.has_banner(show_obj.tvdbid), - } - - if not need_images[self.POSTER] and not need_images[self.BANNER]: - logger.log(u"No new cache images needed, not retrieving new ones") - return - - # check the show dir for images and use them - try: - for cur_provider in sickbeard.metadata_provider_dict.values(): - logger.log(u"Checking if we can use the show image from the "+cur_provider.name+" metadata", logger.DEBUG) - if ek.ek(os.path.isfile, cur_provider.get_poster_path(show_obj)): - cur_file_name = os.path.abspath(cur_provider.get_poster_path(show_obj)) - cur_file_type = self.which_type(cur_file_name) - - if cur_file_type == None: - logger.log(u"Unable to retrieve image type, not using the image from "+str(cur_file_name), logger.WARNING) - continue - - logger.log(u"Checking if image "+cur_file_name+" (type "+str(cur_file_type)+" needs metadata: "+str(need_images[cur_file_type]), logger.DEBUG) - - if cur_file_type in need_images and need_images[cur_file_type]: - logger.log(u"Found an image in the show dir that doesn't exist in the cache, caching it: "+cur_file_name+", type "+str(cur_file_type), logger.DEBUG) - self._cache_image_from_file(cur_file_name, cur_file_type, show_obj.tvdbid) - need_images[cur_file_type] = False - except exceptions.ShowDirNotFoundException: - logger.log(u"Unable to search for images in show dir because it doesn't exist", logger.WARNING) - - # download from TVDB for missing ones - for cur_image_type in [self.POSTER, self.BANNER]: - logger.log(u"Seeing if we still need an image of type "+str(cur_image_type)+": "+str(need_images[cur_image_type]), logger.DEBUG) - if cur_image_type in need_images and need_images[cur_image_type]: - self._cache_image_from_tvdb(show_obj, cur_image_type) - - - logger.log(u"Done cache check") +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import os.path + +import sickbeard + +from sickbeard import helpers, logger, exceptions +from sickbeard import encodingKludge as ek + +from sickbeard.metadata.generic import GenericMetadata + +from lib.hachoir_parser import createParser +from lib.hachoir_metadata import extractMetadata + +class ImageCache: + + def __init__(self): + pass + + def _cache_dir(self): + """ + Builds up the full path to the image cache directory + """ + return ek.ek(os.path.abspath, ek.ek(os.path.join, sickbeard.CACHE_DIR, 'images')) + + def poster_path(self, tvdb_id): + """ + Builds up the path to a poster cache for a given tvdb id + + returns: a full path to the cached poster file for the given tvdb id + + tvdb_id: ID of the show to use in the file name + """ + poster_file_name = str(tvdb_id) + '.poster.jpg' + return ek.ek(os.path.join, self._cache_dir(), poster_file_name) + + def banner_path(self, tvdb_id): + """ + Builds up the path to a banner cache for a given tvdb id + + returns: a full path to the cached banner file for the given tvdb id + + tvdb_id: ID of the show to use in the file name + """ + banner_file_name = str(tvdb_id) + '.banner.jpg' + return ek.ek(os.path.join, self._cache_dir(), banner_file_name) + + def has_poster(self, tvdb_id): + """ + Returns true if a cached poster exists for the given tvdb id + """ + poster_path = self.poster_path(tvdb_id) + logger.log(u"Checking if file "+str(poster_path)+" exists", logger.DEBUG) + return ek.ek(os.path.isfile, poster_path) + + def has_banner(self, tvdb_id): + """ + Returns true if a cached banner exists for the given tvdb id + """ + banner_path = self.banner_path(tvdb_id) + logger.log(u"Checking if file "+str(banner_path)+" exists", logger.DEBUG) + return ek.ek(os.path.isfile, banner_path) + + BANNER = 1 + POSTER = 2 + + def which_type(self, path): + """ + Analyzes the image provided and attempts to determine whether it is a poster or banner. + + returns: BANNER, POSTER if it concluded one or the other, or None if the image was neither (or didn't exist) + + path: full path to the image + """ + + if not ek.ek(os.path.isfile, path): + logger.log(u"Couldn't check the type of "+str(path)+" cause it doesn't exist", logger.WARNING) + return None + + # use hachoir to parse the image for us + img_parser = createParser(path) + img_metadata = extractMetadata(img_parser) + + if not img_metadata: + logger.log(u"Unable to get metadata from "+str(path)+", not using your existing image", logger.DEBUG) + return None + + img_ratio = float(img_metadata.get('width'))/float(img_metadata.get('height')) + + img_parser.stream._input.close() + + # most posters are around 0.68 width/height ratio (eg. 680/1000) + if 0.55 < img_ratio < 0.8: + return self.POSTER + + # most banners are around 5.4 width/height ratio (eg. 758/140) + elif 5 < img_ratio < 6: + return self.BANNER + else: + logger.log(u"Image has size ratio of "+str(img_ratio)+", unknown type", logger.WARNING) + return None + + def _cache_image_from_file(self, image_path, img_type, tvdb_id): + """ + Takes the image provided and copies it to the cache folder + + returns: bool representing success + + image_path: path to the image we're caching + img_type: BANNER or POSTER + tvdb_id: id of the show this image belongs to + """ + + # generate the path based on the type & tvdb_id + if img_type == self.POSTER: + dest_path = self.poster_path(tvdb_id) + elif img_type == self.BANNER: + dest_path = self.banner_path(tvdb_id) + else: + logger.log(u"Invalid cache image type: "+str(img_type), logger.ERROR) + return False + + # make sure the cache folder exists before we try copying to it + if not ek.ek(os.path.isdir, self._cache_dir()): + logger.log(u"Image cache dir didn't exist, creating it at "+str(self._cache_dir())) + ek.ek(os.makedirs, self._cache_dir()) + + logger.log(u"Copying from "+image_path+" to "+dest_path) + helpers.copyFile(image_path, dest_path) + + return True + + def _cache_image_from_tvdb(self, show_obj, img_type): + """ + Retrieves an image of the type specified from TVDB and saves it to the cache folder + + returns: bool representing success + + show_obj: TVShow object that we want to cache an image for + img_type: BANNER or POSTER + """ + + # generate the path based on the type & tvdb_id + if img_type == self.POSTER: + img_type_name = 'poster' + dest_path = self.poster_path(show_obj.tvdbid) + elif img_type == self.BANNER: + img_type_name = 'banner' + dest_path = self.banner_path(show_obj.tvdbid) + else: + logger.log(u"Invalid cache image type: "+str(img_type), logger.ERROR) + return False + + # retrieve the image from TVDB using the generic metadata class + #TODO: refactor + metadata_generator = GenericMetadata() + img_data = metadata_generator._retrieve_show_image(img_type_name, show_obj) + result = metadata_generator._write_image(img_data, dest_path) + + return result + + def fill_cache(self, show_obj): + """ + Caches all images for the given show. Copies them from the show dir if possible, or + downloads them from TVDB if they aren't in the show dir. + + show_obj: TVShow object to cache images for + """ + + logger.log(u"Checking if we need any cache images for show "+str(show_obj.tvdbid), logger.DEBUG) + + # check if the images are already cached or not + need_images = {self.POSTER: not self.has_poster(show_obj.tvdbid), + self.BANNER: not self.has_banner(show_obj.tvdbid), + } + + if not need_images[self.POSTER] and not need_images[self.BANNER]: + logger.log(u"No new cache images needed, not retrieving new ones") + return + + # check the show dir for images and use them + try: + for cur_provider in sickbeard.metadata_provider_dict.values(): + logger.log(u"Checking if we can use the show image from the "+cur_provider.name+" metadata", logger.DEBUG) + if ek.ek(os.path.isfile, cur_provider.get_poster_path(show_obj)): + cur_file_name = os.path.abspath(cur_provider.get_poster_path(show_obj)) + cur_file_type = self.which_type(cur_file_name) + + if cur_file_type == None: + logger.log(u"Unable to retrieve image type, not using the image from "+str(cur_file_name), logger.WARNING) + continue + + logger.log(u"Checking if image "+cur_file_name+" (type "+str(cur_file_type)+" needs metadata: "+str(need_images[cur_file_type]), logger.DEBUG) + + if cur_file_type in need_images and need_images[cur_file_type]: + logger.log(u"Found an image in the show dir that doesn't exist in the cache, caching it: "+cur_file_name+", type "+str(cur_file_type), logger.DEBUG) + self._cache_image_from_file(cur_file_name, cur_file_type, show_obj.tvdbid) + need_images[cur_file_type] = False + except exceptions.ShowDirNotFoundException: + logger.log(u"Unable to search for images in show dir because it doesn't exist", logger.WARNING) + + # download from TVDB for missing ones + for cur_image_type in [self.POSTER, self.BANNER]: + logger.log(u"Seeing if we still need an image of type "+str(cur_image_type)+": "+str(need_images[cur_image_type]), logger.DEBUG) + if cur_image_type in need_images and need_images[cur_image_type]: + self._cache_image_from_tvdb(show_obj, cur_image_type) + + + logger.log(u"Done cache check") diff --git a/sickbeard/logger.py b/sickbeard/logger.py index 6c2953e6dc..cb776d2bad 100644 --- a/sickbeard/logger.py +++ b/sickbeard/logger.py @@ -1,185 +1,185 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from __future__ import with_statement - -import os -import threading - -import logging - -import sickbeard - -from sickbeard import classes - - -# number of log files to keep -NUM_LOGS = 3 - -# log size in bytes -LOG_SIZE = 10000000 # 10 megs - -ERROR = logging.ERROR -WARNING = logging.WARNING -MESSAGE = logging.INFO -DEBUG = logging.DEBUG - -reverseNames = {u'ERROR': ERROR, - u'WARNING': WARNING, - u'INFO': MESSAGE, - u'DEBUG': DEBUG} - -class SBRotatingLogHandler(object): - - def __init__(self, log_file, num_files, num_bytes): - self.num_files = num_files - self.num_bytes = num_bytes - - self.log_file = log_file - self.cur_handler = None - - self.writes_since_check = 0 - - self.log_lock = threading.Lock() - - def initLogging(self, consoleLogging=True): - - self.log_file = os.path.join(sickbeard.LOG_DIR, self.log_file) - - self.cur_handler = self._config_handler() - - logging.getLogger('sickbeard').addHandler(self.cur_handler) - logging.getLogger('subliminal').addHandler(self.cur_handler) - - # define a Handler which writes INFO messages or higher to the sys.stderr - if consoleLogging: - console = logging.StreamHandler() - - console.setLevel(logging.INFO) - - # set a format which is simpler for console use - console.setFormatter(logging.Formatter('%(asctime)s %(levelname)s::%(message)s', '%H:%M:%S')) - - # add the handler to the root logger - logging.getLogger('sickbeard').addHandler(console) - logging.getLogger('subliminal').addHandler(console) - - logging.getLogger('sickbeard').setLevel(logging.DEBUG) - logging.getLogger('subliminal').setLevel(logging.ERROR) - - def _config_handler(self): - """ - Configure a file handler to log at file_name and return it. - """ - - file_handler = logging.FileHandler(self.log_file) - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', '%b-%d %H:%M:%S')) - return file_handler - - def _log_file_name(self, i): - """ - Returns a numbered log file name depending on i. If i==0 it just uses logName, if not it appends - it to the extension (blah.log.3 for i == 3) - - i: Log number to ues - """ - return self.log_file + ('.' + str(i) if i else '') - - def _num_logs(self): - """ - Scans the log folder and figures out how many log files there are already on disk - - Returns: The number of the last used file (eg. mylog.log.3 would return 3). If there are no logs it returns -1 - """ - cur_log = 0 - while os.path.isfile(self._log_file_name(cur_log)): - cur_log += 1 - return cur_log - 1 - - def _rotate_logs(self): - - sb_logger = logging.getLogger('sickbeard') - subli_logger = logging.getLogger('subliminal') - - # delete the old handler - if self.cur_handler: - self.cur_handler.flush() - self.cur_handler.close() - sb_logger.removeHandler(self.cur_handler) - subli_logger.removeHandler(self.cur_handler) - - # rename or delete all the old log files - for i in range(self._num_logs(), -1, -1): - cur_file_name = self._log_file_name(i) - try: - if i >= NUM_LOGS: - os.remove(cur_file_name) - else: - os.rename(cur_file_name, self._log_file_name(i+1)) - except WindowsError: - pass - - # the new log handler will always be on the un-numbered .log file - new_file_handler = self._config_handler() - - self.cur_handler = new_file_handler - - sb_logger.addHandler(new_file_handler) - subli_logger.addHandler(new_file_handler) - - def log(self, toLog, logLevel=MESSAGE): - - with self.log_lock: - - # check the size and see if we need to rotate - if self.writes_since_check >= 10: - if os.path.isfile(self.log_file) and os.path.getsize(self.log_file) >= LOG_SIZE: - self._rotate_logs() - self.writes_since_check = 0 - else: - self.writes_since_check += 1 - - meThread = threading.currentThread().getName() - message = meThread + u" :: " + toLog - - out_line = message.encode('utf-8') - - sb_logger = logging.getLogger('sickbeard') - - try: - if logLevel == DEBUG: - sb_logger.debug(out_line) - elif logLevel == MESSAGE: - sb_logger.info(out_line) - elif logLevel == WARNING: - sb_logger.warning(out_line) - elif logLevel == ERROR: - sb_logger.error(out_line) - - # add errors to the UI logger - classes.ErrorViewer.add(classes.UIError(message)) - else: - sb_logger.log(logLevel, out_line) - except ValueError: - pass - -sb_log_instance = SBRotatingLogHandler('sickbeard.log', NUM_LOGS, LOG_SIZE) - -def log(toLog, logLevel=MESSAGE): +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from __future__ import with_statement + +import os +import threading + +import logging + +import sickbeard + +from sickbeard import classes + + +# number of log files to keep +NUM_LOGS = 3 + +# log size in bytes +LOG_SIZE = 10000000 # 10 megs + +ERROR = logging.ERROR +WARNING = logging.WARNING +MESSAGE = logging.INFO +DEBUG = logging.DEBUG + +reverseNames = {u'ERROR': ERROR, + u'WARNING': WARNING, + u'INFO': MESSAGE, + u'DEBUG': DEBUG} + +class SBRotatingLogHandler(object): + + def __init__(self, log_file, num_files, num_bytes): + self.num_files = num_files + self.num_bytes = num_bytes + + self.log_file = log_file + self.cur_handler = None + + self.writes_since_check = 0 + + self.log_lock = threading.Lock() + + def initLogging(self, consoleLogging=True): + + self.log_file = os.path.join(sickbeard.LOG_DIR, self.log_file) + + self.cur_handler = self._config_handler() + + logging.getLogger('sickbeard').addHandler(self.cur_handler) + logging.getLogger('subliminal').addHandler(self.cur_handler) + + # define a Handler which writes INFO messages or higher to the sys.stderr + if consoleLogging: + console = logging.StreamHandler() + + console.setLevel(logging.INFO) + + # set a format which is simpler for console use + console.setFormatter(logging.Formatter('%(asctime)s %(levelname)s::%(message)s', '%H:%M:%S')) + + # add the handler to the root logger + logging.getLogger('sickbeard').addHandler(console) + logging.getLogger('subliminal').addHandler(console) + + logging.getLogger('sickbeard').setLevel(logging.DEBUG) + logging.getLogger('subliminal').setLevel(logging.ERROR) + + def _config_handler(self): + """ + Configure a file handler to log at file_name and return it. + """ + + file_handler = logging.FileHandler(self.log_file) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', '%b-%d %H:%M:%S')) + return file_handler + + def _log_file_name(self, i): + """ + Returns a numbered log file name depending on i. If i==0 it just uses logName, if not it appends + it to the extension (blah.log.3 for i == 3) + + i: Log number to ues + """ + return self.log_file + ('.' + str(i) if i else '') + + def _num_logs(self): + """ + Scans the log folder and figures out how many log files there are already on disk + + Returns: The number of the last used file (eg. mylog.log.3 would return 3). If there are no logs it returns -1 + """ + cur_log = 0 + while os.path.isfile(self._log_file_name(cur_log)): + cur_log += 1 + return cur_log - 1 + + def _rotate_logs(self): + + sb_logger = logging.getLogger('sickbeard') + subli_logger = logging.getLogger('subliminal') + + # delete the old handler + if self.cur_handler: + self.cur_handler.flush() + self.cur_handler.close() + sb_logger.removeHandler(self.cur_handler) + subli_logger.removeHandler(self.cur_handler) + + # rename or delete all the old log files + for i in range(self._num_logs(), -1, -1): + cur_file_name = self._log_file_name(i) + try: + if i >= NUM_LOGS: + os.remove(cur_file_name) + else: + os.rename(cur_file_name, self._log_file_name(i+1)) + except WindowsError: + pass + + # the new log handler will always be on the un-numbered .log file + new_file_handler = self._config_handler() + + self.cur_handler = new_file_handler + + sb_logger.addHandler(new_file_handler) + subli_logger.addHandler(new_file_handler) + + def log(self, toLog, logLevel=MESSAGE): + + with self.log_lock: + + # check the size and see if we need to rotate + if self.writes_since_check >= 10: + if os.path.isfile(self.log_file) and os.path.getsize(self.log_file) >= LOG_SIZE: + self._rotate_logs() + self.writes_since_check = 0 + else: + self.writes_since_check += 1 + + meThread = threading.currentThread().getName() + message = meThread + u" :: " + toLog + + out_line = message.encode('utf-8') + + sb_logger = logging.getLogger('sickbeard') + + try: + if logLevel == DEBUG: + sb_logger.debug(out_line) + elif logLevel == MESSAGE: + sb_logger.info(out_line) + elif logLevel == WARNING: + sb_logger.warning(out_line) + elif logLevel == ERROR: + sb_logger.error(out_line) + + # add errors to the UI logger + classes.ErrorViewer.add(classes.UIError(message)) + else: + sb_logger.log(logLevel, out_line) + except ValueError: + pass + +sb_log_instance = SBRotatingLogHandler('sickbeard.log', NUM_LOGS, LOG_SIZE) + +def log(toLog, logLevel=MESSAGE): sb_log_instance.log(toLog, logLevel) \ No newline at end of file diff --git a/sickbeard/naming.py b/sickbeard/naming.py index fd0a3b528d..ceedc9af34 100644 --- a/sickbeard/naming.py +++ b/sickbeard/naming.py @@ -1,179 +1,179 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import datetime -import os - -import sickbeard -from sickbeard import encodingKludge as ek -from sickbeard import tv -from sickbeard import common -from sickbeard import logger -from sickbeard.name_parser.parser import NameParser, InvalidNameException - -from common import Quality, DOWNLOADED - -name_presets = ('%SN - %Sx%0E - %EN', - '%S.N.S%0SE%0E.%E.N', - '%Sx%0E - %EN', - 'S%0SE%0E - %EN', - 'Season %0S/%S.N.S%0SE%0E.%Q.N-%RG' - ) - -name_abd_presets = ('%SN - %A-D - %EN', - '%S.N.%A.D.%E.N.%Q.N', - '%Y/%0M/%S.N.%A.D.%E.N-%RG' - ) - -class TVShow(): - def __init__(self): - self.name = "Show Name" - self.genre = "Comedy" - self.air_by_date = 0 - -class TVEpisode(tv.TVEpisode): - def __init__(self, season, episode, name): - self.relatedEps = [] - self._name = name - self._season = season - self._episode = episode - self._airdate = datetime.date(2010, 3, 9) - self.show = TVShow() - self._status = Quality.compositeStatus(common.DOWNLOADED, common.Quality.SDTV) - self._release_name = 'Show.Name.S02E03.HDTV.XviD-RLSGROUP' - -def check_force_season_folders(pattern=None, multi=None): - """ - Checks if the name can still be parsed if you strip off the folders to determine if we need to force season folders - to be enabled or not. - - Returns true if season folders need to be forced on or false otherwise. - """ - if pattern == None: - pattern = sickbeard.NAMING_PATTERN - - valid = not validate_name(pattern, None, file_only=True) - - if multi != None: - valid = valid or not validate_name(pattern, multi, file_only=True) - - return valid - -def check_valid_naming(pattern=None, multi=None): - """ - Checks if the name is can be parsed back to its original form for both single and multi episodes. - - Returns true if the naming is valid, false if not. - """ - if pattern == None: - pattern = sickbeard.NAMING_PATTERN - - logger.log(u"Checking whether the pattern "+pattern+" is valid for a single episode", logger.DEBUG) - valid = validate_name(pattern, None) - - if multi != None: - logger.log(u"Checking whether the pattern "+pattern+" is valid for a multi episode", logger.DEBUG) - valid = valid and validate_name(pattern, multi) - - return valid - -def check_valid_abd_naming(pattern=None): - """ - Checks if the name is can be parsed back to its original form for an air-by-date format. - - Returns true if the naming is valid, false if not. - """ - if pattern == None: - pattern = sickbeard.NAMING_PATTERN - - logger.log(u"Checking whether the pattern "+pattern+" is valid for an air-by-date episode", logger.DEBUG) - valid = validate_name(pattern, abd=True) - - return valid - - -def validate_name(pattern, multi=None, file_only=False, abd=False): - ep = _generate_sample_ep(multi, abd) - - parser = NameParser(True) - - new_name = ep.formatted_filename(pattern, multi) + '.ext' - new_path = ep.formatted_dir(pattern, multi) - if not file_only: - new_name = ek.ek(os.path.join, new_path, new_name) - - if not new_name: - logger.log(u"Unable to create a name out of "+pattern, logger.DEBUG) - return False - - logger.log(u"Trying to parse "+new_name, logger.DEBUG) - - try: - result = parser.parse(new_name) - except InvalidNameException: - logger.log(u"Unable to parse "+new_name+", not valid", logger.DEBUG) - return False - - logger.log("The name "+new_name + " parsed into " + str(result), logger.DEBUG) - - if abd: - if result.air_date != ep.airdate: - logger.log(u"Air date incorrect in parsed episode, pattern isn't valid", logger.DEBUG) - return False - else: - if result.season_number != ep.season: - logger.log(u"Season incorrect in parsed episode, pattern isn't valid", logger.DEBUG) - return False - if result.episode_numbers != [x.episode for x in [ep] + ep.relatedEps]: - logger.log(u"Episode incorrect in parsed episode, pattern isn't valid", logger.DEBUG) - return False - - return True - -def _generate_sample_ep(multi=None, abd=False): - # make a fake episode object - ep = TVEpisode(2,3,"Ep Name") - ep._status = Quality.compositeStatus(DOWNLOADED, Quality.HDTV) - ep._airdate = datetime.date(2011, 3, 9) - if abd: - ep._release_name = 'Show.Name.2011.03.09.HDTV.XviD-RLSGROUP' - else: - ep._release_name = 'Show.Name.S02E03.HDTV.XviD-RLSGROUP' - - if multi != None: - ep._name = "Ep Name (1)" - ep._release_name = 'Show.Name.S02E03E04E05.HDTV.XviD-RLSGROUP' - - secondEp = TVEpisode(2,4,"Ep Name (2)") - secondEp._status = Quality.compositeStatus(DOWNLOADED, Quality.HDTV) - secondEp._release_name = ep._release_name - - thirdEp = TVEpisode(2,5,"Ep Name (3)") - thirdEp._status = Quality.compositeStatus(DOWNLOADED, Quality.HDTV) - thirdEp._release_name = ep._release_name - - ep.relatedEps.append(secondEp) - ep.relatedEps.append(thirdEp) - - return ep - -def test_name(pattern, multi=None, abd=False): - - ep = _generate_sample_ep(multi, abd) - +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import datetime +import os + +import sickbeard +from sickbeard import encodingKludge as ek +from sickbeard import tv +from sickbeard import common +from sickbeard import logger +from sickbeard.name_parser.parser import NameParser, InvalidNameException + +from common import Quality, DOWNLOADED + +name_presets = ('%SN - %Sx%0E - %EN', + '%S.N.S%0SE%0E.%E.N', + '%Sx%0E - %EN', + 'S%0SE%0E - %EN', + 'Season %0S/%S.N.S%0SE%0E.%Q.N-%RG' + ) + +name_abd_presets = ('%SN - %A-D - %EN', + '%S.N.%A.D.%E.N.%Q.N', + '%Y/%0M/%S.N.%A.D.%E.N-%RG' + ) + +class TVShow(): + def __init__(self): + self.name = "Show Name" + self.genre = "Comedy" + self.air_by_date = 0 + +class TVEpisode(tv.TVEpisode): + def __init__(self, season, episode, name): + self.relatedEps = [] + self._name = name + self._season = season + self._episode = episode + self._airdate = datetime.date(2010, 3, 9) + self.show = TVShow() + self._status = Quality.compositeStatus(common.DOWNLOADED, common.Quality.SDTV) + self._release_name = 'Show.Name.S02E03.HDTV.XviD-RLSGROUP' + +def check_force_season_folders(pattern=None, multi=None): + """ + Checks if the name can still be parsed if you strip off the folders to determine if we need to force season folders + to be enabled or not. + + Returns true if season folders need to be forced on or false otherwise. + """ + if pattern == None: + pattern = sickbeard.NAMING_PATTERN + + valid = not validate_name(pattern, None, file_only=True) + + if multi != None: + valid = valid or not validate_name(pattern, multi, file_only=True) + + return valid + +def check_valid_naming(pattern=None, multi=None): + """ + Checks if the name is can be parsed back to its original form for both single and multi episodes. + + Returns true if the naming is valid, false if not. + """ + if pattern == None: + pattern = sickbeard.NAMING_PATTERN + + logger.log(u"Checking whether the pattern "+pattern+" is valid for a single episode", logger.DEBUG) + valid = validate_name(pattern, None) + + if multi != None: + logger.log(u"Checking whether the pattern "+pattern+" is valid for a multi episode", logger.DEBUG) + valid = valid and validate_name(pattern, multi) + + return valid + +def check_valid_abd_naming(pattern=None): + """ + Checks if the name is can be parsed back to its original form for an air-by-date format. + + Returns true if the naming is valid, false if not. + """ + if pattern == None: + pattern = sickbeard.NAMING_PATTERN + + logger.log(u"Checking whether the pattern "+pattern+" is valid for an air-by-date episode", logger.DEBUG) + valid = validate_name(pattern, abd=True) + + return valid + + +def validate_name(pattern, multi=None, file_only=False, abd=False): + ep = _generate_sample_ep(multi, abd) + + parser = NameParser(True) + + new_name = ep.formatted_filename(pattern, multi) + '.ext' + new_path = ep.formatted_dir(pattern, multi) + if not file_only: + new_name = ek.ek(os.path.join, new_path, new_name) + + if not new_name: + logger.log(u"Unable to create a name out of "+pattern, logger.DEBUG) + return False + + logger.log(u"Trying to parse "+new_name, logger.DEBUG) + + try: + result = parser.parse(new_name) + except InvalidNameException: + logger.log(u"Unable to parse "+new_name+", not valid", logger.DEBUG) + return False + + logger.log("The name "+new_name + " parsed into " + str(result), logger.DEBUG) + + if abd: + if result.air_date != ep.airdate: + logger.log(u"Air date incorrect in parsed episode, pattern isn't valid", logger.DEBUG) + return False + else: + if result.season_number != ep.season: + logger.log(u"Season incorrect in parsed episode, pattern isn't valid", logger.DEBUG) + return False + if result.episode_numbers != [x.episode for x in [ep] + ep.relatedEps]: + logger.log(u"Episode incorrect in parsed episode, pattern isn't valid", logger.DEBUG) + return False + + return True + +def _generate_sample_ep(multi=None, abd=False): + # make a fake episode object + ep = TVEpisode(2,3,"Ep Name") + ep._status = Quality.compositeStatus(DOWNLOADED, Quality.HDTV) + ep._airdate = datetime.date(2011, 3, 9) + if abd: + ep._release_name = 'Show.Name.2011.03.09.HDTV.XviD-RLSGROUP' + else: + ep._release_name = 'Show.Name.S02E03.HDTV.XviD-RLSGROUP' + + if multi != None: + ep._name = "Ep Name (1)" + ep._release_name = 'Show.Name.S02E03E04E05.HDTV.XviD-RLSGROUP' + + secondEp = TVEpisode(2,4,"Ep Name (2)") + secondEp._status = Quality.compositeStatus(DOWNLOADED, Quality.HDTV) + secondEp._release_name = ep._release_name + + thirdEp = TVEpisode(2,5,"Ep Name (3)") + thirdEp._status = Quality.compositeStatus(DOWNLOADED, Quality.HDTV) + thirdEp._release_name = ep._release_name + + ep.relatedEps.append(secondEp) + ep.relatedEps.append(thirdEp) + + return ep + +def test_name(pattern, multi=None, abd=False): + + ep = _generate_sample_ep(multi, abd) + return {'name': ep.formatted_filename(pattern, multi), 'dir': ep.formatted_dir(pattern, multi)} \ No newline at end of file diff --git a/sickbeard/notifiers/nma.py b/sickbeard/notifiers/nma.py index 447a7968f1..1c67990f9e 100644 --- a/sickbeard/notifiers/nma.py +++ b/sickbeard/notifiers/nma.py @@ -1,56 +1,56 @@ -import sickbeard - -from sickbeard import logger, common -from lib.pynma import pynma - -class NMA_Notifier: - - def test_notify(self, nma_api, nma_priority): - return self._sendNMA(nma_api, nma_priority, event="Test", message="Testing NMA settings from Sick Beard", force=True) - - def notify_snatch(self, ep_name): - if sickbeard.NMA_NOTIFY_ONSNATCH: - self._sendNMA(nma_api=None, nma_priority=None, event=common.notifyStrings[common.NOTIFY_SNATCH], message=ep_name) - - def notify_download(self, ep_name): - if sickbeard.NMA_NOTIFY_ONDOWNLOAD: - self._sendNMA(nma_api=None, nma_priority=None, event=common.notifyStrings[common.NOTIFY_DOWNLOAD], message=ep_name) - - def notify_subtitle_download(self, ep_name, lang): - if sickbeard.NMA_NOTIFY_ONSUBTITLEDOWNLOAD: - self._sendNMA(nma_api=None, nma_priority=None, event=common.notifyStrings[common.NOTIFY_SUBTITLE_DOWNLOAD], message=ep_name + ": " + lang) - - def _sendNMA(self, nma_api=None, nma_priority=None, event=None, message=None, force=False): - - title = 'Sick-Beard' - - if not sickbeard.USE_NMA and not force: - return False - - if nma_api == None: - nma_api = sickbeard.NMA_API - - if nma_priority == None: - nma_priority = sickbeard.NMA_PRIORITY - - logger.log(u"NMA title: " + title, logger.DEBUG) - logger.log(u"NMA event: " + event, logger.DEBUG) - logger.log(u"NMA message: " + message, logger.DEBUG) - - batch = False - - p = pynma.PyNMA() - keys = nma_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[nma_api][u'code'] == u'200': - logger.log(u'Could not send notification to NotifyMyAndroid', logger.ERROR) - return False - else: - return True - +import sickbeard + +from sickbeard import logger, common +from lib.pynma import pynma + +class NMA_Notifier: + + def test_notify(self, nma_api, nma_priority): + return self._sendNMA(nma_api, nma_priority, event="Test", message="Testing NMA settings from Sick Beard", force=True) + + def notify_snatch(self, ep_name): + if sickbeard.NMA_NOTIFY_ONSNATCH: + self._sendNMA(nma_api=None, nma_priority=None, event=common.notifyStrings[common.NOTIFY_SNATCH], message=ep_name) + + def notify_download(self, ep_name): + if sickbeard.NMA_NOTIFY_ONDOWNLOAD: + self._sendNMA(nma_api=None, nma_priority=None, event=common.notifyStrings[common.NOTIFY_DOWNLOAD], message=ep_name) + + def notify_subtitle_download(self, ep_name, lang): + if sickbeard.NMA_NOTIFY_ONSUBTITLEDOWNLOAD: + self._sendNMA(nma_api=None, nma_priority=None, event=common.notifyStrings[common.NOTIFY_SUBTITLE_DOWNLOAD], message=ep_name + ": " + lang) + + def _sendNMA(self, nma_api=None, nma_priority=None, event=None, message=None, force=False): + + title = 'Sick-Beard' + + if not sickbeard.USE_NMA and not force: + return False + + if nma_api == None: + nma_api = sickbeard.NMA_API + + if nma_priority == None: + nma_priority = sickbeard.NMA_PRIORITY + + logger.log(u"NMA title: " + title, logger.DEBUG) + logger.log(u"NMA event: " + event, logger.DEBUG) + logger.log(u"NMA message: " + message, logger.DEBUG) + + batch = False + + p = pynma.PyNMA() + keys = nma_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[nma_api][u'code'] == u'200': + logger.log(u'Could not send notification to NotifyMyAndroid', logger.ERROR) + return False + else: + return True + notifier = NMA_Notifier \ No newline at end of file diff --git a/sickbeard/notifiers/nmj.py b/sickbeard/notifiers/nmj.py index 2a2d8dc2c9..ef0ce14266 100644 --- a/sickbeard/notifiers/nmj.py +++ b/sickbeard/notifiers/nmj.py @@ -1,183 +1,183 @@ -# Author: Nico Berlee http://nico.berlee.nl/ -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import urllib, urllib2 -import sickbeard -import telnetlib -import re - -from sickbeard import logger - -try: - import xml.etree.cElementTree as etree -except ImportError: - import xml.etree.ElementTree as etree - - -class NMJNotifier: - def notify_settings(self, host): - """ - Retrieves the settings from a NMJ/Popcorn hour - - host: The hostname/IP of the Popcorn Hour server - - Returns: True if the settings were retrieved successfully, False otherwise - """ - - # establish a terminal session to the PC - terminal = False - try: - terminal = telnetlib.Telnet(host) - except Exception: - logger.log(u"Warning: unable to get a telnet session to %s" % (host), logger.ERROR) - return False - - # tell the terminal to output the necessary info to the screen so we can search it later - logger.log(u"Connected to %s via telnet" % (host), logger.DEBUG) - terminal.read_until("sh-3.00# ") - terminal.write("cat /tmp/source\n") - terminal.write("cat /tmp/netshare\n") - terminal.write("exit\n") - tnoutput = terminal.read_all() - - database = "" - device = "" - match = re.search(r"(.+\.db)\r\n?(.+)(?=sh-3.00# cat /tmp/netshare)", tnoutput) - - # if we found the database in the terminal output then save that database to the config - if match: - database = match.group(1) - device = match.group(2) - logger.log(u"Found NMJ database %s on device %s" % (database, device), logger.DEBUG) - sickbeard.NMJ_DATABASE = database - else: - logger.log(u"Could not get current NMJ database on %s, NMJ is probably not running!" % (host), logger.ERROR) - return False - - # if the device is a remote host then try to parse the mounting URL and save it to the config - if device.startswith("NETWORK_SHARE/"): - match = re.search(".*(?=\r\n?%s)" % (re.escape(device[14:])), tnoutput) - - if match: - mount = match.group().replace("127.0.0.1", host) - logger.log(u"Found mounting url on the Popcorn Hour in configuration: %s" % (mount), logger.DEBUG) - sickbeard.NMJ_MOUNT = mount - else: - logger.log(u"Detected a network share on the Popcorn Hour, but could not get the mounting url", logger.DEBUG) - return False - - return True - - def notify_snatch(self, ep_name): - return False - #Not implemented: Start the scanner when snatched does not make any sense - - def notify_download(self, ep_name): - if sickbeard.USE_NMJ: - self._notifyNMJ() - - def notify_subtitle_download(self, ep_name, lang): - if sickbeard.USE_NMJ: - self._notifyNMJ() - - def test_notify(self, host, database, mount): - return self._sendNMJ(host, database, mount) - - def _sendNMJ(self, host, database, mount=None): - """ - Sends a NMJ update command to the specified machine - - host: The hostname/IP to send the request to (no port) - database: The database to send the requst to - mount: The mount URL to use (optional) - - Returns: True if the request succeeded, False otherwise - """ - - # if a mount URL is provided then attempt to open a handle to that URL - if mount: - try: - req = urllib2.Request(mount) - logger.log(u"Try to mount network drive via url: %s" % (mount), logger.DEBUG) - handle = urllib2.urlopen(req) - except IOError, e: - logger.log(u"Warning: Couldn't contact popcorn hour on host %s: %s" % (host, e)) - return False - - # build up the request URL and parameters - UPDATE_URL = "http://%(host)s:8008/metadata_database?%(params)s" - params = { - "arg0": "scanner_start", - "arg1": database, - "arg2": "background", - "arg3": ""} - params = urllib.urlencode(params) - updateUrl = UPDATE_URL % {"host": host, "params": params} - - # send the request to the server - try: - req = urllib2.Request(updateUrl) - logger.log(u"Sending NMJ scan update command via url: %s" % (updateUrl), logger.DEBUG) - handle = urllib2.urlopen(req) - response = handle.read() - except IOError, e: - logger.log(u"Warning: Couldn't contact Popcorn Hour on host %s: %s" % (host, e)) - return False - - # try to parse the resulting XML - try: - et = etree.fromstring(response) - result = et.findtext("returnValue") - except SyntaxError, e: - logger.log(u"Unable to parse XML returned from the Popcorn Hour: %s" % (e), logger.ERROR) - return False - - # if the result was a number then consider that an error - if int(result) > 0: - logger.log(u"Popcorn Hour returned an errorcode: %s" % (result)) - return False - else: - logger.log(u"NMJ started background scan") - return True - - def _notifyNMJ(self, host=None, database=None, mount=None, force=False): - """ - Sends a NMJ update command based on the SB config settings - - host: The host to send the command to (optional, defaults to the host in the config) - database: The database to use (optional, defaults to the database in the config) - mount: The mount URL (optional, defaults to the mount URL in the config) - force: If True then the notification will be sent even if NMJ is disabled in the config - """ - if not sickbeard.USE_NMJ and not force: - logger.log("Notification for NMJ scan update not enabled, skipping this notification", logger.DEBUG) - return False - - # fill in omitted parameters - if not host: - host = sickbeard.NMJ_HOST - if not database: - database = sickbeard.NMJ_DATABASE - if not mount: - mount = sickbeard.NMJ_MOUNT - - logger.log(u"Sending scan command for NMJ ", logger.DEBUG) - - return self._sendNMJ(host, database, mount) - -notifier = NMJNotifier +# Author: Nico Berlee http://nico.berlee.nl/ +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import urllib, urllib2 +import sickbeard +import telnetlib +import re + +from sickbeard import logger + +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + + +class NMJNotifier: + def notify_settings(self, host): + """ + Retrieves the settings from a NMJ/Popcorn hour + + host: The hostname/IP of the Popcorn Hour server + + Returns: True if the settings were retrieved successfully, False otherwise + """ + + # establish a terminal session to the PC + terminal = False + try: + terminal = telnetlib.Telnet(host) + except Exception: + logger.log(u"Warning: unable to get a telnet session to %s" % (host), logger.ERROR) + return False + + # tell the terminal to output the necessary info to the screen so we can search it later + logger.log(u"Connected to %s via telnet" % (host), logger.DEBUG) + terminal.read_until("sh-3.00# ") + terminal.write("cat /tmp/source\n") + terminal.write("cat /tmp/netshare\n") + terminal.write("exit\n") + tnoutput = terminal.read_all() + + database = "" + device = "" + match = re.search(r"(.+\.db)\r\n?(.+)(?=sh-3.00# cat /tmp/netshare)", tnoutput) + + # if we found the database in the terminal output then save that database to the config + if match: + database = match.group(1) + device = match.group(2) + logger.log(u"Found NMJ database %s on device %s" % (database, device), logger.DEBUG) + sickbeard.NMJ_DATABASE = database + else: + logger.log(u"Could not get current NMJ database on %s, NMJ is probably not running!" % (host), logger.ERROR) + return False + + # if the device is a remote host then try to parse the mounting URL and save it to the config + if device.startswith("NETWORK_SHARE/"): + match = re.search(".*(?=\r\n?%s)" % (re.escape(device[14:])), tnoutput) + + if match: + mount = match.group().replace("127.0.0.1", host) + logger.log(u"Found mounting url on the Popcorn Hour in configuration: %s" % (mount), logger.DEBUG) + sickbeard.NMJ_MOUNT = mount + else: + logger.log(u"Detected a network share on the Popcorn Hour, but could not get the mounting url", logger.DEBUG) + return False + + return True + + def notify_snatch(self, ep_name): + return False + #Not implemented: Start the scanner when snatched does not make any sense + + def notify_download(self, ep_name): + if sickbeard.USE_NMJ: + self._notifyNMJ() + + def notify_subtitle_download(self, ep_name, lang): + if sickbeard.USE_NMJ: + self._notifyNMJ() + + def test_notify(self, host, database, mount): + return self._sendNMJ(host, database, mount) + + def _sendNMJ(self, host, database, mount=None): + """ + Sends a NMJ update command to the specified machine + + host: The hostname/IP to send the request to (no port) + database: The database to send the requst to + mount: The mount URL to use (optional) + + Returns: True if the request succeeded, False otherwise + """ + + # if a mount URL is provided then attempt to open a handle to that URL + if mount: + try: + req = urllib2.Request(mount) + logger.log(u"Try to mount network drive via url: %s" % (mount), logger.DEBUG) + handle = urllib2.urlopen(req) + except IOError, e: + logger.log(u"Warning: Couldn't contact popcorn hour on host %s: %s" % (host, e)) + return False + + # build up the request URL and parameters + UPDATE_URL = "http://%(host)s:8008/metadata_database?%(params)s" + params = { + "arg0": "scanner_start", + "arg1": database, + "arg2": "background", + "arg3": ""} + params = urllib.urlencode(params) + updateUrl = UPDATE_URL % {"host": host, "params": params} + + # send the request to the server + try: + req = urllib2.Request(updateUrl) + logger.log(u"Sending NMJ scan update command via url: %s" % (updateUrl), logger.DEBUG) + handle = urllib2.urlopen(req) + response = handle.read() + except IOError, e: + logger.log(u"Warning: Couldn't contact Popcorn Hour on host %s: %s" % (host, e)) + return False + + # try to parse the resulting XML + try: + et = etree.fromstring(response) + result = et.findtext("returnValue") + except SyntaxError, e: + logger.log(u"Unable to parse XML returned from the Popcorn Hour: %s" % (e), logger.ERROR) + return False + + # if the result was a number then consider that an error + if int(result) > 0: + logger.log(u"Popcorn Hour returned an errorcode: %s" % (result)) + return False + else: + logger.log(u"NMJ started background scan") + return True + + def _notifyNMJ(self, host=None, database=None, mount=None, force=False): + """ + Sends a NMJ update command based on the SB config settings + + host: The host to send the command to (optional, defaults to the host in the config) + database: The database to use (optional, defaults to the database in the config) + mount: The mount URL (optional, defaults to the mount URL in the config) + force: If True then the notification will be sent even if NMJ is disabled in the config + """ + if not sickbeard.USE_NMJ and not force: + logger.log("Notification for NMJ scan update not enabled, skipping this notification", logger.DEBUG) + return False + + # fill in omitted parameters + if not host: + host = sickbeard.NMJ_HOST + if not database: + database = sickbeard.NMJ_DATABASE + if not mount: + mount = sickbeard.NMJ_MOUNT + + logger.log(u"Sending scan command for NMJ ", logger.DEBUG) + + return self._sendNMJ(host, database, mount) + +notifier = NMJNotifier diff --git a/sickbeard/providers/__init__.py b/sickbeard/providers/__init__.py index 1e4d2f8698..ccf4957ecd 100755 --- a/sickbeard/providers/__init__.py +++ b/sickbeard/providers/__init__.py @@ -1,129 +1,129 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -__all__ = ['ezrss', - 'tvtorrents', - 'torrentleech', - 'nzbsrus', - 'womble', - 'btn', - 'nzbx', - 'omgwtfnzbs', - 'binnewz', - 't411', - 'cpasbien', - 'piratebay', - 'gks', - 'kat', - ] - -import sickbeard - -from os import sys - - -def sortedProviderList(): - - initialList = sickbeard.providerList + sickbeard.newznabProviderList - providerDict = dict(zip([x.getID() for x in initialList], initialList)) - - newList = [] - - # add all modules in the priority list, in order - for curModule in sickbeard.PROVIDER_ORDER: - if curModule in providerDict: - newList.append(providerDict[curModule]) - - # add any modules that are missing from that list - for curModule in providerDict: - if providerDict[curModule] not in newList: - newList.append(providerDict[curModule]) - - return newList - - -def makeProviderList(): - - return [x.provider for x in [getProviderModule(y) for y in __all__] if x] - - -def getNewznabProviderList(data): - - defaultList = [makeNewznabProvider(x) for x in getDefaultNewznabProviders().split('!!!')] - providerList = filter(lambda x: x, [makeNewznabProvider(x) for x in data.split('!!!')]) - - providerDict = dict(zip([x.name for x in providerList], providerList)) - - for curDefault in defaultList: - if not curDefault: - continue - - # a 0 in the key spot indicates that no key is needed, so set this on the object - if curDefault.key == '0': - curDefault.key = '' - curDefault.needs_auth = False - - if curDefault.name not in providerDict: - curDefault.default = True - providerList.append(curDefault) - else: - providerDict[curDefault.name].default = True - providerDict[curDefault.name].name = curDefault.name - providerDict[curDefault.name].url = curDefault.url - providerDict[curDefault.name].needs_auth = curDefault.needs_auth - - return filter(lambda x: x, providerList) - - -def makeNewznabProvider(configString): - - if not configString: - return None - - name, url, key, enabled = configString.split('|') - - newznab = sys.modules['sickbeard.providers.newznab'] - - newProvider = newznab.NewznabProvider(name, url) - newProvider.key = key - newProvider.enabled = enabled == '1' - - return newProvider - - -def getDefaultNewznabProviders(): - return 'Sick Beard Index|http://lolo.sickbeard.com/|0|0!!!NZBs.org|http://nzbs.org/||0!!!Usenet-Crawler|http://www.usenet-crawler.com/||0' - - -def getProviderModule(name): - name = name.lower() - prefix = "sickbeard.providers." - if name in __all__ and prefix + name in sys.modules: - return sys.modules[prefix + name] - else: - raise Exception("Can't find " + prefix + name + " in " + repr(sys.modules)) - - -def getProviderClass(providerID): - - providerMatch = [x for x in sickbeard.providerList + sickbeard.newznabProviderList if x.getID() == providerID] - - if len(providerMatch) != 1: - return None - else: - return providerMatch[0] +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +__all__ = ['ezrss', + 'tvtorrents', + 'torrentleech', + 'nzbsrus', + 'womble', + 'btn', + 'nzbx', + 'omgwtfnzbs', + 'binnewz', + 't411', + 'cpasbien', + 'piratebay', + 'gks', + 'kat', + ] + +import sickbeard + +from os import sys + + +def sortedProviderList(): + + initialList = sickbeard.providerList + sickbeard.newznabProviderList + providerDict = dict(zip([x.getID() for x in initialList], initialList)) + + newList = [] + + # add all modules in the priority list, in order + for curModule in sickbeard.PROVIDER_ORDER: + if curModule in providerDict: + newList.append(providerDict[curModule]) + + # add any modules that are missing from that list + for curModule in providerDict: + if providerDict[curModule] not in newList: + newList.append(providerDict[curModule]) + + return newList + + +def makeProviderList(): + + return [x.provider for x in [getProviderModule(y) for y in __all__] if x] + + +def getNewznabProviderList(data): + + defaultList = [makeNewznabProvider(x) for x in getDefaultNewznabProviders().split('!!!')] + providerList = filter(lambda x: x, [makeNewznabProvider(x) for x in data.split('!!!')]) + + providerDict = dict(zip([x.name for x in providerList], providerList)) + + for curDefault in defaultList: + if not curDefault: + continue + + # a 0 in the key spot indicates that no key is needed, so set this on the object + if curDefault.key == '0': + curDefault.key = '' + curDefault.needs_auth = False + + if curDefault.name not in providerDict: + curDefault.default = True + providerList.append(curDefault) + else: + providerDict[curDefault.name].default = True + providerDict[curDefault.name].name = curDefault.name + providerDict[curDefault.name].url = curDefault.url + providerDict[curDefault.name].needs_auth = curDefault.needs_auth + + return filter(lambda x: x, providerList) + + +def makeNewznabProvider(configString): + + if not configString: + return None + + name, url, key, enabled = configString.split('|') + + newznab = sys.modules['sickbeard.providers.newznab'] + + newProvider = newznab.NewznabProvider(name, url) + newProvider.key = key + newProvider.enabled = enabled == '1' + + return newProvider + + +def getDefaultNewznabProviders(): + return 'Sick Beard Index|http://lolo.sickbeard.com/|0|0!!!NZBs.org|http://nzbs.org/||0!!!Usenet-Crawler|http://www.usenet-crawler.com/||0' + + +def getProviderModule(name): + name = name.lower() + prefix = "sickbeard.providers." + if name in __all__ and prefix + name in sys.modules: + return sys.modules[prefix + name] + else: + raise Exception("Can't find " + prefix + name + " in " + repr(sys.modules)) + + +def getProviderClass(providerID): + + providerMatch = [x for x in sickbeard.providerList + sickbeard.newznabProviderList if x.getID() == providerID] + + if len(providerMatch) != 1: + return None + else: + return providerMatch[0] diff --git a/sickbeard/providers/binnewz/nzbdownloader.py b/sickbeard/providers/binnewz/nzbdownloader.py index 21e5e45ded..d1e602b747 100644 --- a/sickbeard/providers/binnewz/nzbdownloader.py +++ b/sickbeard/providers/binnewz/nzbdownloader.py @@ -1,96 +1,96 @@ -# Author: Guillaume Serre -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import urllib2 -from StringIO import StringIO -import gzip -import cookielib -import time - -class NZBDownloader(object): - - def __init__( self ): - self.cj = cookielib.CookieJar() - self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cj)) - self.lastRequestTime = None - - def waitBeforeNextRequest(self): - if self.lastRequestTime and self.lastRequestTime > ( time.mktime(time.localtime()) - 3): - time.sleep( 3 ) - self.lastRequestTime = time.gmtime() - - def open(self, request): - self.waitBeforeNextRequest() - return self.opener.open(request) - -class NZBSearchResult(object): - - def __init__(self, downloader, sizeInMegs, refererURL): - self.downloader = downloader - self.refererURL = refererURL - self.sizeInMegs = sizeInMegs - - def readRequest(self, request): - request.add_header('Accept-encoding', 'gzip') - request.add_header('Referer', self.refererURL) - request.add_header('Accept-Encoding', 'gzip') - request.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.17 (KHTML, like Gecko) Chrome/24.0.1312.57 Safari/537.17') - - response = self.downloader.open(request) - if response.info().get('Content-Encoding') == 'gzip': - buf = StringIO( response.read()) - f = gzip.GzipFile(fileobj=buf) - return f.read() - else: - return response.read() - - def getNZB(self): - pass - -class NZBGetURLSearchResult( NZBSearchResult ): - - def __init__(self, downloader, nzburl, sizeInMegs, refererURL): - NZBSearchResult.__init__(self, downloader, sizeInMegs, refererURL) - self.nzburl = nzburl - - def getNZB(self): - request = urllib2.Request( self.nzburl ) - self.nzbdata = NZBSearchResult.readRequest( self, request ) - return self.nzbdata - -class NZBPostURLSearchResult( NZBSearchResult ): - - def __init__(self, downloader, nzburl, postData, sizeInMegs, refererURL): - NZBSearchResult.__init__(self, downloader, sizeInMegs, refererURL) - self.nzburl = nzburl - self.postData = postData - - def getNZB(self): - request = urllib2.Request( self.nzburl, self.postData ) - self.nzbdata = NZBSearchResult.readRequest( self, request ) - return self.nzbdata - -class NZBDataSearchResult( NZBSearchResult ): - - def __init__(self, nzbdata, sizeInMegs, refererURL): - NZBSearchResult.__init__(self, None, refererURL) - self.nzbdata = nzbdata - - def getNZB(self): - return self.nzbdata +# Author: Guillaume Serre +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import urllib2 +from StringIO import StringIO +import gzip +import cookielib +import time + +class NZBDownloader(object): + + def __init__( self ): + self.cj = cookielib.CookieJar() + self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cj)) + self.lastRequestTime = None + + def waitBeforeNextRequest(self): + if self.lastRequestTime and self.lastRequestTime > ( time.mktime(time.localtime()) - 3): + time.sleep( 3 ) + self.lastRequestTime = time.gmtime() + + def open(self, request): + self.waitBeforeNextRequest() + return self.opener.open(request) + +class NZBSearchResult(object): + + def __init__(self, downloader, sizeInMegs, refererURL): + self.downloader = downloader + self.refererURL = refererURL + self.sizeInMegs = sizeInMegs + + def readRequest(self, request): + request.add_header('Accept-encoding', 'gzip') + request.add_header('Referer', self.refererURL) + request.add_header('Accept-Encoding', 'gzip') + request.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.17 (KHTML, like Gecko) Chrome/24.0.1312.57 Safari/537.17') + + response = self.downloader.open(request) + if response.info().get('Content-Encoding') == 'gzip': + buf = StringIO( response.read()) + f = gzip.GzipFile(fileobj=buf) + return f.read() + else: + return response.read() + + def getNZB(self): + pass + +class NZBGetURLSearchResult( NZBSearchResult ): + + def __init__(self, downloader, nzburl, sizeInMegs, refererURL): + NZBSearchResult.__init__(self, downloader, sizeInMegs, refererURL) + self.nzburl = nzburl + + def getNZB(self): + request = urllib2.Request( self.nzburl ) + self.nzbdata = NZBSearchResult.readRequest( self, request ) + return self.nzbdata + +class NZBPostURLSearchResult( NZBSearchResult ): + + def __init__(self, downloader, nzburl, postData, sizeInMegs, refererURL): + NZBSearchResult.__init__(self, downloader, sizeInMegs, refererURL) + self.nzburl = nzburl + self.postData = postData + + def getNZB(self): + request = urllib2.Request( self.nzburl, self.postData ) + self.nzbdata = NZBSearchResult.readRequest( self, request ) + return self.nzbdata + +class NZBDataSearchResult( NZBSearchResult ): + + def __init__(self, nzbdata, sizeInMegs, refererURL): + NZBSearchResult.__init__(self, None, refererURL) + self.nzbdata = nzbdata + + def getNZB(self): + return self.nzbdata \ No newline at end of file diff --git a/sickbeard/providers/cpasbien.py b/sickbeard/providers/cpasbien.py index 0794f087e4..956bd84317 100644 --- a/sickbeard/providers/cpasbien.py +++ b/sickbeard/providers/cpasbien.py @@ -1,154 +1,154 @@ -# -*- coding: latin-1 -*- -# Author: Guillaume Serre -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from bs4 import BeautifulSoup -from sickbeard import logger, classes, show_name_helpers -from sickbeard.common import Quality -from sickbeard.exceptions import ex -import cookielib -import generic -import sickbeard -import urllib -import urllib2 - - -class CpasbienProvider(generic.TorrentProvider): - - def __init__(self): - - generic.TorrentProvider.__init__(self, "Cpasbien") - - self.supportsBacklog = True - - self.cj = cookielib.CookieJar() - self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cj)) - - self.url = "http://www.cpasbien.me" - - - def isEnabled(self): - return sickbeard.Cpasbien - - def _get_season_search_strings(self, show, season): - - showNames = show_name_helpers.allPossibleShowNames(show) - result = [] - for showName in showNames: - result.append( showName + " S%02d" % season ) - return result - - def _get_episode_search_strings(self, ep_obj): - - strings = [] - - showNames = show_name_helpers.allPossibleShowNames(ep_obj.show) - for showName in showNames: - strings.append("%s S%02dE%02d" % ( showName, ep_obj.season, ep_obj.episode) ) - strings.append("%s %dx%d" % ( showName, ep_obj.season, ep_obj.episode ) ) - - return strings - - def _get_title_and_url(self, item): - return (item.title, item.url) - - def getQuality(self, item): - return item.getQuality() - - def _doSearch(self, searchString, show=None, season=None): - - results = [] - searchUrl = self.url + '/recherche/' - - data = urllib.urlencode({'champ_recherche': searchString}) - - try: - soup = BeautifulSoup( urllib2.urlopen(searchUrl, data) ) - except Exception, e: - logger.log(u"Error trying to load cpasbien response: "+ex(e), logger.ERROR) - return [] - - rows = soup.findAll(attrs = {'class' : ["color0", "color1"]}) - - for row in rows: - link = row.find("a", title=True) - title = str(link.text).lower().strip() - pageURL = link['href'] - - if "vostfr" in title and (not show.subtitles) and show.audio_lang == "fr": - continue - - torrentPage = self.opener.open( pageURL ) - torrentSoup = BeautifulSoup( torrentPage ) - - downloadTorrentLink = torrentSoup.find("a", title=u"Cliquer ici pour télécharger ce torrent") - if downloadTorrentLink: - - downloadURL = downloadTorrentLink['href'] - - if "720p" in title: - if "bluray" in title: - quality = Quality.HDBLURAY - elif "web-dl" in title.lower() or "web.dl" in title.lower(): - quality = Quality.HDWEBDL - else: - quality = Quality.HDTV - elif "1080p" in title: - quality = Quality.FULLHDBLURAY - elif "hdtv" in title: - if "720p" in title: - quality = Quality.HDTV - elif "1080p" in title: - quality = Quality.FULLHDTV - else: - quality = Quality.SDTV - else: - quality = Quality.SDTV - - if show: - results.append( CpasbienSearchResult( self.opener, title, downloadURL, quality, str(show.audio_lang) ) ) - else: - results.append( CpasbienSearchResult( self.opener, title, downloadURL, quality ) ) - - return results - - def getResult(self, episodes): - """ - Returns a result of the correct type for this provider - """ - result = classes.TorrentDataSearchResult(episodes) - result.provider = self - - return result - -class CpasbienSearchResult: - - def __init__(self, opener, title, url, quality, audio_langs=None): - self.opener = opener - self.title = title - self.url = url - self.quality = quality - self.audio_langs=audio_langs - - def getNZB(self): - return self.opener.open( self.url , 'wb').read() - - def getQuality(self): - return self.quality - +# -*- coding: latin-1 -*- +# Author: Guillaume Serre +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from bs4 import BeautifulSoup +from sickbeard import logger, classes, show_name_helpers +from sickbeard.common import Quality +from sickbeard.exceptions import ex +import cookielib +import generic +import sickbeard +import urllib +import urllib2 + + +class CpasbienProvider(generic.TorrentProvider): + + def __init__(self): + + generic.TorrentProvider.__init__(self, "Cpasbien") + + self.supportsBacklog = True + + self.cj = cookielib.CookieJar() + self.opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(self.cj)) + + self.url = "http://www.cpasbien.me" + + + def isEnabled(self): + return sickbeard.Cpasbien + + def _get_season_search_strings(self, show, season): + + showNames = show_name_helpers.allPossibleShowNames(show) + result = [] + for showName in showNames: + result.append( showName + " S%02d" % season ) + return result + + def _get_episode_search_strings(self, ep_obj): + + strings = [] + + showNames = show_name_helpers.allPossibleShowNames(ep_obj.show) + for showName in showNames: + strings.append("%s S%02dE%02d" % ( showName, ep_obj.season, ep_obj.episode) ) + strings.append("%s %dx%d" % ( showName, ep_obj.season, ep_obj.episode ) ) + + return strings + + def _get_title_and_url(self, item): + return (item.title, item.url) + + def getQuality(self, item): + return item.getQuality() + + def _doSearch(self, searchString, show=None, season=None): + + results = [] + searchUrl = self.url + '/recherche/' + + data = urllib.urlencode({'champ_recherche': searchString}) + + try: + soup = BeautifulSoup( urllib2.urlopen(searchUrl, data) ) + except Exception, e: + logger.log(u"Error trying to load cpasbien response: "+ex(e), logger.ERROR) + return [] + + rows = soup.findAll(attrs = {'class' : ["color0", "color1"]}) + + for row in rows: + link = row.find("a", title=True) + title = str(link.text).lower().strip() + pageURL = link['href'] + + if "vostfr" in title and (not show.subtitles) and show.audio_lang == "fr": + continue + + torrentPage = self.opener.open( pageURL ) + torrentSoup = BeautifulSoup( torrentPage ) + + downloadTorrentLink = torrentSoup.find("a", title=u"Cliquer ici pour télécharger ce torrent") + if downloadTorrentLink: + + downloadURL = downloadTorrentLink['href'] + + if "720p" in title: + if "bluray" in title: + quality = Quality.HDBLURAY + elif "web-dl" in title.lower() or "web.dl" in title.lower(): + quality = Quality.HDWEBDL + else: + quality = Quality.HDTV + elif "1080p" in title: + quality = Quality.FULLHDBLURAY + elif "hdtv" in title: + if "720p" in title: + quality = Quality.HDTV + elif "1080p" in title: + quality = Quality.FULLHDTV + else: + quality = Quality.SDTV + else: + quality = Quality.SDTV + + if show: + results.append( CpasbienSearchResult( self.opener, title, downloadURL, quality, str(show.audio_lang) ) ) + else: + results.append( CpasbienSearchResult( self.opener, title, downloadURL, quality ) ) + + return results + + def getResult(self, episodes): + """ + Returns a result of the correct type for this provider + """ + result = classes.TorrentDataSearchResult(episodes) + result.provider = self + + return result + +class CpasbienSearchResult: + + def __init__(self, opener, title, url, quality, audio_langs=None): + self.opener = opener + self.title = title + self.url = url + self.quality = quality + self.audio_langs=audio_langs + + def getNZB(self): + return self.opener.open( self.url , 'wb').read() + + def getQuality(self): + return self.quality + provider = CpasbienProvider() \ No newline at end of file diff --git a/sickbeard/providers/newzbin.py b/sickbeard/providers/newzbin.py index 34b833fd00..25b0c0394e 100644 --- a/sickbeard/providers/newzbin.py +++ b/sickbeard/providers/newzbin.py @@ -1,384 +1,384 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import os -import re -import sys -import time -import urllib - -from xml.dom.minidom import parseString -from datetime import datetime, timedelta - -import sickbeard -import generic - -import sickbeard.encodingKludge as ek -from sickbeard import classes, logger, helpers, exceptions, show_name_helpers -from sickbeard import tvcache -from sickbeard.common import Quality -from sickbeard.exceptions import ex -from lib.dateutil.parser import parse as parseDate - -class NewzbinDownloader(urllib.FancyURLopener): - - def __init__(self): - urllib.FancyURLopener.__init__(self) - - def http_error_default(self, url, fp, errcode, errmsg, headers): - - # if newzbin is throttling us, wait seconds and try again - if errcode == 400: - - newzbinErrCode = int(headers.getheader('X-DNZB-RCode')) - - if newzbinErrCode == 450: - rtext = str(headers.getheader('X-DNZB-RText')) - result = re.search("wait (\d+) seconds", rtext) - - elif newzbinErrCode == 401: - raise exceptions.AuthException("Newzbin username or password incorrect") - - elif newzbinErrCode == 402: - raise exceptions.AuthException("Newzbin account not premium status, can't download NZBs") - - logger.log("Newzbin throttled our NZB downloading, pausing for " + result.group(1) + "seconds") - - time.sleep(int(result.group(1))) - - raise exceptions.NewzbinAPIThrottled() - -class NewzbinProvider(generic.NZBProvider): - - def __init__(self): - - generic.NZBProvider.__init__(self, "Newzbin") - - self.supportsBacklog = True - - self.cache = NewzbinCache(self) - - self.url = 'https://www.newzbin2.es/' - - self.NEWZBIN_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S %Z' - - def isEnabled(self): - return sickbeard.NEWZBIN - - def getQuality(self, item): - attributes = item.getElementsByTagName('report:attributes')[0] - attr_dict = {} - - for attribute in attributes.getElementsByTagName('report:attribute'): - cur_attr = attribute.getAttribute('type') - cur_attr_value = helpers.get_xml_text(attribute) - if cur_attr not in attr_dict: - attr_dict[cur_attr] = [cur_attr_value] - else: - attr_dict[cur_attr].append(cur_attr_value) - - logger.log("Finding quality of item based on attributes "+str(attr_dict), logger.DEBUG) - - if self._is_SDTV(attr_dict): - quality = Quality.SDTV - elif self._is_SDDVD(attr_dict): - quality = Quality.SDDVD - elif self._is_HDTV(attr_dict): - quality = Quality.HDTV - elif self._is_WEBDL(attr_dict): - quality = Quality.HDWEBDL - elif self._is_720pBluRay(attr_dict): - quality = Quality.HDBLURAY - elif self._is_1080pBluRay(attr_dict): - quality = Quality.FULLHDBLURAY - else: - quality = Quality.UNKNOWN - - logger.log("Resulting quality: "+str(quality), logger.DEBUG) - - return quality - - def _is_SDTV(self, attrs): - - # Video Fmt: (XviD, DivX, H.264/x264), NOT 720p, NOT 1080p, NOT 1080i - video_fmt = 'Video Fmt' in attrs and ('XviD' in attrs['Video Fmt'] or 'DivX' in attrs['Video Fmt'] or 'H.264/x264' in attrs['Video Fmt']) \ - and ('720p' not in attrs['Video Fmt']) \ - and ('1080p' not in attrs['Video Fmt']) \ - and ('1080i' not in attrs['Video Fmt']) - - # Source: TV Cap or HDTV or (None) - source = 'Source' not in attrs or 'TV Cap' in attrs['Source'] or 'HDTV' in attrs['Source'] - - # Subtitles: (None) - subs = 'Subtitles' not in attrs - - return video_fmt and source and subs - - def _is_SDDVD(self, attrs): - - # Video Fmt: (XviD, DivX, H.264/x264), NOT 720p, NOT 1080p, NOT 1080i - video_fmt = 'Video Fmt' in attrs and ('XviD' in attrs['Video Fmt'] or 'DivX' in attrs['Video Fmt'] or 'H.264/x264' in attrs['Video Fmt']) \ - and ('720p' not in attrs['Video Fmt']) \ - and ('1080p' not in attrs['Video Fmt']) \ - and ('1080i' not in attrs['Video Fmt']) - - # Source: DVD - source = 'Source' in attrs and 'DVD' in attrs['Source'] - - # Subtitles: (None) - subs = 'Subtitles' not in attrs - - return video_fmt and source and subs - - def _is_HDTV(self, attrs): - # Video Fmt: H.264/x264, 720p - video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ - and ('720p' in attrs['Video Fmt']) - - # Source: TV Cap or HDTV or (None) - source = 'Source' not in attrs or 'TV Cap' in attrs['Source'] or 'HDTV' in attrs['Source'] - - # Subtitles: (None) - subs = 'Subtitles' not in attrs - - return video_fmt and source and subs - - def _is_WEBDL(self, attrs): - - # Video Fmt: H.264/x264, 720p - video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ - and ('720p' in attrs['Video Fmt']) - - # Source: WEB-DL - source = 'Source' in attrs and 'WEB-DL' in attrs['Source'] - - # Subtitles: (None) - subs = 'Subtitles' not in attrs - - return video_fmt and source and subs - - def _is_720pBluRay(self, attrs): - - # Video Fmt: H.264/x264, 720p - video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ - and ('720p' in attrs['Video Fmt']) - - # Source: Blu-ray or HD-DVD - source = 'Source' in attrs and ('Blu-ray' in attrs['Source'] or 'HD-DVD' in attrs['Source']) - - return video_fmt and source - - def _is_1080pBluRay(self, attrs): - - # Video Fmt: H.264/x264, 1080p - video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ - and ('1080p' in attrs['Video Fmt']) - - # Source: Blu-ray or HD-DVD - source = 'Source' in attrs and ('Blu-ray' in attrs['Source'] or 'HD-DVD' in attrs['Source']) - - return video_fmt and source - - - def getIDFromURL(self, url): - id_regex = re.escape(self.url) + 'browse/post/(\d+)/' - id_match = re.match(id_regex, url) - if not id_match: - return None - else: - return id_match.group(1) - - def downloadResult(self, nzb): - - id = self.getIDFromURL(nzb.url) - if not id: - logger.log("Unable to get an ID from "+str(nzb.url)+", can't download from Newzbin's API", logger.ERROR) - return False - - logger.log("Downloading an NZB from newzbin with id "+id) - - fileName = ek.ek(os.path.join, sickbeard.NZB_DIR, helpers.sanitizeFileName(nzb.name)+'.nzb') - logger.log("Saving to " + fileName) - - urllib._urlopener = NewzbinDownloader() - - params = urllib.urlencode({"username": sickbeard.NEWZBIN_USERNAME, "password": sickbeard.NEWZBIN_PASSWORD, "reportid": id}) - try: - urllib.urlretrieve(self.url+"api/dnzb/", fileName, data=params) - except exceptions.NewzbinAPIThrottled: - logger.log("Done waiting for Newzbin API throttle limit, starting downloads again") - self.downloadResult(nzb) - except (urllib.ContentTooShortError, IOError), e: - logger.log("Error downloading NZB: " + str(sys.exc_info()) + " - " + ex(e), logger.ERROR) - return False - - return True - - def getURL(self, url): - - myOpener = classes.AuthURLOpener(sickbeard.NEWZBIN_USERNAME, sickbeard.NEWZBIN_PASSWORD) - try: - f = myOpener.openit(url) - except (urllib.ContentTooShortError, IOError), e: - logger.log("Error loading search results: " + str(sys.exc_info()) + " - " + ex(e), logger.ERROR) - return None - - data = f.read() - f.close() - - return data - - def _get_season_search_strings(self, show, season): - - nameList = set(show_name_helpers.allPossibleShowNames(show)) - - if show.air_by_date: - suffix = '' - else: - suffix = 'x' - searchTerms = ['^"'+x+' - '+str(season)+suffix+'"' for x in nameList] - #searchTerms += ['^"'+x+' - Season '+str(season)+'"' for x in nameList] - searchStr = " OR ".join(searchTerms) - - searchStr += " -subpack -extras" - - logger.log("Searching newzbin for string "+searchStr, logger.DEBUG) - - return [searchStr] - - def _get_episode_search_strings(self, ep_obj): - - nameList = set(show_name_helpers.allPossibleShowNames(ep_obj.show)) - if not ep_obj.show.air_by_date: - searchStr = " OR ".join(['^"'+x+' - %dx%02d"'%(ep_obj.season, ep_obj.episode) for x in nameList]) - else: - searchStr = " OR ".join(['^"'+x+' - '+str(ep_obj.airdate)+'"' for x in nameList]) - return [searchStr] - - def _doSearch(self, searchStr, show=None): - - data = self._getRSSData(searchStr.encode('utf-8')) - - item_list = [] - - try: - parsedXML = parseString(data) - items = parsedXML.getElementsByTagName('item') - except Exception, e: - logger.log("Error trying to load Newzbin RSS feed: "+ex(e), logger.ERROR) - return [] - - for cur_item in items: - title = helpers.get_xml_text(cur_item.getElementsByTagName('title')[0]) - if title == 'Feeds Error': - raise exceptions.AuthException("The feed wouldn't load, probably because of invalid auth info") - if sickbeard.USENET_RETENTION is not None: - try: - dateString = helpers.get_xml_text(cur_item.getElementsByTagName('report:postdate')[0]) - # use the parse (imported as parseDate) function from the dateutil lib - # and we have to remove the timezone info from it because the retention_date will not have one - # and a comparison of them is not possible - post_date = parseDate(dateString).replace(tzinfo=None) - retention_date = datetime.now() - timedelta(days=sickbeard.USENET_RETENTION) - if post_date < retention_date: - logger.log(u"Date "+str(post_date)+" is out of retention range, skipping", logger.DEBUG) - continue - except Exception, e: - logger.log("Error parsing date from Newzbin RSS feed: " + str(e), logger.ERROR) - continue - - item_list.append(cur_item) - - return item_list - - - def _getRSSData(self, search=None): - - params = { - 'searchaction': 'Search', - 'fpn': 'p', - 'category': 8, - 'u_nfo_posts_only': 0, - 'u_url_posts_only': 0, - 'u_comment_posts_only': 0, - 'u_show_passworded': 0, - 'u_v3_retention': 0, - 'ps_rb_video_format': 3082257, - 'ps_rb_language': 4096, - 'sort': 'date', - 'order': 'desc', - 'u_post_results_amt': 50, - 'feed': 'rss', - 'hauth': 1, - } - - if search: - params['q'] = search + " AND " - else: - params['q'] = '' - - params['q'] += 'Attr:Lang~Eng AND NOT Attr:VideoF=DVD' - - url = self.url + "search/?%s" % urllib.urlencode(params) - logger.log("Newzbin search URL: " + url, logger.DEBUG) - - data = self.getURL(url) - - return data - - def _checkAuth(self): - if sickbeard.NEWZBIN_USERNAME in (None, "") or sickbeard.NEWZBIN_PASSWORD in (None, ""): - raise exceptions.AuthException("Newzbin authentication details are empty, check your config") - -class NewzbinCache(tvcache.TVCache): - - def __init__(self, provider): - - tvcache.TVCache.__init__(self, provider) - - # only poll Newzbin every 10 mins max - self.minTime = 1 - - def _getRSSData(self): - - data = self.provider._getRSSData() - - return data - - def _parseItem(self, item): - - (title, url) = self.provider._get_title_and_url(item) - - if title == 'Feeds Error': - logger.log("There's an error in the feed, probably bad auth info", logger.DEBUG) - raise exceptions.AuthException("Invalid Newzbin username/password") - - if not title or not url: - logger.log("The XML returned from the "+self.provider.name+" feed is incomplete, this result is unusable", logger.ERROR) - return - - quality = self.provider.getQuality(item) - - logger.log("Found quality "+str(quality), logger.DEBUG) - - logger.log("Adding item from RSS to cache: "+title, logger.DEBUG) - - self._addCacheEntry(title, url, quality=quality) - - -provider = NewzbinProvider() +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import os +import re +import sys +import time +import urllib + +from xml.dom.minidom import parseString +from datetime import datetime, timedelta + +import sickbeard +import generic + +import sickbeard.encodingKludge as ek +from sickbeard import classes, logger, helpers, exceptions, show_name_helpers +from sickbeard import tvcache +from sickbeard.common import Quality +from sickbeard.exceptions import ex +from lib.dateutil.parser import parse as parseDate + +class NewzbinDownloader(urllib.FancyURLopener): + + def __init__(self): + urllib.FancyURLopener.__init__(self) + + def http_error_default(self, url, fp, errcode, errmsg, headers): + + # if newzbin is throttling us, wait seconds and try again + if errcode == 400: + + newzbinErrCode = int(headers.getheader('X-DNZB-RCode')) + + if newzbinErrCode == 450: + rtext = str(headers.getheader('X-DNZB-RText')) + result = re.search("wait (\d+) seconds", rtext) + + elif newzbinErrCode == 401: + raise exceptions.AuthException("Newzbin username or password incorrect") + + elif newzbinErrCode == 402: + raise exceptions.AuthException("Newzbin account not premium status, can't download NZBs") + + logger.log("Newzbin throttled our NZB downloading, pausing for " + result.group(1) + "seconds") + + time.sleep(int(result.group(1))) + + raise exceptions.NewzbinAPIThrottled() + +class NewzbinProvider(generic.NZBProvider): + + def __init__(self): + + generic.NZBProvider.__init__(self, "Newzbin") + + self.supportsBacklog = True + + self.cache = NewzbinCache(self) + + self.url = 'https://www.newzbin2.es/' + + self.NEWZBIN_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S %Z' + + def isEnabled(self): + return sickbeard.NEWZBIN + + def getQuality(self, item): + attributes = item.getElementsByTagName('report:attributes')[0] + attr_dict = {} + + for attribute in attributes.getElementsByTagName('report:attribute'): + cur_attr = attribute.getAttribute('type') + cur_attr_value = helpers.get_xml_text(attribute) + if cur_attr not in attr_dict: + attr_dict[cur_attr] = [cur_attr_value] + else: + attr_dict[cur_attr].append(cur_attr_value) + + logger.log("Finding quality of item based on attributes "+str(attr_dict), logger.DEBUG) + + if self._is_SDTV(attr_dict): + quality = Quality.SDTV + elif self._is_SDDVD(attr_dict): + quality = Quality.SDDVD + elif self._is_HDTV(attr_dict): + quality = Quality.HDTV + elif self._is_WEBDL(attr_dict): + quality = Quality.HDWEBDL + elif self._is_720pBluRay(attr_dict): + quality = Quality.HDBLURAY + elif self._is_1080pBluRay(attr_dict): + quality = Quality.FULLHDBLURAY + else: + quality = Quality.UNKNOWN + + logger.log("Resulting quality: "+str(quality), logger.DEBUG) + + return quality + + def _is_SDTV(self, attrs): + + # Video Fmt: (XviD, DivX, H.264/x264), NOT 720p, NOT 1080p, NOT 1080i + video_fmt = 'Video Fmt' in attrs and ('XviD' in attrs['Video Fmt'] or 'DivX' in attrs['Video Fmt'] or 'H.264/x264' in attrs['Video Fmt']) \ + and ('720p' not in attrs['Video Fmt']) \ + and ('1080p' not in attrs['Video Fmt']) \ + and ('1080i' not in attrs['Video Fmt']) + + # Source: TV Cap or HDTV or (None) + source = 'Source' not in attrs or 'TV Cap' in attrs['Source'] or 'HDTV' in attrs['Source'] + + # Subtitles: (None) + subs = 'Subtitles' not in attrs + + return video_fmt and source and subs + + def _is_SDDVD(self, attrs): + + # Video Fmt: (XviD, DivX, H.264/x264), NOT 720p, NOT 1080p, NOT 1080i + video_fmt = 'Video Fmt' in attrs and ('XviD' in attrs['Video Fmt'] or 'DivX' in attrs['Video Fmt'] or 'H.264/x264' in attrs['Video Fmt']) \ + and ('720p' not in attrs['Video Fmt']) \ + and ('1080p' not in attrs['Video Fmt']) \ + and ('1080i' not in attrs['Video Fmt']) + + # Source: DVD + source = 'Source' in attrs and 'DVD' in attrs['Source'] + + # Subtitles: (None) + subs = 'Subtitles' not in attrs + + return video_fmt and source and subs + + def _is_HDTV(self, attrs): + # Video Fmt: H.264/x264, 720p + video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ + and ('720p' in attrs['Video Fmt']) + + # Source: TV Cap or HDTV or (None) + source = 'Source' not in attrs or 'TV Cap' in attrs['Source'] or 'HDTV' in attrs['Source'] + + # Subtitles: (None) + subs = 'Subtitles' not in attrs + + return video_fmt and source and subs + + def _is_WEBDL(self, attrs): + + # Video Fmt: H.264/x264, 720p + video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ + and ('720p' in attrs['Video Fmt']) + + # Source: WEB-DL + source = 'Source' in attrs and 'WEB-DL' in attrs['Source'] + + # Subtitles: (None) + subs = 'Subtitles' not in attrs + + return video_fmt and source and subs + + def _is_720pBluRay(self, attrs): + + # Video Fmt: H.264/x264, 720p + video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ + and ('720p' in attrs['Video Fmt']) + + # Source: Blu-ray or HD-DVD + source = 'Source' in attrs and ('Blu-ray' in attrs['Source'] or 'HD-DVD' in attrs['Source']) + + return video_fmt and source + + def _is_1080pBluRay(self, attrs): + + # Video Fmt: H.264/x264, 1080p + video_fmt = 'Video Fmt' in attrs and ('H.264/x264' in attrs['Video Fmt']) \ + and ('1080p' in attrs['Video Fmt']) + + # Source: Blu-ray or HD-DVD + source = 'Source' in attrs and ('Blu-ray' in attrs['Source'] or 'HD-DVD' in attrs['Source']) + + return video_fmt and source + + + def getIDFromURL(self, url): + id_regex = re.escape(self.url) + 'browse/post/(\d+)/' + id_match = re.match(id_regex, url) + if not id_match: + return None + else: + return id_match.group(1) + + def downloadResult(self, nzb): + + id = self.getIDFromURL(nzb.url) + if not id: + logger.log("Unable to get an ID from "+str(nzb.url)+", can't download from Newzbin's API", logger.ERROR) + return False + + logger.log("Downloading an NZB from newzbin with id "+id) + + fileName = ek.ek(os.path.join, sickbeard.NZB_DIR, helpers.sanitizeFileName(nzb.name)+'.nzb') + logger.log("Saving to " + fileName) + + urllib._urlopener = NewzbinDownloader() + + params = urllib.urlencode({"username": sickbeard.NEWZBIN_USERNAME, "password": sickbeard.NEWZBIN_PASSWORD, "reportid": id}) + try: + urllib.urlretrieve(self.url+"api/dnzb/", fileName, data=params) + except exceptions.NewzbinAPIThrottled: + logger.log("Done waiting for Newzbin API throttle limit, starting downloads again") + self.downloadResult(nzb) + except (urllib.ContentTooShortError, IOError), e: + logger.log("Error downloading NZB: " + str(sys.exc_info()) + " - " + ex(e), logger.ERROR) + return False + + return True + + def getURL(self, url): + + myOpener = classes.AuthURLOpener(sickbeard.NEWZBIN_USERNAME, sickbeard.NEWZBIN_PASSWORD) + try: + f = myOpener.openit(url) + except (urllib.ContentTooShortError, IOError), e: + logger.log("Error loading search results: " + str(sys.exc_info()) + " - " + ex(e), logger.ERROR) + return None + + data = f.read() + f.close() + + return data + + def _get_season_search_strings(self, show, season): + + nameList = set(show_name_helpers.allPossibleShowNames(show)) + + if show.air_by_date: + suffix = '' + else: + suffix = 'x' + searchTerms = ['^"'+x+' - '+str(season)+suffix+'"' for x in nameList] + #searchTerms += ['^"'+x+' - Season '+str(season)+'"' for x in nameList] + searchStr = " OR ".join(searchTerms) + + searchStr += " -subpack -extras" + + logger.log("Searching newzbin for string "+searchStr, logger.DEBUG) + + return [searchStr] + + def _get_episode_search_strings(self, ep_obj): + + nameList = set(show_name_helpers.allPossibleShowNames(ep_obj.show)) + if not ep_obj.show.air_by_date: + searchStr = " OR ".join(['^"'+x+' - %dx%02d"'%(ep_obj.season, ep_obj.episode) for x in nameList]) + else: + searchStr = " OR ".join(['^"'+x+' - '+str(ep_obj.airdate)+'"' for x in nameList]) + return [searchStr] + + def _doSearch(self, searchStr, show=None): + + data = self._getRSSData(searchStr.encode('utf-8')) + + item_list = [] + + try: + parsedXML = parseString(data) + items = parsedXML.getElementsByTagName('item') + except Exception, e: + logger.log("Error trying to load Newzbin RSS feed: "+ex(e), logger.ERROR) + return [] + + for cur_item in items: + title = helpers.get_xml_text(cur_item.getElementsByTagName('title')[0]) + if title == 'Feeds Error': + raise exceptions.AuthException("The feed wouldn't load, probably because of invalid auth info") + if sickbeard.USENET_RETENTION is not None: + try: + dateString = helpers.get_xml_text(cur_item.getElementsByTagName('report:postdate')[0]) + # use the parse (imported as parseDate) function from the dateutil lib + # and we have to remove the timezone info from it because the retention_date will not have one + # and a comparison of them is not possible + post_date = parseDate(dateString).replace(tzinfo=None) + retention_date = datetime.now() - timedelta(days=sickbeard.USENET_RETENTION) + if post_date < retention_date: + logger.log(u"Date "+str(post_date)+" is out of retention range, skipping", logger.DEBUG) + continue + except Exception, e: + logger.log("Error parsing date from Newzbin RSS feed: " + str(e), logger.ERROR) + continue + + item_list.append(cur_item) + + return item_list + + + def _getRSSData(self, search=None): + + params = { + 'searchaction': 'Search', + 'fpn': 'p', + 'category': 8, + 'u_nfo_posts_only': 0, + 'u_url_posts_only': 0, + 'u_comment_posts_only': 0, + 'u_show_passworded': 0, + 'u_v3_retention': 0, + 'ps_rb_video_format': 3082257, + 'ps_rb_language': 4096, + 'sort': 'date', + 'order': 'desc', + 'u_post_results_amt': 50, + 'feed': 'rss', + 'hauth': 1, + } + + if search: + params['q'] = search + " AND " + else: + params['q'] = '' + + params['q'] += 'Attr:Lang~Eng AND NOT Attr:VideoF=DVD' + + url = self.url + "search/?%s" % urllib.urlencode(params) + logger.log("Newzbin search URL: " + url, logger.DEBUG) + + data = self.getURL(url) + + return data + + def _checkAuth(self): + if sickbeard.NEWZBIN_USERNAME in (None, "") or sickbeard.NEWZBIN_PASSWORD in (None, ""): + raise exceptions.AuthException("Newzbin authentication details are empty, check your config") + +class NewzbinCache(tvcache.TVCache): + + def __init__(self, provider): + + tvcache.TVCache.__init__(self, provider) + + # only poll Newzbin every 10 mins max + self.minTime = 1 + + def _getRSSData(self): + + data = self.provider._getRSSData() + + return data + + def _parseItem(self, item): + + (title, url) = self.provider._get_title_and_url(item) + + if title == 'Feeds Error': + logger.log("There's an error in the feed, probably bad auth info", logger.DEBUG) + raise exceptions.AuthException("Invalid Newzbin username/password") + + if not title or not url: + logger.log("The XML returned from the "+self.provider.name+" feed is incomplete, this result is unusable", logger.ERROR) + return + + quality = self.provider.getQuality(item) + + logger.log("Found quality "+str(quality), logger.DEBUG) + + logger.log("Adding item from RSS to cache: "+title, logger.DEBUG) + + self._addCacheEntry(title, url, quality=quality) + + +provider = NewzbinProvider() diff --git a/sickbeard/providers/nzbmatrix.py b/sickbeard/providers/nzbmatrix.py index daa02df650..dd622d722f 100644 --- a/sickbeard/providers/nzbmatrix.py +++ b/sickbeard/providers/nzbmatrix.py @@ -1,181 +1,181 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import re -import time -import urllib -import datetime - -from xml.dom.minidom import parseString - -import sickbeard -import generic - -from sickbeard import classes, logger, show_name_helpers, helpers -from sickbeard import tvcache -from sickbeard.exceptions import ex - -class NZBMatrixProvider(generic.NZBProvider): - - def __init__(self): - - generic.NZBProvider.__init__(self, "NZBMatrix") - - self.supportsBacklog = True - - self.cache = NZBMatrixCache(self) - - self.url = 'http://www.nzbmatrix.com/' - - def isEnabled(self): - return sickbeard.NZBMATRIX - - def _get_season_search_strings(self, show, season): - sceneSearchStrings = set(show_name_helpers.makeSceneSeasonSearchString(show, season, "nzbmatrix")) - - # search for all show names and episode numbers like ("a","b","c") in a single search - return [' '.join(sceneSearchStrings)] - - def _get_episode_search_strings(self, ep_obj): - - sceneSearchStrings = set(show_name_helpers.makeSceneSearchString(ep_obj)) - - # search for all show names and episode numbers like ("a","b","c") in a single search - return ['("' + '","'.join(sceneSearchStrings) + '")'] - - def _doSearch(self, curString, quotes=False, show=None): - - term = re.sub('[\.\-]', ' ', curString).encode('utf-8') - if quotes: - term = "\""+term+"\"" - - params = {"term": term, - "maxage": sickbeard.USENET_RETENTION, - "page": "download", - "username": sickbeard.NZBMATRIX_USERNAME, - "apikey": sickbeard.NZBMATRIX_APIKEY, - "subcat": "6,41", - "english": 1, - "ssl": 1, - "scenename": 1} - - # don't allow it to be missing - if not params['maxage']: - params['maxage'] = '0' - - # if the show is a documentary use those cats on nzbmatrix - if show and show.genre and 'documentary' in show.genre.lower(): - params['subcat'] = params['subcat'] + ',53,9' - - searchURL = "https://rss.nzbmatrix.com/rss.php?" + urllib.urlencode(params) - - logger.log(u"Search string: " + searchURL, logger.DEBUG) - - logger.log(u"Sleeping 10 seconds to respect NZBMatrix's rules") - time.sleep(10) - - searchResult = self.getURL(searchURL) - - if not searchResult: - return [] - - try: - parsedXML = parseString(searchResult) - items = parsedXML.getElementsByTagName('item') - except Exception, e: - logger.log(u"Error trying to load NZBMatrix RSS feed: "+ex(e), logger.ERROR) - return [] - - results = [] - - for curItem in items: - (title, url) = self._get_title_and_url(curItem) - - if title == 'Error: No Results Found For Your Search': - continue - - if not title or not url: - logger.log(u"The XML returned from the NZBMatrix RSS feed is incomplete, this result is unusable", logger.ERROR) - continue - - results.append(curItem) - - return results - - - def findPropers(self, date=None): - - results = [] - - for curResult in self._doSearch("(PROPER,REPACK)"): - - (title, url) = self._get_title_and_url(curResult) - - description_node = curResult.getElementsByTagName('description')[0] - descriptionStr = helpers.get_xml_text(description_node) - - dateStr = re.search('Added: (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)', descriptionStr).group(1) - if not dateStr: - logger.log(u"Unable to figure out the date for entry "+title+", skipping it") - continue - else: - resultDate = datetime.datetime.strptime(dateStr, "%Y-%m-%d %H:%M:%S") - - if date == None or resultDate > date: - results.append(classes.Proper(title, url, resultDate)) - - return results - - -class NZBMatrixCache(tvcache.TVCache): - - def __init__(self, provider): - - tvcache.TVCache.__init__(self, provider) - - # only poll NZBMatrix every 25 minutes max - self.minTime = 25 - - - def _getRSSData(self): - # get all records since the last timestamp - url = "https://rss.nzbmatrix.com/rss.php?" - - urlArgs = {'page': 'download', - 'username': sickbeard.NZBMATRIX_USERNAME, - 'apikey': sickbeard.NZBMATRIX_APIKEY, - 'maxage': sickbeard.USENET_RETENTION, - 'english': 1, - 'ssl': 1, - 'scenename': 1, - 'subcat': '6,41'} - - # don't allow it to be missing - if not urlArgs['maxage']: - urlArgs['maxage'] = '0' - - url += urllib.urlencode(urlArgs) - - logger.log(u"NZBMatrix cache update URL: "+ url, logger.DEBUG) - - data = self.provider.getURL(url) - - return data - - -provider = NZBMatrixProvider() +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import re +import time +import urllib +import datetime + +from xml.dom.minidom import parseString + +import sickbeard +import generic + +from sickbeard import classes, logger, show_name_helpers, helpers +from sickbeard import tvcache +from sickbeard.exceptions import ex + +class NZBMatrixProvider(generic.NZBProvider): + + def __init__(self): + + generic.NZBProvider.__init__(self, "NZBMatrix") + + self.supportsBacklog = True + + self.cache = NZBMatrixCache(self) + + self.url = 'http://www.nzbmatrix.com/' + + def isEnabled(self): + return sickbeard.NZBMATRIX + + def _get_season_search_strings(self, show, season): + sceneSearchStrings = set(show_name_helpers.makeSceneSeasonSearchString(show, season, "nzbmatrix")) + + # search for all show names and episode numbers like ("a","b","c") in a single search + return [' '.join(sceneSearchStrings)] + + def _get_episode_search_strings(self, ep_obj): + + sceneSearchStrings = set(show_name_helpers.makeSceneSearchString(ep_obj)) + + # search for all show names and episode numbers like ("a","b","c") in a single search + return ['("' + '","'.join(sceneSearchStrings) + '")'] + + def _doSearch(self, curString, quotes=False, show=None): + + term = re.sub('[\.\-]', ' ', curString).encode('utf-8') + if quotes: + term = "\""+term+"\"" + + params = {"term": term, + "maxage": sickbeard.USENET_RETENTION, + "page": "download", + "username": sickbeard.NZBMATRIX_USERNAME, + "apikey": sickbeard.NZBMATRIX_APIKEY, + "subcat": "6,41", + "english": 1, + "ssl": 1, + "scenename": 1} + + # don't allow it to be missing + if not params['maxage']: + params['maxage'] = '0' + + # if the show is a documentary use those cats on nzbmatrix + if show and show.genre and 'documentary' in show.genre.lower(): + params['subcat'] = params['subcat'] + ',53,9' + + searchURL = "https://rss.nzbmatrix.com/rss.php?" + urllib.urlencode(params) + + logger.log(u"Search string: " + searchURL, logger.DEBUG) + + logger.log(u"Sleeping 10 seconds to respect NZBMatrix's rules") + time.sleep(10) + + searchResult = self.getURL(searchURL) + + if not searchResult: + return [] + + try: + parsedXML = parseString(searchResult) + items = parsedXML.getElementsByTagName('item') + except Exception, e: + logger.log(u"Error trying to load NZBMatrix RSS feed: "+ex(e), logger.ERROR) + return [] + + results = [] + + for curItem in items: + (title, url) = self._get_title_and_url(curItem) + + if title == 'Error: No Results Found For Your Search': + continue + + if not title or not url: + logger.log(u"The XML returned from the NZBMatrix RSS feed is incomplete, this result is unusable", logger.ERROR) + continue + + results.append(curItem) + + return results + + + def findPropers(self, date=None): + + results = [] + + for curResult in self._doSearch("(PROPER,REPACK)"): + + (title, url) = self._get_title_and_url(curResult) + + description_node = curResult.getElementsByTagName('description')[0] + descriptionStr = helpers.get_xml_text(description_node) + + dateStr = re.search('Added: (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)', descriptionStr).group(1) + if not dateStr: + logger.log(u"Unable to figure out the date for entry "+title+", skipping it") + continue + else: + resultDate = datetime.datetime.strptime(dateStr, "%Y-%m-%d %H:%M:%S") + + if date == None or resultDate > date: + results.append(classes.Proper(title, url, resultDate)) + + return results + + +class NZBMatrixCache(tvcache.TVCache): + + def __init__(self, provider): + + tvcache.TVCache.__init__(self, provider) + + # only poll NZBMatrix every 25 minutes max + self.minTime = 25 + + + def _getRSSData(self): + # get all records since the last timestamp + url = "https://rss.nzbmatrix.com/rss.php?" + + urlArgs = {'page': 'download', + 'username': sickbeard.NZBMATRIX_USERNAME, + 'apikey': sickbeard.NZBMATRIX_APIKEY, + 'maxage': sickbeard.USENET_RETENTION, + 'english': 1, + 'ssl': 1, + 'scenename': 1, + 'subcat': '6,41'} + + # don't allow it to be missing + if not urlArgs['maxage']: + urlArgs['maxage'] = '0' + + url += urllib.urlencode(urlArgs) + + logger.log(u"NZBMatrix cache update URL: "+ url, logger.DEBUG) + + data = self.provider.getURL(url) + + return data + + +provider = NZBMatrixProvider() diff --git a/sickbeard/providers/nzbsrus.py b/sickbeard/providers/nzbsrus.py index 9d140c5686..84fcbb9e6d 100644 --- a/sickbeard/providers/nzbsrus.py +++ b/sickbeard/providers/nzbsrus.py @@ -1,122 +1,122 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import urllib -import generic -import sickbeard - -try: - import xml.etree.cElementTree as etree -except ImportError: - import xml.etree.ElementTree as etree - -from sickbeard import exceptions, logger -from sickbeard import tvcache, show_name_helpers - - -class NZBsRUSProvider(generic.NZBProvider): - - def __init__(self): - generic.NZBProvider.__init__(self, "NZBs'R'US") - self.cache = NZBsRUSCache(self) - self.url = 'https://www.nzbsrus.com/' - self.supportsBacklog = True - - def isEnabled(self): - return sickbeard.NZBSRUS - - def _checkAuth(self): - if sickbeard.NZBSRUS_UID in (None, "") or sickbeard.NZBSRUS_HASH in (None, ""): - raise exceptions.AuthException("NZBs'R'US authentication details are empty, check your config") - - def _get_season_search_strings(self, show, season): - return [x for x in show_name_helpers.makeSceneSeasonSearchString(show, season)] - - def _get_episode_search_strings(self, ep_obj): - return [x for x in show_name_helpers.makeSceneSearchString(ep_obj)] - - def _doSearch(self, search, show=None, season=None): - params = {'uid': sickbeard.NZBSRUS_UID, - 'key': sickbeard.NZBSRUS_HASH, - 'xml': 1, - 'age': sickbeard.USENET_RETENTION, - 'lang0': 1, # English only from CouchPotato - 'lang1': 1, - 'lang3': 1, - 'c91': 1, # TV:HD - 'c104': 1, # TV:SD-x264 - 'c75': 1, # TV:XviD - 'searchtext': search} - - if not params['age']: - params['age'] = 500 - - searchURL = self.url + 'api.php?' + urllib.urlencode(params) - logger.log(u"NZBS'R'US search url: " + searchURL, logger.DEBUG) - - data = self.getURL(searchURL) - if not data: - return [] - - if not data.startswith(' +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import urllib +import generic +import sickbeard + +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +from sickbeard import exceptions, logger +from sickbeard import tvcache, show_name_helpers + + +class NZBsRUSProvider(generic.NZBProvider): + + def __init__(self): + generic.NZBProvider.__init__(self, "NZBs'R'US") + self.cache = NZBsRUSCache(self) + self.url = 'https://www.nzbsrus.com/' + self.supportsBacklog = True + + def isEnabled(self): + return sickbeard.NZBSRUS + + def _checkAuth(self): + if sickbeard.NZBSRUS_UID in (None, "") or sickbeard.NZBSRUS_HASH in (None, ""): + raise exceptions.AuthException("NZBs'R'US authentication details are empty, check your config") + + def _get_season_search_strings(self, show, season): + return [x for x in show_name_helpers.makeSceneSeasonSearchString(show, season)] + + def _get_episode_search_strings(self, ep_obj): + return [x for x in show_name_helpers.makeSceneSearchString(ep_obj)] + + def _doSearch(self, search, show=None, season=None): + params = {'uid': sickbeard.NZBSRUS_UID, + 'key': sickbeard.NZBSRUS_HASH, + 'xml': 1, + 'age': sickbeard.USENET_RETENTION, + 'lang0': 1, # English only from CouchPotato + 'lang1': 1, + 'lang3': 1, + 'c91': 1, # TV:HD + 'c104': 1, # TV:SD-x264 + 'c75': 1, # TV:XviD + 'searchtext': search} + + if not params['age']: + params['age'] = 500 + + searchURL = self.url + 'api.php?' + urllib.urlencode(params) + logger.log(u"NZBS'R'US search url: " + searchURL, logger.DEBUG) + + data = self.getURL(searchURL) + if not data: + return [] + + if not data.startswith(' -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import re -import urllib, urllib2 -import sys -import os - -import sickbeard -from sickbeard.providers import generic -from sickbeard.common import Quality -from sickbeard.name_parser.parser import NameParser, InvalidNameException -from sickbeard import logger -from sickbeard import tvcache -from sickbeard import helpers -from sickbeard import show_name_helpers -from sickbeard.common import Overview -from sickbeard.exceptions import ex -from sickbeard import encodingKludge as ek - -proxy_dict = { - 'Getprivate.eu (NL)' : 'http://getprivate.eu/', - '15bb51.info (US)' : 'http://15bb51.info/', - 'Hideme.nl (NL)' : 'http://hideme.nl/', - 'Rapidproxy.us (GB)' : 'http://rapidproxy.us/', - 'Proxite.eu (DE)' :'http://proxite.eu/', - 'Shieldmagic.com (GB)' : 'http://www.shieldmagic.com/', - 'Webproxy.cz (CZ)' : 'http://webproxy.cz/', - 'Freeproxy.cz (CZ)' : 'http://www.freeproxy.cz/', - } - -class ThePirateBayProvider(generic.TorrentProvider): - - def __init__(self): - - generic.TorrentProvider.__init__(self, "PirateBay") - - self.supportsBacklog = True - - self.cache = ThePirateBayCache(self) - - self.proxy = ThePirateBayWebproxy() - - self.url = 'http://thepiratebay.se/' - - self.searchurl = self.url+'search/%s/0/7/200' # order by seed - - self.re_title_url = '/torrent/(?P\d+)/(?P.*?)//1".+?(?P<url>magnet.*?)//1".+?(?P<seeders>\d+)</td>.+?(?P<leechers>\d+)</td>' - - def isEnabled(self): - return sickbeard.THEPIRATEBAY - - def imageName(self): - return 'piratebay.png' - - def getQuality(self, item): - - quality = Quality.nameQuality(item[0]) - return quality - - def _reverseQuality(self,quality): - - quality_string = '' - - if quality == Quality.SDTV: - quality_string = 'HDTV x264' - if quality == Quality.SDDVD: - quality_string = 'DVDRIP' - elif quality == Quality.HDTV: - quality_string = '720p HDTV x264' - elif quality == Quality.FULLHDTV: - quality_string = '1080p HDTV x264' - elif quality == Quality.RAWHDTV: - quality_string = '1080i HDTV mpeg2' - elif quality == Quality.HDWEBDL: - quality_string = '720p WEB-DL' - elif quality == Quality.FULLHDWEBDL: - quality_string = '1080p WEB-DL' - elif quality == Quality.HDBLURAY: - quality_string = '720p Bluray x264' - elif quality == Quality.FULLHDBLURAY: - quality_string = '1080p Bluray x264' - - return quality_string - - def _find_season_quality(self,title,torrent_id): - """ Return the modified title of a Season Torrent with the quality found inspecting torrent file list """ - - mediaExtensions = ['avi', 'mkv', 'wmv', 'divx', - 'vob', 'dvr-ms', 'wtv', 'ts' - 'ogv', 'rar', 'zip'] - - quality = Quality.UNKNOWN - - fileName = None - - fileURL = self.proxy._buildURL(self.url+'ajax_details_filelist.php?id='+str(torrent_id)) - - data = self.getURL(fileURL) - - if not data: - return None - - filesList = re.findall('<td.+>(.*?)</td>',data) - - if not filesList: - logger.log(u"Unable to get the torrent file list for "+title, logger.ERROR) - - for fileName in filter(lambda x: x.rpartition(".")[2].lower() in mediaExtensions, filesList): - quality = Quality.nameQuality(os.path.basename(fileName)) - if quality != Quality.UNKNOWN: break - - if fileName!=None and quality == Quality.UNKNOWN: - quality = Quality.assumeQuality(os.path.basename(fileName)) - - if quality == Quality.UNKNOWN: - logger.log(u"No Season quality for "+title, logger.DEBUG) - return None - - try: - myParser = NameParser() - parse_result = myParser.parse(fileName) - except InvalidNameException: - return None - - logger.log(u"Season quality for "+title+" is "+Quality.qualityStrings[quality], logger.DEBUG) - - if parse_result.series_name and parse_result.season_number: - title = parse_result.series_name+' S%02d' % int(parse_result.season_number)+' '+self._reverseQuality(quality) - - return title - - def _get_season_search_strings(self, show, season=None): - - search_string = {'Episode': []} - - if not show: - return [] - - seasonEp = show.getAllEpisodes(season) - - wantedEp = [x for x in seasonEp if show.getOverview(x.status) in (Overview.WANTED, Overview.QUAL)] - - #If Every episode in Season is a wanted Episode then search for Season first - if wantedEp == seasonEp and not show.air_by_date: - search_string = {'Season': [], 'Episode': []} - for show_name in set(show_name_helpers.allPossibleShowNames(show)): - ep_string = show_name +' S%02d' % int(season) #1) ShowName SXX - search_string['Season'].append(ep_string) - - ep_string = show_name+' Season '+str(season)+' -Ep*' #2) ShowName Season X - search_string['Season'].append(ep_string) - - #Building the search string with the episodes we need - for ep_obj in wantedEp: - search_string['Episode'] += self._get_episode_search_strings(ep_obj)[0]['Episode'] - - #If no Episode is needed then return an empty list - if not search_string['Episode']: - return [] - - return [search_string] - - def _get_episode_search_strings(self, ep_obj): - - search_string = {'Episode': []} - - if not ep_obj: - return [] - - if ep_obj.show.air_by_date: - for show_name in set(show_name_helpers.allPossibleShowNames(ep_obj.show)): - ep_string = show_name_helpers.sanitizeSceneName(show_name) +' '+ str(ep_obj.airdate) - search_string['Episode'].append(ep_string) - else: - for show_name in set(show_name_helpers.allPossibleShowNames(ep_obj.show)): - ep_string = show_name_helpers.sanitizeSceneName(show_name) +' '+ \ - sickbeard.config.naming_ep_type[2] % {'seasonnumber': ep_obj.season, 'episodenumber': ep_obj.episode} +'|'+\ - sickbeard.config.naming_ep_type[0] % {'seasonnumber': ep_obj.season, 'episodenumber': ep_obj.episode} +'|'+\ - sickbeard.config.naming_ep_type[3] % {'seasonnumber': ep_obj.season, 'episodenumber': ep_obj.episode} \ - - search_string['Episode'].append(ep_string) - - return [search_string] - - def _doSearch(self, search_params, show=None, season=None): - - results = [] - items = {'Season': [], 'Episode': []} - - for mode in search_params.keys(): - for search_string in search_params[mode]: - - searchURL = self.proxy._buildURL(self.searchurl %(urllib.quote(search_string.encode("utf-8")))) - - logger.log(u"Search string: " + searchURL, logger.DEBUG) - - data = self.getURL(searchURL) - if not data: - return [] - - re_title_url = self.proxy._buildRE(self.re_title_url) - - #Extracting torrent information from data returned by searchURL - match = re.compile(re_title_url, re.DOTALL ).finditer(urllib.unquote(data)) - for torrent in match: - - title = torrent.group('title').replace('_','.')#Do not know why but SickBeard skip release with '_' in name - url = torrent.group('url') - id = int(torrent.group('id')) - seeders = int(torrent.group('seeders')) - leechers = int(torrent.group('leechers')) - - #Filter unseeded torrent - if seeders == 0 or not title \ - or not show_name_helpers.filterBadReleases(title): - continue - - #Accept Torrent only from Good People for every Episode Search - if sickbeard.THEPIRATEBAY_TRUSTED and re.search('(VIP|Trusted|Helper)',torrent.group(0))== None: - logger.log(u"ThePirateBay Provider found result "+torrent.group('title')+" but that doesn't seem like a trusted result so I'm ignoring it",logger.DEBUG) - continue - - #Try to find the real Quality for full season torrent analyzing files in torrent - if mode == 'Season' and Quality.nameQuality(title) == Quality.UNKNOWN: - if not self._find_season_quality(title,id): continue - - item = title, url, id, seeders, leechers - - items[mode].append(item) - - #For each search mode sort all the items by seeders - items[mode].sort(key=lambda tup: tup[3], reverse=True) - - results += items[mode] - - return results - - def _get_title_and_url(self, item): - - title, url, id, seeders, leechers = item - - if url: - url = url.replace('&','&') - - return (title, url) - - def getURL(self, url, headers=None): - - if not headers: - headers = [] - - # Glype Proxies does not support Direct Linking. - # We have to fake a search on the proxy site to get data - if self.proxy.isEnabled(): - headers.append(('Referer', self.proxy.getProxyURL())) - - result = None - - try: - result = helpers.getURL(url, headers) - except (urllib2.HTTPError, IOError), e: - logger.log(u"Error loading "+self.name+" URL: " + str(sys.exc_info()) + " - " + ex(e), logger.ERROR) - return None - - return result - - def downloadResult(self, result): - """ - Save the result to disk. - """ - - #Hack for rtorrent user (it will not work for other torrent client) - if sickbeard.TORRENT_METHOD == "blackhole" and result.url.startswith('magnet'): - magnetFileName = ek.ek(os.path.join, sickbeard.TORRENT_DIR, helpers.sanitizeFileName(result.name) + '.' + self.providerType) - magnetFileContent = 'd10:magnet-uri' + `len(result.url)` + ':' + result.url + 'e' - - try: - fileOut = open(magnetFileName, 'wb') - fileOut.write(magnetFileContent) - fileOut.close() - helpers.chmodAsParent(magnetFileName) - except IOError, e: - logger.log("Unable to save the file: "+ex(e), logger.ERROR) - return False - logger.log(u"Saved magnet link to "+magnetFileName+" ", logger.MESSAGE) - return True - -class ThePirateBayCache(tvcache.TVCache): - - def __init__(self, provider): - - tvcache.TVCache.__init__(self, provider) - - # only poll ThePirateBay every 10 minutes max - self.minTime = 20 - - def updateCache(self): - - re_title_url = self.provider.proxy._buildRE(self.provider.re_title_url) - - if not self.shouldUpdate(): - return - - data = self._getData() - - # as long as the http request worked we count this as an update - if data: - self.setLastUpdate() - else: - return [] - - # now that we've loaded the current RSS feed lets delete the old cache - logger.log(u"Clearing "+self.provider.name+" cache and updating with new information") - self._clearCache() - - match = re.compile(re_title_url, re.DOTALL).finditer(urllib.unquote(data)) - if not match: - logger.log(u"The Data returned from the ThePirateBay is incomplete, this result is unusable", logger.ERROR) - return [] - - for torrent in match: - - title = torrent.group('title').replace('_','.')#Do not know why but SickBeard skip release with '_' in name - url = torrent.group('url') - - #accept torrent only from Trusted people - if sickbeard.THEPIRATEBAY_TRUSTED and re.search('(VIP|Trusted|Helper)',torrent.group(0))== None: - logger.log(u"ThePirateBay Provider found result "+torrent.group('title')+" but that doesn't seem like a trusted result so I'm ignoring it",logger.DEBUG) - continue - - item = (title,url) - - self._parseItem(item) - - def _getData(self): - - #url for the last 50 tv-show - url = self.provider.proxy._buildURL(self.provider.url+'tv/latest/') - - logger.log(u"ThePirateBay cache update URL: "+ url, logger.DEBUG) - - data = self.provider.getURL(url) - - return data - - def _parseItem(self, item): - - (title, url) = item - - if not title or not url: - return - - logger.log(u"Adding item to cache: "+title, logger.DEBUG) - - self._addCacheEntry(title, url) - -class ThePirateBayWebproxy: - - def __init__(self): - self.Type = 'GlypeProxy' - self.param = 'browse.php?u=' - self.option = '&b=32' - - def isEnabled(self): - """ Return True if we Choose to call TPB via Proxy """ - return sickbeard.THEPIRATEBAY_PROXY - - def getProxyURL(self): - """ Return the Proxy URL Choosen via Provider Setting """ - return str(sickbeard.THEPIRATEBAY_PROXY_URL) - - def _buildURL(self,url): - """ Return the Proxyfied URL of the page """ - if self.isEnabled(): - url = self.getProxyURL() + self.param + url + self.option - - return url - - def _buildRE(self,regx): - """ Return the Proxyfied RE string """ - if self.isEnabled(): - regx = re.sub('//1',self.option,regx).replace('&','&') - else: - regx = re.sub('//1','',regx) - - return regx - +# Author: Mr_Orange <mr_orange@hotmail.it> +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. + +import re +import urllib, urllib2 +import sys +import os + +import sickbeard +from sickbeard.providers import generic +from sickbeard.common import Quality +from sickbeard.name_parser.parser import NameParser, InvalidNameException +from sickbeard import logger +from sickbeard import tvcache +from sickbeard import helpers +from sickbeard import show_name_helpers +from sickbeard.common import Overview +from sickbeard.exceptions import ex +from sickbeard import encodingKludge as ek + +proxy_dict = { + 'Getprivate.eu (NL)' : 'http://getprivate.eu/', + '15bb51.info (US)' : 'http://15bb51.info/', + 'Hideme.nl (NL)' : 'http://hideme.nl/', + 'Rapidproxy.us (GB)' : 'http://rapidproxy.us/', + 'Proxite.eu (DE)' :'http://proxite.eu/', + 'Shieldmagic.com (GB)' : 'http://www.shieldmagic.com/', + 'Webproxy.cz (CZ)' : 'http://webproxy.cz/', + 'Freeproxy.cz (CZ)' : 'http://www.freeproxy.cz/', + } + +class ThePirateBayProvider(generic.TorrentProvider): + + def __init__(self): + + generic.TorrentProvider.__init__(self, "PirateBay") + + self.supportsBacklog = True + + self.cache = ThePirateBayCache(self) + + self.proxy = ThePirateBayWebproxy() + + self.url = 'http://thepiratebay.se/' + + self.searchurl = self.url+'search/%s/0/7/200' # order by seed + + self.re_title_url = '/torrent/(?P<id>\d+)/(?P<title>.*?)//1".+?(?P<url>magnet.*?)//1".+?(?P<seeders>\d+)</td>.+?(?P<leechers>\d+)</td>' + + def isEnabled(self): + return sickbeard.THEPIRATEBAY + + def imageName(self): + return 'piratebay.png' + + def getQuality(self, item): + + quality = Quality.nameQuality(item[0]) + return quality + + def _reverseQuality(self,quality): + + quality_string = '' + + if quality == Quality.SDTV: + quality_string = 'HDTV x264' + if quality == Quality.SDDVD: + quality_string = 'DVDRIP' + elif quality == Quality.HDTV: + quality_string = '720p HDTV x264' + elif quality == Quality.FULLHDTV: + quality_string = '1080p HDTV x264' + elif quality == Quality.RAWHDTV: + quality_string = '1080i HDTV mpeg2' + elif quality == Quality.HDWEBDL: + quality_string = '720p WEB-DL' + elif quality == Quality.FULLHDWEBDL: + quality_string = '1080p WEB-DL' + elif quality == Quality.HDBLURAY: + quality_string = '720p Bluray x264' + elif quality == Quality.FULLHDBLURAY: + quality_string = '1080p Bluray x264' + + return quality_string + + def _find_season_quality(self,title,torrent_id): + """ Return the modified title of a Season Torrent with the quality found inspecting torrent file list """ + + mediaExtensions = ['avi', 'mkv', 'wmv', 'divx', + 'vob', 'dvr-ms', 'wtv', 'ts' + 'ogv', 'rar', 'zip'] + + quality = Quality.UNKNOWN + + fileName = None + + fileURL = self.proxy._buildURL(self.url+'ajax_details_filelist.php?id='+str(torrent_id)) + + data = self.getURL(fileURL) + + if not data: + return None + + filesList = re.findall('<td.+>(.*?)</td>',data) + + if not filesList: + logger.log(u"Unable to get the torrent file list for "+title, logger.ERROR) + + for fileName in filter(lambda x: x.rpartition(".")[2].lower() in mediaExtensions, filesList): + quality = Quality.nameQuality(os.path.basename(fileName)) + if quality != Quality.UNKNOWN: break + + if fileName!=None and quality == Quality.UNKNOWN: + quality = Quality.assumeQuality(os.path.basename(fileName)) + + if quality == Quality.UNKNOWN: + logger.log(u"No Season quality for "+title, logger.DEBUG) + return None + + try: + myParser = NameParser() + parse_result = myParser.parse(fileName) + except InvalidNameException: + return None + + logger.log(u"Season quality for "+title+" is "+Quality.qualityStrings[quality], logger.DEBUG) + + if parse_result.series_name and parse_result.season_number: + title = parse_result.series_name+' S%02d' % int(parse_result.season_number)+' '+self._reverseQuality(quality) + + return title + + def _get_season_search_strings(self, show, season=None): + + search_string = {'Episode': []} + + if not show: + return [] + + seasonEp = show.getAllEpisodes(season) + + wantedEp = [x for x in seasonEp if show.getOverview(x.status) in (Overview.WANTED, Overview.QUAL)] + + #If Every episode in Season is a wanted Episode then search for Season first + if wantedEp == seasonEp and not show.air_by_date: + search_string = {'Season': [], 'Episode': []} + for show_name in set(show_name_helpers.allPossibleShowNames(show)): + ep_string = show_name +' S%02d' % int(season) #1) ShowName SXX + search_string['Season'].append(ep_string) + + ep_string = show_name+' Season '+str(season)+' -Ep*' #2) ShowName Season X + search_string['Season'].append(ep_string) + + #Building the search string with the episodes we need + for ep_obj in wantedEp: + search_string['Episode'] += self._get_episode_search_strings(ep_obj)[0]['Episode'] + + #If no Episode is needed then return an empty list + if not search_string['Episode']: + return [] + + return [search_string] + + def _get_episode_search_strings(self, ep_obj): + + search_string = {'Episode': []} + + if not ep_obj: + return [] + + if ep_obj.show.air_by_date: + for show_name in set(show_name_helpers.allPossibleShowNames(ep_obj.show)): + ep_string = show_name_helpers.sanitizeSceneName(show_name) +' '+ str(ep_obj.airdate) + search_string['Episode'].append(ep_string) + else: + for show_name in set(show_name_helpers.allPossibleShowNames(ep_obj.show)): + ep_string = show_name_helpers.sanitizeSceneName(show_name) +' '+ \ + sickbeard.config.naming_ep_type[2] % {'seasonnumber': ep_obj.season, 'episodenumber': ep_obj.episode} +'|'+\ + sickbeard.config.naming_ep_type[0] % {'seasonnumber': ep_obj.season, 'episodenumber': ep_obj.episode} +'|'+\ + sickbeard.config.naming_ep_type[3] % {'seasonnumber': ep_obj.season, 'episodenumber': ep_obj.episode} \ + + search_string['Episode'].append(ep_string) + + return [search_string] + + def _doSearch(self, search_params, show=None, season=None): + + results = [] + items = {'Season': [], 'Episode': []} + + for mode in search_params.keys(): + for search_string in search_params[mode]: + + searchURL = self.proxy._buildURL(self.searchurl %(urllib.quote(search_string.encode("utf-8")))) + + logger.log(u"Search string: " + searchURL, logger.DEBUG) + + data = self.getURL(searchURL) + if not data: + return [] + + re_title_url = self.proxy._buildRE(self.re_title_url) + + #Extracting torrent information from data returned by searchURL + match = re.compile(re_title_url, re.DOTALL ).finditer(urllib.unquote(data)) + for torrent in match: + + title = torrent.group('title').replace('_','.')#Do not know why but SickBeard skip release with '_' in name + url = torrent.group('url') + id = int(torrent.group('id')) + seeders = int(torrent.group('seeders')) + leechers = int(torrent.group('leechers')) + + #Filter unseeded torrent + if seeders == 0 or not title \ + or not show_name_helpers.filterBadReleases(title): + continue + + #Accept Torrent only from Good People for every Episode Search + if sickbeard.THEPIRATEBAY_TRUSTED and re.search('(VIP|Trusted|Helper)',torrent.group(0))== None: + logger.log(u"ThePirateBay Provider found result "+torrent.group('title')+" but that doesn't seem like a trusted result so I'm ignoring it",logger.DEBUG) + continue + + #Try to find the real Quality for full season torrent analyzing files in torrent + if mode == 'Season' and Quality.nameQuality(title) == Quality.UNKNOWN: + if not self._find_season_quality(title,id): continue + + item = title, url, id, seeders, leechers + + items[mode].append(item) + + #For each search mode sort all the items by seeders + items[mode].sort(key=lambda tup: tup[3], reverse=True) + + results += items[mode] + + return results + + def _get_title_and_url(self, item): + + title, url, id, seeders, leechers = item + + if url: + url = url.replace('&','&') + + return (title, url) + + def getURL(self, url, headers=None): + + if not headers: + headers = [] + + # Glype Proxies does not support Direct Linking. + # We have to fake a search on the proxy site to get data + if self.proxy.isEnabled(): + headers.append(('Referer', self.proxy.getProxyURL())) + + result = None + + try: + result = helpers.getURL(url, headers) + except (urllib2.HTTPError, IOError), e: + logger.log(u"Error loading "+self.name+" URL: " + str(sys.exc_info()) + " - " + ex(e), logger.ERROR) + return None + + return result + + def downloadResult(self, result): + """ + Save the result to disk. + """ + + #Hack for rtorrent user (it will not work for other torrent client) + if sickbeard.TORRENT_METHOD == "blackhole" and result.url.startswith('magnet'): + magnetFileName = ek.ek(os.path.join, sickbeard.TORRENT_DIR, helpers.sanitizeFileName(result.name) + '.' + self.providerType) + magnetFileContent = 'd10:magnet-uri' + `len(result.url)` + ':' + result.url + 'e' + + try: + fileOut = open(magnetFileName, 'wb') + fileOut.write(magnetFileContent) + fileOut.close() + helpers.chmodAsParent(magnetFileName) + except IOError, e: + logger.log("Unable to save the file: "+ex(e), logger.ERROR) + return False + logger.log(u"Saved magnet link to "+magnetFileName+" ", logger.MESSAGE) + return True + +class ThePirateBayCache(tvcache.TVCache): + + def __init__(self, provider): + + tvcache.TVCache.__init__(self, provider) + + # only poll ThePirateBay every 10 minutes max + self.minTime = 20 + + def updateCache(self): + + re_title_url = self.provider.proxy._buildRE(self.provider.re_title_url) + + if not self.shouldUpdate(): + return + + data = self._getData() + + # as long as the http request worked we count this as an update + if data: + self.setLastUpdate() + else: + return [] + + # now that we've loaded the current RSS feed lets delete the old cache + logger.log(u"Clearing "+self.provider.name+" cache and updating with new information") + self._clearCache() + + match = re.compile(re_title_url, re.DOTALL).finditer(urllib.unquote(data)) + if not match: + logger.log(u"The Data returned from the ThePirateBay is incomplete, this result is unusable", logger.ERROR) + return [] + + for torrent in match: + + title = torrent.group('title').replace('_','.')#Do not know why but SickBeard skip release with '_' in name + url = torrent.group('url') + + #accept torrent only from Trusted people + if sickbeard.THEPIRATEBAY_TRUSTED and re.search('(VIP|Trusted|Helper)',torrent.group(0))== None: + logger.log(u"ThePirateBay Provider found result "+torrent.group('title')+" but that doesn't seem like a trusted result so I'm ignoring it",logger.DEBUG) + continue + + item = (title,url) + + self._parseItem(item) + + def _getData(self): + + #url for the last 50 tv-show + url = self.provider.proxy._buildURL(self.provider.url+'tv/latest/') + + logger.log(u"ThePirateBay cache update URL: "+ url, logger.DEBUG) + + data = self.provider.getURL(url) + + return data + + def _parseItem(self, item): + + (title, url) = item + + if not title or not url: + return + + logger.log(u"Adding item to cache: "+title, logger.DEBUG) + + self._addCacheEntry(title, url) + +class ThePirateBayWebproxy: + + def __init__(self): + self.Type = 'GlypeProxy' + self.param = 'browse.php?u=' + self.option = '&b=32' + + def isEnabled(self): + """ Return True if we Choose to call TPB via Proxy """ + return sickbeard.THEPIRATEBAY_PROXY + + def getProxyURL(self): + """ Return the Proxy URL Choosen via Provider Setting """ + return str(sickbeard.THEPIRATEBAY_PROXY_URL) + + def _buildURL(self,url): + """ Return the Proxyfied URL of the page """ + if self.isEnabled(): + url = self.getProxyURL() + self.param + url + self.option + + return url + + def _buildRE(self,regx): + """ Return the Proxyfied RE string """ + if self.isEnabled(): + regx = re.sub('//1',self.option,regx).replace('&','&') + else: + regx = re.sub('//1','',regx) + + return regx + provider = ThePirateBayProvider() \ No newline at end of file diff --git a/sickbeard/scene_exceptions.py b/sickbeard/scene_exceptions.py index 759029ef69..22011a7b7c 100644 --- a/sickbeard/scene_exceptions.py +++ b/sickbeard/scene_exceptions.py @@ -1,118 +1,118 @@ -# Author: Nic Wolfe <nic@wolfeden.ca> -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. - -import re - -from sickbeard import helpers -from sickbeard import name_cache -from sickbeard import logger -from sickbeard import db - -def get_scene_exceptions(tvdb_id): - """ - Given a tvdb_id, return a list of all the scene exceptions. - """ - - myDB = db.DBConnection("cache.db") - exceptions = myDB.select("SELECT show_name FROM scene_exceptions WHERE tvdb_id = ?", [tvdb_id]) - return [cur_exception["show_name"] for cur_exception in exceptions] - - -def get_scene_exception_by_name(show_name): - """ - Given a show name, return the tvdbid of the exception, None if no exception - is present. - """ - - myDB = db.DBConnection("cache.db") - - # try the obvious case first - exception_result = myDB.select("SELECT tvdb_id FROM scene_exceptions WHERE LOWER(show_name) = ?", [show_name.lower()]) - if exception_result: - return int(exception_result[0]["tvdb_id"]) - - all_exception_results = myDB.select("SELECT show_name, tvdb_id FROM scene_exceptions") - for cur_exception in all_exception_results: - - cur_exception_name = cur_exception["show_name"] - cur_tvdb_id = int(cur_exception["tvdb_id"]) - - if show_name.lower() in (cur_exception_name.lower(), helpers.sanitizeSceneName(cur_exception_name).lower().replace('.', ' ')): - logger.log(u"Scene exception lookup got tvdb id "+str(cur_tvdb_id)+u", using that", logger.DEBUG) - return cur_tvdb_id - - return None - - -def retrieve_exceptions(): - """ - Looks up the exceptions on github, parses them into a dict, and inserts them into the - scene_exceptions table in cache.db. Also clears the scene name cache. - """ - - exception_dict = {} - - # exceptions are stored on github pages - url = 'http://midgetspy.github.com/sb_tvdb_scene_exceptions/exceptions.txt' - - logger.log(u"Check scene exceptions update") - url_data = helpers.getURL(url) - - if url_data is None: - # When urlData is None, trouble connecting to github - logger.log(u"Check scene exceptions update failed. Unable to get URL: " + url, logger.ERROR) - return - - else: - # each exception is on one line with the format tvdb_id: 'show name 1', 'show name 2', etc - for cur_line in url_data.splitlines(): - cur_line = cur_line.decode('utf-8') - tvdb_id, sep, aliases = cur_line.partition(':') #@UnusedVariable - - if not aliases: - continue - - tvdb_id = int(tvdb_id) - - # regex out the list of shows, taking \' into account - alias_list = [re.sub(r'\\(.)', r'\1', x) for x in re.findall(r"'(.*?)(?<!\\)',?", aliases)] - - exception_dict[tvdb_id] = alias_list - - myDB = db.DBConnection("cache.db") - - changed_exceptions = False - - # write all the exceptions we got off the net into the database - for cur_tvdb_id in exception_dict: - - # get a list of the existing exceptions for this ID - existing_exceptions = [x["show_name"] for x in myDB.select("SELECT * FROM scene_exceptions WHERE tvdb_id = ?", [cur_tvdb_id])] - - for cur_exception in exception_dict[cur_tvdb_id]: - # if this exception isn't already in the DB then add it - if cur_exception not in existing_exceptions: - myDB.action("INSERT INTO scene_exceptions (tvdb_id, show_name) VALUES (?,?)", [cur_tvdb_id, cur_exception]) - changed_exceptions = True - - # since this could invalidate the results of the cache we clear it out after updating - if changed_exceptions: - logger.log(u"Updated scene exceptions") - name_cache.clearCache() - else: +# Author: Nic Wolfe <nic@wolfeden.ca> +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. + +import re + +from sickbeard import helpers +from sickbeard import name_cache +from sickbeard import logger +from sickbeard import db + +def get_scene_exceptions(tvdb_id): + """ + Given a tvdb_id, return a list of all the scene exceptions. + """ + + myDB = db.DBConnection("cache.db") + exceptions = myDB.select("SELECT show_name FROM scene_exceptions WHERE tvdb_id = ?", [tvdb_id]) + return [cur_exception["show_name"] for cur_exception in exceptions] + + +def get_scene_exception_by_name(show_name): + """ + Given a show name, return the tvdbid of the exception, None if no exception + is present. + """ + + myDB = db.DBConnection("cache.db") + + # try the obvious case first + exception_result = myDB.select("SELECT tvdb_id FROM scene_exceptions WHERE LOWER(show_name) = ?", [show_name.lower()]) + if exception_result: + return int(exception_result[0]["tvdb_id"]) + + all_exception_results = myDB.select("SELECT show_name, tvdb_id FROM scene_exceptions") + for cur_exception in all_exception_results: + + cur_exception_name = cur_exception["show_name"] + cur_tvdb_id = int(cur_exception["tvdb_id"]) + + if show_name.lower() in (cur_exception_name.lower(), helpers.sanitizeSceneName(cur_exception_name).lower().replace('.', ' ')): + logger.log(u"Scene exception lookup got tvdb id "+str(cur_tvdb_id)+u", using that", logger.DEBUG) + return cur_tvdb_id + + return None + + +def retrieve_exceptions(): + """ + Looks up the exceptions on github, parses them into a dict, and inserts them into the + scene_exceptions table in cache.db. Also clears the scene name cache. + """ + + exception_dict = {} + + # exceptions are stored on github pages + url = 'http://midgetspy.github.com/sb_tvdb_scene_exceptions/exceptions.txt' + + logger.log(u"Check scene exceptions update") + url_data = helpers.getURL(url) + + if url_data is None: + # When urlData is None, trouble connecting to github + logger.log(u"Check scene exceptions update failed. Unable to get URL: " + url, logger.ERROR) + return + + else: + # each exception is on one line with the format tvdb_id: 'show name 1', 'show name 2', etc + for cur_line in url_data.splitlines(): + cur_line = cur_line.decode('utf-8') + tvdb_id, sep, aliases = cur_line.partition(':') #@UnusedVariable + + if not aliases: + continue + + tvdb_id = int(tvdb_id) + + # regex out the list of shows, taking \' into account + alias_list = [re.sub(r'\\(.)', r'\1', x) for x in re.findall(r"'(.*?)(?<!\\)',?", aliases)] + + exception_dict[tvdb_id] = alias_list + + myDB = db.DBConnection("cache.db") + + changed_exceptions = False + + # write all the exceptions we got off the net into the database + for cur_tvdb_id in exception_dict: + + # get a list of the existing exceptions for this ID + existing_exceptions = [x["show_name"] for x in myDB.select("SELECT * FROM scene_exceptions WHERE tvdb_id = ?", [cur_tvdb_id])] + + for cur_exception in exception_dict[cur_tvdb_id]: + # if this exception isn't already in the DB then add it + if cur_exception not in existing_exceptions: + myDB.action("INSERT INTO scene_exceptions (tvdb_id, show_name) VALUES (?,?)", [cur_tvdb_id, cur_exception]) + changed_exceptions = True + + # since this could invalidate the results of the cache we clear it out after updating + if changed_exceptions: + logger.log(u"Updated scene exceptions") + name_cache.clearCache() + else: logger.log(u"No scene exceptions update needed") \ No newline at end of file diff --git a/sickbeard/tv.py b/sickbeard/tv.py index a6cb51cb0d..cac6be7e33 100644 --- a/sickbeard/tv.py +++ b/sickbeard/tv.py @@ -1,1912 +1,1912 @@ -# Author: Nic Wolfe <nic@wolfeden.ca> -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. - -from __future__ import with_statement - -import os.path -import datetime -import threading -import re -import glob - -import sickbeard - -import xml.etree.cElementTree as etree - -from name_parser.parser import NameParser, InvalidNameException - -from lib import subliminal - -from lib.tvdb_api import tvdb_api, tvdb_exceptions - -from sickbeard import db -from sickbeard import helpers, exceptions, logger -from sickbeard.exceptions import ex -from sickbeard import tvrage -from sickbeard import image_cache -from sickbeard import notifiers -from sickbeard import postProcessor -from sickbeard import subtitles -from sickbeard import history - -from sickbeard import encodingKludge as ek - -from common import Quality, Overview -from common import DOWNLOADED, SNATCHED, SNATCHED_PROPER, ARCHIVED, IGNORED, UNAIRED, WANTED, SKIPPED, UNKNOWN -from common import NAMING_DUPLICATE, NAMING_EXTEND, NAMING_LIMITED_EXTEND, NAMING_SEPARATED_REPEAT, NAMING_LIMITED_EXTEND_E_PREFIXED - -class TVShow(object): - - def __init__ (self, tvdbid, lang="", audio_lang=""): - - self.tvdbid = tvdbid - - self._location = "" - self.name = "" - self.tvrid = 0 - self.tvrname = "" - self.network = "" - self.genre = "" - self.runtime = 0 - self.quality = int(sickbeard.QUALITY_DEFAULT) - self.flatten_folders = int(sickbeard.FLATTEN_FOLDERS_DEFAULT) - - self.status = "" - self.airs = "" - self.startyear = 0 - self.paused = 0 - self.air_by_date = 0 - self.subtitles = int(sickbeard.SUBTITLES_DEFAULT) - self.lang = lang - self.audio_lang = audio_lang - self.custom_search_names = "" - - self.lock = threading.Lock() - self._isDirGood = False - - self.episodes = {} - - otherShow = helpers.findCertainShow(sickbeard.showList, self.tvdbid) - if otherShow != None: - raise exceptions.MultipleShowObjectsException("Can't create a show if it already exists") - - self.loadFromDB() - - self.saveToDB() - - def _getLocation(self): - # no dir check needed if missing show dirs are created during post-processing - if sickbeard.CREATE_MISSING_SHOW_DIRS: - return self._location - - if ek.ek(os.path.isdir, self._location): - return self._location - else: - raise exceptions.ShowDirNotFoundException("Show folder doesn't exist, you shouldn't be using it") - - if self._isDirGood: - return self._location - else: - raise exceptions.NoNFOException("Show folder doesn't exist, you shouldn't be using it") - - def _setLocation(self, newLocation): - logger.log(u"Setter sets location to " + newLocation, logger.DEBUG) - # Don't validate dir if user wants to add shows without creating a dir - if sickbeard.ADD_SHOWS_WO_DIR or ek.ek(os.path.isdir, newLocation): - self._location = newLocation - self._isDirGood = True - else: - raise exceptions.NoNFOException("Invalid folder for the show!") - - location = property(_getLocation, _setLocation) - - # delete references to anything that's not in the internal lists - def flushEpisodes(self): - - for curSeason in self.episodes: - for curEp in self.episodes[curSeason]: - myEp = self.episodes[curSeason][curEp] - self.episodes[curSeason][curEp] = None - del myEp - - def getAllEpisodes(self, season=None, has_location=False): - - myDB = db.DBConnection() - - sql_selection = "SELECT season, episode, " - - # subselection to detect multi-episodes early, share_location > 0 - sql_selection = sql_selection + " (SELECT COUNT (*) FROM tv_episodes WHERE showid = tve.showid AND season = tve.season AND location != '' AND location = tve.location AND episode != tve.episode) AS share_location " - - sql_selection = sql_selection + " FROM tv_episodes tve WHERE showid = " + str(self.tvdbid) - - if season is not None: - sql_selection = sql_selection + " AND season = " + str(season) - if has_location: - sql_selection = sql_selection + " AND location != '' " - - # need ORDER episode ASC to rename multi-episodes in order S01E01-02 - sql_selection = sql_selection + " ORDER BY season ASC, episode ASC" - - results = myDB.select(sql_selection) - - ep_list = [] - for cur_result in results: - cur_ep = self.getEpisode(int(cur_result["season"]), int(cur_result["episode"])) - if cur_ep: - if cur_ep.location: - # if there is a location, check if it's a multi-episode (share_location > 0) and put them in relatedEps - if cur_result["share_location"] > 0: - related_eps_result = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND season = ? AND location = ? AND episode != ? ORDER BY episode ASC", [self.tvdbid, cur_ep.season, cur_ep.location, cur_ep.episode]) - for cur_related_ep in related_eps_result: - related_ep = self.getEpisode(int(cur_related_ep["season"]), int(cur_related_ep["episode"])) - if related_ep not in cur_ep.relatedEps: - cur_ep.relatedEps.append(related_ep) - ep_list.append(cur_ep) - - return ep_list - - - def getEpisode(self, season, episode, file=None, noCreate=False): - - #return TVEpisode(self, season, episode) - - if not season in self.episodes: - self.episodes[season] = {} - - ep = None - - if not episode in self.episodes[season] or self.episodes[season][episode] == None: - if noCreate: - return None - - logger.log(str(self.tvdbid) + ": An object for episode " + str(season) + "x" + str(episode) + " didn't exist in the cache, trying to create it", logger.DEBUG) - - if file != None: - ep = TVEpisode(self, season, episode, file) - else: - ep = TVEpisode(self, season, episode) - - if ep != None: - self.episodes[season][episode] = ep - - return self.episodes[season][episode] - - def writeShowNFO(self): - - result = False - - if not ek.ek(os.path.isdir, self._location): - logger.log(str(self.tvdbid) + u": Show dir doesn't exist, skipping NFO generation") - return False - - for cur_provider in sickbeard.metadata_provider_dict.values(): - result = cur_provider.create_show_metadata(self) or result - - return result - - def writeMetadata(self, show_only=False): - - if not ek.ek(os.path.isdir, self._location): - logger.log(str(self.tvdbid) + u": Show dir doesn't exist, skipping NFO generation") - return - - self.getImages() - - self.writeShowNFO() - - if not show_only: - self.writeEpisodeNFOs() - - def writeEpisodeNFOs (self): - - if not ek.ek(os.path.isdir, self._location): - logger.log(str(self.tvdbid) + ": Show dir doesn't exist, skipping NFO generation") - return - - logger.log(str(self.tvdbid) + ": Writing NFOs for all episodes") - - myDB = db.DBConnection() - sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND location != ''", [self.tvdbid]) - - for epResult in sqlResults: - logger.log(str(self.tvdbid) + ": Retrieving/creating episode " + str(epResult["season"]) + "x" + str(epResult["episode"]), logger.DEBUG) - curEp = self.getEpisode(epResult["season"], epResult["episode"]) - curEp.createMetaFiles() - - - # find all media files in the show folder and create episodes for as many as possible - def loadEpisodesFromDir (self): - - if not ek.ek(os.path.isdir, self._location): - logger.log(str(self.tvdbid) + ": Show dir doesn't exist, not loading episodes from disk") - return - - logger.log(str(self.tvdbid) + ": Loading all episodes from the show directory " + self._location) - - # get file list - mediaFiles = helpers.listMediaFiles(self._location) - - # create TVEpisodes from each media file (if possible) - for mediaFile in mediaFiles: - - curEpisode = None - - logger.log(str(self.tvdbid) + ": Creating episode from " + mediaFile, logger.DEBUG) - try: - curEpisode = self.makeEpFromFile(ek.ek(os.path.join, self._location, mediaFile)) - except (exceptions.ShowNotFoundException, exceptions.EpisodeNotFoundException), e: - logger.log(u"Episode "+mediaFile+" returned an exception: "+ex(e), logger.ERROR) - continue - except exceptions.EpisodeDeletedException: - logger.log(u"The episode deleted itself when I tried making an object for it", logger.DEBUG) - - if curEpisode is None: - continue - - # see if we should save the release name in the db - ep_file_name = ek.ek(os.path.basename, curEpisode.location) - ep_file_name = ek.ek(os.path.splitext, ep_file_name)[0] - - parse_result = None - try: - np = NameParser(False) - parse_result = np.parse(ep_file_name) - except InvalidNameException: - pass - - if not ' ' in ep_file_name and parse_result and parse_result.release_group: - logger.log(u"Name " + ep_file_name + " gave release group of " + parse_result.release_group + ", seems valid", logger.DEBUG) - curEpisode.release_name = ep_file_name - - # store the reference in the show - if curEpisode != None: - if self.subtitles: - try: - curEpisode.refreshSubtitles() - except: - logger.log(str(self.tvdbid) + ": Could not refresh subtitles", logger.ERROR) - logger.log(traceback.format_exc(), logger.DEBUG) - curEpisode.saveToDB() - - - def loadEpisodesFromDB(self): - - logger.log(u"Loading all episodes from the DB") - - myDB = db.DBConnection() - sql = "SELECT * FROM tv_episodes WHERE showid = ?" - sqlResults = myDB.select(sql, [self.tvdbid]) - - scannedEps = {} - - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if self.lang: - ltvdb_api_parms['language'] = self.lang - - t = tvdb_api.Tvdb(**ltvdb_api_parms) - - cachedShow = t[self.tvdbid] - cachedSeasons = {} - - for curResult in sqlResults: - - deleteEp = False - - curSeason = int(curResult["season"]) - curEpisode = int(curResult["episode"]) - if curSeason not in cachedSeasons: - try: - cachedSeasons[curSeason] = cachedShow[curSeason] - except tvdb_exceptions.tvdb_seasonnotfound, e: - logger.log(u"Error when trying to load the episode from TVDB: "+e.message, logger.WARNING) - deleteEp = True - - if not curSeason in scannedEps: - scannedEps[curSeason] = {} - - logger.log(u"Loading episode "+str(curSeason)+"x"+str(curEpisode)+" from the DB", logger.DEBUG) - - try: - curEp = self.getEpisode(curSeason, curEpisode) - - # if we found out that the ep is no longer on TVDB then delete it from our database too - if deleteEp: - curEp.deleteEpisode() - - curEp.loadFromDB(curSeason, curEpisode) - curEp.loadFromTVDB(tvapi=t, cachedSeason=cachedSeasons[curSeason]) - scannedEps[curSeason][curEpisode] = True - except exceptions.EpisodeDeletedException: - logger.log(u"Tried loading an episode from the DB that should have been deleted, skipping it", logger.DEBUG) - continue - - return scannedEps - - - def loadEpisodesFromTVDB(self, cache=True): - - # There's gotta be a better way of doing this but we don't wanna - # change the cache value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if not cache: - ltvdb_api_parms['cache'] = False - - if self.lang: - ltvdb_api_parms['language'] = self.lang - - try: - t = tvdb_api.Tvdb(**ltvdb_api_parms) - showObj = t[self.tvdbid] - except tvdb_exceptions.tvdb_error: - logger.log(u"TVDB timed out, unable to update episodes from TVDB", logger.ERROR) - return None - - logger.log(str(self.tvdbid) + ": Loading all episodes from theTVDB...") - - scannedEps = {} - - for season in showObj: - scannedEps[season] = {} - for episode in showObj[season]: - # need some examples of wtf episode 0 means to decide if we want it or not - if episode == 0: - continue - try: - #ep = TVEpisode(self, season, episode) - ep = self.getEpisode(season, episode) - except exceptions.EpisodeNotFoundException: - logger.log(str(self.tvdbid) + ": TVDB object for " + str(season) + "x" + str(episode) + " is incomplete, skipping this episode") - continue - else: - try: - ep.loadFromTVDB(tvapi=t) - except exceptions.EpisodeDeletedException: - logger.log(u"The episode was deleted, skipping the rest of the load") - continue - - with ep.lock: - logger.log(str(self.tvdbid) + ": Loading info from theTVDB for episode " + str(season) + "x" + str(episode), logger.DEBUG) - ep.loadFromTVDB(season, episode, tvapi=t) - if ep.dirty: - ep.saveToDB() - - scannedEps[season][episode] = True - - return scannedEps - - def setTVRID(self, force=False): - - if self.tvrid != 0 and not force: - logger.log(u"No need to get the TVRage ID, it's already populated", logger.DEBUG) - return - - logger.log(u"Attempting to retrieve the TVRage ID", logger.DEBUG) - - try: - # load the tvrage object, it will set the ID in its constructor if possible - tvrage.TVRage(self) - self.saveToDB() - except exceptions.TVRageException, e: - logger.log(u"Couldn't get TVRage ID because we're unable to sync TVDB and TVRage: "+ex(e), logger.DEBUG) - return - - def getImages(self, fanart=None, poster=None): - - poster_result = fanart_result = season_thumb_result = False - - for cur_provider in sickbeard.metadata_provider_dict.values(): - logger.log("Running season folders for "+cur_provider.name, logger.DEBUG) - poster_result = cur_provider.create_poster(self) or poster_result - fanart_result = cur_provider.create_fanart(self) or fanart_result - season_thumb_result = cur_provider.create_season_thumbs(self) or season_thumb_result - - return poster_result or fanart_result or season_thumb_result - - def loadLatestFromTVRage(self): - - try: - # load the tvrage object - tvr = tvrage.TVRage(self) - - newEp = tvr.findLatestEp() - - if newEp != None: - logger.log(u"TVRage gave us an episode object - saving it for now", logger.DEBUG) - newEp.saveToDB() - - # make an episode out of it - except exceptions.TVRageException, e: - logger.log(u"Unable to add TVRage info: " + ex(e), logger.WARNING) - - - - # make a TVEpisode object from a media file - def makeEpFromFile(self, file): - - if not ek.ek(os.path.isfile, file): - logger.log(str(self.tvdbid) + ": That isn't even a real file dude... " + file) - return None - - logger.log(str(self.tvdbid) + ": Creating episode object from " + file, logger.DEBUG) - - try: - myParser = NameParser() - parse_result = myParser.parse(file) - except InvalidNameException: - logger.log(u"Unable to parse the filename "+file+" into a valid episode", logger.ERROR) - return None - - if len(parse_result.episode_numbers) == 0 and not parse_result.air_by_date: - logger.log("parse_result: "+str(parse_result)) - logger.log(u"No episode number found in "+file+", ignoring it", logger.ERROR) - return None - - # for now lets assume that any episode in the show dir belongs to that show - season = parse_result.season_number if parse_result.season_number != None else 1 - episodes = parse_result.episode_numbers - rootEp = None - - # if we have an air-by-date show then get the real season/episode numbers - if parse_result.air_by_date: - try: - # There's gotta be a better way of doing this but we don't wanna - # change the cache value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if self.lang: - ltvdb_api_parms['language'] = self.lang - - t = tvdb_api.Tvdb(**ltvdb_api_parms) - - epObj = t[self.tvdbid].airedOn(parse_result.air_date)[0] - season = int(epObj["seasonnumber"]) - episodes = [int(epObj["episodenumber"])] - except tvdb_exceptions.tvdb_episodenotfound: - logger.log(u"Unable to find episode with date " + str(parse_result.air_date) + " for show " + self.name + ", skipping", logger.WARNING) - return None - except tvdb_exceptions.tvdb_error, e: - logger.log(u"Unable to contact TVDB: "+ex(e), logger.WARNING) - return None - - for curEpNum in episodes: - - episode = int(curEpNum) - - logger.log(str(self.tvdbid) + ": " + file + " parsed to " + self.name + " " + str(season) + "x" + str(episode), logger.DEBUG) - - checkQualityAgain = False - same_file = False - curEp = self.getEpisode(season, episode) - - if curEp == None: - try: - curEp = self.getEpisode(season, episode, file) - except exceptions.EpisodeNotFoundException: - logger.log(str(self.tvdbid) + ": Unable to figure out what this file is, skipping", logger.ERROR) - continue - - else: - # if there is a new file associated with this ep then re-check the quality - if curEp.location and ek.ek(os.path.normpath, curEp.location) != ek.ek(os.path.normpath, file): - logger.log(u"The old episode had a different file associated with it, I will re-check the quality based on the new filename "+file, logger.DEBUG) - checkQualityAgain = True - - with curEp.lock: - old_size = curEp.file_size - curEp.location = file - # if the sizes are the same then it's probably the same file - if old_size and curEp.file_size == old_size: - same_file = True - else: - same_file = False - - curEp.checkForMetaFiles() - - - if rootEp == None: - rootEp = curEp - else: - if curEp not in rootEp.relatedEps: - rootEp.relatedEps.append(curEp) - - # if it's a new file then - if not same_file: - curEp.release_name = '' - - # if they replace a file on me I'll make some attempt at re-checking the quality unless I know it's the same file - if checkQualityAgain and not same_file: - newQuality = Quality.nameQuality(file) - logger.log(u"Since this file has been renamed, I checked "+file+" and found quality "+Quality.qualityStrings[newQuality], logger.DEBUG) - if newQuality != Quality.UNKNOWN: - curEp.status = Quality.compositeStatus(DOWNLOADED, newQuality) - - - # check for status/quality changes as long as it's a new file - elif not same_file and sickbeard.helpers.isMediaFile(file) and curEp.status not in Quality.DOWNLOADED + [ARCHIVED, IGNORED]: - - oldStatus, oldQuality = Quality.splitCompositeStatus(curEp.status) - newQuality = Quality.nameQuality(file) - if newQuality == Quality.UNKNOWN: - newQuality = Quality.assumeQuality(file) - - newStatus = None - - # if it was snatched and now exists then set the status correctly - if oldStatus == SNATCHED and oldQuality <= newQuality: - logger.log(u"STATUS: this ep used to be snatched with quality "+Quality.qualityStrings[oldQuality]+" but a file exists with quality "+Quality.qualityStrings[newQuality]+" so I'm setting the status to DOWNLOADED", logger.DEBUG) - newStatus = DOWNLOADED - - # if it was snatched proper and we found a higher quality one then allow the status change - elif oldStatus == SNATCHED_PROPER and oldQuality < newQuality: - logger.log(u"STATUS: this ep used to be snatched proper with quality "+Quality.qualityStrings[oldQuality]+" but a file exists with quality "+Quality.qualityStrings[newQuality]+" so I'm setting the status to DOWNLOADED", logger.DEBUG) - newStatus = DOWNLOADED - - elif oldStatus not in (SNATCHED, SNATCHED_PROPER): - newStatus = DOWNLOADED - - if newStatus != None: - with curEp.lock: - logger.log(u"STATUS: we have an associated file, so setting the status from "+str(curEp.status)+" to DOWNLOADED/" + str(Quality.statusFromName(file)), logger.DEBUG) - curEp.status = Quality.compositeStatus(newStatus, newQuality) - - with curEp.lock: - curEp.saveToDB() - - # creating metafiles on the root should be good enough - if rootEp != None: - with rootEp.lock: - rootEp.createMetaFiles() - - return rootEp - - - def loadFromDB(self, skipNFO=False): - - logger.log(str(self.tvdbid) + ": Loading show info from database") - - myDB = db.DBConnection() - - sqlResults = myDB.select("SELECT * FROM tv_shows WHERE tvdb_id = ?", [self.tvdbid]) - - if len(sqlResults) > 1: - raise exceptions.MultipleDBShowsException() - elif len(sqlResults) == 0: - logger.log(str(self.tvdbid) + ": Unable to find the show in the database") - return - else: - if self.name == "": - self.name = sqlResults[0]["show_name"] - self.tvrname = sqlResults[0]["tvr_name"] - if self.network == "": - self.network = sqlResults[0]["network"] - if self.genre == "": - self.genre = sqlResults[0]["genre"] - - self.runtime = sqlResults[0]["runtime"] - - self.status = sqlResults[0]["status"] - if self.status == None: - self.status = "" - self.airs = sqlResults[0]["airs"] - if self.airs == None: - self.airs = "" - self.startyear = sqlResults[0]["startyear"] - if self.startyear == None: - self.startyear = 0 - - self.air_by_date = sqlResults[0]["air_by_date"] - if self.air_by_date == None: - self.air_by_date = 0 - - self.subtitles = sqlResults[0]["subtitles"] - if self.subtitles: - self.subtitles = 1 - else: - self.subtitles = 0 - - self.quality = int(sqlResults[0]["quality"]) - self.flatten_folders = int(sqlResults[0]["flatten_folders"]) - self.paused = int(sqlResults[0]["paused"]) - - self._location = sqlResults[0]["location"] - - if self.tvrid == 0: - self.tvrid = int(sqlResults[0]["tvr_id"]) - - if self.lang == "": - self.lang = sqlResults[0]["lang"] - - if self.audio_lang == "": - self.audio_lang = sqlResults[0]["audio_lang"] - - if self.custom_search_names == "": - self.custom_search_names = sqlResults[0]["custom_search_names"] - - def loadFromTVDB(self, cache=True, tvapi=None, cachedSeason=None): - - logger.log(str(self.tvdbid) + ": Loading show info from theTVDB") - - # There's gotta be a better way of doing this but we don't wanna - # change the cache value elsewhere - if tvapi is None: - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if not cache: - ltvdb_api_parms['cache'] = False - - if self.lang: - ltvdb_api_parms['language'] = self.lang - - t = tvdb_api.Tvdb(**ltvdb_api_parms) - - else: - t = tvapi - - myEp = t[self.tvdbid] - - self.name = myEp["seriesname"] - - self.genre = myEp['genre'] - self.network = myEp['network'] - - if myEp["airs_dayofweek"] != None and myEp["airs_time"] != None: - self.airs = myEp["airs_dayofweek"] + " " + myEp["airs_time"] - - if myEp["firstaired"] != None and myEp["firstaired"]: - self.startyear = int(myEp["firstaired"].split('-')[0]) - - if self.airs == None: - self.airs = "" - - if myEp["status"] != None: - self.status = myEp["status"] - - if self.status == None: - self.status = "" - - self.saveToDB() - - - def loadNFO (self): - - if not os.path.isdir(self._location): - logger.log(str(self.tvdbid) + ": Show dir doesn't exist, can't load NFO") - raise exceptions.NoNFOException("The show dir doesn't exist, no NFO could be loaded") - - logger.log(str(self.tvdbid) + ": Loading show info from NFO") - - xmlFile = os.path.join(self._location, "tvshow.nfo") - - try: - xmlFileObj = open(xmlFile, 'r') - showXML = etree.ElementTree(file = xmlFileObj) - - if showXML.findtext('title') == None or (showXML.findtext('tvdbid') == None and showXML.findtext('id') == None): - raise exceptions.NoNFOException("Invalid info in tvshow.nfo (missing name or id):" \ - + str(showXML.findtext('title')) + " " \ - + str(showXML.findtext('tvdbid')) + " " \ - + str(showXML.findtext('id'))) - - self.name = showXML.findtext('title') - if showXML.findtext('tvdbid') != None: - self.tvdbid = int(showXML.findtext('tvdbid')) - elif showXML.findtext('id'): - self.tvdbid = int(showXML.findtext('id')) - else: - raise exceptions.NoNFOException("Empty <id> or <tvdbid> field in NFO") - - except (exceptions.NoNFOException, SyntaxError, ValueError), e: - logger.log(u"There was an error parsing your existing tvshow.nfo file: " + ex(e), logger.ERROR) - logger.log(u"Attempting to rename it to tvshow.nfo.old", logger.DEBUG) - - try: - xmlFileObj.close() - ek.ek(os.rename, xmlFile, xmlFile + ".old") - except Exception, e: - logger.log(u"Failed to rename your tvshow.nfo file - you need to delete it or fix it: " + ex(e), logger.ERROR) - raise exceptions.NoNFOException("Invalid info in tvshow.nfo") - - if showXML.findtext('studio') != None: - self.network = showXML.findtext('studio') - if self.network == None and showXML.findtext('network') != None: - self.network = "" - if showXML.findtext('genre') != None: - self.genre = showXML.findtext('genre') - else: - self.genre = "" - - # TODO: need to validate the input, I'm assuming it's good until then - - - def nextEpisode(self): - - logger.log(str(self.tvdbid) + ": Finding the episode which airs next", logger.DEBUG) - - myDB = db.DBConnection() - innerQuery = "SELECT airdate FROM tv_episodes WHERE showid = ? AND airdate >= ? AND status = ? ORDER BY airdate ASC LIMIT 1" - innerParams = [self.tvdbid, datetime.date.today().toordinal(), UNAIRED] - query = "SELECT * FROM tv_episodes WHERE showid = ? AND airdate >= ? AND airdate <= (" + innerQuery + ") and status = ?" - params = [self.tvdbid, datetime.date.today().toordinal()] + innerParams + [UNAIRED] - sqlResults = myDB.select(query, params) - - if sqlResults == None or len(sqlResults) == 0: - logger.log(str(self.tvdbid) + ": No episode found... need to implement tvrage and also show status", logger.DEBUG) - return [] - else: - logger.log(str(self.tvdbid) + ": Found episode " + str(sqlResults[0]["season"]) + "x" + str(sqlResults[0]["episode"]), logger.DEBUG) - foundEps = [] - for sqlEp in sqlResults: - curEp = self.getEpisode(int(sqlEp["season"]), int(sqlEp["episode"])) - foundEps.append(curEp) - return foundEps - - # if we didn't get an episode then try getting one from tvrage - - # load tvrage info - - # extract NextEpisode info - - # verify that we don't have it in the DB somehow (ep mismatch) - - - def deleteShow(self): - - myDB = db.DBConnection() - myDB.action("DELETE FROM tv_episodes WHERE showid = ?", [self.tvdbid]) - myDB.action("DELETE FROM tv_shows WHERE tvdb_id = ?", [self.tvdbid]) - - # remove self from show list - sickbeard.showList = [x for x in sickbeard.showList if x.tvdbid != self.tvdbid] - - # clear the cache - image_cache_dir = ek.ek(os.path.join, sickbeard.CACHE_DIR, 'images') - for cache_file in ek.ek(glob.glob, ek.ek(os.path.join, image_cache_dir, str(self.tvdbid)+'.*')): - logger.log(u"Deleting cache file "+cache_file) - os.remove(cache_file) - - def populateCache(self): - cache_inst = image_cache.ImageCache() - - logger.log(u"Checking & filling cache for show "+self.name) - cache_inst.fill_cache(self) - - def refreshDir(self): - - # make sure the show dir is where we think it is unless dirs are created on the fly - if not ek.ek(os.path.isdir, self._location) and not sickbeard.CREATE_MISSING_SHOW_DIRS: - return False - - # load from dir - self.loadEpisodesFromDir() - - # run through all locations from DB, check that they exist - logger.log(str(self.tvdbid) + ": Loading all episodes with a location from the database") - - myDB = db.DBConnection() - sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND location != ''", [self.tvdbid]) - - for ep in sqlResults: - curLoc = os.path.normpath(ep["location"]) - season = int(ep["season"]) - episode = int(ep["episode"]) - - try: - curEp = self.getEpisode(season, episode) - except exceptions.EpisodeDeletedException: - logger.log(u"The episode was deleted while we were refreshing it, moving on to the next one", logger.DEBUG) - continue - - # if the path doesn't exist or if it's not in our show dir - if not ek.ek(os.path.isfile, curLoc) or not os.path.normpath(curLoc).startswith(os.path.normpath(self.location)): - - with curEp.lock: - # if it used to have a file associated with it and it doesn't anymore then set it to IGNORED - if curEp.location and curEp.status in Quality.DOWNLOADED: - logger.log(str(self.tvdbid) + ": Location for " + str(season) + "x" + str(episode) + " doesn't exist, removing it and changing our status to IGNORED", logger.DEBUG) - curEp.status = IGNORED - curEp.subtitles = list() - curEp.subtitles_searchcount = 0 - curEp.subtitles_lastsearch = str(datetime.datetime.min) - curEp.location = '' - curEp.hasnfo = False - curEp.hastbn = False - curEp.release_name = '' - curEp.saveToDB() - - - def downloadSubtitles(self): - #TODO: Add support for force option - if not ek.ek(os.path.isdir, self._location): - logger.log(str(self.tvdbid) + ": Show dir doesn't exist, can't download subtitles", logger.DEBUG) - return - logger.log(str(self.tvdbid) + ": Downloading subtitles", logger.DEBUG) - - try: - episodes = db.DBConnection().select("SELECT location FROM tv_episodes WHERE showid = ? AND location NOT LIKE '' ORDER BY season DESC, episode DESC", [self.tvdbid]) - for episodeLoc in episodes: - episode = self.makeEpFromFile(episodeLoc['location']); - subtitles = episode.downloadSubtitles() - - except Exception as e: - logger.log("Error occurred when downloading subtitles: " + str(e), logger.DEBUG) - return - - - def saveToDB(self): - logger.log(str(self.tvdbid) + ": Saving show info to database", logger.DEBUG) - - myDB = db.DBConnection() - - controlValueDict = {"tvdb_id": self.tvdbid} - newValueDict = {"show_name": self.name, - "tvr_id": self.tvrid, - "location": self._location, - "network": self.network, - "genre": self.genre, - "runtime": self.runtime, - "quality": self.quality, - "airs": self.airs, - "status": self.status, - "flatten_folders": self.flatten_folders, - "paused": self.paused, - "air_by_date": self.air_by_date, - "subtitles": self.subtitles, - "startyear": self.startyear, - "tvr_name": self.tvrname, - "lang": self.lang, - "audio_lang": self.audio_lang, - "custom_search_names": self.custom_search_names - } - - myDB.upsert("tv_shows", newValueDict, controlValueDict) - - - def __str__(self): - toReturn = "" - toReturn += "name: " + self.name + "\n" - toReturn += "location: " + self._location + "\n" - toReturn += "tvdbid: " + str(self.tvdbid) + "\n" - if self.network != None: - toReturn += "network: " + self.network + "\n" - if self.airs != None: - toReturn += "airs: " + self.airs + "\n" - if self.status != None: - toReturn += "status: " + self.status + "\n" - toReturn += "startyear: " + str(self.startyear) + "\n" - toReturn += "genre: " + self.genre + "\n" - toReturn += "runtime: " + str(self.runtime) + "\n" - toReturn += "quality: " + str(self.quality) + "\n" - return toReturn - - - def wantEpisode(self, season, episode, quality, manualSearch=False): - - logger.log(u"Checking if we want episode "+str(season)+"x"+str(episode)+" at quality "+Quality.qualityStrings[quality], logger.DEBUG) - - # if the quality isn't one we want under any circumstances then just say no - anyQualities, bestQualities = Quality.splitQuality(self.quality) - logger.log(u"any,best = "+str(anyQualities)+" "+str(bestQualities)+" and we are "+str(quality), logger.DEBUG) - - if quality not in anyQualities + bestQualities: - logger.log(u"I know for sure I don't want this episode, saying no", logger.DEBUG) - return False - - myDB = db.DBConnection() - sqlResults = myDB.select("SELECT status FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ?", [self.tvdbid, season, episode]) - - if not sqlResults or not len(sqlResults): - logger.log(u"Unable to find the episode", logger.DEBUG) - return False - - epStatus = int(sqlResults[0]["status"]) - - logger.log(u"current episode status: "+str(epStatus), logger.DEBUG) - - # if we know we don't want it then just say no - if epStatus in (SKIPPED, IGNORED, ARCHIVED) and not manualSearch: - logger.log(u"Ep is skipped, not bothering", logger.DEBUG) - return False - - # if it's one of these then we want it as long as it's in our allowed initial qualities - if quality in anyQualities + bestQualities: - if epStatus in (WANTED, UNAIRED, SKIPPED): - logger.log(u"Ep is wanted/unaired/skipped, definitely get it", logger.DEBUG) - return True - elif manualSearch: - logger.log(u"Usually I would ignore this ep but because you forced the search I'm overriding the default and allowing the quality", logger.DEBUG) - return True - else: - logger.log(u"This quality looks like something we might want but I don't know for sure yet", logger.DEBUG) - - curStatus, curQuality = Quality.splitCompositeStatus(epStatus) - - # if we are re-downloading then we only want it if it's in our bestQualities list and better than what we have - if curStatus in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER and quality in bestQualities and quality > curQuality: - logger.log(u"We already have this ep but the new one is better quality, saying yes", logger.DEBUG) - return True - - logger.log(u"None of the conditions were met so I'm just saying no", logger.DEBUG) - return False - - - def getOverview(self, epStatus): - - if epStatus == WANTED: - return Overview.WANTED - elif epStatus in (UNAIRED, UNKNOWN): - return Overview.UNAIRED - elif epStatus in (SKIPPED, IGNORED): - return Overview.SKIPPED - elif epStatus == ARCHIVED: - return Overview.GOOD - elif epStatus in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER: - - anyQualities, bestQualities = Quality.splitQuality(self.quality) #@UnusedVariable - if bestQualities: - maxBestQuality = max(bestQualities) - else: - maxBestQuality = None - - epStatus, curQuality = Quality.splitCompositeStatus(epStatus) - - if epStatus in (SNATCHED, SNATCHED_PROPER): - return Overview.SNATCHED - # if they don't want re-downloads then we call it good if they have anything - elif maxBestQuality == None: - return Overview.GOOD - # if they have one but it's not the best they want then mark it as qual - elif curQuality < maxBestQuality: - return Overview.QUAL - # if it's >= maxBestQuality then it's good - else: - return Overview.GOOD - -def dirty_setter(attr_name): - def wrapper(self, val): - if getattr(self, attr_name) != val: - setattr(self, attr_name, val) - self.dirty = True - return wrapper - -class TVEpisode(object): - - def __init__(self, show, season, episode, file=""): - - self._name = "" - self._season = season - self._episode = episode - self._description = "" - self._subtitles = list() - self._subtitles_searchcount = 0 - self._subtitles_lastsearch = str(datetime.datetime.min) - self._airdate = datetime.date.fromordinal(1) - self._hasnfo = False - self._hastbn = False - self._status = UNKNOWN - self._tvdbid = 0 - self._file_size = 0 - self._audio_langs = '' - self._release_name = '' - - # setting any of the above sets the dirty flag - self.dirty = True - - self.show = show - self._location = file - - self.lock = threading.Lock() - - self.specifyEpisode(self.season, self.episode) - - self.relatedEps = [] - - self.checkForMetaFiles() - - name = property(lambda self: self._name, dirty_setter("_name")) - season = property(lambda self: self._season, dirty_setter("_season")) - episode = property(lambda self: self._episode, dirty_setter("_episode")) - description = property(lambda self: self._description, dirty_setter("_description")) - subtitles = property(lambda self: self._subtitles, dirty_setter("_subtitles")) - subtitles_searchcount = property(lambda self: self._subtitles_searchcount, dirty_setter("_subtitles_searchcount")) - subtitles_lastsearch = property(lambda self: self._subtitles_lastsearch, dirty_setter("_subtitles_lastsearch")) - airdate = property(lambda self: self._airdate, dirty_setter("_airdate")) - hasnfo = property(lambda self: self._hasnfo, dirty_setter("_hasnfo")) - hastbn = property(lambda self: self._hastbn, dirty_setter("_hastbn")) - status = property(lambda self: self._status, dirty_setter("_status")) - tvdbid = property(lambda self: self._tvdbid, dirty_setter("_tvdbid")) - #location = property(lambda self: self._location, dirty_setter("_location")) - file_size = property(lambda self: self._file_size, dirty_setter("_file_size")) - audio_langs = property(lambda self: self._audio_langs, dirty_setter("_audio_langs")) - release_name = property(lambda self: self._release_name, dirty_setter("_release_name")) - - def _set_location(self, new_location): - logger.log(u"Setter sets location to " + new_location, logger.DEBUG) - - #self._location = newLocation - dirty_setter("_location")(self, new_location) - - if new_location and ek.ek(os.path.isfile, new_location): - self.file_size = ek.ek(os.path.getsize, new_location) - else: - self.file_size = 0 - - location = property(lambda self: self._location, _set_location) - def refreshSubtitles(self): - """Look for subtitles files and refresh the subtitles property""" - self.subtitles = subtitles.subtitlesLanguages(self.location) - - def downloadSubtitles(self): - #TODO: Add support for force option - if not ek.ek(os.path.isfile, self.location): - logger.log(str(self.show.tvdbid) + ": Episode file doesn't exist, can't download subtitles for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) - return - logger.log(str(self.show.tvdbid) + ": Downloading subtitles for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) - - previous_subtitles = self.subtitles - - try: - - need_languages = set(sickbeard.SUBTITLES_LANGUAGES) - set(self.subtitles) - subtitles = subliminal.download_subtitles([self.location], languages=need_languages, services=sickbeard.subtitles.getEnabledServiceList(), force=False, multi=True, cache_dir=sickbeard.CACHE_DIR) - - except Exception as e: - logger.log("Error occurred when downloading subtitles: " + str(e), logger.DEBUG) - return - - self.refreshSubtitles() - self.subtitles_searchcount = self.subtitles_searchcount + 1 - self.subtitles_lastsearch = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - self.saveToDB() - - newsubtitles = set(self.subtitles).difference(set(previous_subtitles)) - - if newsubtitles: - subtitleList = ", ".join(subliminal.language.Language(x).name for x in newsubtitles) - logger.log(str(self.show.tvdbid) + ": Downloaded " + subtitleList + " subtitles for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) - - notifiers.notify_subtitle_download(self.prettyName(), subtitleList) - - else: - logger.log(str(self.show.tvdbid) + ": No subtitles downloaded for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) - - if sickbeard.SUBTITLES_HISTORY: - for video in subtitles: - for subtitle in subtitles.get(video): - history.logSubtitle(self.show.tvdbid, self.season, self.episode, self.status, subtitle) - if sickbeard.SUBTITLES_DIR: - for video in subtitles: - subs_new_path = ek.ek(os.path.join, os.path.dirname(video.path), sickbeard.SUBTITLES_DIR) - if not ek.ek(os.path.isdir, subs_new_path): - ek.ek(os.mkdir, subs_new_path) - - for subtitle in subtitles.get(video): - new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) - helpers.moveFile(subtitle.path, new_file_path) - if sickbeard.SUBSNOLANG: - helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") - - elif sickbeard.SUBTITLES_DIR_SUB: - for video in subtitles: - subs_new_path = os.path.join(os.path.dirname(video.path), "Subs") - if not os.path.isdir(subs_new_path): - os.makedirs(subs_new_path) - - for subtitle in subtitles.get(video): - new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) - helpers.moveFile(subtitle.path, new_file_path) - subtitle.path=new_file_path - if sickbeard.SUBSNOLANG: - helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") - subtitle.path=new_file_path - else: - for video in subtitles: - for subtitle in subtitles.get(video): - if sickbeard.SUBSNOLANG: - helpers.copyFile(subtitle.path,subtitle.path[:-6]+"srt") - helpers.chmodAsParent(subtitle.path[:-6]+"srt") - helpers.chmodAsParent(subtitle.path) - return subtitles - - - def checkForMetaFiles(self): - - oldhasnfo = self.hasnfo - oldhastbn = self.hastbn - - cur_nfo = False - cur_tbn = False - - # check for nfo and tbn - if ek.ek(os.path.isfile, self.location): - for cur_provider in sickbeard.metadata_provider_dict.values(): - if cur_provider.episode_metadata: - new_result = cur_provider._has_episode_metadata(self) - else: - new_result = False - cur_nfo = new_result or cur_nfo - - if cur_provider.episode_thumbnails: - new_result = cur_provider._has_episode_thumb(self) - else: - new_result = False - cur_tbn = new_result or cur_tbn - - self.hasnfo = cur_nfo - self.hastbn = cur_tbn - - # if either setting has changed return true, if not return false - return oldhasnfo != self.hasnfo or oldhastbn != self.hastbn - - def specifyEpisode(self, season, episode): - - sqlResult = self.loadFromDB(season, episode) - - if not sqlResult: - # only load from NFO if we didn't load from DB - if ek.ek(os.path.isfile, self.location): - try: - self.loadFromNFO(self.location) - except exceptions.NoNFOException: - logger.log(str(self.show.tvdbid) + ": There was an error loading the NFO for episode " + str(season) + "x" + str(episode), logger.ERROR) - pass - - # if we tried loading it from NFO and didn't find the NFO, use TVDB - if self.hasnfo == False: - try: - result = self.loadFromTVDB(season, episode) - except exceptions.EpisodeDeletedException: - result = False - - # if we failed SQL *and* NFO, TVDB then fail - if result == False: - raise exceptions.EpisodeNotFoundException("Couldn't find episode " + str(season) + "x" + str(episode)) - - # don't update if not needed - if self.dirty: - self.saveToDB() - - def loadFromDB(self, season, episode): - - logger.log(str(self.show.tvdbid) + ": Loading episode details from DB for episode " + str(season) + "x" + str(episode), logger.DEBUG) - - myDB = db.DBConnection() - sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ?", [self.show.tvdbid, season, episode]) - - if len(sqlResults) > 1: - raise exceptions.MultipleDBEpisodesException("Your DB has two records for the same show somehow.") - elif len(sqlResults) == 0: - logger.log(str(self.show.tvdbid) + ": Episode " + str(self.season) + "x" + str(self.episode) + " not found in the database", logger.DEBUG) - return False - else: - #NAMEIT logger.log(u"AAAAA from" + str(self.season)+"x"+str(self.episode) + " -" + self.name + " to " + str(sqlResults[0]["name"])) - if sqlResults[0]["name"] != None: - self.name = sqlResults[0]["name"] - self.season = season - self.episode = episode - self.description = sqlResults[0]["description"] - if self.description == None: - self.description = "" - if sqlResults[0]["subtitles"] != None and sqlResults[0]["subtitles"] != '': - self.subtitles = sqlResults[0]["subtitles"].split(",") - self.subtitles_searchcount = sqlResults[0]["subtitles_searchcount"] - self.subtitles_lastsearch = sqlResults[0]["subtitles_lastsearch"] - self.airdate = datetime.date.fromordinal(int(sqlResults[0]["airdate"])) - #logger.log(u"1 Status changes from " + str(self.status) + " to " + str(sqlResults[0]["status"]), logger.DEBUG) - self.status = int(sqlResults[0]["status"]) - - # don't overwrite my location - if sqlResults[0]["location"] != "" and sqlResults[0]["location"] != None: - self.location = os.path.normpath(sqlResults[0]["location"]) - if sqlResults[0]["file_size"]: - self.file_size = int(sqlResults[0]["file_size"]) - else: - self.file_size = 0 - - self.tvdbid = int(sqlResults[0]["tvdbid"]) - - if sqlResults[0]["audio_langs"] != None: - self.audio_langs = sqlResults[0]["audio_langs"] - - if sqlResults[0]["release_name"] != None: - self.release_name = sqlResults[0]["release_name"] - - self.dirty = False - return True - - def loadFromTVDB(self, season=None, episode=None, cache=True, tvapi=None, cachedSeason=None): - - if season == None: - season = self.season - if episode == None: - episode = self.episode - - logger.log(str(self.show.tvdbid) + ": Loading episode details from theTVDB for episode " + str(season) + "x" + str(episode), logger.DEBUG) - - tvdb_lang = self.show.lang - - try: - if cachedSeason is None: - if tvapi is None: - # There's gotta be a better way of doing this but we don't wanna - # change the cache value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if not cache: - ltvdb_api_parms['cache'] = False - - if tvdb_lang: - ltvdb_api_parms['language'] = tvdb_lang - - t = tvdb_api.Tvdb(**ltvdb_api_parms) - else: - t = tvapi - myEp = t[self.show.tvdbid][season][episode] - else: - myEp = cachedSeason[episode] - - except (tvdb_exceptions.tvdb_error, IOError), e: - logger.log(u"TVDB threw up an error: "+ex(e), logger.DEBUG) - # if the episode is already valid just log it, if not throw it up - if self.name: - logger.log(u"TVDB timed out but we have enough info from other sources, allowing the error", logger.DEBUG) - return - else: - logger.log(u"TVDB timed out, unable to create the episode", logger.ERROR) - return False - except (tvdb_exceptions.tvdb_episodenotfound, tvdb_exceptions.tvdb_seasonnotfound): - logger.log(u"Unable to find the episode on tvdb... has it been removed? Should I delete from db?", logger.DEBUG) - # if I'm no longer on TVDB but I once was then delete myself from the DB - if self.tvdbid != -1: - self.deleteEpisode() - return - - - if not myEp["firstaired"] or myEp["firstaired"] == "0000-00-00": - myEp["firstaired"] = str(datetime.date.fromordinal(1)) - - if myEp["episodename"] == None or myEp["episodename"] == "": - logger.log(u"This episode ("+self.show.name+" - "+str(season)+"x"+str(episode)+") has no name on TVDB") - # if I'm incomplete on TVDB but I once was complete then just delete myself from the DB for now - if self.tvdbid != -1: - self.deleteEpisode() - return False - - #NAMEIT logger.log(u"BBBBBBBB from " + str(self.season)+"x"+str(self.episode) + " -" +self.name+" to "+myEp["episodename"]) - self.name = myEp["episodename"] - self.season = season - self.episode = episode - tmp_description = myEp["overview"] - if tmp_description == None: - self.description = "" - else: - self.description = tmp_description - rawAirdate = [int(x) for x in myEp["firstaired"].split("-")] - try: - self.airdate = datetime.date(rawAirdate[0], rawAirdate[1], rawAirdate[2]) - except ValueError: - logger.log(u"Malformed air date retrieved from TVDB ("+self.show.name+" - "+str(season)+"x"+str(episode)+")", logger.ERROR) - # if I'm incomplete on TVDB but I once was complete then just delete myself from the DB for now - if self.tvdbid != -1: - self.deleteEpisode() - return False - - #early conversion to int so that episode doesn't get marked dirty - self.tvdbid = int(myEp["id"]) - - #don't update show status if show dir is missing, unless missing show dirs are created during post-processing - if not ek.ek(os.path.isdir, self.show._location) and not sickbeard.CREATE_MISSING_SHOW_DIRS: - logger.log(u"The show dir is missing, not bothering to change the episode statuses since it'd probably be invalid") - return - - logger.log(str(self.show.tvdbid) + ": Setting status for " + str(season) + "x" + str(episode) + " based on status " + str(self.status) + " and existence of " + self.location, logger.DEBUG) - - if not ek.ek(os.path.isfile, self.location): - - # if we don't have the file - if self.airdate >= datetime.date.today() and self.status not in Quality.SNATCHED + Quality.SNATCHED_PROPER: - # and it hasn't aired yet set the status to UNAIRED - logger.log(u"Episode airs in the future, changing status from " + str(self.status) + " to " + str(UNAIRED), logger.DEBUG) - self.status = UNAIRED - # if there's no airdate then set it to skipped (and respect ignored) - elif self.airdate == datetime.date.fromordinal(1): - if self.status == IGNORED: - logger.log(u"Episode has no air date, but it's already marked as ignored", logger.DEBUG) - else: - logger.log(u"Episode has no air date, automatically marking it skipped", logger.DEBUG) - self.status = SKIPPED - # if we don't have the file and the airdate is in the past - else: - if self.status == UNAIRED: - self.status = WANTED - - # if we somehow are still UNKNOWN then just skip it - elif self.status == UNKNOWN: - self.status = SKIPPED - - else: - logger.log(u"Not touching status because we have no ep file, the airdate is in the past, and the status is "+str(self.status), logger.DEBUG) - - # if we have a media file then it's downloaded - elif sickbeard.helpers.isMediaFile(self.location): - # leave propers alone, you have to either post-process them or manually change them back - if self.status not in Quality.SNATCHED_PROPER + Quality.DOWNLOADED + Quality.SNATCHED + [ARCHIVED]: - logger.log(u"5 Status changes from " + str(self.status) + " to " + str(Quality.statusFromName(self.location)), logger.DEBUG) - self.status = Quality.statusFromName(self.location) - - # shouldn't get here probably - else: - logger.log(u"6 Status changes from " + str(self.status) + " to " + str(UNKNOWN), logger.DEBUG) - self.status = UNKNOWN - - - # hasnfo, hastbn, status? - - - def loadFromNFO(self, location): - - if not os.path.isdir(self.show._location): - logger.log(str(self.show.tvdbid) + ": The show dir is missing, not bothering to try loading the episode NFO") - return - - logger.log(str(self.show.tvdbid) + ": Loading episode details from the NFO file associated with " + location, logger.DEBUG) - - self.location = location - - if self.location != "": - - if self.status == UNKNOWN: - if sickbeard.helpers.isMediaFile(self.location): - logger.log(u"7 Status changes from " + str(self.status) + " to " + str(Quality.statusFromName(self.location)), logger.DEBUG) - self.status = Quality.statusFromName(self.location) - - nfoFile = sickbeard.helpers.replaceExtension(self.location, "nfo") - logger.log(str(self.show.tvdbid) + ": Using NFO name " + nfoFile, logger.DEBUG) - - if ek.ek(os.path.isfile, nfoFile): - try: - showXML = etree.ElementTree(file = nfoFile) - except (SyntaxError, ValueError), e: - logger.log(u"Error loading the NFO, backing up the NFO and skipping for now: " + ex(e), logger.ERROR) #TODO: figure out what's wrong and fix it - try: - ek.ek(os.rename, nfoFile, nfoFile + ".old") - except Exception, e: - logger.log(u"Failed to rename your episode's NFO file - you need to delete it or fix it: " + ex(e), logger.ERROR) - raise exceptions.NoNFOException("Error in NFO format") - - for epDetails in showXML.getiterator('episodedetails'): - if epDetails.findtext('season') == None or int(epDetails.findtext('season')) != self.season or \ - epDetails.findtext('episode') == None or int(epDetails.findtext('episode')) != self.episode: - logger.log(str(self.show.tvdbid) + ": NFO has an <episodedetails> block for a different episode - wanted " + str(self.season) + "x" + str(self.episode) + " but got " + str(epDetails.findtext('season')) + "x" + str(epDetails.findtext('episode')), logger.DEBUG) - continue - - if epDetails.findtext('title') == None or epDetails.findtext('aired') == None: - raise exceptions.NoNFOException("Error in NFO format (missing episode title or airdate)") - - self.name = epDetails.findtext('title') - self.episode = int(epDetails.findtext('episode')) - self.season = int(epDetails.findtext('season')) - - self.description = epDetails.findtext('plot') - if self.description == None: - self.description = "" - - if epDetails.findtext('aired'): - rawAirdate = [int(x) for x in epDetails.findtext('aired').split("-")] - self.airdate = datetime.date(rawAirdate[0], rawAirdate[1], rawAirdate[2]) - else: - self.airdate = datetime.date.fromordinal(1) - - self.hasnfo = True - else: - self.hasnfo = False - - if ek.ek(os.path.isfile, sickbeard.helpers.replaceExtension(nfoFile, "tbn")): - self.hastbn = True - else: - self.hastbn = False - - def __str__ (self): - - toReturn = "" - toReturn += str(self.show.name) + " - " + str(self.season) + "x" + str(self.episode) + " - " + str(self.name) + "\n" - toReturn += "location: " + str(self.location) + "\n" - toReturn += "description: " + str(self.description) + "\n" - toReturn += "subtitles: " + str(",".join(self.subtitles)) + "\n" - toReturn += "subtitles_searchcount: " + str(self.subtitles_searchcount) + "\n" - toReturn += "subtitles_lastsearch: " + str(self.subtitles_lastsearch) + "\n" - toReturn += "airdate: " + str(self.airdate.toordinal()) + " (" + str(self.airdate) + ")\n" - toReturn += "hasnfo: " + str(self.hasnfo) + "\n" - toReturn += "hastbn: " + str(self.hastbn) + "\n" - toReturn += "status: " + str(self.status) + "\n" - toReturn += "languages: " + str(self.audio_langs) + "\n" - return toReturn - - def createMetaFiles(self, force=False): - - if not ek.ek(os.path.isdir, self.show._location): - logger.log(str(self.show.tvdbid) + ": The show dir is missing, not bothering to try to create metadata") - return - - self.createNFO(force) - self.createThumbnail(force) - - if self.checkForMetaFiles(): - self.saveToDB() - - def createNFO(self, force=False): - - result = False - - for cur_provider in sickbeard.metadata_provider_dict.values(): - result = cur_provider.create_episode_metadata(self) or result - - return result - - def createThumbnail(self, force=False): - - result = False - - for cur_provider in sickbeard.metadata_provider_dict.values(): - result = cur_provider.create_episode_thumb(self) or result - - return result - - def deleteEpisode(self): - - logger.log(u"Deleting "+self.show.name+" "+str(self.season)+"x"+str(self.episode)+" from the DB", logger.DEBUG) - - # remove myself from the show dictionary - if self.show.getEpisode(self.season, self.episode, noCreate=True) == self: - logger.log(u"Removing myself from my show's list", logger.DEBUG) - del self.show.episodes[self.season][self.episode] - - # delete myself from the DB - logger.log(u"Deleting myself from the database", logger.DEBUG) - myDB = db.DBConnection() - sql = "DELETE FROM tv_episodes WHERE showid="+str(self.show.tvdbid)+" AND season="+str(self.season)+" AND episode="+str(self.episode) - myDB.action(sql) - - raise exceptions.EpisodeDeletedException() - - def saveToDB(self, forceSave=False): - """ - Saves this episode to the database if any of its data has been changed since the last save. - - forceSave: If True it will save to the database even if no data has been changed since the - last save (aka if the record is not dirty). - """ - - if not self.dirty and not forceSave: - logger.log(str(self.show.tvdbid) + ": Not saving episode to db - record is not dirty", logger.DEBUG) - return - - logger.log(str(self.show.tvdbid) + ": Saving episode details to database", logger.DEBUG) - - logger.log(u"STATUS IS " + str(self.status), logger.DEBUG) - - myDB = db.DBConnection() - - newValueDict = {"tvdbid": self.tvdbid, - "name": self.name, - "description": self.description, - "subtitles": ",".join([sub for sub in self.subtitles]), - "subtitles_searchcount": self.subtitles_searchcount, - "subtitles_lastsearch": self.subtitles_lastsearch, - "airdate": self.airdate.toordinal(), - "hasnfo": self.hasnfo, - "hastbn": self.hastbn, - "status": self.status, - "location": self.location, - "audio_langs": self.audio_langs, - "file_size": self.file_size, - "release_name": self.release_name} - controlValueDict = {"showid": self.show.tvdbid, - "season": self.season, - "episode": self.episode} - - # use a custom update/insert method to get the data into the DB - myDB.upsert("tv_episodes", newValueDict, controlValueDict) - - def fullPath (self): - if self.location == None or self.location == "": - return None - else: - return ek.ek(os.path.join, self.show.location, self.location) - - def prettyName(self): - """ - Returns the name of this episode in a "pretty" human-readable format. Used for logging - and notifications and such. - - Returns: A string representing the episode's name and season/ep numbers - """ - - return self._format_pattern('%SN - %Sx%0E - %EN') - - def _ep_name(self): - """ - Returns the name of the episode to use during renaming. Combines the names of related episodes. - Eg. "Ep Name (1)" and "Ep Name (2)" becomes "Ep Name" - "Ep Name" and "Other Ep Name" becomes "Ep Name & Other Ep Name" - """ - - multiNameRegex = "(.*) \(\d\)" - - self.relatedEps = sorted(self.relatedEps, key=lambda x: x.episode) - - if len(self.relatedEps) == 0: - goodName = self.name - - else: - goodName = '' - - singleName = True - curGoodName = None - - for curName in [self.name] + [x.name for x in self.relatedEps]: - match = re.match(multiNameRegex, curName) - if not match: - singleName = False - break - - if curGoodName == None: - curGoodName = match.group(1) - elif curGoodName != match.group(1): - singleName = False - break - - if singleName: - goodName = curGoodName - else: - goodName = self.name - for relEp in self.relatedEps: - goodName += " & " + relEp.name - - return goodName - - def _replace_map(self): - """ - Generates a replacement map for this episode which maps all possible custom naming patterns to the correct - value for this episode. - - Returns: A dict with patterns as the keys and their replacement values as the values. - """ - - ep_name = self._ep_name() - - def dot(name): - return helpers.sanitizeSceneName(name) - - def us(name): - return re.sub('[ -]','_', name) - - def release_name(name): - if name and name.lower().endswith('.nzb'): - name = name.rpartition('.')[0] - return name - - def release_group(name): - if not name: - return '' - - np = NameParser(name) - - try: - parse_result = np.parse(name) - except InvalidNameException, e: - logger.log(u"Unable to get parse release_group: "+ex(e), logger.DEBUG) - return '' - - if not parse_result.release_group: - return '' - return parse_result.release_group - - epStatus, epQual = Quality.splitCompositeStatus(self.status) #@UnusedVariable - - return { - '%SN': self.show.name, - '%S.N': dot(self.show.name), - '%S_N': us(self.show.name), - '%EN': ep_name, - '%E.N': dot(ep_name), - '%E_N': us(ep_name), - '%QN': Quality.qualityStrings[epQual], - '%Q.N': dot(Quality.qualityStrings[epQual]), - '%Q_N': us(Quality.qualityStrings[epQual]), - '%S': str(self.season), - '%0S': '%02d' % self.season, - '%E': str(self.episode), - '%0E': '%02d' % self.episode, - '%RN': release_name(self.release_name), - '%RG': release_group(self.release_name), - '%AD': str(self.airdate).replace('-', ' '), - '%A.D': str(self.airdate).replace('-', '.'), - '%A_D': us(str(self.airdate)), - '%A-D': str(self.airdate), - '%Y': str(self.airdate.year), - '%M': str(self.airdate.month), - '%D': str(self.airdate.day), - '%0M': '%02d' % self.airdate.month, - '%0D': '%02d' % self.airdate.day, - } - - def _format_string(self, pattern, replace_map): - """ - Replaces all template strings with the correct value - """ - - result_name = pattern - - # do the replacements - for cur_replacement in sorted(replace_map.keys(), reverse=True): - result_name = result_name.replace(cur_replacement, helpers.sanitizeFileName(replace_map[cur_replacement])) - result_name = result_name.replace(cur_replacement.lower(), helpers.sanitizeFileName(replace_map[cur_replacement].lower())) - - return result_name - - def _format_pattern(self, pattern=None, multi=None): - """ - Manipulates an episode naming pattern and then fills the template in - """ - - if pattern == None: - pattern = sickbeard.NAMING_PATTERN - - if multi == None: - multi = sickbeard.NAMING_MULTI_EP - - replace_map = self._replace_map() - - result_name = pattern - - # if there's no release group then replace it with a reasonable facsimile - if not replace_map['%RN']: - if self.show.air_by_date: - result_name = result_name.replace('%RN', '%S.N.%A.D.%E.N-SiCKBEARD') - result_name = result_name.replace('%rn', '%s.n.%A.D.%e.n-sickbeard') - else: - result_name = result_name.replace('%RN', '%S.N.S%0SE%0E.%E.N-SiCKBEARD') - result_name = result_name.replace('%rn', '%s.n.s%0se%0e.%e.n-sickbeard') - - result_name = result_name.replace('%RG', 'SiCKBEARD') - result_name = result_name.replace('%rg', 'sickbeard') - logger.log(u"Episode has no release name, replacing it with a generic one: "+result_name, logger.DEBUG) - - # split off ep name part only - name_groups = re.split(r'[\\/]', result_name) - - # figure out the double-ep numbering style for each group, if applicable - for cur_name_group in name_groups: - - season_format = sep = ep_sep = ep_format = None - - season_ep_regex = ''' - (?P<pre_sep>[ _.-]*) - ((?:s(?:eason|eries)?\s*)?%0?S(?![._]?N)) - (.*?) - (%0?E(?![._]?N)) - (?P<post_sep>[ _.-]*) - ''' - ep_only_regex = '(E?%0?E(?![._]?N))' - - # try the normal way - season_ep_match = re.search(season_ep_regex, cur_name_group, re.I|re.X) - ep_only_match = re.search(ep_only_regex, cur_name_group, re.I|re.X) - - # if we have a season and episode then collect the necessary data - if season_ep_match: - season_format = season_ep_match.group(2) - ep_sep = season_ep_match.group(3) - ep_format = season_ep_match.group(4) - sep = season_ep_match.group('pre_sep') - if not sep: - sep = season_ep_match.group('post_sep') - if not sep: - sep = ' ' - - # force 2-3-4 format if they chose to extend - if multi in (NAMING_EXTEND, NAMING_LIMITED_EXTEND, NAMING_LIMITED_EXTEND_E_PREFIXED): - ep_sep = '-' - - regex_used = season_ep_regex - - # if there's no season then there's not much choice so we'll just force them to use 03-04-05 style - elif ep_only_match: - season_format = '' - ep_sep = '-' - ep_format = ep_only_match.group(1) - sep = '' - regex_used = ep_only_regex - - else: - continue - - # we need at least this much info to continue - if not ep_sep or not ep_format: - continue - - # start with the ep string, eg. E03 - ep_string = self._format_string(ep_format.upper(), replace_map) - for other_ep in self.relatedEps: - - # for limited extend we only append the last ep - if multi in (NAMING_LIMITED_EXTEND, NAMING_LIMITED_EXTEND_E_PREFIXED) and other_ep != self.relatedEps[-1]: - continue - - elif multi == NAMING_DUPLICATE: - # add " - S01" - ep_string += sep + season_format - - elif multi == NAMING_SEPARATED_REPEAT: - ep_string += sep - - # add "E04" - ep_string += ep_sep - - if multi == NAMING_LIMITED_EXTEND_E_PREFIXED: - ep_string += 'E' - - ep_string += other_ep._format_string(ep_format.upper(), other_ep._replace_map()) - - if season_ep_match: - regex_replacement = r'\g<pre_sep>\g<2>\g<3>' + ep_string + r'\g<post_sep>' - elif ep_only_match: - regex_replacement = ep_string - - # fill out the template for this piece and then insert this piece into the actual pattern - cur_name_group_result = re.sub('(?i)(?x)'+regex_used, regex_replacement, cur_name_group) - #cur_name_group_result = cur_name_group.replace(ep_format, ep_string) - #logger.log(u"found "+ep_format+" as the ep pattern using "+regex_used+" and replaced it with "+regex_replacement+" to result in "+cur_name_group_result+" from "+cur_name_group, logger.DEBUG) - result_name = result_name.replace(cur_name_group, cur_name_group_result) - - result_name = self._format_string(result_name, replace_map) - - logger.log(u"formatting pattern: "+pattern+" -> "+result_name, logger.DEBUG) - - - return result_name - - def proper_path(self): - """ - Figures out the path where this episode SHOULD live according to the renaming rules, relative from the show dir - """ - - result = self.formatted_filename() - - # if they want us to flatten it and we're allowed to flatten it then we will - if self.show.flatten_folders and not sickbeard.NAMING_FORCE_FOLDERS: - return result - - # if not we append the folder on and use that - else: - result = ek.ek(os.path.join, self.formatted_dir(), result) - - return result - - - def formatted_dir(self, pattern=None, multi=None): - """ - Just the folder name of the episode - """ - - if pattern == None: - # we only use ABD if it's enabled, this is an ABD show, AND this is not a multi-ep - if self.show.air_by_date and sickbeard.NAMING_CUSTOM_ABD and not self.relatedEps: - pattern = sickbeard.NAMING_ABD_PATTERN - else: - pattern = sickbeard.NAMING_PATTERN - - # split off the dirs only, if they exist - name_groups = re.split(r'[\\/]', pattern) - - if len(name_groups) == 1: - return '' - else: - return self._format_pattern(os.sep.join(name_groups[:-1]), multi) - - - def formatted_filename(self, pattern=None, multi=None): - """ - Just the filename of the episode, formatted based on the naming settings - """ - - if pattern == None: - # we only use ABD if it's enabled, this is an ABD show, AND this is not a multi-ep - if self.show.air_by_date and sickbeard.NAMING_CUSTOM_ABD and not self.relatedEps: - pattern = sickbeard.NAMING_ABD_PATTERN - else: - pattern = sickbeard.NAMING_PATTERN - - # split off the filename only, if they exist - name_groups = re.split(r'[\\/]', pattern) - - return self._format_pattern(name_groups[-1], multi) - - def rename(self): - """ - Renames an episode file and all related files to the location and filename as specified - in the naming settings. - """ - - if not ek.ek(os.path.isfile, self.location): - logger.log(u"Can't perform rename on " + self.location + " when it doesn't exist, skipping", logger.WARNING) - return - - proper_path = self.proper_path() - absolute_proper_path = ek.ek(os.path.join, self.show.location, proper_path) - absolute_current_path_no_ext, file_ext = os.path.splitext(self.location) - - related_subs = [] - - current_path = absolute_current_path_no_ext - - if absolute_current_path_no_ext.startswith(self.show.location): - current_path = absolute_current_path_no_ext[len(self.show.location):] - - logger.log(u"Renaming/moving episode from the base path " + self.location + " to " + absolute_proper_path, logger.DEBUG) - - # if it's already named correctly then don't do anything - if proper_path == current_path: - logger.log(str(self.tvdbid) + ": File " + self.location + " is already named correctly, skipping", logger.DEBUG) - return - - related_files = postProcessor.PostProcessor(self.location)._list_associated_files(self.location) - - if self.show.subtitles and sickbeard.SUBTITLES_DIR != '': - related_subs = postProcessor.PostProcessor(self.location)._list_associated_files(sickbeard.SUBTITLES_DIR, subtitles_only=True) - absolute_proper_subs_path = ek.ek(os.path.join, sickbeard.SUBTITLES_DIR, self.formatted_filename()) - - if self.show.subtitles and sickbeard.SUBTITLES_DIR_SUB: - related_subs = postProcessor.PostProcessor(self.location)._list_associated_files(os.path.dirname(self.location)+"\\Subs", subtitles_only=True) - absolute_proper_subs_path = ek.ek(os.path.join, os.path.dirname(self.location)+"\\Subs", self.formatted_filename()) - - logger.log(u"Files associated to " + self.location + ": " + str(related_files), logger.DEBUG) - - # move the ep file - result = helpers.rename_ep_file(self.location, absolute_proper_path) - - # move related files - for cur_related_file in related_files: - cur_result = helpers.rename_ep_file(cur_related_file, absolute_proper_path) - if cur_result == False: - logger.log(str(self.tvdbid) + ": Unable to rename file " + cur_related_file, logger.ERROR) - - for cur_related_sub in related_subs: - cur_result = helpers.rename_ep_file(cur_related_sub, absolute_proper_subs_path) - if cur_result == False: - logger.log(str(self.tvdbid) + ": Unable to rename file " + cur_related_sub, logger.ERROR) - - # save the ep - with self.lock: - if result != False: - self.location = absolute_proper_path + file_ext - for relEp in self.relatedEps: - relEp.location = absolute_proper_path + file_ext - - # in case something changed with the metadata just do a quick check - for curEp in [self] + self.relatedEps: - curEp.checkForMetaFiles() - - # save any changes to the database - with self.lock: - self.saveToDB() - for relEp in self.relatedEps: - relEp.saveToDB() +# Author: Nic Wolfe <nic@wolfeden.ca> +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import with_statement + +import os.path +import datetime +import threading +import re +import glob + +import sickbeard + +import xml.etree.cElementTree as etree + +from name_parser.parser import NameParser, InvalidNameException + +from lib import subliminal + +from lib.tvdb_api import tvdb_api, tvdb_exceptions + +from sickbeard import db +from sickbeard import helpers, exceptions, logger +from sickbeard.exceptions import ex +from sickbeard import tvrage +from sickbeard import image_cache +from sickbeard import notifiers +from sickbeard import postProcessor +from sickbeard import subtitles +from sickbeard import history + +from sickbeard import encodingKludge as ek + +from common import Quality, Overview +from common import DOWNLOADED, SNATCHED, SNATCHED_PROPER, ARCHIVED, IGNORED, UNAIRED, WANTED, SKIPPED, UNKNOWN +from common import NAMING_DUPLICATE, NAMING_EXTEND, NAMING_LIMITED_EXTEND, NAMING_SEPARATED_REPEAT, NAMING_LIMITED_EXTEND_E_PREFIXED + +class TVShow(object): + + def __init__ (self, tvdbid, lang="", audio_lang=""): + + self.tvdbid = tvdbid + + self._location = "" + self.name = "" + self.tvrid = 0 + self.tvrname = "" + self.network = "" + self.genre = "" + self.runtime = 0 + self.quality = int(sickbeard.QUALITY_DEFAULT) + self.flatten_folders = int(sickbeard.FLATTEN_FOLDERS_DEFAULT) + + self.status = "" + self.airs = "" + self.startyear = 0 + self.paused = 0 + self.air_by_date = 0 + self.subtitles = int(sickbeard.SUBTITLES_DEFAULT) + self.lang = lang + self.audio_lang = audio_lang + self.custom_search_names = "" + + self.lock = threading.Lock() + self._isDirGood = False + + self.episodes = {} + + otherShow = helpers.findCertainShow(sickbeard.showList, self.tvdbid) + if otherShow != None: + raise exceptions.MultipleShowObjectsException("Can't create a show if it already exists") + + self.loadFromDB() + + self.saveToDB() + + def _getLocation(self): + # no dir check needed if missing show dirs are created during post-processing + if sickbeard.CREATE_MISSING_SHOW_DIRS: + return self._location + + if ek.ek(os.path.isdir, self._location): + return self._location + else: + raise exceptions.ShowDirNotFoundException("Show folder doesn't exist, you shouldn't be using it") + + if self._isDirGood: + return self._location + else: + raise exceptions.NoNFOException("Show folder doesn't exist, you shouldn't be using it") + + def _setLocation(self, newLocation): + logger.log(u"Setter sets location to " + newLocation, logger.DEBUG) + # Don't validate dir if user wants to add shows without creating a dir + if sickbeard.ADD_SHOWS_WO_DIR or ek.ek(os.path.isdir, newLocation): + self._location = newLocation + self._isDirGood = True + else: + raise exceptions.NoNFOException("Invalid folder for the show!") + + location = property(_getLocation, _setLocation) + + # delete references to anything that's not in the internal lists + def flushEpisodes(self): + + for curSeason in self.episodes: + for curEp in self.episodes[curSeason]: + myEp = self.episodes[curSeason][curEp] + self.episodes[curSeason][curEp] = None + del myEp + + def getAllEpisodes(self, season=None, has_location=False): + + myDB = db.DBConnection() + + sql_selection = "SELECT season, episode, " + + # subselection to detect multi-episodes early, share_location > 0 + sql_selection = sql_selection + " (SELECT COUNT (*) FROM tv_episodes WHERE showid = tve.showid AND season = tve.season AND location != '' AND location = tve.location AND episode != tve.episode) AS share_location " + + sql_selection = sql_selection + " FROM tv_episodes tve WHERE showid = " + str(self.tvdbid) + + if season is not None: + sql_selection = sql_selection + " AND season = " + str(season) + if has_location: + sql_selection = sql_selection + " AND location != '' " + + # need ORDER episode ASC to rename multi-episodes in order S01E01-02 + sql_selection = sql_selection + " ORDER BY season ASC, episode ASC" + + results = myDB.select(sql_selection) + + ep_list = [] + for cur_result in results: + cur_ep = self.getEpisode(int(cur_result["season"]), int(cur_result["episode"])) + if cur_ep: + if cur_ep.location: + # if there is a location, check if it's a multi-episode (share_location > 0) and put them in relatedEps + if cur_result["share_location"] > 0: + related_eps_result = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND season = ? AND location = ? AND episode != ? ORDER BY episode ASC", [self.tvdbid, cur_ep.season, cur_ep.location, cur_ep.episode]) + for cur_related_ep in related_eps_result: + related_ep = self.getEpisode(int(cur_related_ep["season"]), int(cur_related_ep["episode"])) + if related_ep not in cur_ep.relatedEps: + cur_ep.relatedEps.append(related_ep) + ep_list.append(cur_ep) + + return ep_list + + + def getEpisode(self, season, episode, file=None, noCreate=False): + + #return TVEpisode(self, season, episode) + + if not season in self.episodes: + self.episodes[season] = {} + + ep = None + + if not episode in self.episodes[season] or self.episodes[season][episode] == None: + if noCreate: + return None + + logger.log(str(self.tvdbid) + ": An object for episode " + str(season) + "x" + str(episode) + " didn't exist in the cache, trying to create it", logger.DEBUG) + + if file != None: + ep = TVEpisode(self, season, episode, file) + else: + ep = TVEpisode(self, season, episode) + + if ep != None: + self.episodes[season][episode] = ep + + return self.episodes[season][episode] + + def writeShowNFO(self): + + result = False + + if not ek.ek(os.path.isdir, self._location): + logger.log(str(self.tvdbid) + u": Show dir doesn't exist, skipping NFO generation") + return False + + for cur_provider in sickbeard.metadata_provider_dict.values(): + result = cur_provider.create_show_metadata(self) or result + + return result + + def writeMetadata(self, show_only=False): + + if not ek.ek(os.path.isdir, self._location): + logger.log(str(self.tvdbid) + u": Show dir doesn't exist, skipping NFO generation") + return + + self.getImages() + + self.writeShowNFO() + + if not show_only: + self.writeEpisodeNFOs() + + def writeEpisodeNFOs (self): + + if not ek.ek(os.path.isdir, self._location): + logger.log(str(self.tvdbid) + ": Show dir doesn't exist, skipping NFO generation") + return + + logger.log(str(self.tvdbid) + ": Writing NFOs for all episodes") + + myDB = db.DBConnection() + sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND location != ''", [self.tvdbid]) + + for epResult in sqlResults: + logger.log(str(self.tvdbid) + ": Retrieving/creating episode " + str(epResult["season"]) + "x" + str(epResult["episode"]), logger.DEBUG) + curEp = self.getEpisode(epResult["season"], epResult["episode"]) + curEp.createMetaFiles() + + + # find all media files in the show folder and create episodes for as many as possible + def loadEpisodesFromDir (self): + + if not ek.ek(os.path.isdir, self._location): + logger.log(str(self.tvdbid) + ": Show dir doesn't exist, not loading episodes from disk") + return + + logger.log(str(self.tvdbid) + ": Loading all episodes from the show directory " + self._location) + + # get file list + mediaFiles = helpers.listMediaFiles(self._location) + + # create TVEpisodes from each media file (if possible) + for mediaFile in mediaFiles: + + curEpisode = None + + logger.log(str(self.tvdbid) + ": Creating episode from " + mediaFile, logger.DEBUG) + try: + curEpisode = self.makeEpFromFile(ek.ek(os.path.join, self._location, mediaFile)) + except (exceptions.ShowNotFoundException, exceptions.EpisodeNotFoundException), e: + logger.log(u"Episode "+mediaFile+" returned an exception: "+ex(e), logger.ERROR) + continue + except exceptions.EpisodeDeletedException: + logger.log(u"The episode deleted itself when I tried making an object for it", logger.DEBUG) + + if curEpisode is None: + continue + + # see if we should save the release name in the db + ep_file_name = ek.ek(os.path.basename, curEpisode.location) + ep_file_name = ek.ek(os.path.splitext, ep_file_name)[0] + + parse_result = None + try: + np = NameParser(False) + parse_result = np.parse(ep_file_name) + except InvalidNameException: + pass + + if not ' ' in ep_file_name and parse_result and parse_result.release_group: + logger.log(u"Name " + ep_file_name + " gave release group of " + parse_result.release_group + ", seems valid", logger.DEBUG) + curEpisode.release_name = ep_file_name + + # store the reference in the show + if curEpisode != None: + if self.subtitles: + try: + curEpisode.refreshSubtitles() + except: + logger.log(str(self.tvdbid) + ": Could not refresh subtitles", logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + curEpisode.saveToDB() + + + def loadEpisodesFromDB(self): + + logger.log(u"Loading all episodes from the DB") + + myDB = db.DBConnection() + sql = "SELECT * FROM tv_episodes WHERE showid = ?" + sqlResults = myDB.select(sql, [self.tvdbid]) + + scannedEps = {} + + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if self.lang: + ltvdb_api_parms['language'] = self.lang + + t = tvdb_api.Tvdb(**ltvdb_api_parms) + + cachedShow = t[self.tvdbid] + cachedSeasons = {} + + for curResult in sqlResults: + + deleteEp = False + + curSeason = int(curResult["season"]) + curEpisode = int(curResult["episode"]) + if curSeason not in cachedSeasons: + try: + cachedSeasons[curSeason] = cachedShow[curSeason] + except tvdb_exceptions.tvdb_seasonnotfound, e: + logger.log(u"Error when trying to load the episode from TVDB: "+e.message, logger.WARNING) + deleteEp = True + + if not curSeason in scannedEps: + scannedEps[curSeason] = {} + + logger.log(u"Loading episode "+str(curSeason)+"x"+str(curEpisode)+" from the DB", logger.DEBUG) + + try: + curEp = self.getEpisode(curSeason, curEpisode) + + # if we found out that the ep is no longer on TVDB then delete it from our database too + if deleteEp: + curEp.deleteEpisode() + + curEp.loadFromDB(curSeason, curEpisode) + curEp.loadFromTVDB(tvapi=t, cachedSeason=cachedSeasons[curSeason]) + scannedEps[curSeason][curEpisode] = True + except exceptions.EpisodeDeletedException: + logger.log(u"Tried loading an episode from the DB that should have been deleted, skipping it", logger.DEBUG) + continue + + return scannedEps + + + def loadEpisodesFromTVDB(self, cache=True): + + # There's gotta be a better way of doing this but we don't wanna + # change the cache value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if not cache: + ltvdb_api_parms['cache'] = False + + if self.lang: + ltvdb_api_parms['language'] = self.lang + + try: + t = tvdb_api.Tvdb(**ltvdb_api_parms) + showObj = t[self.tvdbid] + except tvdb_exceptions.tvdb_error: + logger.log(u"TVDB timed out, unable to update episodes from TVDB", logger.ERROR) + return None + + logger.log(str(self.tvdbid) + ": Loading all episodes from theTVDB...") + + scannedEps = {} + + for season in showObj: + scannedEps[season] = {} + for episode in showObj[season]: + # need some examples of wtf episode 0 means to decide if we want it or not + if episode == 0: + continue + try: + #ep = TVEpisode(self, season, episode) + ep = self.getEpisode(season, episode) + except exceptions.EpisodeNotFoundException: + logger.log(str(self.tvdbid) + ": TVDB object for " + str(season) + "x" + str(episode) + " is incomplete, skipping this episode") + continue + else: + try: + ep.loadFromTVDB(tvapi=t) + except exceptions.EpisodeDeletedException: + logger.log(u"The episode was deleted, skipping the rest of the load") + continue + + with ep.lock: + logger.log(str(self.tvdbid) + ": Loading info from theTVDB for episode " + str(season) + "x" + str(episode), logger.DEBUG) + ep.loadFromTVDB(season, episode, tvapi=t) + if ep.dirty: + ep.saveToDB() + + scannedEps[season][episode] = True + + return scannedEps + + def setTVRID(self, force=False): + + if self.tvrid != 0 and not force: + logger.log(u"No need to get the TVRage ID, it's already populated", logger.DEBUG) + return + + logger.log(u"Attempting to retrieve the TVRage ID", logger.DEBUG) + + try: + # load the tvrage object, it will set the ID in its constructor if possible + tvrage.TVRage(self) + self.saveToDB() + except exceptions.TVRageException, e: + logger.log(u"Couldn't get TVRage ID because we're unable to sync TVDB and TVRage: "+ex(e), logger.DEBUG) + return + + def getImages(self, fanart=None, poster=None): + + poster_result = fanart_result = season_thumb_result = False + + for cur_provider in sickbeard.metadata_provider_dict.values(): + logger.log("Running season folders for "+cur_provider.name, logger.DEBUG) + poster_result = cur_provider.create_poster(self) or poster_result + fanart_result = cur_provider.create_fanart(self) or fanart_result + season_thumb_result = cur_provider.create_season_thumbs(self) or season_thumb_result + + return poster_result or fanart_result or season_thumb_result + + def loadLatestFromTVRage(self): + + try: + # load the tvrage object + tvr = tvrage.TVRage(self) + + newEp = tvr.findLatestEp() + + if newEp != None: + logger.log(u"TVRage gave us an episode object - saving it for now", logger.DEBUG) + newEp.saveToDB() + + # make an episode out of it + except exceptions.TVRageException, e: + logger.log(u"Unable to add TVRage info: " + ex(e), logger.WARNING) + + + + # make a TVEpisode object from a media file + def makeEpFromFile(self, file): + + if not ek.ek(os.path.isfile, file): + logger.log(str(self.tvdbid) + ": That isn't even a real file dude... " + file) + return None + + logger.log(str(self.tvdbid) + ": Creating episode object from " + file, logger.DEBUG) + + try: + myParser = NameParser() + parse_result = myParser.parse(file) + except InvalidNameException: + logger.log(u"Unable to parse the filename "+file+" into a valid episode", logger.ERROR) + return None + + if len(parse_result.episode_numbers) == 0 and not parse_result.air_by_date: + logger.log("parse_result: "+str(parse_result)) + logger.log(u"No episode number found in "+file+", ignoring it", logger.ERROR) + return None + + # for now lets assume that any episode in the show dir belongs to that show + season = parse_result.season_number if parse_result.season_number != None else 1 + episodes = parse_result.episode_numbers + rootEp = None + + # if we have an air-by-date show then get the real season/episode numbers + if parse_result.air_by_date: + try: + # There's gotta be a better way of doing this but we don't wanna + # change the cache value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if self.lang: + ltvdb_api_parms['language'] = self.lang + + t = tvdb_api.Tvdb(**ltvdb_api_parms) + + epObj = t[self.tvdbid].airedOn(parse_result.air_date)[0] + season = int(epObj["seasonnumber"]) + episodes = [int(epObj["episodenumber"])] + except tvdb_exceptions.tvdb_episodenotfound: + logger.log(u"Unable to find episode with date " + str(parse_result.air_date) + " for show " + self.name + ", skipping", logger.WARNING) + return None + except tvdb_exceptions.tvdb_error, e: + logger.log(u"Unable to contact TVDB: "+ex(e), logger.WARNING) + return None + + for curEpNum in episodes: + + episode = int(curEpNum) + + logger.log(str(self.tvdbid) + ": " + file + " parsed to " + self.name + " " + str(season) + "x" + str(episode), logger.DEBUG) + + checkQualityAgain = False + same_file = False + curEp = self.getEpisode(season, episode) + + if curEp == None: + try: + curEp = self.getEpisode(season, episode, file) + except exceptions.EpisodeNotFoundException: + logger.log(str(self.tvdbid) + ": Unable to figure out what this file is, skipping", logger.ERROR) + continue + + else: + # if there is a new file associated with this ep then re-check the quality + if curEp.location and ek.ek(os.path.normpath, curEp.location) != ek.ek(os.path.normpath, file): + logger.log(u"The old episode had a different file associated with it, I will re-check the quality based on the new filename "+file, logger.DEBUG) + checkQualityAgain = True + + with curEp.lock: + old_size = curEp.file_size + curEp.location = file + # if the sizes are the same then it's probably the same file + if old_size and curEp.file_size == old_size: + same_file = True + else: + same_file = False + + curEp.checkForMetaFiles() + + + if rootEp == None: + rootEp = curEp + else: + if curEp not in rootEp.relatedEps: + rootEp.relatedEps.append(curEp) + + # if it's a new file then + if not same_file: + curEp.release_name = '' + + # if they replace a file on me I'll make some attempt at re-checking the quality unless I know it's the same file + if checkQualityAgain and not same_file: + newQuality = Quality.nameQuality(file) + logger.log(u"Since this file has been renamed, I checked "+file+" and found quality "+Quality.qualityStrings[newQuality], logger.DEBUG) + if newQuality != Quality.UNKNOWN: + curEp.status = Quality.compositeStatus(DOWNLOADED, newQuality) + + + # check for status/quality changes as long as it's a new file + elif not same_file and sickbeard.helpers.isMediaFile(file) and curEp.status not in Quality.DOWNLOADED + [ARCHIVED, IGNORED]: + + oldStatus, oldQuality = Quality.splitCompositeStatus(curEp.status) + newQuality = Quality.nameQuality(file) + if newQuality == Quality.UNKNOWN: + newQuality = Quality.assumeQuality(file) + + newStatus = None + + # if it was snatched and now exists then set the status correctly + if oldStatus == SNATCHED and oldQuality <= newQuality: + logger.log(u"STATUS: this ep used to be snatched with quality "+Quality.qualityStrings[oldQuality]+" but a file exists with quality "+Quality.qualityStrings[newQuality]+" so I'm setting the status to DOWNLOADED", logger.DEBUG) + newStatus = DOWNLOADED + + # if it was snatched proper and we found a higher quality one then allow the status change + elif oldStatus == SNATCHED_PROPER and oldQuality < newQuality: + logger.log(u"STATUS: this ep used to be snatched proper with quality "+Quality.qualityStrings[oldQuality]+" but a file exists with quality "+Quality.qualityStrings[newQuality]+" so I'm setting the status to DOWNLOADED", logger.DEBUG) + newStatus = DOWNLOADED + + elif oldStatus not in (SNATCHED, SNATCHED_PROPER): + newStatus = DOWNLOADED + + if newStatus != None: + with curEp.lock: + logger.log(u"STATUS: we have an associated file, so setting the status from "+str(curEp.status)+" to DOWNLOADED/" + str(Quality.statusFromName(file)), logger.DEBUG) + curEp.status = Quality.compositeStatus(newStatus, newQuality) + + with curEp.lock: + curEp.saveToDB() + + # creating metafiles on the root should be good enough + if rootEp != None: + with rootEp.lock: + rootEp.createMetaFiles() + + return rootEp + + + def loadFromDB(self, skipNFO=False): + + logger.log(str(self.tvdbid) + ": Loading show info from database") + + myDB = db.DBConnection() + + sqlResults = myDB.select("SELECT * FROM tv_shows WHERE tvdb_id = ?", [self.tvdbid]) + + if len(sqlResults) > 1: + raise exceptions.MultipleDBShowsException() + elif len(sqlResults) == 0: + logger.log(str(self.tvdbid) + ": Unable to find the show in the database") + return + else: + if self.name == "": + self.name = sqlResults[0]["show_name"] + self.tvrname = sqlResults[0]["tvr_name"] + if self.network == "": + self.network = sqlResults[0]["network"] + if self.genre == "": + self.genre = sqlResults[0]["genre"] + + self.runtime = sqlResults[0]["runtime"] + + self.status = sqlResults[0]["status"] + if self.status == None: + self.status = "" + self.airs = sqlResults[0]["airs"] + if self.airs == None: + self.airs = "" + self.startyear = sqlResults[0]["startyear"] + if self.startyear == None: + self.startyear = 0 + + self.air_by_date = sqlResults[0]["air_by_date"] + if self.air_by_date == None: + self.air_by_date = 0 + + self.subtitles = sqlResults[0]["subtitles"] + if self.subtitles: + self.subtitles = 1 + else: + self.subtitles = 0 + + self.quality = int(sqlResults[0]["quality"]) + self.flatten_folders = int(sqlResults[0]["flatten_folders"]) + self.paused = int(sqlResults[0]["paused"]) + + self._location = sqlResults[0]["location"] + + if self.tvrid == 0: + self.tvrid = int(sqlResults[0]["tvr_id"]) + + if self.lang == "": + self.lang = sqlResults[0]["lang"] + + if self.audio_lang == "": + self.audio_lang = sqlResults[0]["audio_lang"] + + if self.custom_search_names == "": + self.custom_search_names = sqlResults[0]["custom_search_names"] + + def loadFromTVDB(self, cache=True, tvapi=None, cachedSeason=None): + + logger.log(str(self.tvdbid) + ": Loading show info from theTVDB") + + # There's gotta be a better way of doing this but we don't wanna + # change the cache value elsewhere + if tvapi is None: + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if not cache: + ltvdb_api_parms['cache'] = False + + if self.lang: + ltvdb_api_parms['language'] = self.lang + + t = tvdb_api.Tvdb(**ltvdb_api_parms) + + else: + t = tvapi + + myEp = t[self.tvdbid] + + self.name = myEp["seriesname"] + + self.genre = myEp['genre'] + self.network = myEp['network'] + + if myEp["airs_dayofweek"] != None and myEp["airs_time"] != None: + self.airs = myEp["airs_dayofweek"] + " " + myEp["airs_time"] + + if myEp["firstaired"] != None and myEp["firstaired"]: + self.startyear = int(myEp["firstaired"].split('-')[0]) + + if self.airs == None: + self.airs = "" + + if myEp["status"] != None: + self.status = myEp["status"] + + if self.status == None: + self.status = "" + + self.saveToDB() + + + def loadNFO (self): + + if not os.path.isdir(self._location): + logger.log(str(self.tvdbid) + ": Show dir doesn't exist, can't load NFO") + raise exceptions.NoNFOException("The show dir doesn't exist, no NFO could be loaded") + + logger.log(str(self.tvdbid) + ": Loading show info from NFO") + + xmlFile = os.path.join(self._location, "tvshow.nfo") + + try: + xmlFileObj = open(xmlFile, 'r') + showXML = etree.ElementTree(file = xmlFileObj) + + if showXML.findtext('title') == None or (showXML.findtext('tvdbid') == None and showXML.findtext('id') == None): + raise exceptions.NoNFOException("Invalid info in tvshow.nfo (missing name or id):" \ + + str(showXML.findtext('title')) + " " \ + + str(showXML.findtext('tvdbid')) + " " \ + + str(showXML.findtext('id'))) + + self.name = showXML.findtext('title') + if showXML.findtext('tvdbid') != None: + self.tvdbid = int(showXML.findtext('tvdbid')) + elif showXML.findtext('id'): + self.tvdbid = int(showXML.findtext('id')) + else: + raise exceptions.NoNFOException("Empty <id> or <tvdbid> field in NFO") + + except (exceptions.NoNFOException, SyntaxError, ValueError), e: + logger.log(u"There was an error parsing your existing tvshow.nfo file: " + ex(e), logger.ERROR) + logger.log(u"Attempting to rename it to tvshow.nfo.old", logger.DEBUG) + + try: + xmlFileObj.close() + ek.ek(os.rename, xmlFile, xmlFile + ".old") + except Exception, e: + logger.log(u"Failed to rename your tvshow.nfo file - you need to delete it or fix it: " + ex(e), logger.ERROR) + raise exceptions.NoNFOException("Invalid info in tvshow.nfo") + + if showXML.findtext('studio') != None: + self.network = showXML.findtext('studio') + if self.network == None and showXML.findtext('network') != None: + self.network = "" + if showXML.findtext('genre') != None: + self.genre = showXML.findtext('genre') + else: + self.genre = "" + + # TODO: need to validate the input, I'm assuming it's good until then + + + def nextEpisode(self): + + logger.log(str(self.tvdbid) + ": Finding the episode which airs next", logger.DEBUG) + + myDB = db.DBConnection() + innerQuery = "SELECT airdate FROM tv_episodes WHERE showid = ? AND airdate >= ? AND status = ? ORDER BY airdate ASC LIMIT 1" + innerParams = [self.tvdbid, datetime.date.today().toordinal(), UNAIRED] + query = "SELECT * FROM tv_episodes WHERE showid = ? AND airdate >= ? AND airdate <= (" + innerQuery + ") and status = ?" + params = [self.tvdbid, datetime.date.today().toordinal()] + innerParams + [UNAIRED] + sqlResults = myDB.select(query, params) + + if sqlResults == None or len(sqlResults) == 0: + logger.log(str(self.tvdbid) + ": No episode found... need to implement tvrage and also show status", logger.DEBUG) + return [] + else: + logger.log(str(self.tvdbid) + ": Found episode " + str(sqlResults[0]["season"]) + "x" + str(sqlResults[0]["episode"]), logger.DEBUG) + foundEps = [] + for sqlEp in sqlResults: + curEp = self.getEpisode(int(sqlEp["season"]), int(sqlEp["episode"])) + foundEps.append(curEp) + return foundEps + + # if we didn't get an episode then try getting one from tvrage + + # load tvrage info + + # extract NextEpisode info + + # verify that we don't have it in the DB somehow (ep mismatch) + + + def deleteShow(self): + + myDB = db.DBConnection() + myDB.action("DELETE FROM tv_episodes WHERE showid = ?", [self.tvdbid]) + myDB.action("DELETE FROM tv_shows WHERE tvdb_id = ?", [self.tvdbid]) + + # remove self from show list + sickbeard.showList = [x for x in sickbeard.showList if x.tvdbid != self.tvdbid] + + # clear the cache + image_cache_dir = ek.ek(os.path.join, sickbeard.CACHE_DIR, 'images') + for cache_file in ek.ek(glob.glob, ek.ek(os.path.join, image_cache_dir, str(self.tvdbid)+'.*')): + logger.log(u"Deleting cache file "+cache_file) + os.remove(cache_file) + + def populateCache(self): + cache_inst = image_cache.ImageCache() + + logger.log(u"Checking & filling cache for show "+self.name) + cache_inst.fill_cache(self) + + def refreshDir(self): + + # make sure the show dir is where we think it is unless dirs are created on the fly + if not ek.ek(os.path.isdir, self._location) and not sickbeard.CREATE_MISSING_SHOW_DIRS: + return False + + # load from dir + self.loadEpisodesFromDir() + + # run through all locations from DB, check that they exist + logger.log(str(self.tvdbid) + ": Loading all episodes with a location from the database") + + myDB = db.DBConnection() + sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND location != ''", [self.tvdbid]) + + for ep in sqlResults: + curLoc = os.path.normpath(ep["location"]) + season = int(ep["season"]) + episode = int(ep["episode"]) + + try: + curEp = self.getEpisode(season, episode) + except exceptions.EpisodeDeletedException: + logger.log(u"The episode was deleted while we were refreshing it, moving on to the next one", logger.DEBUG) + continue + + # if the path doesn't exist or if it's not in our show dir + if not ek.ek(os.path.isfile, curLoc) or not os.path.normpath(curLoc).startswith(os.path.normpath(self.location)): + + with curEp.lock: + # if it used to have a file associated with it and it doesn't anymore then set it to IGNORED + if curEp.location and curEp.status in Quality.DOWNLOADED: + logger.log(str(self.tvdbid) + ": Location for " + str(season) + "x" + str(episode) + " doesn't exist, removing it and changing our status to IGNORED", logger.DEBUG) + curEp.status = IGNORED + curEp.subtitles = list() + curEp.subtitles_searchcount = 0 + curEp.subtitles_lastsearch = str(datetime.datetime.min) + curEp.location = '' + curEp.hasnfo = False + curEp.hastbn = False + curEp.release_name = '' + curEp.saveToDB() + + + def downloadSubtitles(self): + #TODO: Add support for force option + if not ek.ek(os.path.isdir, self._location): + logger.log(str(self.tvdbid) + ": Show dir doesn't exist, can't download subtitles", logger.DEBUG) + return + logger.log(str(self.tvdbid) + ": Downloading subtitles", logger.DEBUG) + + try: + episodes = db.DBConnection().select("SELECT location FROM tv_episodes WHERE showid = ? AND location NOT LIKE '' ORDER BY season DESC, episode DESC", [self.tvdbid]) + for episodeLoc in episodes: + episode = self.makeEpFromFile(episodeLoc['location']); + subtitles = episode.downloadSubtitles() + + except Exception as e: + logger.log("Error occurred when downloading subtitles: " + str(e), logger.DEBUG) + return + + + def saveToDB(self): + logger.log(str(self.tvdbid) + ": Saving show info to database", logger.DEBUG) + + myDB = db.DBConnection() + + controlValueDict = {"tvdb_id": self.tvdbid} + newValueDict = {"show_name": self.name, + "tvr_id": self.tvrid, + "location": self._location, + "network": self.network, + "genre": self.genre, + "runtime": self.runtime, + "quality": self.quality, + "airs": self.airs, + "status": self.status, + "flatten_folders": self.flatten_folders, + "paused": self.paused, + "air_by_date": self.air_by_date, + "subtitles": self.subtitles, + "startyear": self.startyear, + "tvr_name": self.tvrname, + "lang": self.lang, + "audio_lang": self.audio_lang, + "custom_search_names": self.custom_search_names + } + + myDB.upsert("tv_shows", newValueDict, controlValueDict) + + + def __str__(self): + toReturn = "" + toReturn += "name: " + self.name + "\n" + toReturn += "location: " + self._location + "\n" + toReturn += "tvdbid: " + str(self.tvdbid) + "\n" + if self.network != None: + toReturn += "network: " + self.network + "\n" + if self.airs != None: + toReturn += "airs: " + self.airs + "\n" + if self.status != None: + toReturn += "status: " + self.status + "\n" + toReturn += "startyear: " + str(self.startyear) + "\n" + toReturn += "genre: " + self.genre + "\n" + toReturn += "runtime: " + str(self.runtime) + "\n" + toReturn += "quality: " + str(self.quality) + "\n" + return toReturn + + + def wantEpisode(self, season, episode, quality, manualSearch=False): + + logger.log(u"Checking if we want episode "+str(season)+"x"+str(episode)+" at quality "+Quality.qualityStrings[quality], logger.DEBUG) + + # if the quality isn't one we want under any circumstances then just say no + anyQualities, bestQualities = Quality.splitQuality(self.quality) + logger.log(u"any,best = "+str(anyQualities)+" "+str(bestQualities)+" and we are "+str(quality), logger.DEBUG) + + if quality not in anyQualities + bestQualities: + logger.log(u"I know for sure I don't want this episode, saying no", logger.DEBUG) + return False + + myDB = db.DBConnection() + sqlResults = myDB.select("SELECT status FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ?", [self.tvdbid, season, episode]) + + if not sqlResults or not len(sqlResults): + logger.log(u"Unable to find the episode", logger.DEBUG) + return False + + epStatus = int(sqlResults[0]["status"]) + + logger.log(u"current episode status: "+str(epStatus), logger.DEBUG) + + # if we know we don't want it then just say no + if epStatus in (SKIPPED, IGNORED, ARCHIVED) and not manualSearch: + logger.log(u"Ep is skipped, not bothering", logger.DEBUG) + return False + + # if it's one of these then we want it as long as it's in our allowed initial qualities + if quality in anyQualities + bestQualities: + if epStatus in (WANTED, UNAIRED, SKIPPED): + logger.log(u"Ep is wanted/unaired/skipped, definitely get it", logger.DEBUG) + return True + elif manualSearch: + logger.log(u"Usually I would ignore this ep but because you forced the search I'm overriding the default and allowing the quality", logger.DEBUG) + return True + else: + logger.log(u"This quality looks like something we might want but I don't know for sure yet", logger.DEBUG) + + curStatus, curQuality = Quality.splitCompositeStatus(epStatus) + + # if we are re-downloading then we only want it if it's in our bestQualities list and better than what we have + if curStatus in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER and quality in bestQualities and quality > curQuality: + logger.log(u"We already have this ep but the new one is better quality, saying yes", logger.DEBUG) + return True + + logger.log(u"None of the conditions were met so I'm just saying no", logger.DEBUG) + return False + + + def getOverview(self, epStatus): + + if epStatus == WANTED: + return Overview.WANTED + elif epStatus in (UNAIRED, UNKNOWN): + return Overview.UNAIRED + elif epStatus in (SKIPPED, IGNORED): + return Overview.SKIPPED + elif epStatus == ARCHIVED: + return Overview.GOOD + elif epStatus in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER: + + anyQualities, bestQualities = Quality.splitQuality(self.quality) #@UnusedVariable + if bestQualities: + maxBestQuality = max(bestQualities) + else: + maxBestQuality = None + + epStatus, curQuality = Quality.splitCompositeStatus(epStatus) + + if epStatus in (SNATCHED, SNATCHED_PROPER): + return Overview.SNATCHED + # if they don't want re-downloads then we call it good if they have anything + elif maxBestQuality == None: + return Overview.GOOD + # if they have one but it's not the best they want then mark it as qual + elif curQuality < maxBestQuality: + return Overview.QUAL + # if it's >= maxBestQuality then it's good + else: + return Overview.GOOD + +def dirty_setter(attr_name): + def wrapper(self, val): + if getattr(self, attr_name) != val: + setattr(self, attr_name, val) + self.dirty = True + return wrapper + +class TVEpisode(object): + + def __init__(self, show, season, episode, file=""): + + self._name = "" + self._season = season + self._episode = episode + self._description = "" + self._subtitles = list() + self._subtitles_searchcount = 0 + self._subtitles_lastsearch = str(datetime.datetime.min) + self._airdate = datetime.date.fromordinal(1) + self._hasnfo = False + self._hastbn = False + self._status = UNKNOWN + self._tvdbid = 0 + self._file_size = 0 + self._audio_langs = '' + self._release_name = '' + + # setting any of the above sets the dirty flag + self.dirty = True + + self.show = show + self._location = file + + self.lock = threading.Lock() + + self.specifyEpisode(self.season, self.episode) + + self.relatedEps = [] + + self.checkForMetaFiles() + + name = property(lambda self: self._name, dirty_setter("_name")) + season = property(lambda self: self._season, dirty_setter("_season")) + episode = property(lambda self: self._episode, dirty_setter("_episode")) + description = property(lambda self: self._description, dirty_setter("_description")) + subtitles = property(lambda self: self._subtitles, dirty_setter("_subtitles")) + subtitles_searchcount = property(lambda self: self._subtitles_searchcount, dirty_setter("_subtitles_searchcount")) + subtitles_lastsearch = property(lambda self: self._subtitles_lastsearch, dirty_setter("_subtitles_lastsearch")) + airdate = property(lambda self: self._airdate, dirty_setter("_airdate")) + hasnfo = property(lambda self: self._hasnfo, dirty_setter("_hasnfo")) + hastbn = property(lambda self: self._hastbn, dirty_setter("_hastbn")) + status = property(lambda self: self._status, dirty_setter("_status")) + tvdbid = property(lambda self: self._tvdbid, dirty_setter("_tvdbid")) + #location = property(lambda self: self._location, dirty_setter("_location")) + file_size = property(lambda self: self._file_size, dirty_setter("_file_size")) + audio_langs = property(lambda self: self._audio_langs, dirty_setter("_audio_langs")) + release_name = property(lambda self: self._release_name, dirty_setter("_release_name")) + + def _set_location(self, new_location): + logger.log(u"Setter sets location to " + new_location, logger.DEBUG) + + #self._location = newLocation + dirty_setter("_location")(self, new_location) + + if new_location and ek.ek(os.path.isfile, new_location): + self.file_size = ek.ek(os.path.getsize, new_location) + else: + self.file_size = 0 + + location = property(lambda self: self._location, _set_location) + def refreshSubtitles(self): + """Look for subtitles files and refresh the subtitles property""" + self.subtitles = subtitles.subtitlesLanguages(self.location) + + def downloadSubtitles(self): + #TODO: Add support for force option + if not ek.ek(os.path.isfile, self.location): + logger.log(str(self.show.tvdbid) + ": Episode file doesn't exist, can't download subtitles for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) + return + logger.log(str(self.show.tvdbid) + ": Downloading subtitles for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) + + previous_subtitles = self.subtitles + + try: + + need_languages = set(sickbeard.SUBTITLES_LANGUAGES) - set(self.subtitles) + subtitles = subliminal.download_subtitles([self.location], languages=need_languages, services=sickbeard.subtitles.getEnabledServiceList(), force=False, multi=True, cache_dir=sickbeard.CACHE_DIR) + + except Exception as e: + logger.log("Error occurred when downloading subtitles: " + str(e), logger.DEBUG) + return + + self.refreshSubtitles() + self.subtitles_searchcount = self.subtitles_searchcount + 1 + self.subtitles_lastsearch = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.saveToDB() + + newsubtitles = set(self.subtitles).difference(set(previous_subtitles)) + + if newsubtitles: + subtitleList = ", ".join(subliminal.language.Language(x).name for x in newsubtitles) + logger.log(str(self.show.tvdbid) + ": Downloaded " + subtitleList + " subtitles for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) + + notifiers.notify_subtitle_download(self.prettyName(), subtitleList) + + else: + logger.log(str(self.show.tvdbid) + ": No subtitles downloaded for episode " + str(self.season) + "x" + str(self.episode), logger.DEBUG) + + if sickbeard.SUBTITLES_HISTORY: + for video in subtitles: + for subtitle in subtitles.get(video): + history.logSubtitle(self.show.tvdbid, self.season, self.episode, self.status, subtitle) + if sickbeard.SUBTITLES_DIR: + for video in subtitles: + subs_new_path = ek.ek(os.path.join, os.path.dirname(video.path), sickbeard.SUBTITLES_DIR) + if not ek.ek(os.path.isdir, subs_new_path): + ek.ek(os.mkdir, subs_new_path) + + for subtitle in subtitles.get(video): + new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) + helpers.moveFile(subtitle.path, new_file_path) + if sickbeard.SUBSNOLANG: + helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") + + elif sickbeard.SUBTITLES_DIR_SUB: + for video in subtitles: + subs_new_path = os.path.join(os.path.dirname(video.path), "Subs") + if not os.path.isdir(subs_new_path): + os.makedirs(subs_new_path) + + for subtitle in subtitles.get(video): + new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) + helpers.moveFile(subtitle.path, new_file_path) + subtitle.path=new_file_path + if sickbeard.SUBSNOLANG: + helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") + subtitle.path=new_file_path + else: + for video in subtitles: + for subtitle in subtitles.get(video): + if sickbeard.SUBSNOLANG: + helpers.copyFile(subtitle.path,subtitle.path[:-6]+"srt") + helpers.chmodAsParent(subtitle.path[:-6]+"srt") + helpers.chmodAsParent(subtitle.path) + return subtitles + + + def checkForMetaFiles(self): + + oldhasnfo = self.hasnfo + oldhastbn = self.hastbn + + cur_nfo = False + cur_tbn = False + + # check for nfo and tbn + if ek.ek(os.path.isfile, self.location): + for cur_provider in sickbeard.metadata_provider_dict.values(): + if cur_provider.episode_metadata: + new_result = cur_provider._has_episode_metadata(self) + else: + new_result = False + cur_nfo = new_result or cur_nfo + + if cur_provider.episode_thumbnails: + new_result = cur_provider._has_episode_thumb(self) + else: + new_result = False + cur_tbn = new_result or cur_tbn + + self.hasnfo = cur_nfo + self.hastbn = cur_tbn + + # if either setting has changed return true, if not return false + return oldhasnfo != self.hasnfo or oldhastbn != self.hastbn + + def specifyEpisode(self, season, episode): + + sqlResult = self.loadFromDB(season, episode) + + if not sqlResult: + # only load from NFO if we didn't load from DB + if ek.ek(os.path.isfile, self.location): + try: + self.loadFromNFO(self.location) + except exceptions.NoNFOException: + logger.log(str(self.show.tvdbid) + ": There was an error loading the NFO for episode " + str(season) + "x" + str(episode), logger.ERROR) + pass + + # if we tried loading it from NFO and didn't find the NFO, use TVDB + if self.hasnfo == False: + try: + result = self.loadFromTVDB(season, episode) + except exceptions.EpisodeDeletedException: + result = False + + # if we failed SQL *and* NFO, TVDB then fail + if result == False: + raise exceptions.EpisodeNotFoundException("Couldn't find episode " + str(season) + "x" + str(episode)) + + # don't update if not needed + if self.dirty: + self.saveToDB() + + def loadFromDB(self, season, episode): + + logger.log(str(self.show.tvdbid) + ": Loading episode details from DB for episode " + str(season) + "x" + str(episode), logger.DEBUG) + + myDB = db.DBConnection() + sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ?", [self.show.tvdbid, season, episode]) + + if len(sqlResults) > 1: + raise exceptions.MultipleDBEpisodesException("Your DB has two records for the same show somehow.") + elif len(sqlResults) == 0: + logger.log(str(self.show.tvdbid) + ": Episode " + str(self.season) + "x" + str(self.episode) + " not found in the database", logger.DEBUG) + return False + else: + #NAMEIT logger.log(u"AAAAA from" + str(self.season)+"x"+str(self.episode) + " -" + self.name + " to " + str(sqlResults[0]["name"])) + if sqlResults[0]["name"] != None: + self.name = sqlResults[0]["name"] + self.season = season + self.episode = episode + self.description = sqlResults[0]["description"] + if self.description == None: + self.description = "" + if sqlResults[0]["subtitles"] != None and sqlResults[0]["subtitles"] != '': + self.subtitles = sqlResults[0]["subtitles"].split(",") + self.subtitles_searchcount = sqlResults[0]["subtitles_searchcount"] + self.subtitles_lastsearch = sqlResults[0]["subtitles_lastsearch"] + self.airdate = datetime.date.fromordinal(int(sqlResults[0]["airdate"])) + #logger.log(u"1 Status changes from " + str(self.status) + " to " + str(sqlResults[0]["status"]), logger.DEBUG) + self.status = int(sqlResults[0]["status"]) + + # don't overwrite my location + if sqlResults[0]["location"] != "" and sqlResults[0]["location"] != None: + self.location = os.path.normpath(sqlResults[0]["location"]) + if sqlResults[0]["file_size"]: + self.file_size = int(sqlResults[0]["file_size"]) + else: + self.file_size = 0 + + self.tvdbid = int(sqlResults[0]["tvdbid"]) + + if sqlResults[0]["audio_langs"] != None: + self.audio_langs = sqlResults[0]["audio_langs"] + + if sqlResults[0]["release_name"] != None: + self.release_name = sqlResults[0]["release_name"] + + self.dirty = False + return True + + def loadFromTVDB(self, season=None, episode=None, cache=True, tvapi=None, cachedSeason=None): + + if season == None: + season = self.season + if episode == None: + episode = self.episode + + logger.log(str(self.show.tvdbid) + ": Loading episode details from theTVDB for episode " + str(season) + "x" + str(episode), logger.DEBUG) + + tvdb_lang = self.show.lang + + try: + if cachedSeason is None: + if tvapi is None: + # There's gotta be a better way of doing this but we don't wanna + # change the cache value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if not cache: + ltvdb_api_parms['cache'] = False + + if tvdb_lang: + ltvdb_api_parms['language'] = tvdb_lang + + t = tvdb_api.Tvdb(**ltvdb_api_parms) + else: + t = tvapi + myEp = t[self.show.tvdbid][season][episode] + else: + myEp = cachedSeason[episode] + + except (tvdb_exceptions.tvdb_error, IOError), e: + logger.log(u"TVDB threw up an error: "+ex(e), logger.DEBUG) + # if the episode is already valid just log it, if not throw it up + if self.name: + logger.log(u"TVDB timed out but we have enough info from other sources, allowing the error", logger.DEBUG) + return + else: + logger.log(u"TVDB timed out, unable to create the episode", logger.ERROR) + return False + except (tvdb_exceptions.tvdb_episodenotfound, tvdb_exceptions.tvdb_seasonnotfound): + logger.log(u"Unable to find the episode on tvdb... has it been removed? Should I delete from db?", logger.DEBUG) + # if I'm no longer on TVDB but I once was then delete myself from the DB + if self.tvdbid != -1: + self.deleteEpisode() + return + + + if not myEp["firstaired"] or myEp["firstaired"] == "0000-00-00": + myEp["firstaired"] = str(datetime.date.fromordinal(1)) + + if myEp["episodename"] == None or myEp["episodename"] == "": + logger.log(u"This episode ("+self.show.name+" - "+str(season)+"x"+str(episode)+") has no name on TVDB") + # if I'm incomplete on TVDB but I once was complete then just delete myself from the DB for now + if self.tvdbid != -1: + self.deleteEpisode() + return False + + #NAMEIT logger.log(u"BBBBBBBB from " + str(self.season)+"x"+str(self.episode) + " -" +self.name+" to "+myEp["episodename"]) + self.name = myEp["episodename"] + self.season = season + self.episode = episode + tmp_description = myEp["overview"] + if tmp_description == None: + self.description = "" + else: + self.description = tmp_description + rawAirdate = [int(x) for x in myEp["firstaired"].split("-")] + try: + self.airdate = datetime.date(rawAirdate[0], rawAirdate[1], rawAirdate[2]) + except ValueError: + logger.log(u"Malformed air date retrieved from TVDB ("+self.show.name+" - "+str(season)+"x"+str(episode)+")", logger.ERROR) + # if I'm incomplete on TVDB but I once was complete then just delete myself from the DB for now + if self.tvdbid != -1: + self.deleteEpisode() + return False + + #early conversion to int so that episode doesn't get marked dirty + self.tvdbid = int(myEp["id"]) + + #don't update show status if show dir is missing, unless missing show dirs are created during post-processing + if not ek.ek(os.path.isdir, self.show._location) and not sickbeard.CREATE_MISSING_SHOW_DIRS: + logger.log(u"The show dir is missing, not bothering to change the episode statuses since it'd probably be invalid") + return + + logger.log(str(self.show.tvdbid) + ": Setting status for " + str(season) + "x" + str(episode) + " based on status " + str(self.status) + " and existence of " + self.location, logger.DEBUG) + + if not ek.ek(os.path.isfile, self.location): + + # if we don't have the file + if self.airdate >= datetime.date.today() and self.status not in Quality.SNATCHED + Quality.SNATCHED_PROPER: + # and it hasn't aired yet set the status to UNAIRED + logger.log(u"Episode airs in the future, changing status from " + str(self.status) + " to " + str(UNAIRED), logger.DEBUG) + self.status = UNAIRED + # if there's no airdate then set it to skipped (and respect ignored) + elif self.airdate == datetime.date.fromordinal(1): + if self.status == IGNORED: + logger.log(u"Episode has no air date, but it's already marked as ignored", logger.DEBUG) + else: + logger.log(u"Episode has no air date, automatically marking it skipped", logger.DEBUG) + self.status = SKIPPED + # if we don't have the file and the airdate is in the past + else: + if self.status == UNAIRED: + self.status = WANTED + + # if we somehow are still UNKNOWN then just skip it + elif self.status == UNKNOWN: + self.status = SKIPPED + + else: + logger.log(u"Not touching status because we have no ep file, the airdate is in the past, and the status is "+str(self.status), logger.DEBUG) + + # if we have a media file then it's downloaded + elif sickbeard.helpers.isMediaFile(self.location): + # leave propers alone, you have to either post-process them or manually change them back + if self.status not in Quality.SNATCHED_PROPER + Quality.DOWNLOADED + Quality.SNATCHED + [ARCHIVED]: + logger.log(u"5 Status changes from " + str(self.status) + " to " + str(Quality.statusFromName(self.location)), logger.DEBUG) + self.status = Quality.statusFromName(self.location) + + # shouldn't get here probably + else: + logger.log(u"6 Status changes from " + str(self.status) + " to " + str(UNKNOWN), logger.DEBUG) + self.status = UNKNOWN + + + # hasnfo, hastbn, status? + + + def loadFromNFO(self, location): + + if not os.path.isdir(self.show._location): + logger.log(str(self.show.tvdbid) + ": The show dir is missing, not bothering to try loading the episode NFO") + return + + logger.log(str(self.show.tvdbid) + ": Loading episode details from the NFO file associated with " + location, logger.DEBUG) + + self.location = location + + if self.location != "": + + if self.status == UNKNOWN: + if sickbeard.helpers.isMediaFile(self.location): + logger.log(u"7 Status changes from " + str(self.status) + " to " + str(Quality.statusFromName(self.location)), logger.DEBUG) + self.status = Quality.statusFromName(self.location) + + nfoFile = sickbeard.helpers.replaceExtension(self.location, "nfo") + logger.log(str(self.show.tvdbid) + ": Using NFO name " + nfoFile, logger.DEBUG) + + if ek.ek(os.path.isfile, nfoFile): + try: + showXML = etree.ElementTree(file = nfoFile) + except (SyntaxError, ValueError), e: + logger.log(u"Error loading the NFO, backing up the NFO and skipping for now: " + ex(e), logger.ERROR) #TODO: figure out what's wrong and fix it + try: + ek.ek(os.rename, nfoFile, nfoFile + ".old") + except Exception, e: + logger.log(u"Failed to rename your episode's NFO file - you need to delete it or fix it: " + ex(e), logger.ERROR) + raise exceptions.NoNFOException("Error in NFO format") + + for epDetails in showXML.getiterator('episodedetails'): + if epDetails.findtext('season') == None or int(epDetails.findtext('season')) != self.season or \ + epDetails.findtext('episode') == None or int(epDetails.findtext('episode')) != self.episode: + logger.log(str(self.show.tvdbid) + ": NFO has an <episodedetails> block for a different episode - wanted " + str(self.season) + "x" + str(self.episode) + " but got " + str(epDetails.findtext('season')) + "x" + str(epDetails.findtext('episode')), logger.DEBUG) + continue + + if epDetails.findtext('title') == None or epDetails.findtext('aired') == None: + raise exceptions.NoNFOException("Error in NFO format (missing episode title or airdate)") + + self.name = epDetails.findtext('title') + self.episode = int(epDetails.findtext('episode')) + self.season = int(epDetails.findtext('season')) + + self.description = epDetails.findtext('plot') + if self.description == None: + self.description = "" + + if epDetails.findtext('aired'): + rawAirdate = [int(x) for x in epDetails.findtext('aired').split("-")] + self.airdate = datetime.date(rawAirdate[0], rawAirdate[1], rawAirdate[2]) + else: + self.airdate = datetime.date.fromordinal(1) + + self.hasnfo = True + else: + self.hasnfo = False + + if ek.ek(os.path.isfile, sickbeard.helpers.replaceExtension(nfoFile, "tbn")): + self.hastbn = True + else: + self.hastbn = False + + def __str__ (self): + + toReturn = "" + toReturn += str(self.show.name) + " - " + str(self.season) + "x" + str(self.episode) + " - " + str(self.name) + "\n" + toReturn += "location: " + str(self.location) + "\n" + toReturn += "description: " + str(self.description) + "\n" + toReturn += "subtitles: " + str(",".join(self.subtitles)) + "\n" + toReturn += "subtitles_searchcount: " + str(self.subtitles_searchcount) + "\n" + toReturn += "subtitles_lastsearch: " + str(self.subtitles_lastsearch) + "\n" + toReturn += "airdate: " + str(self.airdate.toordinal()) + " (" + str(self.airdate) + ")\n" + toReturn += "hasnfo: " + str(self.hasnfo) + "\n" + toReturn += "hastbn: " + str(self.hastbn) + "\n" + toReturn += "status: " + str(self.status) + "\n" + toReturn += "languages: " + str(self.audio_langs) + "\n" + return toReturn + + def createMetaFiles(self, force=False): + + if not ek.ek(os.path.isdir, self.show._location): + logger.log(str(self.show.tvdbid) + ": The show dir is missing, not bothering to try to create metadata") + return + + self.createNFO(force) + self.createThumbnail(force) + + if self.checkForMetaFiles(): + self.saveToDB() + + def createNFO(self, force=False): + + result = False + + for cur_provider in sickbeard.metadata_provider_dict.values(): + result = cur_provider.create_episode_metadata(self) or result + + return result + + def createThumbnail(self, force=False): + + result = False + + for cur_provider in sickbeard.metadata_provider_dict.values(): + result = cur_provider.create_episode_thumb(self) or result + + return result + + def deleteEpisode(self): + + logger.log(u"Deleting "+self.show.name+" "+str(self.season)+"x"+str(self.episode)+" from the DB", logger.DEBUG) + + # remove myself from the show dictionary + if self.show.getEpisode(self.season, self.episode, noCreate=True) == self: + logger.log(u"Removing myself from my show's list", logger.DEBUG) + del self.show.episodes[self.season][self.episode] + + # delete myself from the DB + logger.log(u"Deleting myself from the database", logger.DEBUG) + myDB = db.DBConnection() + sql = "DELETE FROM tv_episodes WHERE showid="+str(self.show.tvdbid)+" AND season="+str(self.season)+" AND episode="+str(self.episode) + myDB.action(sql) + + raise exceptions.EpisodeDeletedException() + + def saveToDB(self, forceSave=False): + """ + Saves this episode to the database if any of its data has been changed since the last save. + + forceSave: If True it will save to the database even if no data has been changed since the + last save (aka if the record is not dirty). + """ + + if not self.dirty and not forceSave: + logger.log(str(self.show.tvdbid) + ": Not saving episode to db - record is not dirty", logger.DEBUG) + return + + logger.log(str(self.show.tvdbid) + ": Saving episode details to database", logger.DEBUG) + + logger.log(u"STATUS IS " + str(self.status), logger.DEBUG) + + myDB = db.DBConnection() + + newValueDict = {"tvdbid": self.tvdbid, + "name": self.name, + "description": self.description, + "subtitles": ",".join([sub for sub in self.subtitles]), + "subtitles_searchcount": self.subtitles_searchcount, + "subtitles_lastsearch": self.subtitles_lastsearch, + "airdate": self.airdate.toordinal(), + "hasnfo": self.hasnfo, + "hastbn": self.hastbn, + "status": self.status, + "location": self.location, + "audio_langs": self.audio_langs, + "file_size": self.file_size, + "release_name": self.release_name} + controlValueDict = {"showid": self.show.tvdbid, + "season": self.season, + "episode": self.episode} + + # use a custom update/insert method to get the data into the DB + myDB.upsert("tv_episodes", newValueDict, controlValueDict) + + def fullPath (self): + if self.location == None or self.location == "": + return None + else: + return ek.ek(os.path.join, self.show.location, self.location) + + def prettyName(self): + """ + Returns the name of this episode in a "pretty" human-readable format. Used for logging + and notifications and such. + + Returns: A string representing the episode's name and season/ep numbers + """ + + return self._format_pattern('%SN - %Sx%0E - %EN') + + def _ep_name(self): + """ + Returns the name of the episode to use during renaming. Combines the names of related episodes. + Eg. "Ep Name (1)" and "Ep Name (2)" becomes "Ep Name" + "Ep Name" and "Other Ep Name" becomes "Ep Name & Other Ep Name" + """ + + multiNameRegex = "(.*) \(\d\)" + + self.relatedEps = sorted(self.relatedEps, key=lambda x: x.episode) + + if len(self.relatedEps) == 0: + goodName = self.name + + else: + goodName = '' + + singleName = True + curGoodName = None + + for curName in [self.name] + [x.name for x in self.relatedEps]: + match = re.match(multiNameRegex, curName) + if not match: + singleName = False + break + + if curGoodName == None: + curGoodName = match.group(1) + elif curGoodName != match.group(1): + singleName = False + break + + if singleName: + goodName = curGoodName + else: + goodName = self.name + for relEp in self.relatedEps: + goodName += " & " + relEp.name + + return goodName + + def _replace_map(self): + """ + Generates a replacement map for this episode which maps all possible custom naming patterns to the correct + value for this episode. + + Returns: A dict with patterns as the keys and their replacement values as the values. + """ + + ep_name = self._ep_name() + + def dot(name): + return helpers.sanitizeSceneName(name) + + def us(name): + return re.sub('[ -]','_', name) + + def release_name(name): + if name and name.lower().endswith('.nzb'): + name = name.rpartition('.')[0] + return name + + def release_group(name): + if not name: + return '' + + np = NameParser(name) + + try: + parse_result = np.parse(name) + except InvalidNameException, e: + logger.log(u"Unable to get parse release_group: "+ex(e), logger.DEBUG) + return '' + + if not parse_result.release_group: + return '' + return parse_result.release_group + + epStatus, epQual = Quality.splitCompositeStatus(self.status) #@UnusedVariable + + return { + '%SN': self.show.name, + '%S.N': dot(self.show.name), + '%S_N': us(self.show.name), + '%EN': ep_name, + '%E.N': dot(ep_name), + '%E_N': us(ep_name), + '%QN': Quality.qualityStrings[epQual], + '%Q.N': dot(Quality.qualityStrings[epQual]), + '%Q_N': us(Quality.qualityStrings[epQual]), + '%S': str(self.season), + '%0S': '%02d' % self.season, + '%E': str(self.episode), + '%0E': '%02d' % self.episode, + '%RN': release_name(self.release_name), + '%RG': release_group(self.release_name), + '%AD': str(self.airdate).replace('-', ' '), + '%A.D': str(self.airdate).replace('-', '.'), + '%A_D': us(str(self.airdate)), + '%A-D': str(self.airdate), + '%Y': str(self.airdate.year), + '%M': str(self.airdate.month), + '%D': str(self.airdate.day), + '%0M': '%02d' % self.airdate.month, + '%0D': '%02d' % self.airdate.day, + } + + def _format_string(self, pattern, replace_map): + """ + Replaces all template strings with the correct value + """ + + result_name = pattern + + # do the replacements + for cur_replacement in sorted(replace_map.keys(), reverse=True): + result_name = result_name.replace(cur_replacement, helpers.sanitizeFileName(replace_map[cur_replacement])) + result_name = result_name.replace(cur_replacement.lower(), helpers.sanitizeFileName(replace_map[cur_replacement].lower())) + + return result_name + + def _format_pattern(self, pattern=None, multi=None): + """ + Manipulates an episode naming pattern and then fills the template in + """ + + if pattern == None: + pattern = sickbeard.NAMING_PATTERN + + if multi == None: + multi = sickbeard.NAMING_MULTI_EP + + replace_map = self._replace_map() + + result_name = pattern + + # if there's no release group then replace it with a reasonable facsimile + if not replace_map['%RN']: + if self.show.air_by_date: + result_name = result_name.replace('%RN', '%S.N.%A.D.%E.N-SiCKBEARD') + result_name = result_name.replace('%rn', '%s.n.%A.D.%e.n-sickbeard') + else: + result_name = result_name.replace('%RN', '%S.N.S%0SE%0E.%E.N-SiCKBEARD') + result_name = result_name.replace('%rn', '%s.n.s%0se%0e.%e.n-sickbeard') + + result_name = result_name.replace('%RG', 'SiCKBEARD') + result_name = result_name.replace('%rg', 'sickbeard') + logger.log(u"Episode has no release name, replacing it with a generic one: "+result_name, logger.DEBUG) + + # split off ep name part only + name_groups = re.split(r'[\\/]', result_name) + + # figure out the double-ep numbering style for each group, if applicable + for cur_name_group in name_groups: + + season_format = sep = ep_sep = ep_format = None + + season_ep_regex = ''' + (?P<pre_sep>[ _.-]*) + ((?:s(?:eason|eries)?\s*)?%0?S(?![._]?N)) + (.*?) + (%0?E(?![._]?N)) + (?P<post_sep>[ _.-]*) + ''' + ep_only_regex = '(E?%0?E(?![._]?N))' + + # try the normal way + season_ep_match = re.search(season_ep_regex, cur_name_group, re.I|re.X) + ep_only_match = re.search(ep_only_regex, cur_name_group, re.I|re.X) + + # if we have a season and episode then collect the necessary data + if season_ep_match: + season_format = season_ep_match.group(2) + ep_sep = season_ep_match.group(3) + ep_format = season_ep_match.group(4) + sep = season_ep_match.group('pre_sep') + if not sep: + sep = season_ep_match.group('post_sep') + if not sep: + sep = ' ' + + # force 2-3-4 format if they chose to extend + if multi in (NAMING_EXTEND, NAMING_LIMITED_EXTEND, NAMING_LIMITED_EXTEND_E_PREFIXED): + ep_sep = '-' + + regex_used = season_ep_regex + + # if there's no season then there's not much choice so we'll just force them to use 03-04-05 style + elif ep_only_match: + season_format = '' + ep_sep = '-' + ep_format = ep_only_match.group(1) + sep = '' + regex_used = ep_only_regex + + else: + continue + + # we need at least this much info to continue + if not ep_sep or not ep_format: + continue + + # start with the ep string, eg. E03 + ep_string = self._format_string(ep_format.upper(), replace_map) + for other_ep in self.relatedEps: + + # for limited extend we only append the last ep + if multi in (NAMING_LIMITED_EXTEND, NAMING_LIMITED_EXTEND_E_PREFIXED) and other_ep != self.relatedEps[-1]: + continue + + elif multi == NAMING_DUPLICATE: + # add " - S01" + ep_string += sep + season_format + + elif multi == NAMING_SEPARATED_REPEAT: + ep_string += sep + + # add "E04" + ep_string += ep_sep + + if multi == NAMING_LIMITED_EXTEND_E_PREFIXED: + ep_string += 'E' + + ep_string += other_ep._format_string(ep_format.upper(), other_ep._replace_map()) + + if season_ep_match: + regex_replacement = r'\g<pre_sep>\g<2>\g<3>' + ep_string + r'\g<post_sep>' + elif ep_only_match: + regex_replacement = ep_string + + # fill out the template for this piece and then insert this piece into the actual pattern + cur_name_group_result = re.sub('(?i)(?x)'+regex_used, regex_replacement, cur_name_group) + #cur_name_group_result = cur_name_group.replace(ep_format, ep_string) + #logger.log(u"found "+ep_format+" as the ep pattern using "+regex_used+" and replaced it with "+regex_replacement+" to result in "+cur_name_group_result+" from "+cur_name_group, logger.DEBUG) + result_name = result_name.replace(cur_name_group, cur_name_group_result) + + result_name = self._format_string(result_name, replace_map) + + logger.log(u"formatting pattern: "+pattern+" -> "+result_name, logger.DEBUG) + + + return result_name + + def proper_path(self): + """ + Figures out the path where this episode SHOULD live according to the renaming rules, relative from the show dir + """ + + result = self.formatted_filename() + + # if they want us to flatten it and we're allowed to flatten it then we will + if self.show.flatten_folders and not sickbeard.NAMING_FORCE_FOLDERS: + return result + + # if not we append the folder on and use that + else: + result = ek.ek(os.path.join, self.formatted_dir(), result) + + return result + + + def formatted_dir(self, pattern=None, multi=None): + """ + Just the folder name of the episode + """ + + if pattern == None: + # we only use ABD if it's enabled, this is an ABD show, AND this is not a multi-ep + if self.show.air_by_date and sickbeard.NAMING_CUSTOM_ABD and not self.relatedEps: + pattern = sickbeard.NAMING_ABD_PATTERN + else: + pattern = sickbeard.NAMING_PATTERN + + # split off the dirs only, if they exist + name_groups = re.split(r'[\\/]', pattern) + + if len(name_groups) == 1: + return '' + else: + return self._format_pattern(os.sep.join(name_groups[:-1]), multi) + + + def formatted_filename(self, pattern=None, multi=None): + """ + Just the filename of the episode, formatted based on the naming settings + """ + + if pattern == None: + # we only use ABD if it's enabled, this is an ABD show, AND this is not a multi-ep + if self.show.air_by_date and sickbeard.NAMING_CUSTOM_ABD and not self.relatedEps: + pattern = sickbeard.NAMING_ABD_PATTERN + else: + pattern = sickbeard.NAMING_PATTERN + + # split off the filename only, if they exist + name_groups = re.split(r'[\\/]', pattern) + + return self._format_pattern(name_groups[-1], multi) + + def rename(self): + """ + Renames an episode file and all related files to the location and filename as specified + in the naming settings. + """ + + if not ek.ek(os.path.isfile, self.location): + logger.log(u"Can't perform rename on " + self.location + " when it doesn't exist, skipping", logger.WARNING) + return + + proper_path = self.proper_path() + absolute_proper_path = ek.ek(os.path.join, self.show.location, proper_path) + absolute_current_path_no_ext, file_ext = os.path.splitext(self.location) + + related_subs = [] + + current_path = absolute_current_path_no_ext + + if absolute_current_path_no_ext.startswith(self.show.location): + current_path = absolute_current_path_no_ext[len(self.show.location):] + + logger.log(u"Renaming/moving episode from the base path " + self.location + " to " + absolute_proper_path, logger.DEBUG) + + # if it's already named correctly then don't do anything + if proper_path == current_path: + logger.log(str(self.tvdbid) + ": File " + self.location + " is already named correctly, skipping", logger.DEBUG) + return + + related_files = postProcessor.PostProcessor(self.location)._list_associated_files(self.location) + + if self.show.subtitles and sickbeard.SUBTITLES_DIR != '': + related_subs = postProcessor.PostProcessor(self.location)._list_associated_files(sickbeard.SUBTITLES_DIR, subtitles_only=True) + absolute_proper_subs_path = ek.ek(os.path.join, sickbeard.SUBTITLES_DIR, self.formatted_filename()) + + if self.show.subtitles and sickbeard.SUBTITLES_DIR_SUB: + related_subs = postProcessor.PostProcessor(self.location)._list_associated_files(os.path.dirname(self.location)+"\\Subs", subtitles_only=True) + absolute_proper_subs_path = ek.ek(os.path.join, os.path.dirname(self.location)+"\\Subs", self.formatted_filename()) + + logger.log(u"Files associated to " + self.location + ": " + str(related_files), logger.DEBUG) + + # move the ep file + result = helpers.rename_ep_file(self.location, absolute_proper_path) + + # move related files + for cur_related_file in related_files: + cur_result = helpers.rename_ep_file(cur_related_file, absolute_proper_path) + if cur_result == False: + logger.log(str(self.tvdbid) + ": Unable to rename file " + cur_related_file, logger.ERROR) + + for cur_related_sub in related_subs: + cur_result = helpers.rename_ep_file(cur_related_sub, absolute_proper_subs_path) + if cur_result == False: + logger.log(str(self.tvdbid) + ": Unable to rename file " + cur_related_sub, logger.ERROR) + + # save the ep + with self.lock: + if result != False: + self.location = absolute_proper_path + file_ext + for relEp in self.relatedEps: + relEp.location = absolute_proper_path + file_ext + + # in case something changed with the metadata just do a quick check + for curEp in [self] + self.relatedEps: + curEp.checkForMetaFiles() + + # save any changes to the database + with self.lock: + self.saveToDB() + for relEp in self.relatedEps: + relEp.saveToDB() diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 06c5c25e5a..f36a56a298 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -1,529 +1,516 @@ -# Author: Nic Wolfe <nic@wolfeden.ca> -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. - -import sickbeard -from sickbeard import version, ui -from sickbeard import logger -from sickbeard import scene_exceptions -from sickbeard.exceptions import ex - -import os, platform, shutil -import subprocess, re -import urllib, urllib2 -import zipfile, tarfile - -from urllib2 import URLError -import gh_api as github - -class CheckVersion(): - """ - Version check class meant to run as a thread object with the SB scheduler. - """ - - def __init__(self): - self.install_type = self.find_install_type() - - if self.install_type == 'win': - self.updater = WindowsUpdateManager() - elif self.install_type == 'git': - self.updater = GitUpdateManager() - elif self.install_type == 'source': - self.updater = SourceUpdateManager() - else: - self.updater = None - - def run(self): - self.check_for_new_version() - - # refresh scene exceptions too - scene_exceptions.retrieve_exceptions() - - def find_install_type(self): - """ - Determines how this copy of SB was installed. - - returns: type of installation. Possible values are: - 'win': any compiled windows build - 'git': running from source using git - 'source': running from source without git - """ - - # check if we're a windows build - if version.SICKBEARD_VERSION.startswith('build '): - install_type = 'win' - elif os.path.isdir(os.path.join(sickbeard.PROG_DIR, '.git')): - install_type = 'git' - else: - install_type = 'source' - - return install_type - - def check_for_new_version(self, force=False): - """ - Checks the internet for a newer version. - - returns: bool, True for new version or False for no new version. - - force: if true the VERSION_NOTIFY setting will be ignored and a check will be forced - """ - - if not sickbeard.VERSION_NOTIFY and not force: - logger.log(u"Version checking is disabled, not checking for the newest version") - return False - - logger.log(u"Checking if "+self.install_type+" needs an update") - if not self.updater.need_update(): - logger.log(u"No update needed") - if force: - ui.notifications.message('No update needed') - return False - - self.updater.set_newest_text() - return True - - def update(self): - if self.updater.need_update(): - return self.updater.update() - -class UpdateManager(): - def get_update_url(self): - return sickbeard.WEB_ROOT+"/home/update/?pid="+str(sickbeard.PID) - -class WindowsUpdateManager(UpdateManager): - - def __init__(self): - self._cur_version = None - self._cur_commit_hash = None - self._newest_version = None - - self.gc_url = 'http://code.google.com/p/sickbeard/downloads/list' - self.version_url = 'https://raw.github.com/sarakha63/Sick-Beard/windows_binaries/updates.txt' - - def _find_installed_version(self): - return int(sickbeard.version.SICKBEARD_VERSION[6:]) - - def _find_newest_version(self, whole_link=False): - """ - Checks git for the newest Windows binary build. Returns either the - build number or the entire build URL depending on whole_link's value. - - whole_link: If True, returns the entire URL to the release. If False, it returns - only the build number. default: False - """ - - regex = ".*SickBeard\-win32\-alpha\-build(\d+)(?:\.\d+)?\.zip" - - svnFile = urllib.urlopen(self.version_url) - - for curLine in svnFile.readlines(): - logger.log(u"checking line "+curLine, logger.DEBUG) - match = re.match(regex, curLine) - if match: - logger.log(u"found a match", logger.DEBUG) - if whole_link: - return curLine.strip() - else: - return int(match.group(1)) - - return None - - def need_update(self): - self._cur_version = self._find_installed_version() - self._newest_version = self._find_newest_version() - - logger.log(u"newest version: "+repr(self._newest_version), logger.DEBUG) - - if self._newest_version and self._newest_version > self._cur_version: - return True - - def set_newest_text(self): - new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' - new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - sickbeard.NEWEST_VERSION_STRING = new_str - - def update(self): - - new_link = self._find_newest_version(True) - - logger.log(u"new_link: " + repr(new_link), logger.DEBUG) - - if not new_link: - logger.log(u"Unable to find a new version link on google code, not updating") - return False - - # download the zip - try: - logger.log(u"Downloading update file from "+str(new_link)) - (filename, headers) = urllib.urlretrieve(new_link) #@UnusedVariable - - # prepare the update dir - sb_update_dir = os.path.join(sickbeard.PROG_DIR, 'sb-update') - logger.log(u"Clearing out update folder "+sb_update_dir+" before unzipping") - if os.path.isdir(sb_update_dir): - shutil.rmtree(sb_update_dir) - - # unzip it to sb-update - logger.log(u"Unzipping from "+str(filename)+" to "+sb_update_dir) - update_zip = zipfile.ZipFile(filename, 'r') - update_zip.extractall(sb_update_dir) - update_zip.close() - - # find update dir name - update_dir_contents = os.listdir(sb_update_dir) - if len(update_dir_contents) != 1: - logger.log("Invalid update data, update failed. Maybe try deleting your sb-update folder?", logger.ERROR) - return False - - content_dir = os.path.join(sb_update_dir, update_dir_contents[0]) - old_update_path = os.path.join(content_dir, 'updater.exe') - new_update_path = os.path.join(sickbeard.PROG_DIR, 'updater.exe') - logger.log(u"Copying new update.exe file from "+old_update_path+" to "+new_update_path) - shutil.move(old_update_path, new_update_path) - - # delete the zip - logger.log(u"Deleting zip file from "+str(filename)) - os.remove(filename) - - except Exception, e: - logger.log(u"Error while trying to update: "+ex(e), logger.ERROR) - return False - - return True - -class GitUpdateManager(UpdateManager): - - def __init__(self): - self._cur_commit_hash = None - self._newest_commit_hash = None - self._num_commits_behind = 0 - - self.git_url = 'http://code.google.com/p/sickbeard/downloads/list' - - self.branch = self._find_git_branch() - - def _git_error(self): - error_message = 'Unable to find your git executable - either delete your .git folder and run from source OR <a href="http://code.google.com/p/sickbeard/wiki/AdvancedSettings" onclick="window.open(this.href); return false;">set git_path in your config.ini</a> to enable updates.' - sickbeard.NEWEST_VERSION_STRING = error_message - - return None - - def _run_git(self, args): - - if sickbeard.GIT_PATH: - git_locations = ['"'+sickbeard.GIT_PATH+'"'] - else: - git_locations = ['git'] - - # osx people who start SB from launchd have a broken path, so try a hail-mary attempt for them - 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.log(u"Executing "+cmd+" with your shell in "+sickbeard.PROG_DIR, logger.DEBUG) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) - output, err = p.communicate() - logger.log(u"git output: "+output, logger.DEBUG) - except OSError: - logger.log(u"Command "+cmd+" didn't work, couldn't find git.") - continue - - if p.returncode != 0 or 'not found' in output or "not recognized as an internal or external command" in output: - logger.log(u"Unable to find git with command "+cmd, logger.DEBUG) - output = None - elif 'fatal:' in output or err: - logger.log(u"Git returned bad info, are you sure this is a git installation?", logger.ERROR) - output = None - elif output: - break - - return (output, err) - - - def _find_installed_version(self): - """ - Attempts to find the currently installed version of Sick Beard. - - Uses git show to get commit version. - - Returns: True for success or False for failure - """ - - output, err = self._run_git('rev-parse HEAD') #@UnusedVariable - - if not output: - return self._git_error() - - logger.log(u"Git output: "+str(output), logger.DEBUG) - cur_commit_hash = output.strip() - - if not re.match('^[a-z0-9]+$', cur_commit_hash): - logger.log(u"Output doesn't look like a hash, not using it", logger.ERROR) - return self._git_error() - - self._cur_commit_hash = cur_commit_hash - - return True - - def _find_git_branch(self): - - branch_info = self._run_git('symbolic-ref -q HEAD') - - if not branch_info or not branch_info[0]: - return 'master' - - branch = branch_info[0].strip().replace('refs/heads/', '', 1) - - return branch or 'master' - - - def _check_github_for_update(self): - """ - Uses pygithub to ask github if there is a newer version that the provided - commit hash. If there is a newer version it sets Sick Beard's version text. - - commit_hash: hash that we're checking against - """ - - self._num_commits_behind = 0 - self._newest_commit_hash = None - - gh = github.GitHub() - - # find newest commit - for curCommit in gh.commits('sarakha63', 'Sick-Beard', self.branch): - if not self._newest_commit_hash: - self._newest_commit_hash = curCommit['sha'] - if not self._cur_commit_hash: - break - - if curCommit['sha'] == self._cur_commit_hash: - break - - self._num_commits_behind += 1 - - logger.log(u"newest: "+str(self._newest_commit_hash)+" and current: "+str(self._cur_commit_hash)+" and num_commits: "+str(self._num_commits_behind), logger.DEBUG) - - def set_newest_text(self): - - # if we're up to date then don't set this - if self._num_commits_behind == 100: - message = "or else you're ahead of master" - - elif self._num_commits_behind > 0: - message = "you're %d commit" % self._num_commits_behind - if self._num_commits_behind > 1: message += 's' - message += ' behind' - - else: - return - - if self._newest_commit_hash: - url = 'http://github.com/sarakha63/Sick-Beard/compare/'+self._cur_commit_hash+'...'+self._newest_commit_hash - else: - url = 'http://github.com/sarakha63/Sick-Beard/commits/' - - new_str = 'There is a <a href="'+url+'" onclick="window.open(this.href); return false;">newer version available</a> ('+message+')' - new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - - sickbeard.NEWEST_VERSION_STRING = new_str - - def need_update(self): - self._find_installed_version() - try: - self._check_github_for_update() - except Exception, e: - logger.log(u"Unable to contact github, can't check for update: "+repr(e), logger.ERROR) - return False - - logger.log(u"After checking, cur_commit = "+str(self._cur_commit_hash)+", newest_commit = "+str(self._newest_commit_hash)+", num_commits_behind = "+str(self._num_commits_behind), logger.DEBUG) - - if self._num_commits_behind > 0: - return True - - return False - - def update(self): - """ - Calls git pull origin <branch> in order to update Sick Beard. Returns a bool depending - on the call's success. - """ - self._run_git('config remote.origin.url git://github.com/sarakha63/Sick-Beard.git') - self._run_git('stash') - output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable - logger.log(u"Writing commit History into the file", logger.DEBUG) - if sickbeard.GIT_PATH: - git_locations = ['"'+sickbeard.GIT_PATH+'"'] - else: - git_locations = ['git'] - for cur_git in git_locations: - cmd = cur_git +' log --pretty="%ar %h - %s" --no-merges -200' - - try: - logger.log(u"Executing "+cmd+" with your shell in "+sickbeard.PROG_DIR, logger.DEBUG) - p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) - output1, err1 = p.communicate() - fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (output1[0][0]) - fp.close () - os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) - except OSError: - logger.log(u"Command "+cmd+" didn't work, couldn't find git.") - - - if not output: - return self._git_error() - - pull_regex = '(\d+) .+,.+(\d+).+\(\+\),.+(\d+) .+\(\-\)' - - (files, insertions, deletions) = (None, None, None) - - for line in output.split('\n'): - - if 'Already up-to-date.' in line: - logger.log(u"No update available, not updating") - logger.log(u"Output: "+str(output)) - return False - elif line.endswith('Aborting.'): - logger.log(u"Unable to update from git: "+line, logger.ERROR) - logger.log(u"Output: "+str(output)) - return False - - match = re.search(pull_regex, line) - if match: - (files, insertions, deletions) = match.groups() - break - - if None in (files, insertions, deletions): - logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) - logger.log(u"Output: "+str(output)) - return True - - return True - - - -class SourceUpdateManager(GitUpdateManager): - - def _find_installed_version(self): - - version_file = os.path.join(sickbeard.PROG_DIR, 'version.txt') - - if not os.path.isfile(version_file): - self._cur_commit_hash = None - return - - fp = open(version_file, 'r') - self._cur_commit_hash = fp.read().strip(' \n\r') - fp.close() - - if not self._cur_commit_hash: - self._cur_commit_hash = None - - def need_update(self): - - parent_result = GitUpdateManager.need_update(self) - - if not self._cur_commit_hash: - return True - else: - return parent_result - - - def set_newest_text(self): - if not self._cur_commit_hash: - logger.log(u"Unknown current version, don't know if we should update or not", logger.DEBUG) - - new_str = "Unknown version: If you've never used the Sick Beard upgrade system then I don't know what version you have." - new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - - sickbeard.NEWEST_VERSION_STRING = new_str - - else: - GitUpdateManager.set_newest_text(self) - - def update(self): - """ - Downloads the latest source tarball from github and installs it over the existing version. - """ - - tar_download_url = 'https://github.com/sarakha63/Sick-Beard/tarball/'+version.SICKBEARD_VERSION - sb_update_dir = os.path.join(sickbeard.PROG_DIR, 'sb-update') - version_path = os.path.join(sickbeard.PROG_DIR, 'version.txt') - - # retrieve file - try: - logger.log(u"Downloading update from "+tar_download_url) - data = urllib2.urlopen(tar_download_url) - except (IOError, URLError): - logger.log(u"Unable to retrieve new version from "+tar_download_url+", can't update", logger.ERROR) - return False - - download_name = data.geturl().split('/')[-1].split('?')[0] - - tar_download_path = os.path.join(sickbeard.PROG_DIR, download_name) - - # save to disk - f = open(tar_download_path, 'wb') - f.write(data.read()) - f.close() - - # extract to temp folder - logger.log(u"Extracting file "+tar_download_path) - tar = tarfile.open(tar_download_path) - tar.extractall(sb_update_dir) - tar.close() - - # delete .tar.gz - logger.log(u"Deleting file "+tar_download_path) - os.remove(tar_download_path) - - # find update dir name - update_dir_contents = [x for x in os.listdir(sb_update_dir) if os.path.isdir(os.path.join(sb_update_dir, x))] - if len(update_dir_contents) != 1: - logger.log(u"Invalid update data, update failed: "+str(update_dir_contents), logger.ERROR) - return False - content_dir = os.path.join(sb_update_dir, update_dir_contents[0]) - - # walk temp folder and move files to main folder - for dirname, dirnames, filenames in os.walk(content_dir): #@UnusedVariable - dirname = dirname[len(content_dir)+1:] - for curfile in filenames: - old_path = os.path.join(content_dir, dirname, curfile) - new_path = os.path.join(sickbeard.PROG_DIR, dirname, curfile) - - if os.path.isfile(new_path): - os.remove(new_path) - os.renames(old_path, new_path) - - # update version.txt with commit hash - try: - ver_file = open(version_path, 'w') - ver_file.write(self._newest_commit_hash) - ver_file.close() - except IOError, e: - logger.log(u"Unable to write version file, update not complete: "+ex(e), logger.ERROR) - return False - - return True - +# Author: Nic Wolfe <nic@wolfeden.ca> +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. + +import sickbeard +from sickbeard import version, ui +from sickbeard import logger +from sickbeard import scene_exceptions +from sickbeard.exceptions import ex + +import os, platform, shutil +import subprocess, re +import urllib, urllib2 +import zipfile, tarfile + +from urllib2 import URLError +import gh_api as github + +class CheckVersion(): + """ + Version check class meant to run as a thread object with the SB scheduler. + """ + + def __init__(self): + self.install_type = self.find_install_type() + + if self.install_type == 'win': + self.updater = WindowsUpdateManager() + elif self.install_type == 'git': + self.updater = GitUpdateManager() + elif self.install_type == 'source': + self.updater = SourceUpdateManager() + else: + self.updater = None + + def run(self): + self.check_for_new_version() + + # refresh scene exceptions too + scene_exceptions.retrieve_exceptions() + + def find_install_type(self): + """ + Determines how this copy of SB was installed. + + returns: type of installation. Possible values are: + 'win': any compiled windows build + 'git': running from source using git + 'source': running from source without git + """ + + # check if we're a windows build + if version.SICKBEARD_VERSION.startswith('build '): + install_type = 'win' + elif os.path.isdir(os.path.join(sickbeard.PROG_DIR, '.git')): + install_type = 'git' + else: + install_type = 'source' + + return install_type + + def check_for_new_version(self, force=False): + """ + Checks the internet for a newer version. + + returns: bool, True for new version or False for no new version. + + force: if true the VERSION_NOTIFY setting will be ignored and a check will be forced + """ + + if not sickbeard.VERSION_NOTIFY and not force: + logger.log(u"Version checking is disabled, not checking for the newest version") + return False + + logger.log(u"Checking if "+self.install_type+" needs an update") + if not self.updater.need_update(): + logger.log(u"No update needed") + if force: + ui.notifications.message('No update needed') + return False + + self.updater.set_newest_text() + return True + + def update(self): + if self.updater.need_update(): + return self.updater.update() + +class UpdateManager(): + def get_update_url(self): + return sickbeard.WEB_ROOT+"/home/update/?pid="+str(sickbeard.PID) + +class WindowsUpdateManager(UpdateManager): + + def __init__(self): + self._cur_version = None + self._cur_commit_hash = None + self._newest_version = None + + self.gc_url = 'http://code.google.com/p/sickbeard/downloads/list' + self.version_url = 'https://raw.github.com/sarakha63/Sick-Beard/windows_binaries/updates.txt' + + def _find_installed_version(self): + return int(sickbeard.version.SICKBEARD_VERSION[6:]) + + def _find_newest_version(self, whole_link=False): + """ + Checks git for the newest Windows binary build. Returns either the + build number or the entire build URL depending on whole_link's value. + + whole_link: If True, returns the entire URL to the release. If False, it returns + only the build number. default: False + """ + + regex = ".*SickBeard\-win32\-alpha\-build(\d+)(?:\.\d+)?\.zip" + + svnFile = urllib.urlopen(self.version_url) + + for curLine in svnFile.readlines(): + logger.log(u"checking line "+curLine, logger.DEBUG) + match = re.match(regex, curLine) + if match: + logger.log(u"found a match", logger.DEBUG) + if whole_link: + return curLine.strip() + else: + return int(match.group(1)) + + return None + + def need_update(self): + self._cur_version = self._find_installed_version() + self._newest_version = self._find_newest_version() + + logger.log(u"newest version: "+repr(self._newest_version), logger.DEBUG) + + if self._newest_version and self._newest_version > self._cur_version: + return True + + def set_newest_text(self): + new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' + new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" + sickbeard.NEWEST_VERSION_STRING = new_str + + def update(self): + + new_link = self._find_newest_version(True) + + logger.log(u"new_link: " + repr(new_link), logger.DEBUG) + + if not new_link: + logger.log(u"Unable to find a new version link on google code, not updating") + return False + + # download the zip + try: + logger.log(u"Downloading update file from "+str(new_link)) + (filename, headers) = urllib.urlretrieve(new_link) #@UnusedVariable + + # prepare the update dir + sb_update_dir = os.path.join(sickbeard.PROG_DIR, 'sb-update') + logger.log(u"Clearing out update folder "+sb_update_dir+" before unzipping") + if os.path.isdir(sb_update_dir): + shutil.rmtree(sb_update_dir) + + # unzip it to sb-update + logger.log(u"Unzipping from "+str(filename)+" to "+sb_update_dir) + update_zip = zipfile.ZipFile(filename, 'r') + update_zip.extractall(sb_update_dir) + update_zip.close() + + # find update dir name + update_dir_contents = os.listdir(sb_update_dir) + if len(update_dir_contents) != 1: + logger.log("Invalid update data, update failed. Maybe try deleting your sb-update folder?", logger.ERROR) + return False + + content_dir = os.path.join(sb_update_dir, update_dir_contents[0]) + old_update_path = os.path.join(content_dir, 'updater.exe') + new_update_path = os.path.join(sickbeard.PROG_DIR, 'updater.exe') + logger.log(u"Copying new update.exe file from "+old_update_path+" to "+new_update_path) + shutil.move(old_update_path, new_update_path) + + # delete the zip + logger.log(u"Deleting zip file from "+str(filename)) + os.remove(filename) + + except Exception, e: + logger.log(u"Error while trying to update: "+ex(e), logger.ERROR) + return False + + return True + +class GitUpdateManager(UpdateManager): + + def __init__(self): + self._cur_commit_hash = None + self._newest_commit_hash = None + self._num_commits_behind = 0 + + self.git_url = 'http://code.google.com/p/sickbeard/downloads/list' + + self.branch = self._find_git_branch() + + def _git_error(self): + error_message = 'Unable to find your git executable - either delete your .git folder and run from source OR <a href="http://code.google.com/p/sickbeard/wiki/AdvancedSettings" onclick="window.open(this.href); return false;">set git_path in your config.ini</a> to enable updates.' + sickbeard.NEWEST_VERSION_STRING = error_message + + return None + + def _run_git(self, args): + + if sickbeard.GIT_PATH: + git_locations = ['"'+sickbeard.GIT_PATH+'"'] + else: + git_locations = ['git'] + + # osx people who start SB from launchd have a broken path, so try a hail-mary attempt for them + 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.log(u"Executing "+cmd+" with your shell in "+sickbeard.PROG_DIR, logger.DEBUG) + p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) + output, err = p.communicate() + logger.log(u"git output: "+output, logger.DEBUG) + except OSError: + logger.log(u"Command "+cmd+" didn't work, couldn't find git.") + continue + + if p.returncode != 0 or 'not found' in output or "not recognized as an internal or external command" in output: + logger.log(u"Unable to find git with command "+cmd, logger.DEBUG) + output = None + elif 'fatal:' in output or err: + logger.log(u"Git returned bad info, are you sure this is a git installation?", logger.ERROR) + output = None + elif output: + break + + return (output, err) + + + def _find_installed_version(self): + """ + Attempts to find the currently installed version of Sick Beard. + + Uses git show to get commit version. + + Returns: True for success or False for failure + """ + + output, err = self._run_git('rev-parse HEAD') #@UnusedVariable + + if not output: + return self._git_error() + + logger.log(u"Git output: "+str(output), logger.DEBUG) + cur_commit_hash = output.strip() + + if not re.match('^[a-z0-9]+$', cur_commit_hash): + logger.log(u"Output doesn't look like a hash, not using it", logger.ERROR) + return self._git_error() + + self._cur_commit_hash = cur_commit_hash + + return True + + def _find_git_branch(self): + + branch_info = self._run_git('symbolic-ref -q HEAD') + + if not branch_info or not branch_info[0]: + return 'master' + + branch = branch_info[0].strip().replace('refs/heads/', '', 1) + + return branch or 'master' + + + def _check_github_for_update(self): + """ + Uses pygithub to ask github if there is a newer version that the provided + commit hash. If there is a newer version it sets Sick Beard's version text. + + commit_hash: hash that we're checking against + """ + + self._num_commits_behind = 0 + self._newest_commit_hash = None + + gh = github.GitHub() + + # find newest commit + for curCommit in gh.commits('sarakha63', 'Sick-Beard', self.branch): + if not self._newest_commit_hash: + self._newest_commit_hash = curCommit['sha'] + if not self._cur_commit_hash: + break + + if curCommit['sha'] == self._cur_commit_hash: + break + + self._num_commits_behind += 1 + + logger.log(u"newest: "+str(self._newest_commit_hash)+" and current: "+str(self._cur_commit_hash)+" and num_commits: "+str(self._num_commits_behind), logger.DEBUG) + + def set_newest_text(self): + + # if we're up to date then don't set this + if self._num_commits_behind == 100: + message = "or else you're ahead of master" + + elif self._num_commits_behind > 0: + message = "you're %d commit" % self._num_commits_behind + if self._num_commits_behind > 1: message += 's' + message += ' behind' + + else: + return + + if self._newest_commit_hash: + url = 'http://github.com/sarakha63/Sick-Beard/compare/'+self._cur_commit_hash+'...'+self._newest_commit_hash + else: + url = 'http://github.com/sarakha63/Sick-Beard/commits/' + + new_str = 'There is a <a href="'+url+'" onclick="window.open(this.href); return false;">newer version available</a> ('+message+')' + new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" + + sickbeard.NEWEST_VERSION_STRING = new_str + + def need_update(self): + self._find_installed_version() + try: + self._check_github_for_update() + except Exception, e: + logger.log(u"Unable to contact github, can't check for update: "+repr(e), logger.ERROR) + return False + + logger.log(u"After checking, cur_commit = "+str(self._cur_commit_hash)+", newest_commit = "+str(self._newest_commit_hash)+", num_commits_behind = "+str(self._num_commits_behind), logger.DEBUG) + + if self._num_commits_behind > 0: + return True + + return False + + def update(self): + """ + Calls git pull origin <branch> in order to update Sick Beard. Returns a bool depending + on the call's success. + """ + self._run_git('config remote.origin.url git://github.com/sarakha63/Sick-Beard.git') + self._run_git('stash') + output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable + logger.log(u"Writing commit History into the file", logger.DEBUG) + output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') + fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') + fp.write (output1[0][0]) + fp.close () + os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) + + if not output: + return self._git_error() + + pull_regex = '(\d+) .+,.+(\d+).+\(\+\),.+(\d+) .+\(\-\)' + + (files, insertions, deletions) = (None, None, None) + + for line in output.split('\n'): + + if 'Already up-to-date.' in line: + logger.log(u"No update available, not updating") + logger.log(u"Output: "+str(output)) + return False + elif line.endswith('Aborting.'): + logger.log(u"Unable to update from git: "+line, logger.ERROR) + logger.log(u"Output: "+str(output)) + return False + + match = re.search(pull_regex, line) + if match: + (files, insertions, deletions) = match.groups() + break + + if None in (files, insertions, deletions): + logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) + logger.log(u"Output: "+str(output)) + return True + + return True + + + +class SourceUpdateManager(GitUpdateManager): + + def _find_installed_version(self): + + version_file = os.path.join(sickbeard.PROG_DIR, 'version.txt') + + if not os.path.isfile(version_file): + self._cur_commit_hash = None + return + + fp = open(version_file, 'r') + self._cur_commit_hash = fp.read().strip(' \n\r') + fp.close() + + if not self._cur_commit_hash: + self._cur_commit_hash = None + + def need_update(self): + + parent_result = GitUpdateManager.need_update(self) + + if not self._cur_commit_hash: + return True + else: + return parent_result + + + def set_newest_text(self): + if not self._cur_commit_hash: + logger.log(u"Unknown current version, don't know if we should update or not", logger.DEBUG) + + new_str = "Unknown version: If you've never used the Sick Beard upgrade system then I don't know what version you have." + new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" + + sickbeard.NEWEST_VERSION_STRING = new_str + + else: + GitUpdateManager.set_newest_text(self) + + def update(self): + """ + Downloads the latest source tarball from github and installs it over the existing version. + """ + + tar_download_url = 'https://github.com/sarakha63/Sick-Beard/tarball/'+version.SICKBEARD_VERSION + sb_update_dir = os.path.join(sickbeard.PROG_DIR, 'sb-update') + version_path = os.path.join(sickbeard.PROG_DIR, 'version.txt') + + # retrieve file + try: + logger.log(u"Downloading update from "+tar_download_url) + data = urllib2.urlopen(tar_download_url) + except (IOError, URLError): + logger.log(u"Unable to retrieve new version from "+tar_download_url+", can't update", logger.ERROR) + return False + + download_name = data.geturl().split('/')[-1].split('?')[0] + + tar_download_path = os.path.join(sickbeard.PROG_DIR, download_name) + + # save to disk + f = open(tar_download_path, 'wb') + f.write(data.read()) + f.close() + + # extract to temp folder + logger.log(u"Extracting file "+tar_download_path) + tar = tarfile.open(tar_download_path) + tar.extractall(sb_update_dir) + tar.close() + + # delete .tar.gz + logger.log(u"Deleting file "+tar_download_path) + os.remove(tar_download_path) + + # find update dir name + update_dir_contents = [x for x in os.listdir(sb_update_dir) if os.path.isdir(os.path.join(sb_update_dir, x))] + if len(update_dir_contents) != 1: + logger.log(u"Invalid update data, update failed: "+str(update_dir_contents), logger.ERROR) + return False + content_dir = os.path.join(sb_update_dir, update_dir_contents[0]) + + # walk temp folder and move files to main folder + for dirname, dirnames, filenames in os.walk(content_dir): #@UnusedVariable + dirname = dirname[len(content_dir)+1:] + for curfile in filenames: + old_path = os.path.join(content_dir, dirname, curfile) + new_path = os.path.join(sickbeard.PROG_DIR, dirname, curfile) + + if os.path.isfile(new_path): + os.remove(new_path) + os.renames(old_path, new_path) + + # update version.txt with commit hash + try: + ver_file = open(version_path, 'w') + ver_file.write(self._newest_commit_hash) + ver_file.close() + except IOError, e: + logger.log(u"Unable to write version file, update not complete: "+ex(e), logger.ERROR) + return False + + return True + From 4b06d7268713e4fdcbec935b7c9cb3b954118635 Mon Sep 17 00:00:00 2001 From: sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 02:43:32 +0200 Subject: [PATCH 033/492] Update COPYING.txt --- COPYING.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/COPYING.txt b/COPYING.txt index 859d1c05b8..9a44754a20 100644 --- a/COPYING.txt +++ b/COPYING.txt @@ -1,4 +1,4 @@ - GNU GENERAL PUBLIC LICENSE + GN U GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> From ad8c93609a4e071320b11cb3ed9be907c57bccaf Mon Sep 17 00:00:00 2001 From: sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:46:55 +0300 Subject: [PATCH 034/492] Update versionChecker.py --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index f36a56a298..5de4b68323 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -374,7 +374,7 @@ def update(self): logger.log(u"Writing commit History into the file", logger.DEBUG) output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (output1[0][0]) + fp.write (output1) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) From cfb11048646171b57b4853020b2ce7289db91c91 Mon Sep 17 00:00:00 2001 From: sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:52:11 +0300 Subject: [PATCH 035/492] Update versionChecker.py --- sickbeard/versionChecker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 5de4b68323..24a822aebf 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -373,8 +373,9 @@ def update(self): output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History into the file", logger.DEBUG) output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') + text=output1.read() fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') - fp.write (output1) + fp.write (text) fp.close () os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) From f4e716f6f2113232bd5c9e090e41acd4ef7c2fcf Mon Sep 17 00:00:00 2001 From: sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:53:40 +0300 Subject: [PATCH 036/492] Update versionChecker.py --- sickbeard/versionChecker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 24a822aebf..b306838b10 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -374,10 +374,10 @@ def update(self): logger.log(u"Writing commit History into the file", logger.DEBUG) output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') text=output1.read() - fp = open (os.path.join(sickbeard.DATA_DIR, "hist.log"), 'wb') + fp = open (os.path.join(sickbeard.DATA_DIR, 'hist.log'), 'wb') fp.write (text) fp.close () - os.chmod(os.path.join(sickbeard.DATA_DIR, "hist.log"), 0777) + os.chmod(os.path.join(sickbeard.DATA_DIR, 'hist.log'), 0777) if not output: return self._git_error() From da4a8b14bd3afa3ba13027e6dcb88020dc83c482 Mon Sep 17 00:00:00 2001 From: sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:55:19 +0300 Subject: [PATCH 037/492] Update versionChecker.py --- sickbeard/versionChecker.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index b306838b10..46db49d665 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -373,11 +373,12 @@ def update(self): output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History into the file", logger.DEBUG) output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') - text=output1.read() - fp = open (os.path.join(sickbeard.DATA_DIR, 'hist.log'), 'wb') + text=str(output1).read() + path=os.path.join(sickbeard.DATA_DIR, 'hist.log') + fp = open (path, 'wb') fp.write (text) fp.close () - os.chmod(os.path.join(sickbeard.DATA_DIR, 'hist.log'), 0777) + os.chmod(os.path.join(path), 0777) if not output: return self._git_error() From c6ee352df8dae10eef7970e9540c3019d271c729 Mon Sep 17 00:00:00 2001 From: sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 02:56:09 +0200 Subject: [PATCH 038/492] Update versionChecker.py --- sickbeard/versionChecker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 46db49d665..57e459d22c 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -373,8 +373,10 @@ def update(self): output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable logger.log(u"Writing commit History into the file", logger.DEBUG) output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') - text=str(output1).read() path=os.path.join(sickbeard.DATA_DIR, 'hist.log') + + text=str(output1).read() + fp = open (path, 'wb') fp.write (text) fp.close () From f14e636607f12fa2170d6ad59eb6295dadb5a4a8 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:07:40 +0200 Subject: [PATCH 039/492] reverted hist log for now --- .project | 2 +- .pydevproject | 6 +++--- sickbeard/versionChecker.py | 12 +----------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.project b/.project index c0407428d3..04c09b1ae1 100644 --- a/.project +++ b/.project @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="UTF-8"?> <projectDescription> - <name>SickBeard</name> + <name>Sick-Beard</name> <comment></comment> <projects> </projects> diff --git a/.pydevproject b/.pydevproject index cb27afca5e..25e64fd80f 100644 --- a/.pydevproject +++ b/.pydevproject @@ -1,9 +1,9 @@ <?xml version="1.0" encoding="UTF-8" standalone="no"?> <?eclipse-pydev version="1.0"?><pydev_project> <pydev_pathproperty name="org.python.pydev.PROJECT_SOURCE_PATH"> -<path>/SickBeard</path> -<path>/SickBeard/lib</path> -<path>/SickBeard/sickbeard</path> +<path>/Sick-Beard</path> +<path>/Sick-Beard/lib</path> +<path>/Sick-Beard/sickbeard</path> </pydev_pathproperty> <pydev_property name="org.python.pydev.PYTHON_PROJECT_VERSION">python 2.7</pydev_property> <pydev_property name="org.python.pydev.PYTHON_PROJECT_INTERPRETER">Default</pydev_property> diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 57e459d22c..ef4e86cd39 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -371,17 +371,7 @@ def update(self): self._run_git('config remote.origin.url git://github.com/sarakha63/Sick-Beard.git') self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable - logger.log(u"Writing commit History into the file", logger.DEBUG) - output1,err1=self._run_git(' log --pretty="%ar %h - %s" --no-merges -200') - path=os.path.join(sickbeard.DATA_DIR, 'hist.log') - - text=str(output1).read() - - fp = open (path, 'wb') - fp.write (text) - fp.close () - os.chmod(os.path.join(path), 0777) - + if not output: return self._git_error() From 831d698ac591d50de5997229eb5ada681ea9d8bb Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:10:52 +0200 Subject: [PATCH 040/492] reverted --- sickbeard/versionChecker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index ef4e86cd39..4bd9962046 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -371,7 +371,7 @@ def update(self): self._run_git('config remote.origin.url git://github.com/sarakha63/Sick-Beard.git') self._run_git('stash') output, err = self._run_git('pull git://github.com/sarakha63/Sick-Beard.git '+self.branch) #@UnusedVariable - + if not output: return self._git_error() @@ -399,7 +399,7 @@ def update(self): logger.log(u"Didn't find indication of success in output, assuming git pull succeeded", logger.DEBUG) logger.log(u"Output: "+str(output)) return True - + return True From 2485c03e6d58e97aafbb598d5286c5e744956a61 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 03:12:49 +0200 Subject: [PATCH 041/492] to do --- readme.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/readme.md b/readme.md index 3f1f3ec8e1..b15642b1b5 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,8 @@ And much more.... To come : subliminal integration and much more +TODO:histlog integration + *Sick Beard is currently an alpha release. There may be severe bugs in it and at any given time it may not work at all.* From 989f269b27fc04190aeab96a9d8ac814621d82cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= <stephane.cremel@gmail.com> Date: Fri, 24 May 2013 10:40:07 +0200 Subject: [PATCH 042/492] Correction d'un probleme de log pour le mail notifier --- sickbeard/notifiers/mail.py | 182 ++++++++++++++++++------------------ 1 file changed, 91 insertions(+), 91 deletions(-) diff --git a/sickbeard/notifiers/mail.py b/sickbeard/notifiers/mail.py index 11203b01c6..a4b3f0360a 100644 --- a/sickbeard/notifiers/mail.py +++ b/sickbeard/notifiers/mail.py @@ -1,91 +1,91 @@ -# Author: Stephane CREMEL <stephane.cremel@gmail.com> -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. - - - -import os -import subprocess - -import sickbeard - -from sickbeard import logger -from sickbeard import encodingKludge as ek -from sickbeard.exceptions import ex -from email.mime.text import MIMEText -import smtplib - -class MailNotifier: - - def test_notify(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): - return self._notifyMail("This is a test notification from SickBeard", "SickBeard message", mail_from, mail_to,mail_server,mail_ssl,mail_username,mail_password) - - def notify_snatch(self, ep_name): - logger.log("Notification MAIL SNATCH", logger.DEBUG) - if sickbeard.MAIL_NOTIFY_ONSNATCH: - message = str(ep_name) - return self._notifyMail("SickBeard Snatch", message, None, None, None, None, None, None) - else: - return - - def notify_download(self, ep_name): - logger.log("Notification MAIL SNATCH", logger.DEBUG) - message = str(ep_name) - return self._notifyMail("SickBeard Download", message, None, None, None, None, None, None) - - def notify_subtitle_download(self, ep_name, lang): - pass - - - def _notifyMail(self, title, message, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): - - if not sickbeard.USE_MAIL: - logger.log("Notification for Mail not enabled, skipping this notification", logger.DEBUG) - return False - - logger.log("Sending notification Mail", logger.DEBUG) - - if not mail_from: - mail_from = sickbeard.MAIL_FROM - if not mail_to: - mail_to = sickbeard.MAIL_TO - if not mail_ssl: - mail_ssl = sickbeard.MAIL_SSL - if not mail_server: - mail_server = sickbeard.MAIL_SERVER - if not mail_username: - mail_username = sickbeard.MAIL_USERNAME - if not mail_password: - mail_password = sickbeard.MAIL_PASSWORD - - if mail_ssl : - mailserver = smtplib.SMTP_SSL(mail_server) - else: - mailserver = smtplib.SMTP(mail_server) - - if len(mail_username) > 0: - mailserver.login(mail_username, mail_password) - - message = MIMEText(message) - message['Subject'] = title - message['From'] = mail_from - message['To'] = mail_to - - mailserver.sendmail(mail_from,mail_to,message.as_string()) - - return True - -notifier = MailNotifier +# Author: Stephane CREMEL <stephane.cremel@gmail.com> +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. + + + +import os +import subprocess + +import sickbeard + +from sickbeard import logger +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex +from email.mime.text import MIMEText +import smtplib + +class MailNotifier: + + def test_notify(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): + return self._notifyMail("This is a test notification from SickBeard", "SickBeard message", mail_from, mail_to,mail_server,mail_ssl,mail_username,mail_password) + + def notify_snatch(self, ep_name): + if sickbeard.MAIL_NOTIFY_ONSNATCH: + logger.log("Notification MAIL SNATCH", logger.DEBUG) + message = str(ep_name) + return self._notifyMail("SickBeard Snatch", message, None, None, None, None, None, None) + else: + return + + def notify_download(self, ep_name): + logger.log("Notification MAIL DOWNLOAD", logger.DEBUG) + message = str(ep_name) + return self._notifyMail("SickBeard Download", message, None, None, None, None, None, None) + + def notify_subtitle_download(self, ep_name, lang): + pass + + + def _notifyMail(self, title, message, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): + + if not sickbeard.USE_MAIL: + logger.log("Notification for Mail not enabled, skipping this notification", logger.DEBUG) + return False + + logger.log("Sending notification Mail", logger.DEBUG) + + if not mail_from: + mail_from = sickbeard.MAIL_FROM + if not mail_to: + mail_to = sickbeard.MAIL_TO + if not mail_ssl: + mail_ssl = sickbeard.MAIL_SSL + if not mail_server: + mail_server = sickbeard.MAIL_SERVER + if not mail_username: + mail_username = sickbeard.MAIL_USERNAME + if not mail_password: + mail_password = sickbeard.MAIL_PASSWORD + + if mail_ssl : + mailserver = smtplib.SMTP_SSL(mail_server) + else: + mailserver = smtplib.SMTP(mail_server) + + if len(mail_username) > 0: + mailserver.login(mail_username, mail_password) + + message = MIMEText(message) + message['Subject'] = title + message['From'] = mail_from + message['To'] = mail_to + + mailserver.sendmail(mail_from,mail_to,message.as_string()) + + return True + +notifier = MailNotifier From 21ff6b0fb41fcca112184fa518253f90a34a9537 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 12:49:14 +0200 Subject: [PATCH 043/492] added paypal in gui --- data/interfaces/default/config.tmpl | 96 +++++++------- data/interfaces/default/inc_top.tmpl | 6 +- sickbeard/notifiers/mail.py | 182 +++++++++++++-------------- 3 files changed, 144 insertions(+), 140 deletions(-) diff --git a/data/interfaces/default/config.tmpl b/data/interfaces/default/config.tmpl index ec28766dcb..c88f3fe588 100644 --- a/data/interfaces/default/config.tmpl +++ b/data/interfaces/default/config.tmpl @@ -1,48 +1,48 @@ -#import sickbeard -#from sickbeard import db -#import os.path -#set global $title="Configuration" - -#set global $sbPath=".." - -#set global $topmenu="config"# -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -<div id="summary" class="align-left"> - <table class="infoTable"> - <tr> - <td class="infoTableHeader">SB Version: </td> - <td> - alpha ($sickbeard.version.SICKBEARD_VERSION) -- $sickbeard.versionCheckScheduler.action.install_type : - #if $sickbeard.versionCheckScheduler.action.install_type != 'win' and not $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash: - $sickbeard.versionCheckScheduler.action.updater._find_installed_version() - #end if - #if $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash: - $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash - #end if - </td> - </tr> - <tr><td class="infoTableHeader">SB Config file: </td><td>$sickbeard.CONFIG_FILE</td></tr> - <tr><td class="infoTableHeader">SB Database file: </td><td>$db.dbFilename()</td></tr> - <tr><td class="infoTableHeader">SB Cache Dir: </td><td>$sickbeard.CACHE_DIR</td></tr> - <tr><td class="infoTableHeader">SB Arguments: </td><td>$sickbeard.MY_ARGS</td></tr> - <tr><td class="infoTableHeader">SB Web Root: </td><td>$sickbeard.WEB_ROOT</td></tr> - <tr><td class="infoTableHeader">Python Version: </td><td>$sys.version[:120]</td></tr> - <tr class="infoTableSeperator"><td class="infoTableHeader"><i class="icon16-sb"></i> Homepage </td><td><a href="http://www.sickbeard.com/">http://www.sickbeard.com/</a></td></tr> - <tr><td class="infoTableHeader"><i class="icon16-web"></i> Forums </td><td><a href="http://sickbeard.com/forums/">http://sickbeard.com/forums/</a></td></tr> - <tr><td class="infoTableHeader"><i class="icon16-github"></i> Source </td><td><a href="https://github.com/midgetspy/Sick-Beard/">https://github.com/midgetspy/Sick-Beard/</a></td></tr> - <tr><td class="infoTableHeader"><i class="icon16-win"></i> Bug Tracker &<br/> Windows Builds </td><td><a href="http://code.google.com/p/sickbeard/">http://code.google.com/p/sickbeard/</a></td></tr> - <tr><td class="infoTableHeader"><i class="icon16-mirc"></i> Internet Relay Chat </td><td><a href="irc://irc.freenode.net/#sickbeard"><i>#sickbeard</i> on <i>irc.freenode.net</i></a></td></tr> - </table> -</div> - -<div class="container padding" style="width: 600px;"> - <table class="infoTable"> - <tr> - <td><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=JA8M7VDY89SQ4" onclick="window.open(this.href); return false;"><img src="$sbRoot/images/paypal/btn_donateCC_LG.gif" alt="[donate]" /></a></td> - <td>Sick Beard is free, but you can contribute by giving a <b><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=JA8M7VDY89SQ4" onclick="window.open(this.href); return false;">donation</a></b>.</td> - </tr> - </table> -</div> - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import db +#import os.path +#set global $title="Configuration" + +#set global $sbPath=".." + +#set global $topmenu="config"# +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + +<div id="summary" class="align-left"> + <table class="infoTable"> + <tr> + <td class="infoTableHeader">SB Version: </td> + <td> + ($sickbeard.version.SICKBEARD_VERSION) -- $sickbeard.versionCheckScheduler.action.install_type : + #if $sickbeard.versionCheckScheduler.action.install_type != 'win' and not $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash: + $sickbeard.versionCheckScheduler.action.updater._find_installed_version() + #end if + #if $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash: + $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash + #end if + </td> + </tr> + <tr><td class="infoTableHeader">SB Config file: </td><td>$sickbeard.CONFIG_FILE</td></tr> + <tr><td class="infoTableHeader">SB Database file: </td><td>$db.dbFilename()</td></tr> + <tr><td class="infoTableHeader">SB Cache Dir: </td><td>$sickbeard.CACHE_DIR</td></tr> + <tr><td class="infoTableHeader">SB Arguments: </td><td>$sickbeard.MY_ARGS</td></tr> + <tr><td class="infoTableHeader">SB Web Root: </td><td>$sickbeard.WEB_ROOT</td></tr> + <tr><td class="infoTableHeader">Python Version: </td><td>$sys.version[:120]</td></tr> + <tr class="infoTableSeperator"><td class="infoTableHeader"><i class="icon16-sb"></i> Homepage </td><td><a href="http://www.sickbeard.com/">http://www.sickbeard.com/</a></td></tr> + <tr><td class="infoTableHeader"><i class="icon16-web"></i> Forums </td><td><a href="http://sickbeard.com/forums/">http://sickbeard.com/forums/</a></td></tr> + <tr><td class="infoTableHeader"><i class="icon16-github"></i> Source </td><td><a href="https://github.com/sarakha63/Sick-Beard/">https://github.com/sarakha63/Sick-Beard/</a></td></tr> + <tr><td class="infoTableHeader"><i class="icon16-win"></i> Bug Tracker &<br/> Windows Builds </td><td><a href="http://code.google.com/p/sickbeard/">http://code.google.com/p/sickbeard/</a></td></tr> + <tr><td class="infoTableHeader"><i class="icon16-mirc"></i> Internet Relay Chat </td><td><a href="irc://irc.freenode.net/#sickbeard"><i>#sickbeard</i> on <i>irc.freenode.net</i></a></td></tr> + </table> +</div> + +<div class="container padding" style="width: 600px;"> + <table class="infoTable"> + <tr> + <td><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=L2EWZMTRPW2SE" onclick="window.open(this.href); return false;"><img src="$sbRoot/images/paypal/btn_donateCC_LG.gif" alt="[donate]" /></a></td> + <td>Sick Beard VO/VF is free, but you can contribute by giving a <b><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=L2EWZMTRPW2SE" onclick="window.open(this.href); return false;">donation</a></b>.</td> + </tr> + </table> +</div> + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index 5e10f9a224..7ee38a689e 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -213,7 +213,11 @@ </li> <li class="divider-vertical"></li> </ul> - + <ul class="nav pull-right"> + <li> + <a id="navDonate" href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=L2EWZMTRPW2SE" onclick="window.open(this.href); return false;"><img src="$sbRoot/images/paypal/btn_donate_LG.gif" alt="[donate]" height="26" width="92" /></a> + </li> + </ul> </div><!-- /nav-collapse --> </div><!-- /container --> diff --git a/sickbeard/notifiers/mail.py b/sickbeard/notifiers/mail.py index a4b3f0360a..61cae31bc3 100644 --- a/sickbeard/notifiers/mail.py +++ b/sickbeard/notifiers/mail.py @@ -1,91 +1,91 @@ -# Author: Stephane CREMEL <stephane.cremel@gmail.com> -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. - - - -import os -import subprocess - -import sickbeard - -from sickbeard import logger -from sickbeard import encodingKludge as ek -from sickbeard.exceptions import ex -from email.mime.text import MIMEText -import smtplib - -class MailNotifier: - - def test_notify(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): - return self._notifyMail("This is a test notification from SickBeard", "SickBeard message", mail_from, mail_to,mail_server,mail_ssl,mail_username,mail_password) - - def notify_snatch(self, ep_name): - if sickbeard.MAIL_NOTIFY_ONSNATCH: - logger.log("Notification MAIL SNATCH", logger.DEBUG) - message = str(ep_name) - return self._notifyMail("SickBeard Snatch", message, None, None, None, None, None, None) - else: - return - - def notify_download(self, ep_name): - logger.log("Notification MAIL DOWNLOAD", logger.DEBUG) - message = str(ep_name) - return self._notifyMail("SickBeard Download", message, None, None, None, None, None, None) - - def notify_subtitle_download(self, ep_name, lang): - pass - - - def _notifyMail(self, title, message, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): - - if not sickbeard.USE_MAIL: - logger.log("Notification for Mail not enabled, skipping this notification", logger.DEBUG) - return False - - logger.log("Sending notification Mail", logger.DEBUG) - - if not mail_from: - mail_from = sickbeard.MAIL_FROM - if not mail_to: - mail_to = sickbeard.MAIL_TO - if not mail_ssl: - mail_ssl = sickbeard.MAIL_SSL - if not mail_server: - mail_server = sickbeard.MAIL_SERVER - if not mail_username: - mail_username = sickbeard.MAIL_USERNAME - if not mail_password: - mail_password = sickbeard.MAIL_PASSWORD - - if mail_ssl : - mailserver = smtplib.SMTP_SSL(mail_server) - else: - mailserver = smtplib.SMTP(mail_server) - - if len(mail_username) > 0: - mailserver.login(mail_username, mail_password) - - message = MIMEText(message) - message['Subject'] = title - message['From'] = mail_from - message['To'] = mail_to - - mailserver.sendmail(mail_from,mail_to,message.as_string()) - - return True - -notifier = MailNotifier +# Author: Stephane CREMEL <stephane.cremel@gmail.com> +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see <http://www.gnu.org/licenses/>. + + + +import os +import subprocess + +import sickbeard + +from sickbeard import logger +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex +from email.mime.text import MIMEText +import smtplib + +class MailNotifier: + + def test_notify(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): + return self._notifyMail("This is a test notification from SickBeard", "SickBeard message", mail_from, mail_to,mail_server,mail_ssl,mail_username,mail_password) + + def notify_snatch(self, ep_name): + if sickbeard.MAIL_NOTIFY_ONSNATCH: + logger.log("Notification MAIL SNATCH", logger.DEBUG) + message = str(ep_name) + return self._notifyMail("SickBeard Snatch", message, None, None, None, None, None, None) + else: + return + + def notify_download(self, ep_name): + logger.log("Notification MAIL DOWNLOAD", logger.DEBUG) + message = str(ep_name) + return self._notifyMail("SickBeard Download", message, None, None, None, None, None, None) + + def notify_subtitle_download(self, ep_name, lang): + pass + + + def _notifyMail(self, title, message, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_username=None, mail_password=None): + + if not sickbeard.USE_MAIL: + logger.log("Notification for Mail not enabled, skipping this notification", logger.DEBUG) + return False + + logger.log("Sending notification Mail", logger.DEBUG) + + if not mail_from: + mail_from = sickbeard.MAIL_FROM + if not mail_to: + mail_to = sickbeard.MAIL_TO + if not mail_ssl: + mail_ssl = sickbeard.MAIL_SSL + if not mail_server: + mail_server = sickbeard.MAIL_SERVER + if not mail_username: + mail_username = sickbeard.MAIL_USERNAME + if not mail_password: + mail_password = sickbeard.MAIL_PASSWORD + + if mail_ssl : + mailserver = smtplib.SMTP_SSL(mail_server) + else: + mailserver = smtplib.SMTP(mail_server) + + if len(mail_username) > 0: + mailserver.login(mail_username, mail_password) + + message = MIMEText(message) + message['Subject'] = title + message['From'] = mail_from + message['To'] = mail_to + + mailserver.sendmail(mail_from,mail_to,message.as_string()) + + return True + +notifier = MailNotifier From 49648e7b1f577d2e68b254ac30650ca0b7b0a604 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 12:58:01 +0200 Subject: [PATCH 044/492] update : new commit bar --- sickbeard/versionChecker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 4bd9962046..cf7d24ab97 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -154,6 +154,7 @@ def need_update(self): def set_newest_text(self): new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" + new_str += 'Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def update(self): From 2750e217d2ce87ea08f4ed04ea95d425ae398a71 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:01:05 +0200 Subject: [PATCH 045/492] corrected indent --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index cf7d24ab97..71be19ecc9 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -154,7 +154,7 @@ def need_update(self): def set_newest_text(self): new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - new_str += 'Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' + new_str += 'Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def update(self): From 09330e4c54bfc3ba2c03c278bd50c1dd0cd73dff Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:20:46 +0200 Subject: [PATCH 046/492] added changelog link --- data/interfaces/default/inc_top.tmpl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index 7ee38a689e..e219b876f6 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -141,6 +141,8 @@ <li id="NAVsystem" class="dropdown"> <a data-toggle="dropdown" class="dropdown-toggle" href="#" onclick="return false;"><img src="$sbRoot/images/menu/system18.png" alt="" width="18" height="18" /> <b class="caret"></b></a> <ul class="dropdown-menu"> + <li><a href="https://github.com/sarakha63/Sick-Beard/commits/master" onclick="window.open(this.href); return false;"><img class="notifier-icon" src="$sbRoot/images/menu/github-16.png" alt="" title="git" /></i> See Changelog</a></li> + <li class="divider"></li> <li><a href="$sbRoot/manage/manageSearches/forceVersionCheck"><i class="icon-check"></i> Force Version Check</a></li> <li class="divider"></li> <li><a href="$sbRoot/home/restart/?pid=$sbPID" class="confirm"><i class="icon-repeat"></i> Restart</a></li> From ea66a6b15e23a23cc15bb1c4ae8157047bb7c56e Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:25:22 +0200 Subject: [PATCH 047/492] forgot image git --- data/images/menu/github-16.png | Bin 0 -> 490 bytes sickbeard/versionChecker.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 data/images/menu/github-16.png diff --git a/data/images/menu/github-16.png b/data/images/menu/github-16.png new file mode 100644 index 0000000000000000000000000000000000000000..96c95e0f6de7329c21b09d21bd923402cb385e4b GIT binary patch literal 490 zcmV<G0Tup<P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00009a7bBm000XU z000XU0RWnu7ytkOAY({UO#lFTB>(_`g8%^e{{R4h=>PzAFaQARU;qF*m;eA5Z<1fd zMgRZ-a7jc#RCwBq(?3YnQ4|O8k1v%Hts$zVKTAUn1uo&_P(uXq26y`dZ;8SPT!g}* zsi7&tMG<L=sE7)eT%;&L-qa#RN{NLvWYJQ>bNN2_@l&${mwWHI=li|)p7T8|Hri$M zVLw*Tg*JS{UCi3Tj^PdN;8cK3+lPy&W3&jWWIl>1%-|yy@GCdNPyEF-9LN3g?RA{b zx{<LI;0k`kS4+$y2Aklkc$@X<0>NhES>n3!A-VDb{W*BD4M78k68i|_l{g>Ax!3k~ zF!MBax1tkglC-t|AUKkQo@`d3f?a6Ab<F<*Jc0KG+zxn-JqZ{>FV11P@b4*HD3Oie zVI>N?Q8;*jp0NLk?-eFX1Xawz34Dp#;0m5%6<_m$dnNE~yez!j&8V+E`}R`lUKhXv zAwLYQao)lKyu&a)rzaNBf$wRq;VAYjK1t@4*lSVQVu*G|AJq_Dio9(`Ak9)ugZ)m4 g8%fw$Su5)U02>u!%JWZvq5uE@07*qoM6N<$g8RYJU;qFB literal 0 HcmV?d00001 diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 71be19ecc9..65b6cd3068 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -154,7 +154,7 @@ def need_update(self): def set_newest_text(self): new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - new_str += 'Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' + new_str += ' Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def update(self): From 12a18c30943a3ae04619368ef73e3192db54d1e8 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:27:35 +0200 Subject: [PATCH 048/492] added message on update --- sickbeard/versionChecker.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 65b6cd3068..0c7b114ab3 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -346,9 +346,8 @@ def set_newest_text(self): new_str = 'There is a <a href="'+url+'" onclick="window.open(this.href); return false;">newer version available</a> ('+message+')' new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - + new_str += ' Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str - def need_update(self): self._find_installed_version() try: From 9bee6739c6bcb8f4d74b4605ce82cff6519235b4 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:29:36 +0200 Subject: [PATCH 049/492] changed title in readme --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index b15642b1b5..7556d01598 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -Sick Beard_Lang_Version (French / English) +SickBeard VO/VF ===== This version is base on Midgetspy's work. From 3ba2ecf67b42cd684a46684fe3834510ad1921ed Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:32:32 +0200 Subject: [PATCH 050/492] corrected message --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 0c7b114ab3..22d57515b0 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -346,7 +346,7 @@ def set_newest_text(self): new_str = 'There is a <a href="'+url+'" onclick="window.open(this.href); return false;">newer version available</a> ('+message+')' new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - new_str += ' Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' + new_str += ' Click <a href="'+url+'" onclick="window.open(this.href); return false;"><b>Here<\b></a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def need_update(self): self._find_installed_version() From cda69d9add58e8ba0907d25d159fccbb3fc2d3e6 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:33:30 +0200 Subject: [PATCH 051/492] uppdated readme.md --- readme.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 7556d01598..38beac2672 100644 --- a/readme.md +++ b/readme.md @@ -10,12 +10,9 @@ Nzb Scraper added : binnews (with nzbindex, binsearch and nzbclub) Torrent scraper added : t411 Torrent gestion with transmission, utorrent deluge gestion Interfaces change -And much more.... - -To come : subliminal integration +subliminal integration and much more -TODO:histlog integration *Sick Beard is currently an alpha release. There may be severe bugs in it and at any given time it may not work at all.* From 1f3bddb370184f9d4a181db6f77d20726153b593 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:36:02 +0200 Subject: [PATCH 052/492] final implementation of changelog --- sickbeard/versionChecker.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 22d57515b0..0deb052203 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -153,8 +153,8 @@ def need_update(self): def set_newest_text(self): new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' - new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - new_str += ' Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">Here</a> to see new upgrades' + new_str += "— <a href=\""+self.get_update_url()+"\">UPDATE NOW</a>" + new_str += ' Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">HERE</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def update(self): @@ -345,8 +345,8 @@ def set_newest_text(self): url = 'http://github.com/sarakha63/Sick-Beard/commits/' new_str = 'There is a <a href="'+url+'" onclick="window.open(this.href); return false;">newer version available</a> ('+message+')' - new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" - new_str += ' Click <a href="'+url+'" onclick="window.open(this.href); return false;"><b>Here<\b></a> to see new upgrades' + new_str += "— <a href=\""+self.get_update_url()+"\">UPDATE NOW</a>" + new_str += ' Click <a href="'+url+'" onclick="window.open(this.href); return false;">HERE</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def need_update(self): self._find_installed_version() @@ -436,7 +436,7 @@ def set_newest_text(self): logger.log(u"Unknown current version, don't know if we should update or not", logger.DEBUG) new_str = "Unknown version: If you've never used the Sick Beard upgrade system then I don't know what version you have." - new_str += "— <a href=\""+self.get_update_url()+"\">Update Now</a>" + new_str += "— <a href=\""+self.get_update_url()+"\">UPDATE NOW/a>" sickbeard.NEWEST_VERSION_STRING = new_str From 8eae2621e62cb2b2cb15691797812a512d289626 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:38:33 +0200 Subject: [PATCH 053/492] changed bottom message --- data/interfaces/default/inc_bottom.tmpl | 52 ++++++++++++------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/data/interfaces/default/inc_bottom.tmpl b/data/interfaces/default/inc_bottom.tmpl index c7bb99a7d2..6989c20b15 100644 --- a/data/interfaces/default/inc_bottom.tmpl +++ b/data/interfaces/default/inc_bottom.tmpl @@ -1,26 +1,26 @@ -#import sickbeard -#import datetime -#from sickbeard import db -#from sickbeard.common import * - </div> <!-- /content --> -</div> <!-- /contentWrapper --> - -<footer> - <div class="container footer"> - #set $myDB = $db.DBConnection() - #set $today = str($datetime.date.today().toordinal()) - #set $numShows = len($sickbeard.showList) - #set $numGoodShows = len([x for x in $sickbeard.showList if x.paused == 0 and x.status != "Ended"]) - #set $numDLEpisodes = $myDB.select("SELECT COUNT(*) FROM tv_episodes WHERE status IN ("+",".join([str(x) for x in $Quality.DOWNLOADED + [$ARCHIVED]])+") AND season != 0 and episode != 0 AND airdate <= " + $today + "")[0][0] - #set $numEpisodes = $myDB.select("SELECT COUNT(*) FROM tv_episodes WHERE season != 0 and episode != 0 AND (airdate != 1 OR status IN ("+",".join([str(x) for x in ($Quality.DOWNLOADED + $Quality.SNATCHED + $Quality.SNATCHED_PROPER) + [$ARCHIVED]]) + ")) AND airdate <= " + $today + " AND status != " + str($IGNORED) + "")[0][0] - <b>$numShows shows</b> ($numGoodShows active) | <b>$numDLEpisodes/$numEpisodes</b> episodes downloaded - <br /> - <b>Search</b>: <%=str(sickbeard.currentSearchScheduler.timeLeft()).split('.')[0]%> | - <b>Backlog</b>: $sickbeard.backlogSearchScheduler.nextRun().strftime("%a %b %d").decode($sickbeard.SYS_ENCODING) - <br /> - Modifed by mozvip and sarakha63 based on midgetspy's work - </div> <!-- /container footer --> -</footer> - -</body> -</html> +#import sickbeard +#import datetime +#from sickbeard import db +#from sickbeard.common import * + </div> <!-- /content --> +</div> <!-- /contentWrapper --> + +<footer> + <div class="container footer"> + #set $myDB = $db.DBConnection() + #set $today = str($datetime.date.today().toordinal()) + #set $numShows = len($sickbeard.showList) + #set $numGoodShows = len([x for x in $sickbeard.showList if x.paused == 0 and x.status != "Ended"]) + #set $numDLEpisodes = $myDB.select("SELECT COUNT(*) FROM tv_episodes WHERE status IN ("+",".join([str(x) for x in $Quality.DOWNLOADED + [$ARCHIVED]])+") AND season != 0 and episode != 0 AND airdate <= " + $today + "")[0][0] + #set $numEpisodes = $myDB.select("SELECT COUNT(*) FROM tv_episodes WHERE season != 0 and episode != 0 AND (airdate != 1 OR status IN ("+",".join([str(x) for x in ($Quality.DOWNLOADED + $Quality.SNATCHED + $Quality.SNATCHED_PROPER) + [$ARCHIVED]]) + ")) AND airdate <= " + $today + " AND status != " + str($IGNORED) + "")[0][0] + <b>$numShows shows</b> ($numGoodShows active) | <b>$numDLEpisodes/$numEpisodes</b> episodes downloaded + <br /> + <b>Search</b>: <%=str(sickbeard.currentSearchScheduler.timeLeft()).split('.')[0]%> | + <b>Backlog</b>: $sickbeard.backlogSearchScheduler.nextRun().strftime("%a %b %d").decode($sickbeard.SYS_ENCODING) + <br /> + Modifed by sarakha63 with the help of mozvip, based on midgetspy's work + </div> <!-- /container footer --> +</footer> + +</body> +</html> From 66c8fa9a7f90c79068dbfa3fe89a7b5af1cb1a91 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:40:22 +0200 Subject: [PATCH 054/492] pretty pattern --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 0deb052203..9fd837cf8f 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -154,7 +154,7 @@ def need_update(self): def set_newest_text(self): new_str = 'There is a <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">newer version available</a> (build '+str(self._newest_version)+')' new_str += "— <a href=\""+self.get_update_url()+"\">UPDATE NOW</a>" - new_str += ' Click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">HERE</a> to see new upgrades' + new_str += ' - Or click <a href="'+self.gc_url+'" onclick="window.open(this.href); return false;">HERE</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def update(self): From d51da3442751cbdffa18d729c03437ae7912a95b Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:42:16 +0200 Subject: [PATCH 055/492] changed readme --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 38beac2672..14ed7aab56 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -SickBeard VO/VF +SickBeard VO/VF by sarakha63 ===== This version is base on Midgetspy's work. From c9f94843c3a1a5b74bbfc8fccfb032566ee1ce30 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:44:20 +0200 Subject: [PATCH 056/492] more info --- readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 14ed7aab56..4870d929cb 100644 --- a/readme.md +++ b/readme.md @@ -7,10 +7,11 @@ IT includes : French and english audio language Nzb Scraper added : binnews (with nzbindex, binsearch and nzbclub) -Torrent scraper added : t411 +Torrent scraper added : t411, cpasbien, piratebay, gks, kat Torrent gestion with transmission, utorrent deluge gestion Interfaces change subliminal integration +next possiblble release download and much more From f0b8e7b0d6a779d00fd6770bddb51b098ea3ddb7 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:45:33 +0200 Subject: [PATCH 057/492] correction of pretty message --- sickbeard/versionChecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 9fd837cf8f..73e249c67c 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -346,7 +346,7 @@ def set_newest_text(self): new_str = 'There is a <a href="'+url+'" onclick="window.open(this.href); return false;">newer version available</a> ('+message+')' new_str += "— <a href=\""+self.get_update_url()+"\">UPDATE NOW</a>" - new_str += ' Click <a href="'+url+'" onclick="window.open(this.href); return false;">HERE</a> to see new upgrades' + new_str += ' - Or click <a href="'+url+'" onclick="window.open(this.href); return false;">HERE</a> to see new upgrades' sickbeard.NEWEST_VERSION_STRING = new_str def need_update(self): self._find_installed_version() From 537a84b1f2b7517cb5fdd0b8289ba7a7509fae03 Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Fri, 24 May 2013 13:46:17 +0200 Subject: [PATCH 058/492] changed readme --- readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/readme.md b/readme.md index 4870d929cb..2169677098 100644 --- a/readme.md +++ b/readme.md @@ -12,6 +12,7 @@ Torrent gestion with transmission, utorrent deluge gestion Interfaces change subliminal integration next possiblble release download +torrent/nzb preferred choice and much more From 350881af7424648803091b01b8178d03811b1a1f Mon Sep 17 00:00:00 2001 From: Sarakha63 <sarakha_ludovic@yahoo.fr> Date: Sun, 26 May 2013 01:31:55 +0200 Subject: [PATCH 059/492] big changes Interface change Ratings Imdb info Network timezones (not quite yet finished, must find good css colors now) --- data/css/browser.css | 54 + data/css/comingEpisodes.css | 222 + data/css/config.css | 163 + data/css/config.less | 78 + data/css/default.css | 1874 +++++ data/css/default.less | 542 ++ data/css/formwizard.css | 91 + data/css/imports/config.less | 78 + data/css/iphone.css | 24 + data/css/jquery-ui-1.8.23.custom.css | 461 ++ data/css/jquery.pnotify.default.css | 101 + .../{lib/jquery.qtip.css => jquery.qtip2.css} | 41 +- data/css/lib/bootstrap.css | 4960 ----------- data/css/lib/images/tablesorter/asc.gif | Bin 0 -> 54 bytes data/css/lib/images/tablesorter/bg.gif | Bin 0 -> 64 bytes data/css/lib/images/tablesorter/desc.gif | Bin 0 -> 54 bytes .../ui-bg_fine-grain_10_eceadf_60x60.png | Bin 4429 -> 0 bytes .../lib/images/ui-bg_flat_0_000000_40x100.png | Bin 178 -> 0 bytes ...00.png => ui-bg_flat_75_ffffff_40x100.png} | Bin .../images/ui-bg_glass_55_fbf9ee_1x400.png | Bin 144 -> 120 bytes .../images/ui-bg_glass_65_ffffff_1x400.png | Bin 0 -> 105 bytes .../images/ui-bg_glass_75_dadada_1x400.png | Bin 0 -> 111 bytes .../images/ui-bg_glass_75_e6e6e6_1x400.png | Bin 0 -> 110 bytes .../ui-bg_highlight-soft_75_cccccc_1x100.png | Bin 0 -> 101 bytes .../ui-bg_highlight-soft_75_dcdcdc_1x100.png | Bin 98 -> 0 bytes .../ui-bg_highlight-soft_75_dddddd_1x100.png | Bin 96 -> 0 bytes .../ui-bg_highlight-soft_75_efefef_1x100.png | Bin 128 -> 0 bytes .../ui-bg_inset-soft_75_dfdfdf_1x100.png | Bin 98 -> 0 bytes ...56x240.png => ui-icons_454545_256x240.png} | Bin 4369 -> 4369 bytes .../lib/images/ui-icons_888888_256x240.png | Bin 0 -> 4369 bytes data/css/lib/jquery-ui-1.8.17.custom.css | 567 ++ data/css/lib/jquery.pnotify.default.css | 140 +- data/css/lib/jquery.qtip2.css | 536 ++ data/css/lib/tablesorter.css | 100 + data/css/style.css | 1280 --- data/css/superfish.css | 285 + data/css/superfish.less | 211 + data/css/tablesorter.less | 91 + data/css/token-input-facebook.css | 122 + data/css/token-input-mac.css | 204 + data/images/bg.gif | Bin 0 -> 48 bytes data/images/changelog16.png | Bin 0 -> 161 bytes data/images/flags/ad.png | Bin 0 -> 643 bytes data/images/flags/ae.png | Bin 0 -> 408 bytes data/images/flags/af.png | Bin 0 -> 604 bytes data/images/flags/ag.png | Bin 0 -> 591 bytes data/images/flags/ai.png | Bin 0 -> 643 bytes data/images/flags/al.png | Bin 0 -> 600 bytes data/images/flags/am.png | Bin 0 -> 497 bytes data/images/flags/an.png | Bin 0 -> 488 bytes data/images/flags/ao.png | Bin 0 -> 428 bytes data/images/flags/ar.png | Bin 0 -> 506 bytes data/images/flags/as.png | Bin 0 -> 647 bytes data/images/flags/at.png | Bin 0 -> 403 bytes data/images/flags/au.png | Bin 0 -> 673 bytes data/images/flags/aw.png | Bin 0 -> 524 bytes data/images/flags/ax.png | Bin 0 -> 663 bytes data/images/flags/az.png | Bin 0 -> 589 bytes data/images/flags/ba.png | Bin 0 -> 593 bytes data/images/flags/bb.png | Bin 0 -> 585 bytes data/images/flags/bd.png | Bin 0 -> 504 bytes data/images/flags/be.png | Bin 0 -> 449 bytes data/images/flags/bf.png | Bin 0 -> 497 bytes data/images/flags/bg.png | Bin 0 -> 462 bytes data/images/flags/bh.png | Bin 0 -> 457 bytes data/images/flags/bi.png | Bin 0 -> 675 bytes data/images/flags/bj.png | Bin 0 -> 486 bytes data/images/flags/bm.png | Bin 0 -> 611 bytes data/images/flags/bn.png | Bin 0 -> 639 bytes data/images/flags/bo.png | Bin 0 -> 500 bytes data/images/flags/br.png | Bin 0 -> 593 bytes data/images/flags/bs.png | Bin 0 -> 526 bytes data/images/flags/bt.png | Bin 0 -> 631 bytes data/images/flags/bv.png | Bin 0 -> 512 bytes data/images/flags/bw.png | Bin 0 -> 443 bytes data/images/flags/by.png | Bin 0 -> 514 bytes data/images/flags/bz.png | Bin 0 -> 600 bytes data/images/flags/ca.png | Bin 0 -> 628 bytes data/images/flags/cc.png | Bin 0 -> 625 bytes data/images/flags/cd.png | Bin 0 -> 528 bytes data/images/flags/cf.png | Bin 0 -> 614 bytes data/images/flags/cg.png | Bin 0 -> 521 bytes data/images/flags/ch.png | Bin 0 -> 367 bytes data/images/flags/ci.png | Bin 0 -> 453 bytes data/images/flags/ck.png | Bin 0 -> 586 bytes data/images/flags/cl.png | Bin 0 -> 450 bytes data/images/flags/cm.png | Bin 0 -> 525 bytes data/images/flags/cn.png | Bin 0 -> 472 bytes data/images/flags/co.png | Bin 0 -> 483 bytes data/images/flags/cr.png | Bin 0 -> 477 bytes data/images/flags/cu.png | Bin 0 -> 563 bytes data/images/flags/cv.png | Bin 0 -> 529 bytes data/images/flags/cx.png | Bin 0 -> 608 bytes data/images/flags/cy.png | Bin 0 -> 428 bytes data/images/flags/cz.png | Bin 0 -> 476 bytes data/images/flags/dj.png | Bin 0 -> 572 bytes data/images/flags/dk.png | Bin 0 -> 495 bytes data/images/flags/dm.png | Bin 0 -> 620 bytes data/images/flags/do.png | Bin 0 -> 508 bytes data/images/flags/dz.png | Bin 0 -> 582 bytes data/images/flags/ec.png | Bin 0 -> 500 bytes data/images/flags/ee.png | Bin 0 -> 429 bytes data/images/flags/eg.png | Bin 0 -> 465 bytes data/images/flags/eh.png | Bin 0 -> 508 bytes data/images/flags/er.png | Bin 0 -> 653 bytes data/images/flags/et.png | Bin 0 -> 592 bytes data/images/flags/fam.png | Bin 0 -> 532 bytes data/images/flags/fj.png | Bin 0 -> 610 bytes data/images/flags/fk.png | Bin 0 -> 648 bytes data/images/flags/fm.png | Bin 0 -> 552 bytes data/images/flags/fo.png | Bin 0 -> 474 bytes data/images/flags/ga.png | Bin 0 -> 489 bytes data/images/flags/gb.png | Bin 0 -> 599 bytes data/images/flags/gd.png | Bin 0 -> 637 bytes data/images/flags/ge.png | Bin 0 -> 594 bytes data/images/flags/gf.png | Bin 0 -> 545 bytes data/images/flags/gh.png | Bin 0 -> 490 bytes data/images/flags/gi.png | Bin 0 -> 463 bytes data/images/flags/gl.png | Bin 0 -> 470 bytes data/images/flags/gm.png | Bin 0 -> 493 bytes data/images/flags/gn.png | Bin 0 -> 480 bytes data/images/flags/gp.png | Bin 0 -> 488 bytes data/images/flags/gq.png | Bin 0 -> 537 bytes data/images/flags/gr.png | Bin 0 -> 487 bytes data/images/flags/gs.png | Bin 0 -> 630 bytes data/images/flags/gt.png | Bin 0 -> 493 bytes data/images/flags/gu.png | Bin 0 -> 509 bytes data/images/flags/gw.png | Bin 0 -> 516 bytes data/images/flags/gy.png | Bin 0 -> 645 bytes data/images/flags/hk.png | Bin 0 -> 527 bytes data/images/flags/hm.png | Bin 0 -> 673 bytes data/images/flags/hn.png | Bin 0 -> 537 bytes data/images/flags/ht.png | Bin 0 -> 487 bytes data/images/flags/id.png | Bin 0 -> 430 bytes data/images/flags/ie.png | Bin 0 -> 481 bytes data/images/flags/il.png | Bin 0 -> 431 bytes data/images/flags/in.png | Bin 0 -> 503 bytes data/images/flags/io.png | Bin 0 -> 658 bytes data/images/flags/iq.png | Bin 0 -> 515 bytes data/images/flags/ir.png | Bin 0 -> 512 bytes data/images/flags/is.png | Bin 0 -> 532 bytes data/images/flags/jm.png | Bin 0 -> 637 bytes data/images/flags/jo.png | Bin 0 -> 473 bytes data/images/flags/jp.png | Bin 0 -> 420 bytes data/images/flags/ke.png | Bin 0 -> 569 bytes data/images/flags/kg.png | Bin 0 -> 510 bytes data/images/flags/kh.png | Bin 0 -> 549 bytes data/images/flags/ki.png | Bin 0 -> 656 bytes data/images/flags/km.png | Bin 0 -> 577 bytes data/images/flags/kn.png | Bin 0 -> 604 bytes data/images/flags/kp.png | Bin 0 -> 561 bytes data/images/flags/kr.png | Bin 0 -> 592 bytes data/images/flags/kw.png | Bin 0 -> 486 bytes data/images/flags/ky.png | Bin 0 -> 643 bytes data/images/flags/kz.png | Bin 0 -> 616 bytes data/images/flags/la.png | Bin 0 -> 563 bytes data/images/flags/lb.png | Bin 0 -> 517 bytes data/images/flags/lc.png | Bin 0 -> 520 bytes data/images/flags/li.png | Bin 0 -> 537 bytes data/images/flags/lk.png | Bin 0 -> 627 bytes data/images/flags/lr.png | Bin 0 -> 466 bytes data/images/flags/ls.png | Bin 0 -> 628 bytes data/images/flags/lt.png | Bin 0 -> 508 bytes data/images/flags/lu.png | Bin 0 -> 481 bytes data/images/flags/lv.png | Bin 0 -> 465 bytes data/images/flags/ly.png | Bin 0 -> 419 bytes data/images/flags/ma.png | Bin 0 -> 432 bytes data/images/flags/mc.png | Bin 0 -> 380 bytes data/images/flags/md.png | Bin 0 -> 566 bytes data/images/flags/me.png | Bin 0 -> 448 bytes data/images/flags/mg.png | Bin 0 -> 453 bytes data/images/flags/mh.png | Bin 0 -> 628 bytes data/images/flags/mk.png | Bin 0 -> 664 bytes data/images/flags/ml.png | Bin 0 -> 474 bytes data/images/flags/mm.png | Bin 0 -> 483 bytes data/images/flags/mn.png | Bin 0 -> 492 bytes data/images/flags/mo.png | Bin 0 -> 588 bytes data/images/flags/mp.png | Bin 0 -> 597 bytes data/images/flags/mq.png | Bin 0 -> 655 bytes data/images/flags/mr.png | Bin 0 -> 569 bytes data/images/flags/ms.png | Bin 0 -> 614 bytes data/images/flags/mt.png | Bin 0 -> 420 bytes data/images/flags/mu.png | Bin 0 -> 496 bytes data/images/flags/mv.png | Bin 0 -> 542 bytes data/images/flags/mw.png | Bin 0 -> 529 bytes data/images/flags/mx.png | Bin 0 -> 574 bytes data/images/flags/my.png | Bin 0 -> 571 bytes data/images/flags/mz.png | Bin 0 -> 584 bytes data/images/flags/na.png | Bin 0 -> 647 bytes data/images/flags/nc.png | Bin 0 -> 591 bytes data/images/flags/ne.png | Bin 0 -> 537 bytes data/images/flags/nf.png | Bin 0 -> 602 bytes data/images/flags/ng.png | Bin 0 -> 482 bytes data/images/flags/ni.png | Bin 0 -> 508 bytes data/images/flags/np.png | Bin 0 -> 443 bytes data/images/flags/nr.png | Bin 0 -> 527 bytes data/images/flags/nu.png | Bin 0 -> 572 bytes data/images/flags/nz.png | Bin 0 -> 639 bytes data/images/flags/om.png | Bin 0 -> 478 bytes data/images/flags/pa.png | Bin 0 -> 519 bytes data/images/flags/pb.png | Bin 0 -> 593 bytes data/images/flags/pe.png | Bin 0 -> 397 bytes data/images/flags/pf.png | Bin 0 -> 498 bytes data/images/flags/pg.png | Bin 0 -> 593 bytes data/images/flags/ph.png | Bin 0 -> 538 bytes data/images/flags/pk.png | Bin 0 -> 569 bytes data/images/flags/pm.png | Bin 0 -> 689 bytes data/images/flags/pn.png | Bin 0 -> 657 bytes data/images/flags/pr.png | Bin 0 -> 556 bytes data/images/flags/ps.png | Bin 0 -> 472 bytes data/images/flags/pw.png | Bin 0 -> 550 bytes data/images/flags/py.png | Bin 0 -> 473 bytes data/images/flags/qa.png | Bin 0 -> 450 bytes data/images/flags/re.png | Bin 0 -> 545 bytes data/images/flags/ro.png | Bin 0 -> 495 bytes data/images/flags/rs.png | Bin 0 -> 423 bytes data/images/flags/rw.png | Bin 0 -> 533 bytes data/images/flags/sa.png | Bin 0 -> 551 bytes data/images/flags/sb.png | Bin 0 -> 624 bytes data/images/flags/sc.png | Bin 0 -> 608 bytes data/images/flags/sd.png | Bin 0 -> 492 bytes data/images/flags/se.png | Bin 0 -> 542 bytes data/images/flags/sg.png | Bin 0 -> 468 bytes data/images/flags/sh.png | Bin 0 -> 645 bytes data/images/flags/si.png | Bin 0 -> 510 bytes data/images/flags/sj.png | Bin 0 -> 512 bytes data/images/flags/sk.png | Bin 0 -> 562 bytes data/images/flags/sm.png | Bin 0 -> 502 bytes data/images/flags/sn.png | Bin 0 -> 532 bytes data/images/flags/so.png | Bin 0 -> 527 bytes data/images/flags/sr.png | Bin 0 -> 513 bytes data/images/flags/st.png | Bin 0 -> 584 bytes data/images/flags/sy.png | Bin 0 -> 422 bytes data/images/flags/sz.png | Bin 0 -> 643 bytes data/images/flags/tc.png | Bin 0 -> 624 bytes data/images/flags/td.png | Bin 0 -> 570 bytes data/images/flags/tf.png | Bin 0 -> 527 bytes data/images/flags/tg.png | Bin 0 -> 562 bytes data/images/flags/th.png | Bin 0 -> 452 bytes data/images/flags/tj.png | Bin 0 -> 496 bytes data/images/flags/tk.png | Bin 0 -> 638 bytes data/images/flags/tl.png | Bin 0 -> 514 bytes data/images/flags/tm.png | Bin 0 -> 593 bytes data/images/flags/tn.png | Bin 0 -> 495 bytes data/images/flags/to.png | Bin 0 -> 426 bytes data/images/flags/tt.png | Bin 0 -> 617 bytes data/images/flags/tv.png | Bin 0 -> 536 bytes data/images/flags/tw.png | Bin 0 -> 465 bytes data/images/flags/tz.png | Bin 0 -> 642 bytes data/images/flags/ua.png | Bin 0 -> 446 bytes data/images/flags/ug.png | Bin 0 -> 531 bytes data/images/flags/um.png | Bin 0 -> 571 bytes data/images/flags/us.png | Bin 0 -> 609 bytes data/images/flags/uy.png | Bin 0 -> 532 bytes data/images/flags/uz.png | Bin 0 -> 515 bytes data/images/flags/va.png | Bin 0 -> 553 bytes data/images/flags/vc.png | Bin 0 -> 577 bytes data/images/flags/ve.png | Bin 0 -> 528 bytes data/images/flags/vg.png | Bin 0 -> 630 bytes data/images/flags/vi.png | Bin 0 -> 616 bytes data/images/flags/vn.png | Bin 0 -> 474 bytes data/images/flags/vu.png | Bin 0 -> 604 bytes data/images/flags/wf.png | Bin 0 -> 554 bytes data/images/flags/ws.png | Bin 0 -> 476 bytes data/images/flags/ye.png | Bin 0 -> 413 bytes data/images/flags/yt.png | Bin 0 -> 593 bytes data/images/flags/za.png | Bin 0 -> 642 bytes data/images/flags/zm.png | Bin 0 -> 500 bytes data/images/flags/zw.png | Bin 0 -> 574 bytes data/images/imdb.png | Bin 0 -> 9158 bytes data/images/like.png | Bin 0 -> 132 bytes data/images/menu/system18.png | Bin 1259 -> 3278 bytes data/images/network/cinemax.png | Bin 0 -> 4017 bytes data/images/network/city.png | Bin 0 -> 4048 bytes data/images/notifiers/email.png | Bin 0 -> 4258 bytes data/images/notifiers/pushalot.png | Bin 0 -> 927 bytes data/images/notifiers/synologynotifier.png | Bin 0 -> 2041 bytes data/images/providers/dailytvtorrents.gif | Bin 0 -> 586 bytes data/images/providers/iptorrents.png | Bin 0 -> 406 bytes data/images/providers/thepiratebay.png | Bin 0 -> 2967 bytes data/images/providers/thepiratebay_.png | Bin 0 -> 1609 bytes data/images/ratings/0.png | Bin 0 -> 3631 bytes data/images/ratings/1.png | Bin 0 -> 4443 bytes data/images/ratings/10.png | Bin 0 -> 5848 bytes data/images/ratings/2.png | Bin 0 -> 4541 bytes data/images/ratings/3.png | Bin 0 -> 5179 bytes data/images/ratings/4.png | Bin 0 -> 4667 bytes data/images/ratings/5.png | Bin 0 -> 5910 bytes data/images/ratings/6.png | Bin 0 -> 5887 bytes data/images/ratings/7.png | Bin 0 -> 6321 bytes data/images/ratings/8.png | Bin 0 -> 6174 bytes data/images/ratings/9.png | Bin 0 -> 6110 bytes data/images/search32.png | Bin 0 -> 321 bytes data/images/subtitles/itasa.png | Bin 0 -> 1150 bytes data/images/tablesorter/asc.gif | Bin 0 -> 54 bytes data/images/tablesorter/bg.gif | Bin 0 -> 64 bytes data/images/tablesorter/desc.gif | Bin 0 -> 54 bytes data/images/xbmc-notify.png | Bin 0 -> 8616 bytes data/interfaces/default/apiBuilder.tmpl | 81 +- data/interfaces/default/comingEpisodes.tmpl | 636 +- data/interfaces/default/config.tmpl | 57 +- data/interfaces/default/config_general.tmpl | 53 +- .../default/config_notifications.tmpl | 450 +- .../default/config_postProcessing.tmpl | 250 +- data/interfaces/default/config_providers.tmpl | 609 +- data/interfaces/default/config_search.tmpl | 881 +- data/interfaces/default/config_subtitles.tmpl | 314 +- data/interfaces/default/displayShow.tmpl | 651 +- data/interfaces/default/editShow.tmpl | 265 +- data/interfaces/default/errorlogs.tmpl | 55 +- data/interfaces/default/genericMessage.tmpl | 28 +- data/interfaces/default/history.tmpl | 189 +- data/interfaces/default/home.tmpl | 522 +- .../default/home_addExistingShow.tmpl | 129 +- data/interfaces/default/home_addShows.tmpl | 90 +- .../interfaces/default/home_massAddTable.tmpl | 52 +- data/interfaces/default/home_newShow.tmpl | 34 +- data/interfaces/default/home_postprocess.tmpl | 47 +- .../default/inc_addShowOptions.tmpl | 124 +- data/interfaces/default/inc_bottom.tmpl | 44 +- .../default/inc_qualityChooser.tmpl | 94 +- data/interfaces/default/inc_rootDirs.tmpl | 56 +- data/interfaces/default/inc_top.tmpl | 395 +- data/interfaces/default/manage.tmpl | 337 +- .../default/manage_backlogOverview.tmpl | 164 +- .../default/manage_episodeStatuses.tmpl | 154 +- .../default/manage_manageSearches.tmpl | 10 +- data/interfaces/default/manage_massEdit.tmpl | 358 +- .../default/manage_subtitleMissed.tmpl | 10 +- data/interfaces/default/restart_bare.tmpl | 88 +- data/interfaces/default/testRename.tmpl | 160 +- data/interfaces/default/viewlogs.tmpl | 93 +- data/js/addExistingShow.js | 154 +- data/js/addShowOptions.js | 52 +- data/js/ajaxNotifications.js | 14 +- data/js/browser.js | 349 +- data/js/config.js | 99 +- data/js/configPostProcessing.js | 590 +- data/js/configProviders.js | 19 +- data/js/configSearch.js | 268 +- data/js/displayShow.js | 379 +- data/js/fancybox/blank.gif | Bin 0 -> 43 bytes data/js/fancybox/fancy_close.png | Bin 0 -> 1517 bytes data/js/fancybox/fancy_loading.png | Bin 0 -> 10195 bytes data/js/fancybox/fancy_nav_left.png | Bin 0 -> 1446 bytes data/js/fancybox/fancy_nav_right.png | Bin 0 -> 1454 bytes data/js/fancybox/fancy_shadow_e.png | Bin 0 -> 107 bytes data/js/fancybox/fancy_shadow_n.png | Bin 0 -> 106 bytes data/js/fancybox/fancy_shadow_ne.png | Bin 0 -> 347 bytes data/js/fancybox/fancy_shadow_nw.png | Bin 0 -> 324 bytes data/js/fancybox/fancy_shadow_s.png | Bin 0 -> 111 bytes data/js/fancybox/fancy_shadow_se.png | Bin 0 -> 352 bytes data/js/fancybox/fancy_shadow_sw.png | Bin 0 -> 340 bytes data/js/fancybox/fancy_shadow_w.png | Bin 0 -> 103 bytes data/js/fancybox/fancy_title_left.png | Bin 0 -> 503 bytes data/js/fancybox/fancy_title_main.png | Bin 0 -> 96 bytes data/js/fancybox/fancy_title_over.png | Bin 0 -> 70 bytes data/js/fancybox/fancy_title_right.png | Bin 0 -> 506 bytes data/js/fancybox/fancybox-x.png | Bin 0 -> 203 bytes data/js/fancybox/fancybox-y.png | Bin 0 -> 176 bytes data/js/fancybox/fancybox.png | Bin 0 -> 15287 bytes data/js/fancybox/jquery.easing-1.3.pack.js | 72 + data/js/fancybox/jquery.fancybox-1.3.4.css | 359 + data/js/fancybox/jquery.fancybox-1.3.4.js | 1156 +++ .../js/fancybox/jquery.fancybox-1.3.4.pack.js | 46 + .../fancybox/jquery.mousewheel-3.0.4.pack.js | 14 + data/js/{lib => }/formwizard.js | 0 data/js/lib/bootstrap.min.js | 6 - data/js/lib/jquery-1.7.2.min.js | 4 - data/js/lib/jquery-1.8.3.min.js | 2 + data/js/lib/jquery.bookmarkscroll.js | 100 +- data/js/lib/jquery.pnotify-1.0.2.min.js | 34 + data/js/lib/jquery.selectboxes.min.js | 26 +- data/js/lib/jquery.tokeninput.js | 1722 ++-- data/js/lib/superfish-1.4.8.js | 120 + data/js/lib/supersubs-0.2b.js | 90 + data/js/manageEpisodeStatuses.js | 112 +- data/js/massEdit.js | 49 +- data/js/massUpdate.js | 232 +- data/js/plotTooltip.js | 81 +- data/js/script.js | 2 - data/js/tableClick.js | 24 +- lib/dateutil/zoneinfo/zoneinfo-2010g.tar.gz | Bin 171995 -> 0 bytes lib/dateutil/zoneinfo/zoneinfo-2013c.tar.gz | Bin 0 -> 181496 bytes lib/imdb/Character.py | 201 + lib/imdb/Company.py | 195 + lib/imdb/Movie.py | 398 + lib/imdb/Person.py | 275 + lib/imdb/__init__.py | 959 +++ lib/imdb/_compat.py | 72 + lib/imdb/_exceptions.py | 45 + lib/imdb/_logging.py | 63 + lib/imdb/helpers.py | 640 ++ lib/imdb/imdbpy.cfg | 78 + lib/imdb/linguistics.py | 203 + lib/imdb/locale/__init__.py | 29 + lib/imdb/locale/generatepot.py | 78 + lib/imdb/locale/imdbpy-en.po | 1257 +++ lib/imdb/locale/imdbpy-it.po | 1300 +++ lib/imdb/locale/imdbpy-tr.po | 1300 +++ lib/imdb/locale/imdbpy.pot | 1301 +++ lib/imdb/locale/msgfmt.py | 204 + lib/imdb/locale/rebuildmo.py | 49 + lib/imdb/parser/__init__.py | 28 + lib/imdb/parser/http/__init__.py | 830 ++ lib/imdb/parser/http/bsouplxml/__init__.py | 0 lib/imdb/parser/http/bsouplxml/_bsoup.py | 1970 +++++ lib/imdb/parser/http/bsouplxml/bsoupxpath.py | 410 + lib/imdb/parser/http/bsouplxml/etree.py | 75 + lib/imdb/parser/http/bsouplxml/html.py | 31 + lib/imdb/parser/http/characterParser.py | 203 + lib/imdb/parser/http/companyParser.py | 91 + lib/imdb/parser/http/movieParser.py | 1892 +++++ lib/imdb/parser/http/personParser.py | 507 ++ lib/imdb/parser/http/searchCharacterParser.py | 69 + lib/imdb/parser/http/searchCompanyParser.py | 71 + lib/imdb/parser/http/searchKeywordParser.py | 111 + lib/imdb/parser/http/searchMovieParser.py | 182 + lib/imdb/parser/http/searchPersonParser.py | 92 + lib/imdb/parser/http/topBottomParser.py | 106 + lib/imdb/parser/http/utils.py | 876 ++ lib/imdb/parser/mobile/__init__.py | 844 ++ lib/imdb/parser/sql/__init__.py | 1589 ++++ lib/imdb/parser/sql/alchemyadapter.py | 508 ++ lib/imdb/parser/sql/cutils.c | 269 + lib/imdb/parser/sql/cutils.so | Bin 0 -> 28469 bytes lib/imdb/parser/sql/dbschema.py | 476 ++ lib/imdb/parser/sql/objectadapter.py | 207 + lib/imdb/utils.py | 1572 ++++ sickbeard/__init__.py | 3148 +++---- sickbeard/databases/cache_db.py | 9 +- sickbeard/databases/mainDB.py | 24 +- sickbeard/db.py | 16 +- sickbeard/helpers.py | 1568 ++-- sickbeard/image_cache.py | 107 +- sickbeard/logger.py | 54 +- sickbeard/metadata/generic.py | 1287 +-- sickbeard/network_timezones.py | 166 + sickbeard/notifiers/__init__.py | 190 +- sickbeard/notifiers/pushalot.py | 83 + sickbeard/notifiers/synologynotifier.py | 55 + sickbeard/show_queue.py | 1013 +-- sickbeard/tv.py | 123 +- sickbeard/versionChecker.py | 4 + sickbeard/webserve.py | 7260 +++++++++-------- 445 files changed, 43566 insertions(+), 19077 deletions(-) create mode 100644 data/css/browser.css create mode 100644 data/css/comingEpisodes.css create mode 100644 data/css/config.css create mode 100644 data/css/config.less create mode 100644 data/css/default.css create mode 100644 data/css/default.less create mode 100644 data/css/formwizard.css create mode 100644 data/css/imports/config.less create mode 100644 data/css/iphone.css create mode 100644 data/css/jquery-ui-1.8.23.custom.css create mode 100644 data/css/jquery.pnotify.default.css rename data/css/{lib/jquery.qtip.css => jquery.qtip2.css} (95%) delete mode 100644 data/css/lib/bootstrap.css create mode 100644 data/css/lib/images/tablesorter/asc.gif create mode 100644 data/css/lib/images/tablesorter/bg.gif create mode 100644 data/css/lib/images/tablesorter/desc.gif delete mode 100644 data/css/lib/images/ui-bg_fine-grain_10_eceadf_60x60.png delete mode 100644 data/css/lib/images/ui-bg_flat_0_000000_40x100.png rename data/css/lib/images/{ui-bg_flat_0_ffffff_40x100.png => ui-bg_flat_75_ffffff_40x100.png} (100%) create mode 100644 data/css/lib/images/ui-bg_glass_65_ffffff_1x400.png create mode 100644 data/css/lib/images/ui-bg_glass_75_dadada_1x400.png create mode 100644 data/css/lib/images/ui-bg_glass_75_e6e6e6_1x400.png create mode 100644 data/css/lib/images/ui-bg_highlight-soft_75_cccccc_1x100.png delete mode 100644 data/css/lib/images/ui-bg_highlight-soft_75_dcdcdc_1x100.png delete mode 100644 data/css/lib/images/ui-bg_highlight-soft_75_dddddd_1x100.png delete mode 100644 data/css/lib/images/ui-bg_highlight-soft_75_efefef_1x100.png delete mode 100644 data/css/lib/images/ui-bg_inset-soft_75_dfdfdf_1x100.png rename data/css/lib/images/{ui-icons_8c291d_256x240.png => ui-icons_454545_256x240.png} (92%) create mode 100644 data/css/lib/images/ui-icons_888888_256x240.png create mode 100644 data/css/lib/jquery-ui-1.8.17.custom.css create mode 100644 data/css/lib/jquery.qtip2.css create mode 100644 data/css/lib/tablesorter.css delete mode 100644 data/css/style.css create mode 100644 data/css/superfish.css create mode 100644 data/css/superfish.less create mode 100644 data/css/tablesorter.less create mode 100644 data/css/token-input-facebook.css create mode 100644 data/css/token-input-mac.css create mode 100644 data/images/bg.gif create mode 100644 data/images/changelog16.png create mode 100644 data/images/flags/ad.png create mode 100644 data/images/flags/ae.png create mode 100644 data/images/flags/af.png create mode 100644 data/images/flags/ag.png create mode 100644 data/images/flags/ai.png create mode 100644 data/images/flags/al.png create mode 100644 data/images/flags/am.png create mode 100644 data/images/flags/an.png create mode 100644 data/images/flags/ao.png create mode 100644 data/images/flags/ar.png create mode 100644 data/images/flags/as.png create mode 100644 data/images/flags/at.png create mode 100644 data/images/flags/au.png create mode 100644 data/images/flags/aw.png create mode 100644 data/images/flags/ax.png create mode 100644 data/images/flags/az.png create mode 100644 data/images/flags/ba.png create mode 100644 data/images/flags/bb.png create mode 100644 data/images/flags/bd.png create mode 100644 data/images/flags/be.png create mode 100644 data/images/flags/bf.png create mode 100644 data/images/flags/bg.png create mode 100644 data/images/flags/bh.png create mode 100644 data/images/flags/bi.png create mode 100644 data/images/flags/bj.png create mode 100644 data/images/flags/bm.png create mode 100644 data/images/flags/bn.png create mode 100644 data/images/flags/bo.png create mode 100644 data/images/flags/br.png create mode 100644 data/images/flags/bs.png create mode 100644 data/images/flags/bt.png create mode 100644 data/images/flags/bv.png create mode 100644 data/images/flags/bw.png create mode 100644 data/images/flags/by.png create mode 100644 data/images/flags/bz.png create mode 100644 data/images/flags/ca.png create mode 100644 data/images/flags/cc.png create mode 100644 data/images/flags/cd.png create mode 100644 data/images/flags/cf.png create mode 100644 data/images/flags/cg.png create mode 100644 data/images/flags/ch.png create mode 100644 data/images/flags/ci.png create mode 100644 data/images/flags/ck.png create mode 100644 data/images/flags/cl.png create mode 100644 data/images/flags/cm.png create mode 100644 data/images/flags/cn.png create mode 100644 data/images/flags/co.png create mode 100644 data/images/flags/cr.png create mode 100644 data/images/flags/cu.png create mode 100644 data/images/flags/cv.png create mode 100644 data/images/flags/cx.png create mode 100644 data/images/flags/cy.png create mode 100644 data/images/flags/cz.png create mode 100644 data/images/flags/dj.png create mode 100644 data/images/flags/dk.png create mode 100644 data/images/flags/dm.png create mode 100644 data/images/flags/do.png create mode 100644 data/images/flags/dz.png create mode 100644 data/images/flags/ec.png create mode 100644 data/images/flags/ee.png create mode 100644 data/images/flags/eg.png create mode 100644 data/images/flags/eh.png create mode 100644 data/images/flags/er.png create mode 100644 data/images/flags/et.png create mode 100644 data/images/flags/fam.png create mode 100644 data/images/flags/fj.png create mode 100644 data/images/flags/fk.png create mode 100644 data/images/flags/fm.png create mode 100644 data/images/flags/fo.png create mode 100644 data/images/flags/ga.png create mode 100644 data/images/flags/gb.png create mode 100644 data/images/flags/gd.png create mode 100644 data/images/flags/ge.png create mode 100644 data/images/flags/gf.png create mode 100644 data/images/flags/gh.png create mode 100644 data/images/flags/gi.png create mode 100644 data/images/flags/gl.png create mode 100644 data/images/flags/gm.png create mode 100644 data/images/flags/gn.png create mode 100644 data/images/flags/gp.png create mode 100644 data/images/flags/gq.png create mode 100644 data/images/flags/gr.png create mode 100644 data/images/flags/gs.png create mode 100644 data/images/flags/gt.png create mode 100644 data/images/flags/gu.png create mode 100644 data/images/flags/gw.png create mode 100644 data/images/flags/gy.png create mode 100644 data/images/flags/hk.png create mode 100644 data/images/flags/hm.png create mode 100644 data/images/flags/hn.png create mode 100644 data/images/flags/ht.png create mode 100644 data/images/flags/id.png create mode 100644 data/images/flags/ie.png create mode 100644 data/images/flags/il.png create mode 100644 data/images/flags/in.png create mode 100644 data/images/flags/io.png create mode 100644 data/images/flags/iq.png create mode 100644 data/images/flags/ir.png create mode 100644 data/images/flags/is.png create mode 100644 data/images/flags/jm.png create mode 100644 data/images/flags/jo.png create mode 100644 data/images/flags/jp.png create mode 100644 data/images/flags/ke.png create mode 100644 data/images/flags/kg.png create mode 100644 data/images/flags/kh.png create mode 100644 data/images/flags/ki.png create mode 100644 data/images/flags/km.png create mode 100644 data/images/flags/kn.png create mode 100644 data/images/flags/kp.png create mode 100644 data/images/flags/kr.png create mode 100644 data/images/flags/kw.png create mode 100644 data/images/flags/ky.png create mode 100644 data/images/flags/kz.png create mode 100644 data/images/flags/la.png create mode 100644 data/images/flags/lb.png create mode 100644 data/images/flags/lc.png create mode 100644 data/images/flags/li.png create mode 100644 data/images/flags/lk.png create mode 100644 data/images/flags/lr.png create mode 100644 data/images/flags/ls.png create mode 100644 data/images/flags/lt.png create mode 100644 data/images/flags/lu.png create mode 100644 data/images/flags/lv.png create mode 100644 data/images/flags/ly.png create mode 100644 data/images/flags/ma.png create mode 100644 data/images/flags/mc.png create mode 100644 data/images/flags/md.png create mode 100644 data/images/flags/me.png create mode 100644 data/images/flags/mg.png create mode 100644 data/images/flags/mh.png create mode 100644 data/images/flags/mk.png create mode 100644 data/images/flags/ml.png create mode 100644 data/images/flags/mm.png create mode 100644 data/images/flags/mn.png create mode 100644 data/images/flags/mo.png create mode 100644 data/images/flags/mp.png create mode 100644 data/images/flags/mq.png create mode 100644 data/images/flags/mr.png create mode 100644 data/images/flags/ms.png create mode 100644 data/images/flags/mt.png create mode 100644 data/images/flags/mu.png create mode 100644 data/images/flags/mv.png create mode 100644 data/images/flags/mw.png create mode 100644 data/images/flags/mx.png create mode 100644 data/images/flags/my.png create mode 100644 data/images/flags/mz.png create mode 100644 data/images/flags/na.png create mode 100644 data/images/flags/nc.png create mode 100644 data/images/flags/ne.png create mode 100644 data/images/flags/nf.png create mode 100644 data/images/flags/ng.png create mode 100644 data/images/flags/ni.png create mode 100644 data/images/flags/np.png create mode 100644 data/images/flags/nr.png create mode 100644 data/images/flags/nu.png create mode 100644 data/images/flags/nz.png create mode 100644 data/images/flags/om.png create mode 100644 data/images/flags/pa.png create mode 100644 data/images/flags/pb.png create mode 100644 data/images/flags/pe.png create mode 100644 data/images/flags/pf.png create mode 100644 data/images/flags/pg.png create mode 100644 data/images/flags/ph.png create mode 100644 data/images/flags/pk.png create mode 100644 data/images/flags/pm.png create mode 100644 data/images/flags/pn.png create mode 100644 data/images/flags/pr.png create mode 100644 data/images/flags/ps.png create mode 100644 data/images/flags/pw.png create mode 100644 data/images/flags/py.png create mode 100644 data/images/flags/qa.png create mode 100644 data/images/flags/re.png create mode 100644 data/images/flags/ro.png create mode 100644 data/images/flags/rs.png create mode 100644 data/images/flags/rw.png create mode 100644 data/images/flags/sa.png create mode 100644 data/images/flags/sb.png create mode 100644 data/images/flags/sc.png create mode 100644 data/images/flags/sd.png create mode 100644 data/images/flags/se.png create mode 100644 data/images/flags/sg.png create mode 100644 data/images/flags/sh.png create mode 100644 data/images/flags/si.png create mode 100644 data/images/flags/sj.png create mode 100644 data/images/flags/sk.png create mode 100644 data/images/flags/sm.png create mode 100644 data/images/flags/sn.png create mode 100644 data/images/flags/so.png create mode 100644 data/images/flags/sr.png create mode 100644 data/images/flags/st.png create mode 100644 data/images/flags/sy.png create mode 100644 data/images/flags/sz.png create mode 100644 data/images/flags/tc.png create mode 100644 data/images/flags/td.png create mode 100644 data/images/flags/tf.png create mode 100644 data/images/flags/tg.png create mode 100644 data/images/flags/th.png create mode 100644 data/images/flags/tj.png create mode 100644 data/images/flags/tk.png create mode 100644 data/images/flags/tl.png create mode 100644 data/images/flags/tm.png create mode 100644 data/images/flags/tn.png create mode 100644 data/images/flags/to.png create mode 100644 data/images/flags/tt.png create mode 100644 data/images/flags/tv.png create mode 100644 data/images/flags/tw.png create mode 100644 data/images/flags/tz.png create mode 100644 data/images/flags/ua.png create mode 100644 data/images/flags/ug.png create mode 100644 data/images/flags/um.png create mode 100644 data/images/flags/us.png create mode 100644 data/images/flags/uy.png create mode 100644 data/images/flags/uz.png create mode 100644 data/images/flags/va.png create mode 100644 data/images/flags/vc.png create mode 100644 data/images/flags/ve.png create mode 100644 data/images/flags/vg.png create mode 100644 data/images/flags/vi.png create mode 100644 data/images/flags/vn.png create mode 100644 data/images/flags/vu.png create mode 100644 data/images/flags/wf.png create mode 100644 data/images/flags/ws.png create mode 100644 data/images/flags/ye.png create mode 100644 data/images/flags/yt.png create mode 100644 data/images/flags/za.png create mode 100644 data/images/flags/zm.png create mode 100644 data/images/flags/zw.png create mode 100644 data/images/imdb.png create mode 100644 data/images/like.png create mode 100644 data/images/network/cinemax.png create mode 100644 data/images/network/city.png create mode 100644 data/images/notifiers/email.png create mode 100644 data/images/notifiers/pushalot.png create mode 100644 data/images/notifiers/synologynotifier.png create mode 100644 data/images/providers/dailytvtorrents.gif create mode 100644 data/images/providers/iptorrents.png create mode 100644 data/images/providers/thepiratebay.png create mode 100644 data/images/providers/thepiratebay_.png create mode 100644 data/images/ratings/0.png create mode 100644 data/images/ratings/1.png create mode 100644 data/images/ratings/10.png create mode 100644 data/images/ratings/2.png create mode 100644 data/images/ratings/3.png create mode 100644 data/images/ratings/4.png create mode 100644 data/images/ratings/5.png create mode 100644 data/images/ratings/6.png create mode 100644 data/images/ratings/7.png create mode 100644 data/images/ratings/8.png create mode 100644 data/images/ratings/9.png create mode 100644 data/images/search32.png create mode 100644 data/images/subtitles/itasa.png create mode 100644 data/images/tablesorter/asc.gif create mode 100644 data/images/tablesorter/bg.gif create mode 100644 data/images/tablesorter/desc.gif create mode 100644 data/images/xbmc-notify.png create mode 100644 data/js/fancybox/blank.gif create mode 100644 data/js/fancybox/fancy_close.png create mode 100644 data/js/fancybox/fancy_loading.png create mode 100644 data/js/fancybox/fancy_nav_left.png create mode 100644 data/js/fancybox/fancy_nav_right.png create mode 100644 data/js/fancybox/fancy_shadow_e.png create mode 100644 data/js/fancybox/fancy_shadow_n.png create mode 100644 data/js/fancybox/fancy_shadow_ne.png create mode 100644 data/js/fancybox/fancy_shadow_nw.png create mode 100644 data/js/fancybox/fancy_shadow_s.png create mode 100644 data/js/fancybox/fancy_shadow_se.png create mode 100644 data/js/fancybox/fancy_shadow_sw.png create mode 100644 data/js/fancybox/fancy_shadow_w.png create mode 100644 data/js/fancybox/fancy_title_left.png create mode 100644 data/js/fancybox/fancy_title_main.png create mode 100644 data/js/fancybox/fancy_title_over.png create mode 100644 data/js/fancybox/fancy_title_right.png create mode 100644 data/js/fancybox/fancybox-x.png create mode 100644 data/js/fancybox/fancybox-y.png create mode 100644 data/js/fancybox/fancybox.png create mode 100644 data/js/fancybox/jquery.easing-1.3.pack.js create mode 100644 data/js/fancybox/jquery.fancybox-1.3.4.css create mode 100644 data/js/fancybox/jquery.fancybox-1.3.4.js create mode 100644 data/js/fancybox/jquery.fancybox-1.3.4.pack.js create mode 100644 data/js/fancybox/jquery.mousewheel-3.0.4.pack.js rename data/js/{lib => }/formwizard.js (100%) delete mode 100644 data/js/lib/bootstrap.min.js delete mode 100644 data/js/lib/jquery-1.7.2.min.js create mode 100644 data/js/lib/jquery-1.8.3.min.js create mode 100644 data/js/lib/jquery.pnotify-1.0.2.min.js create mode 100644 data/js/lib/superfish-1.4.8.js create mode 100644 data/js/lib/supersubs-0.2b.js delete mode 100644 lib/dateutil/zoneinfo/zoneinfo-2010g.tar.gz create mode 100644 lib/dateutil/zoneinfo/zoneinfo-2013c.tar.gz create mode 100644 lib/imdb/Character.py create mode 100644 lib/imdb/Company.py create mode 100644 lib/imdb/Movie.py create mode 100644 lib/imdb/Person.py create mode 100644 lib/imdb/__init__.py create mode 100644 lib/imdb/_compat.py create mode 100644 lib/imdb/_exceptions.py create mode 100644 lib/imdb/_logging.py create mode 100644 lib/imdb/helpers.py create mode 100644 lib/imdb/imdbpy.cfg create mode 100644 lib/imdb/linguistics.py create mode 100644 lib/imdb/locale/__init__.py create mode 100644 lib/imdb/locale/generatepot.py create mode 100644 lib/imdb/locale/imdbpy-en.po create mode 100644 lib/imdb/locale/imdbpy-it.po create mode 100644 lib/imdb/locale/imdbpy-tr.po create mode 100644 lib/imdb/locale/imdbpy.pot create mode 100644 lib/imdb/locale/msgfmt.py create mode 100644 lib/imdb/locale/rebuildmo.py create mode 100644 lib/imdb/parser/__init__.py create mode 100644 lib/imdb/parser/http/__init__.py create mode 100644 lib/imdb/parser/http/bsouplxml/__init__.py create mode 100644 lib/imdb/parser/http/bsouplxml/_bsoup.py create mode 100644 lib/imdb/parser/http/bsouplxml/bsoupxpath.py create mode 100644 lib/imdb/parser/http/bsouplxml/etree.py create mode 100644 lib/imdb/parser/http/bsouplxml/html.py create mode 100644 lib/imdb/parser/http/characterParser.py create mode 100644 lib/imdb/parser/http/companyParser.py create mode 100644 lib/imdb/parser/http/movieParser.py create mode 100644 lib/imdb/parser/http/personParser.py create mode 100644 lib/imdb/parser/http/searchCharacterParser.py create mode 100644 lib/imdb/parser/http/searchCompanyParser.py create mode 100644 lib/imdb/parser/http/searchKeywordParser.py create mode 100644 lib/imdb/parser/http/searchMovieParser.py create mode 100644 lib/imdb/parser/http/searchPersonParser.py create mode 100644 lib/imdb/parser/http/topBottomParser.py create mode 100644 lib/imdb/parser/http/utils.py create mode 100644 lib/imdb/parser/mobile/__init__.py create mode 100644 lib/imdb/parser/sql/__init__.py create mode 100644 lib/imdb/parser/sql/alchemyadapter.py create mode 100644 lib/imdb/parser/sql/cutils.c create mode 100644 lib/imdb/parser/sql/cutils.so create mode 100644 lib/imdb/parser/sql/dbschema.py create mode 100644 lib/imdb/parser/sql/objectadapter.py create mode 100644 lib/imdb/utils.py create mode 100644 sickbeard/network_timezones.py create mode 100644 sickbeard/notifiers/pushalot.py create mode 100644 sickbeard/notifiers/synologynotifier.py diff --git a/data/css/browser.css b/data/css/browser.css new file mode 100644 index 0000000000..42b6a38779 --- /dev/null +++ b/data/css/browser.css @@ -0,0 +1,54 @@ +#fileBrowserDialog { + max-height: 480px; + overflow-y: auto; +} +#fileBrowserDialog ul { + margin: 0; + padding: 0; +} +#fileBrowserDialog ul li { + margin: 2px 0; + cursor: pointer; + list-style-type: none; +} +#fileBrowserDialog ul li a { + display: block; + padding: 4px 0; +} +#fileBrowserDialog ul li a:hover { + color: blue; + background: none; +} +#fileBrowserDialog ul li a span.ui-icon { + margin: 0 4px; + float: left; +} +/* +.browserDialog.busy .ui-dialog-buttonpane { + background: url("/images/loading.gif") 10px 50% no-repeat; +} +*/ + +/* jquery ui autocomplete overrides to make it look more like the old autocomplete */ +.ui-autocomplete { + max-height: 180px; + overflow-y: auto; + /* prevent horizontal scrollbar */ + overflow-x: hidden; + /* add padding to account for vertical scrollbar */ + padding-right: 20px; +} +* html .ui-autocomplete { + height: 180px; +} +.ui-menu .ui-menu-item { + background-color: #eeeeee; +} +.ui-menu .ui-menu-item-alternate{ + background-color: #ffffff; +} +.ui-menu a.ui-state-hover{ + background: none; + background-color: #0A246A; + color: #ffffff; +} diff --git a/data/css/comingEpisodes.css b/data/css/comingEpisodes.css new file mode 100644 index 0000000000..4c747c8f56 --- /dev/null +++ b/data/css/comingEpisodes.css @@ -0,0 +1,222 @@ +.tvshowDiv { + display: block; + clear: both; + border-left: 1px solid #CCCCCC; + border-right: 1px solid #CCCCCC; + border-bottom: 1px solid #CCCCCC; + margin: auto; + padding: 0px; + text-align: left; + width: 750px; +} + +.tvshowDiv a, .tvshowDiv a:link, .tvshowDiv a:visited, .tvshowDiv a:hover { + text-decoration: none; + background: none; +} + +.tvshowTitle a { + color: #000000; + float: left; + padding-top: 3px; + line-height: 1.2em; + font-size: 1.1em; + text-shadow: -1px -1px 0 #FFF); +} + +.tvshowTitleIcons { + float: right; + padding: 3px 5px; +} + +.tvshowDiv .title { + font-weight: 900; + color: #333; +} +.imgWrapper { + background: url("../images/loading.gif") no-repeat scroll center center #FFFFFF; + border: 3px solid #FFFFFF; + box-shadow: 1px 1px 2px 0 #555555; + float: left; + height: 50px; + overflow: hidden; + text-indent: -3000px; + width: 50px; +} +.imgWrapper .posterThumb { + float: left; + min-height: 100%; + min-width: 100%; + width: 50px; + height: auto; + position: relative; + border: none; + vertical-align: middle; +} +.posterThumb { + -ms-interpolation-mode: bicubic; /* make scaling look nicer for ie */ + vertical-align: top; + height: auto; + width: 160px; + border-top: 1px solid #ccc; + border-right: 1px solid #ccc; +} +.bannerThumb { + -ms-interpolation-mode: bicubic; /* make scaling look nicer for ie */ + vertical-align: top; + height: auto; + width: 750px; + /* margin-bottom: 1px; */ +} + +.tvshowDiv th { + color: #000; + letter-spacing: 1px; + text-align: left; + background-color: #333333; +} + +.tvshowDiv th.nobg { + background: #efefef; + border-top: 1px solid #666; + text-align: center; +} + +.tvshowDiv td { + border-top: 1px solid #d2ebe8; + background: #fff; + padding: 5px 10px 5px 10px; + color: #000; +} + +.tvshowDiv td.next_episode { + width: 100%; + height: 90%; + border-top: 1px solid #ccc; + vertical-align: top; + background: #F5FAFA; + color: #000; +} + +h1.day { + font-weight: bold; + margin-top: 10px; + padding: 4px; + letter-spacing: 1px; + background-image: -moz-linear-gradient(#555555, #333333) !important; + background-image: linear-gradient(#555555, #333333) !important; + background-image: -webkit-linear-gradient(#555555, #333333) !important; + background-image: -o-linear-gradient(#555555, #333333) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + color: #fff; + text-shadow: -1px -1px 0 rgba(0, 0, 0, 0.3); + text-align: center; +} + +h1.network { + font-weight: bold; + padding: 4px; + letter-spacing: 1px; + background-image: -moz-linear-gradient(#555555, #333333) !important; + background-image: linear-gradient(#555555, #333333) !important; + background-image: -webkit-linear-gradient(#555555, #333333) !important; + background-image: -o-linear-gradient(#555555, #333333) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + color: #fff; + text-shadow: -1px -1px 0 rgba(0, 0, 0, 0.3); + text-align: center; +} + +.ep_listing { + width: auto; + border: 1px solid #CCCCCC; + margin-bottom: 10px; + /* margin: 10px; */ + /* overflow: hidden; */ + padding: 10px; +} + +.h2footer .listing_default, +.h2footer .listing_current, +.h2footer .listing_waiting, +.h2footer .listing_overdue, +.h2footer .listing_toofar { + padding: 2px 10px; + display: inline-block; + font-size: 13px; + font-weight: bold; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +.listing_default { + background-color: #F5F1E4; + color: #cec198; +} + +.listing_current { + background-color: #E2FFD8; + color: #AAD450; +} + +.listing_waiting { + background-color: #99ff99; +} + +.listing_overdue { + background-color: #FDEBF3; + color: #F49AC1; +} + +.listing_toofar { + background-color: #E9F7FB; + color: #90D5EC; +} + +.listing_unknown { + background-color: #ffdc89; +} + +tr.listing_default { + color: #000000; +} + +tr.listing_current { + color: #000000; +} + +tr.listing_waiting { + color: #000000; +} + +tr.listing_overdue { + color: #000000; +} + +tr.listing_toofar { + color: #000000; +} + +tr.listing_unknown { + color: #000000; +} + +span.pause { + color: #FF0000; + font-size: 12px; +} + +.ep_summaryTrigger { +/* float: left; + padding-top: 9px;*/ + margin-top: -1px; +} +.ep_summary { + margin-left: 5px; +/* padding-top: 5px; */ + font-style: italic; + line-height: 21px; +} \ No newline at end of file diff --git a/data/css/config.css b/data/css/config.css new file mode 100644 index 0000000000..6110e17940 --- /dev/null +++ b/data/css/config.css @@ -0,0 +1,163 @@ +#config{text-align:center;padding:0 0 0 0;font-size: 12px;} +#config /**{font-family:Verdana, sans-serif;}*/ +#config *{font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;} +#config a:hover {color: #005580 !important;/*text-decoration: underline;*/} +#config h3 a {text-decoration: none;font-size: 18px;} +#config h3 img {vertical-align: text-bottom; padding-right: 5px;} +#config ul{list-style-type:none;} +#config h3{font-size:1.5em;color:#000;} +#config h4{font-size:1em;color:#333;text-transform:uppercase;margin:0 0 .2em;line-height: 12px;margin-top: 2px;} +#config h5{font-size:1em;color:#000;margin:0 0 .2em;} +#config p{font-size:1.2em;line-height:1.3;} +#config .path{font-size:1em;color:#333;font-family:Verdana;} +#config .jumbo{font-size:15px !important;} +#config-content{display:block;width:100%;text-align:left;clear:both;background:#fff;margin:0 auto;padding:0 0 0;} +#config-components{width:auto;} +#config-components-border{float:left;width:auto;border-top:1px solid #999;padding:5px 0;} +#config .title-group{border-bottom:1px dotted #666;position:relative;padding:25px 15px 25px;} +#config .component-group{border-bottom:1px dotted #666;padding:15px 15px 25px;} +#config .component-group-desc{float:left;width:235px;} +#config .component-group-desc h3{font-size:1.5em;} +#config .component-group-desc p{width:85%;font-size:1.0em;color:#666;margin:.8em 0;} +#config .component-group-desc p.note{width:90%;/*font-size:1.2em*/;color:#333;margin:.8em 0;} +#config .component-group-list{float:left;width:605px;margin-top:16px;} + +#config fieldset{border:0;outline:0;} +#config div.field-pair{margin:.9em 0 1.4em;} +#config div.field-pair input:not(.btn){float:left;margin-right:6px;margin-top:5px;padding-top:4px;padding-bottom:4px;padding-right:4px;padding-left:4px;border-bottom-left-radius:3px;border-bottom-right-radius:3px;border-top-left-radius:3px;border-top-right-radius:3px;border-top-width: 1px; + border-left-width: 1px; + border-left-style: solid; + border-bottom-color: #CCCCCC; + border-right-color: #CCCCCC; + border-left-color: #CCCCCC; + border-right-style: solid; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-style: solid; + border-bottom-width: 1px; + border-right-width: 1px; + border-right-style: solid; + border-right-width-value: 1px; + border-top-color: #CCCCCC; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-style: solid; + border-top-width: 1px; +} +#config label.nocheck,#config div.providerDiv,#config div #customQuality{padding-left:20px;} +#config label span.component-title{font-size:13px;font-weight:700;float:left;width:175px;margin-right:10px;} +#config label span.component-desc{font-size:11px; margin-left:190px; display:block; font-size:11px;} +#config label.nocheck span.component-desc{margin-left:170px;} +#config div.field-pair select{font-size:12px;} +/* #config div.field-pair select{/*font-size:1.1em*//*;border:1px solid #d4d0c8;padding-top:4px;padding-bottom:4px;padding-right:4px;padding-left:4px;border-bottom-left-radius:3px;border-bottom-right-radius:3px;border-top-left-radius:3px;border-top-right-radius:3px;} +#config div.field-pair select option{line-height:1.4;padding:0 10px; border-bottom: 1px dotted #D7D7D7;}*/ +#config-settings{float:right;width:200px;background:#fffae5;border-bottom:1px dotted #666;border-top:1px solid #999;margin-right:20px;padding:20px 0 30px;} +#config-settings .config-settings-group{border-bottom:1px dotted #999;padding-bottom:15px;margin:0 15px 15px;} +#config-settings .config-settings-group h2{margin-bottom:0;} +#config-settings .config-settings-group p{font-size:1.1em;color:#666;margin:.6em 0 1em;} +#config-settings .config-settings-group div.field-pair{margin:1.2em 0 .6em;} +#config-settings .config-settings-group input{float:left;} +.clearfix:after{content:".";display:block;height:0;clear:both;visibility:hidden;} +.clearfix{display:block;} +/* Hides from IE-mac \*/ +* html .clearfix{height:1%;} +/* End hide from IE-mac */ +#provider_order_list, #service_order_list{list-style-type:none;width:270px;margin:0;padding:0;} +#provider_order_list li, #service_order_list li{font-size:15px;font-weight:bold;height:1.3em;font-family:Verdana;margin:0 5px 5px;padding:6px;} +#provider_order_list input, #service_order_list input{margin:0px 2px;} +.providerDiv{display:none;padding-left:20px;}#config div.metadataDiv{display:none;} +#config div.metadataDiv{display:none;} +#config div.metadata_options{float:left;font-size:14px;font-family:Verdana;width:185px;color:#036;background:#F5F1E4;overflow:auto;border-left:1px solid #404040;border-top:1px solid #404040;border-bottom:1px solid #d4d0c8;border-right:1px solid #d4d0c8;padding:7px;} +#config div.metadata_options label:hover{background-color:#9f9;} +#config div.metadata_options label{display:block;color:#036;line-height:1.5em;padding-left:7px;} +#config div.metadata_example{float:right;font-size:14px;font-family:Verdana;width:265px;color:#036;overflow:auto;margin-right:30px;padding:7px;} +#config div.metadata_example label{display:block;color:#000;line-height:1.5em;} +#config div.metadataDiv .disabled{color:#ccc;} +#config div #metadataLegend{font-size:14px;font-family:Verdana;font-weight:900;display:block;width:auto;text-align:center;padding-bottom:3px;} + +.infoTable {border-collapse: collapse;} +.infoTableHeader, .infoTableCell {padding: 5px;} +.infoTableHeader{font-weight:700;} +.infoTableSeperator { border-top: 1px dotted #666666; } + +#config div.testNotification {border: 1px dotted #CCCCCC; padding: 5px; margin-bottom: 10px; line-height:20px;} + +.config_message { + width: 100%; + text-align: center; + font-size: 1.3em; + background: #ffecac; +} + +[class^="icon16-"], [class*=" icon16-"] { + background-image: url("/images/glyphicons-config.png"); + background-position: -40px 0; + background-repeat: no-repeat; + display: inline-block; + height: 16px; + line-height: 16px; + vertical-align: text-top; + width: 16px; +} + +.icon16-github { + background-position: 0 0; +} +.icon16-mirc { + background-position: -20px 0; +} +.icon16-sb { + background-position: -40px 0; +} +.icon16-web { + background-position: -60px 0; +} +.icon16-win { + background-position: -80px 0; +} + +#config span.path { + background-color: #F5F1E4; + color: #6666FF; + padding-bottom: 3px; + padding-left: 6px; + padding-right: 6px; + padding-top: 3px; +} + +/* ======================================================================= +config_postProcessing.tmpl +========================================================================== */ +#config div.example { + padding: 4px 10px 4px 10px; + margin-top: 2px; + background-color: #dfdede; +} +.Key { + width: 100%; + padding: 6px; + font-family: sans-serif; + font-size: 13px; + background-color: #f4f4f4; + border: 1px solid #ccc; + border-collapse: collapse; + border-spacing: 0; + line-height: 18px !important; + margin-left: 15px !important; +} +.Key th, .tableHeader { + padding: 3px 9px; + margin: 0; + color: #fff; + text-align: center; + background: none repeat scroll 0 0 #666; +} +.Key td { + padding: 1px 5px !important; +} +.Key tr { + border-bottom: 1px solid #ccc; +} +.Key tr.even { + background-color: #dfdede; +} \ No newline at end of file diff --git a/data/css/config.less b/data/css/config.less new file mode 100644 index 0000000000..ed02b06ff3 --- /dev/null +++ b/data/css/config.less @@ -0,0 +1,78 @@ +/* Variables */ +@base-font-face: "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; +@alt-font-face: "Trebuchet MS", Helvetica, Arial, sans-serif; +@base-font-size: 12px; +@text-color: #343434; +@swatch-blue: #4183C4; +@swatch-green: #BDE433; +@swatch-grey: #666666; +@link-color: #75ADD8; +@border-color: #CCCCCC; +@msg-bg: #FFF6A9; +@msg-bg-success: #D3FFD7; +@msg-bg-error: #FFD3D3; + +/* Mixins */ +.rounded(@radius: 5px) { + -moz-border-radius: @radius; + -webkit-border-radius: @radius; + border-radius: @radius; +} +.roundedTop(@radius: 5px) { + -moz-border-radius-topleft: @radius; + -moz-border-radius-topright: @radius; + -webkit-border-top-right-radius: @radius; + -webkit-border-top-left-radius: @radius; + border-top-left-radius: @radius; + border-top-right-radius: @radius; +} +.roundedLeftTop(@radius: 5px) { + -moz-border-radius-topleft: @radius; + -webkit-border-top-left-radius: @radius; + border-top-left-radius: @radius; +} +.roundedRightTop(@radius: 5px) { + -moz-border-radius-topright: @radius; + -webkit-border-top-right-radius: @radius; + border-top-right-radius: @radius; +} +.roundedBottom(@radius: 5px) { + -moz-border-radius-bottomleft: @radius; + -moz-border-radius-bottomright: @radius; + -webkit-border-bottom-right-radius: @radius; + -webkit-border-bottom-left-radius: @radius; + border-bottom-left-radius: @radius; + border-bottom-right-radius: @radius; +} +.roundedLeftBottom(@radius: 5px) { + -moz-border-radius-bottomleft: @radius; + -webkit-border-bottom-left-radius: @radius; + border-bottom-left-radius: @radius; +} +.roundedRightBottom(@radius: 5px) { + -moz-border-radius-bottomright: @radius; + -webkit-border-bottom-right-radius: @radius; + border-bottom-right-radius: @radius; +} +.shadow(@shadow: 0 17px 11px -1px #ced8d9) { + -moz-box-shadow: @shadow; + -webkit-box-shadow: @shadow; + -o-box-shadow: @shadow; + box-shadow: @shadow; +} +.gradient(@gradientFrom: #FFFFFF, @gradientTo: #EEEEEE){ + background-image: -moz-linear-gradient(@gradientFrom, @gradientTo) !important; + background-image: linear-gradient(@gradientFrom, @gradientTo) !important; + background-image: -webkit-linear-gradient(@gradientFrom, @gradientTo) !important; + background-image: -o-linear-gradient(@gradientFrom, @gradientTo) !important; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=@gradientFrom, endColorstr=@gradientTo) !important; + -ms-filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=@gradientFrom, endColorstr=@gradientTo) !important; +} +.opacity(@opacity_percent:85) { + filter: ~"alpha(opacity=85)"; + -moz-opacity: @opacity_percent / 100 !important; + -khtml-opacity:@opacity_percent / 100 !important; + -o-opacity:@opacity_percent / 100 !important; + opacity:@opacity_percent / 100 !important; +} + diff --git a/data/css/default.css b/data/css/default.css new file mode 100644 index 0000000000..2a5ba73b72 --- /dev/null +++ b/data/css/default.css @@ -0,0 +1,1874 @@ +/* Variables *//* Mixins */ +* { + outline: 0; + margin: 0; +} + +*:focus { + outline: none; +} + +input, textarea { + -moz-transition-delay: 0s, 0s; + -webkit-transition-delay: 0s, 0s; + -o-transition-delay: 0s, 0s; + -moz-transition-duration: 0.2s, 0.2s; + -webkit-transition-duration: 0.2s, 0.2s; + -o-transition-duration: 0.2s, 0.2s; + -moz-transition-property: border, box-shadow; + -webkit-transition-property: border, box-shadow; + -o-transition-property: border, box-shadow; + -moz-transition-timing-function: linear, linear; + -webkit-transition-timing-function: linear, linear; + -o-transition-timing-function: linear, linear; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset; +} +input:focus, textarea:focus { + border-bottom-color: rgba(0, 168, 236, 0.8); + border-left-color-ltr-source: physical; + border-left-color-rtl-source: physical; + border-left-color-value: rgba(0, 168, 236, 0.8); + border-right-color-ltr-source: physical; + border-right-color-rtl-source: physical; + border-right-color-value: rgba(0, 168, 236, 0.8); + border-top-color: rgba(0, 168, 236, 0.8); + border-right-color: rgba(0, 168, 236, 0.8); + border-left-color: rgba(0, 168, 236, 0.8); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 2px rgba(0, 168, 236, 1.0); + outline-color: -moz-use-text-color; + outline-style: none; + outline-width: 0; +} + +/*input:focus, select:focus, textarea:focus { + box-shadow: 0 0 3px 0 #0066FF; + z-index: 1; +}*/ + +input[type="checkbox"]:focus, input[type="checkbox"]:active, input[type="submit"]:focus, +input[type="submit"]:active,input[type="button"]:focus, input[type="button"]:active { + box-shadow: none; +} + +img { + border: 0; +/* vertical-align: middle; + vertical-align: sub;*/ + vertical-align: text-top; +} + +.imgLink img { + padding-bottom: 1px; + padding-left: 2px; + padding-right: 2px; +} + +.imgHomeWrapper { + background: url("../images/loading.gif") no-repeat scroll center center #FFFFFF; + border: 3px solid #FFFFFF; + box-shadow: 1px 1px 2px 0 #555555; + float: left; + height: 52px; + overflow: hidden; +} + +.imgHomeWrapperRounded{ + border-radius: 8px; +} + +.imgHomeWrapper .poster { + float: left; + width: 50px; + height: auto; + position: relative; + vertical-align: middle; +} + +.imgHomeWrapper .banner { + height: 52px; + overflow: hidden; + border-radius: 8px; + vertical-align: top; + height: auto; + width: 300px; + border-radius: 8px; +} +.imgHomeWrapperbig { + background: url("../images/loading.gif") no-repeat scroll center center #FFFFFF; + border: 3px solid #FFFFFF; + box-shadow: 1px 1px 2px 0 #555555; + margin-left: 200px; + height: 100px; + overflow: hidden; +} + +.imgHomeWrapperRoundedbig{ + border-radius: 8px; +} + +.imgHomeWrapper .posterbig { + float: left; + width: 50px; + height: auto; + position: relative; + vertical-align: middle; +} + +.imgHomeWrapper .bannerbig { + height: 100px; + overflow: hidden; + border-radius: 8px; + vertical-align: top; + height: auto; + width: 500px; + border-radius: 8px; +} +html { + margin: 0; + padding: 0; +} +body { + text-rendering: optimizeLegibility; + background: none repeat scroll 0 0 #FFFFFF; + color: #343434; + font-family: "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; + margin: 0; + overflow-y: scroll; + padding: 0; + font-size: 14px; +} +form { + border: none; + display: inline; + margin: 0; + padding: 0; +} +a{ + color: #75add8; + -moz-text-decoration-line: none; + text-decoration: none; +} + +.update { + color: #339933; + text-decoration: blink; +} + +/* these are for inc_top.tmpl */ +#upgrade-notification { + position: fixed; + line-height: 0.5em; +/* color: #000; */ + font-size: 1em; + font-weight: bold; + height: 0px; + text-align: center; + width: 100%; + z-index: 100; + margin: 0; + padding: 0; +} +#upgrade-notification div { +/* background-image: -moz-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -webkit-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -o-linear-gradient(#fdf0d5, #fff9ee) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + border-bottom: 1px solid #af986b; */ + background-color: #C6B695; + border-bottom-color: #AF986B; + border-bottom-style: solid; + border-bottom-width: 1px; + padding: 7px 0; +} +#header-fix { + *margin-bottom: -31px; + /* IE fix */ + height: 21px; + padding: 0; +} +#header { + background-image: -moz-linear-gradient(#555555, #333333) !important; + background-image: linear-gradient(#555555, #333333) !important; + background-image: -webkit-linear-gradient(#555555, #333333) !important; + background-image: -o-linear-gradient(#555555, #333333) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + border-bottom: 1px solid #CACACA; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + height: 58px; + width: 100%; + z-index: 999; +} +#header .wrapper { + margin: 0 auto; + position: relative; + width: 960px; +} +#header a:hover { + background: none; +} + +#header .showInfo .checkboxControls { + margin: 0 auto; + position: relative; + width: 960px; +} + +#logo { + float: left; + position: relative; + top: 0px; + left: -5px; +} +#versiontext { + color: #FFFFFF; + font-family: Arial, Helvetica, sans-serif; + font-size: 11px; + position: relative; +/* text-transform: lowercase;*/ + top: 42px; + left: -5px +} + +.update { + color: #339933; + text-decoration: none; +} + +.navShows { + margin-top: -15px; + margin-bottom: -20px; +} +.tvshowImg { + background: url("../images/loading.gif") no-repeat scroll center center #ffffff; + border: 5px solid #FFFFFF; + -moz-box-shadow: 1px 1px 2px 0 #555555; + -webkit-box-shadow: 1px 1px 2px 0 #555555; + -o-box-shadow: 1px 1px 2px 0 #555555; + box-shadow: 1px 1px 2px 0 #555555; + float: right; + height: auto; + margin-bottom: 0px; + margin-top: 73px; + margin-right: 0px; + overflow: hidden; + text-indent: -3000px; + width: 200px; +} +.tvshowImg img { + float: right; + min-width: 100%; + position: relative; +} +/* --------------------------------------------- */ +table { + margin: 0; +} + +table .ep_name { + color: #000000; +} + + +table td a { + color: #555; +} +table td a:hover { + color: #343434; +} + +h1 { + text-align: left; + font-size: 21px; + line-height: 23px; + font-weight: 400; +} +h1.title { + padding-bottom: 10px; + margin-bottom: 12px; + font-weight: bold; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + font-size: 38px; + line-height: 5px; + text-rendering: optimizelegibility; +} + +h1.header { + padding-bottom: 10px; + margin-top: 12px; + margin-bottom: 12px; + font-size: 30px; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + font-weight: bold; +} +h1 a { + text-decoration: none; +} +h2 { + font-size: 18px; + font-weight: 700; +} +.h2footer { + margin: -35px 5px 6px 0px; +} +.h2footer select { + margin-top: -6px; + margin-bottom: -6px; +} +.separator { + font-size: 90%; + color: #999; +} + +div select { +/* font-size: 12px; !important*/ + border: 1px solid #d4d0c8; +} +div select{font-size:0.9em ;height:28px;border:1px solid #d4d0c8;padding-top:4px;padding-bottom:4px;padding-right:4px;padding-left:4px;border-bottom-left-radius:3px;border-bottom-right-radius:3px;border-top-left-radius:3px;border-top-right-radius:3px;} + +div select[multiple], div select[size] { + height: auto; +} + +div select option { +/* line-height: 1.4; + padding: 0 10px; + border-bottom: 1px dotted #D7D7D7;*/ + font-size:13px; + padding-bottom: 2px; + padding-left: 8px; + padding-right: 8px; + padding-top: 2px; +} + +input:not(.btn){margin-right:6px;margin-top:5px;padding-top:4px;padding-bottom:4px;padding-right:4px;padding-left:4px;border-bottom-left-radius:3px;border-bottom-right-radius:3px;border-top-left-radius:3px;border-top-right-radius:3px;border-top-width: 1px; + border-left-width: 1px; + border-left-style: solid; + border-bottom-color: #CCCCCC; + border-right-color: #CCCCCC; + border-left-color: #CCCCCC; + border-right-style: solid; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-style: solid; + border-bottom-width: 1px; + border-right-width: 1px; + border-right-style: solid; + border-right-width-value: 1px; + border-top-color: #CCCCCC; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-style: solid; + border-top-width: 1px; +} + +/* --------------- alignment ------------------- */ +.float-left { + float: left; +} +.float-right { + float: right; +} +.align-left { + text-align: left; +} +.align-right { + text-align: right; +} +.nowrap { + white-space: nowrap; +} +/* --------------------------------------------- */ +.footer { + clear: both; + width: 960px; + text-align: center; + padding-top: 5px; + padding-bottom: 5px; + margin: 20px auto; + border-top: 1px solid #eee; + line-height: 1.4em; + font-size: 11px; +} +.footer a { + color: #75add8; + text-decoration: none; +} +.footer ul li { + list-style: none; + float: left; + margin-left: 10px; +} +.footer ul li img { + margin-right: 1px; + position: relative; + top: -2px; + vertical-align: middle; +} +.sickbeardTable { + width: 100%; + margin-left: auto; + margin-right: auto; + text-align: left; + color: #343434; + background-color: #fff; + border-spacing: 0; +} +.sickbeardTable th, +.sickbeardTable td { + padding: 4px; + border-top: #fff 1px solid; + vertical-align: middle; +} + +#massUpdateTable.sickbeardTable th { + padding: 4px 0 !important; + border-top: #fff 1px solid; + vertical-align: middle; +} + +#massUpdateTable.sickbeardTable th { + padding: 4px 0 !important; + border-top: #fff 1px solid; + vertical-align: middle; +} + +#massUpdateTable.tablesorter td { + padding: 8px 5px; +} + +#massUpdateTable.tablesorter td.tvShow a{ + font-size: 16px; +} + +.sickbeardTable th:first-child, +.sickbeardTable td:first-child { + border-left: none; +} +.sickbeardTable th { + border-collapse: collapse; + background-image: -moz-linear-gradient(#555555, #333333) !important; + background-image: linear-gradient(#555555, #333333) !important; + background-image: -webkit-linear-gradient(#555555, #333333) !important; + background-image: -o-linear-gradient(#555555, #333333) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + color: #fff; + text-shadow: -1px -1px 0 rgba(0, 0, 0, 0.3); + text-align: center; +} +.sickbeardTable tfoot a { + text-decoration: none; + color: #bde433; + float: left; +} +.sickbeardTable td { + font-size: 14px; +} + +.sickbeardTable td.title { + font-size: 14px; + line-height: normal; +} + +.sickbeardTable td.filename { +width: 30%; + +} + +.sickbeardTable td.subtitles_column { + vertical-align: middle; +/* width: 10%; */ +} + +.sickbeardTable td.subtitles_column img { + padding-right: 2px; + padding-top: 2px; +} + + +.sickbeardTable td.status_column { + font-weight: bold; + line-height: 20px; + text-align: center; + width: 13%; + color: #555555; +} + +.sickbeardTable td.search img { + padding-right: 2px; +} + +.sickbeardTable td small { + font-size: 11px; + font-style: italic; + line-height: normal; +} +.row { + clear: left; +} + +.plotInfo { + cursor: help; + float: right; + font-weight: 700; + position: relative; + padding-left: 2px; +} + +.sickbeardTable td.plotInfo { + align: center; +} + +#actions .selectAll { + margin-right: 10px; + border-right: 1px solid #eee; + padding-right: 10px; +} +#tooltip { + display: none; + z-index: 3343434; + border: 1px solid #111; + background-color: #eee; + padding: 5px; + margin-right: 10px; +} +.progressbarText { + text-shadow: 0 0 0.1em #fff; + position: absolute; + top: 0; + font-size: 12px; + color: #555555; + font-weight: bold; + width: 100%; + height: 100%; + overflow: visible; + text-align: center; + vertical-align: middle; +} +.ui-progressbar .ui-widget-header { + background-image: -moz-linear-gradient(#a3e532, #90cc2a) !important; + background-image: linear-gradient(#a3e532, #90cc2a) !important; + background-image: -webkit-linear-gradient(#a3e532, #90cc2a) !important; + background-image: -o-linear-gradient(#a3e532, #90cc2a) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; +} +tr.seasonheader { + background-color: #FFFFFF; + padding-bottom: 5px; + padding-top: 10px; + text-align: left; +/* white-space: nowrap;*/ +} +tr.seasonheader td { + padding-top: 20px; + padding-bottom: 10px; +} +tr.seasonheader h2 { + display: inline; + font-size: 22px; + line-height: 20px; + letter-spacing: 1px; + margin: 0; + color: #343434; +} +tr.seasonheader a:not(.btn) { + text-decoration: none; +} + +tr.seasonheader a:hover:not(.btn) { + background-color: #fff; + color: #343434; +} +#checkboxControls label { + white-space: nowrap; +} +tr.unaired, +span.unaired { + background-color: #F5F1E4; + padding: 5px; + font-size: 13px; + color: #cec198; + font-weight: bold; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} +tr.unaired b, +span.unaired b { + color: #343434; +} +tr.skipped, +span.skipped { + background-color: #E9F7FB; + color: #90D5EC; + font-weight: bold; + font-size: 13px; + padding: 5px; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} +tr.skipped b, +span.skipped b { + color: #343434; +} +tr.good, +span.good { + background-color: #E2FFD8; + color: #AAD450; + padding: 5px; + font-size: 13px; + font-weight: bold; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} +tr.good b, +span.good b { + color: #343434; +} +tr.qual, +span.qual { + background-color: #FDF0D5; + padding: 5px; + font-size: 13px; + font-weight: bold; + color: #F7B42C; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +tr.qual b, +span.qual b { + color: #343434; +} +tr.wanted, +span.wanted { + background-color: #FDEBF3; + padding: 5px; + font-size: 13px; + font-weight: bold; + color: #F49AC1; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +tr.wanted b, +span.wanted b { + color: #343434; +} + +tr.snatched, +span.snatched { + background-color: #efefef; + padding: 5px; + font-size: 13px; + font-weight: bold; + color: #F49AC1; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +tr.snatched b, +span.snatched b { + color: #343434; +} + +tr.wanted, +tr.qual, +tr.good, +tr.skipped, +tr.unaired, +tr.snatched { + font-size: 14px; + font-weight: normal; + color: #343434; + height: 35px; +} +.showInfo { + width: 745px; + float: left; + margin-left: 0px; + padding-top: 2px; +} + +span .headerInfo { + color: #666666; +} + +div .seasonList { + padding-top: 12px; + padding-right: 10px; +} + +div#summary { + background-color: #f9f9f9; + -moz-border-radius: 10px; + -webkit-border-radius: 10px; + border-radius: 10px; + padding: 5px 10px; + border: 1px solid #ddd; + margin: 3px 0 10px 0; +} +div#summary .infoTable { + width: 90%; +} +div#summary tr { + line-height: 17px; +} +div#summary tr td { + font-size: 14px; + line-height: 25px; +} +#MainMenu { + float: right; + height: 58px; + margin: 0; + padding: 0 0 0 10px; +} +#SubMenu { + float: right; + margin-top: -35px; + position: relative; + z-index: inherit; +} +#SubMenu a { + font-size: 12px; + text-decoration: none; +} +#SubMenu span b { + margin-left: 20px; +} +#donate { + line-height: 1em; + float: right; +/* display: none;*/ +} +#donate a, +#donate a:hover { + border: 0 ; + padding: 4px 15px 4px; +} + +#contentWrapper { + background: none; +} +#content { + line-height: 24px; + margin: 0 auto; + padding: 105px 0 0; + width: 960px; +} +.showLegend { + font-weight: 700; + padding-right: 10px; + padding-bottom: 1px; +} +/* for the add new/existing show */ +.alt { + background-color: #efefef; +} + +#tabs div.field-pair, +.stepDiv div.field-pair { + padding: 0.75em 0; +} + +#tabs div.field-pair input, +.stepDiv div.field-pair input { + float: left; + margin-top: 5px; + margin-left: 3px; +} + +#tabs label.nocheck, +#tabs div.providerDiv, +#tabs div #customQuality, +.stepDiv label.nocheck, +.stepDiv div.providerDiv, +.stepDiv div #customQuality { + padding-left: 23px; +} + +#tabs label span.component-title, +.stepDiv label span.component-title { +/* font-size: 1.1em; */ + font-weight: 700; + float: left; + width: 165px; + padding-left: 6px; + margin-right: 10px; +} + +#tabs label span.component-desc, +.stepDiv label span.component-desc { + font-size: .9em; + float: left; +} + +#tabs div.field-pair select, +.stepDiv div.field-pair select { + font-size: 12px; + border: 1px solid #d4d0c8; +} + +#tabs div.field-pair select option, +.stepDiv div.field-pair select option { + line-height: 1.4; + padding: 0 10px; +/* border-bottom: 1px dotted #D7D7D7;*/ +} +ul#rootDirStaticList { + width: 90%; + text-align: left; + margin-left: auto; + margin-right: auto; + padding-left: 0; +} +ul#rootDirStaticList li { + list-style: none outside none; + margin: 2px; + padding: 4px 5px 4px 5px; + cursor: pointer; +} + +/*#rootDirs, #rootDirsControls { + width: 50%; + min-width: 400px; + height:auto !important; +}*/ + +#episodeDir, #newRootDir { + margin-right: 6px; + } + +#displayText { + background-color: #efefef; + padding: 8px; + border: 1px solid #DFDEDE; + font-size: 1.1em; + overflow: hidden; +} +div#addShowPortal { + margin: 50px auto; + width: 100%; +} +div#addShowPortal button { + float: left; + margin-left: 20px; + padding: 10px; + width: 47%; +} +div#addShowPortal button div.button img { + position: absolute; + display: block; + top: 35%; + padding-left: 0.4em; + text-align: center; +} +div#addShowPortal button .buttontext { + position: relative; + display: block; + padding: 0.1em 0.4em 0.1em 4.4em; + text-align: left; +} + +td.tvShow a { + text-decoration: none; + font-size: 18px; + font-weight: bold; +} + +.navShow { + display: inline; + cursor: pointer; + vertical-align: top; + position: relative; + top: 3px; + filter: alpha(opacity=85); + -moz-opacity: 0.5 !important; + -khtml-opacity: 0.5 !important; + -o-opacity: 0.5 !important; + opacity: 0.5 !important; +} +.navShow:hover { + filter: alpha(opacity=85); + -moz-opacity: 1 !important; + -khtml-opacity: 1 !important; + -o-opacity: 1 !important; + opacity: 1 !important; +} +/* for manage_massEdit */ +.optionWrapper { + width: 450px; + margin-left: auto; + margin-right: auto; + padding: 6px 12px; +} +.optionWrapper span.selectTitle { + float: left; + font-weight: 700; + font-size: 1.2em; + text-align: left; + width: 225px; +} +.optionWrapper div.selectChoices { + float: left; + width: 175px; + margin-left: 25px; +} +.optionWrapper br { + clear: both; +} +a.whitelink { + color: white; +} +/* for displayShow notice */ +#show_message { + border: 1px solid #cccccc; + background-image: -moz-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -webkit-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -o-linear-gradient(#fdf0d5, #fff9ee) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -moz-border-radius: 7px; + -webkit-border-radius: 7px; + border-radius: 7px; + font-size: 14px; + right: 10px; + -moz-box-shadow: 0px 0px 2px #aaaaaa; + -webkit-box-shadow: 0px 0px 2px #aaaaaa; + -o-box-shadow: 0px 0px 2px #aaaaaa; + box-shadow: 0px 0px 2px #aaaaaa; + padding: 7px 10px; + position: fixed; + text-align: center; + bottom: 10px; + min-height: 22px; + width: 250px; + z-index: 9999; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; + filter: alpha(opacity=85); + -moz-opacity: 0.8 !important; + -khtml-opacity: 0.8 !important; + -o-opacity: 0.8 !important; + opacity: 0.8 !important; +} +#show_message .msg { + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; + padding-left: 20px; +} +#show_message .loader { + position: relative; + top: 2px; +} +#show_message.success { + background-image: -moz-linear-gradient(#d3ffd7, #c2edc6) !important; + background-image: linear-gradient(#d3ffd7, #c2edc6) !important; + background-image: -webkit-linear-gradient(#d3ffd7, #c2edc6) !important; + background-image: -o-linear-gradient(#d3ffd7, #c2edc6) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + padding: 15px 10px; + text-align: left; +} +#show_message.error { + background-image: -moz-linear-gradient(#ffd3d3, #edc4c4) !important; + background-image: linear-gradient(#ffd3d3, #edc4c4) !important; + background-image: -webkit-linear-gradient(#ffd3d3, #edc4c4) !important; + background-image: -o-linear-gradient(#ffd3d3, #edc4c4) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; + padding: 15px 10px; + text-align: left; +} +#show_message .ui-icon { + display: inline-block; + margin-left: -20px; + top: 2px; + position: relative; + margin-right: 3px; +} + +ui-pnotify-text img { + /*padding-top: 3px;*/ + margin-top: -3px; +} + +div.ui-pnotify { + min-width: 340px; + max-width: 550px; + width: auto !important; +} +/* override for qtip2 */ +.ui-tooltip-sb .ui-tooltip-titlebar a { + color: #222222; + text-decoration: none; +} +.ui-tooltip, +.qtip { + max-width: 500px !important; +} + +.changelog { max-width: 650px !important; } + +option.flag { + padding-left: 35px; + background-color: #fff; + background-repeat: no-repeat; + background-position: 10px 50%; +} + +span.quality { + font: bold 1em/1.2em verdana, sans-serif; + background: none repeat scroll 0 0 #999999; + color: #FFFFFF; + display: inline-block; + padding: 2px 4px; + text-align: center; + -webkit-border-radius: 4px; + font-size: 12px; + -moz-border-radius: 4px; + border-radius: 4px; +} +span.Custom { + background: none repeat scroll 0 0 #449; + /* purplish blue */ +} + +span.HD { + background: none repeat scroll 0 0 #008fbb; + /* greenish blue */ +} + +span.HD720p { + background: none repeat scroll 0 0 #494; + /* green */ +} + +span.HD1080p { + background: none repeat scroll 0 0 #499; + /* blue */ +} + +span.RawHD { + background: none repeat scroll 0 0 #999944; + /* dark orange */ +} + +span.SD { + background: none repeat scroll 0 0 #944; + /* red */ +} + +span.Any { + background: none repeat scroll 0 0 #444; + /* black */ +} + +span.Proper { + background: none repeat scroll 0 0 #CD7300; + /* orange_red */ +} + +span.false { + color: #993333; + /* red */ + +} +span.true { + color: #669966; + /* green */ + +} +.ui-progressbar { + height: 18px !important; + line-height: 17px; +} + +.pull-left { + float: left; + padding-right: 2px +} + +.pull-right { + float: right; +} + +#searchResults a { + color: #343434; +} + +.btn { + display: inline-block; + *display: inline; + padding: 3.5px 10px 3.5px; + margin-bottom: 0; + *margin-left: .3em; + font-size: 13px; + line-height: 18px; + *line-height: 20px; + color: #333333; + text-align: center; + text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); +/* vertical-align: middle; */ + cursor: pointer; + background-color: #f5f5f5; + *background-color: #e6e6e6; + background-image: -ms-linear-gradient(top, #ffffff, #e6e6e6); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); + background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); + background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); + background-image: linear-gradient(top, #ffffff, #e6e6e6); + background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); + background-repeat: repeat-x; + border: 1px solid #cccccc; + *border: 0; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + border-color: #e6e6e6 #e6e6e6 #bfbfbf; + border-bottom-color: #b3b3b3; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); + *zoom: 1; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + margin-top: 4px; +} + +.btn:hover, +.btn:active, +.btn.active, +.btn.disabled, +.btn[disabled] { + background-color: #e6e6e6; + *background-color: #d9d9d9; +} + +.btn:active, +.btn.active { + background-color: #cccccc \9; +} + +.btn:first-child { + *margin-left: 0; +} + +.btn:hover { + color: #333333; + text-decoration: none; + background-color: #e6e6e6; + *background-color: #d9d9d9; + /* Buttons in IE7 don't get borders, so darken on hover */ + + background-position: 0 -15px; + -webkit-transition: background-position 0.1s linear; + -moz-transition: background-position 0.1s linear; + -ms-transition: background-position 0.1s linear; + -o-transition: background-position 0.1s linear; + transition: background-position 0.1s linear; +} + +.btn:focus { + outline: thin dotted #333; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} + +.btn.active, +.btn:active { + background-color: #e6e6e6; + background-color: #d9d9d9 \9; + background-image: none; + outline: 0; + -webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.btn.disabled, +.btn[disabled] { + cursor: default; + background-color: #e6e6e6; + background-image: none; + opacity: 0.65; + filter: alpha(opacity=65); + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} + +.btn-large { + padding: 9px 14px; + font-size: 15px; + line-height: normal; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} + +.btn-large [class^="icon-"] { + margin-top: 1px; +} + +.btn-small { + padding: 5px 9px; + font-size: 11px; + line-height: 16px; +} + +.btn-small [class^="icon-"] { + margin-top: -1px; +} + +.btn-mini { + padding-left: 6px; + padding-right: 6px; + font-size: 11px; + line-height: 14px; + margin-top: -2px; +} + +.btn-mini a { + color: #333333; +} + +.btn-primary, +.btn-primary:hover, +.btn-warning, +.btn-warning:hover, +.btn-danger, +.btn-danger:hover, +.btn-success, +.btn-success:hover, +.btn-info, +.btn-info:hover, +.btn-inverse, +.btn-inverse:hover { + color: #ffffff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} + +.btn-primary.active, +.btn-warning.active, +.btn-danger.active, +.btn-success.active, +.btn-info.active, +.btn-inverse.active { + color: rgba(255, 255, 255, 0.75); +} + +.btn { + border-color: #ccc; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); +} + +.btn-primary { + background-color: #0074cc; + *background-color: #0055cc; + background-image: -ms-linear-gradient(top, #0088cc, #0055cc); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0055cc)); + background-image: -webkit-linear-gradient(top, #0088cc, #0055cc); + background-image: -o-linear-gradient(top, #0088cc, #0055cc); + background-image: -moz-linear-gradient(top, #0088cc, #0055cc); + background-image: linear-gradient(top, #0088cc, #0055cc); + background-repeat: repeat-x; + border-color: #0055cc #0055cc #003580; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#0088cc', endColorstr='#0055cc', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); +} + +.btn-primary:hover, +.btn-primary:active, +.btn-primary.active, +.btn-primary.disabled, +.btn-primary[disabled] { + background-color: #0055cc; + *background-color: #004ab3; +} + +.btn-primary:active, +.btn-primary.active { + background-color: #004099 \9; +} + +.btn-warning { + background-color: #faa732; + *background-color: #f89406; + background-image: -ms-linear-gradient(top, #fbb450, #f89406); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406)); + background-image: -webkit-linear-gradient(top, #fbb450, #f89406); + background-image: -o-linear-gradient(top, #fbb450, #f89406); + background-image: -moz-linear-gradient(top, #fbb450, #f89406); + background-image: linear-gradient(top, #fbb450, #f89406); + background-repeat: repeat-x; + border-color: #f89406 #f89406 #ad6704; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#fbb450', endColorstr='#f89406', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); +} + +.btn-warning:hover, +.btn-warning:active, +.btn-warning.active, +.btn-warning.disabled, +.btn-warning[disabled] { + background-color: #f89406; + *background-color: #df8505; +} + +.btn-warning:active, +.btn-warning.active { + background-color: #c67605 \9; +} + +.btn-danger { + background-color: #da4f49; + *background-color: #bd362f; + background-image: -ms-linear-gradient(top, #ee5f5b, #bd362f); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#bd362f)); + background-image: -webkit-linear-gradient(top, #ee5f5b, #bd362f); + background-image: -o-linear-gradient(top, #ee5f5b, #bd362f); + background-image: -moz-linear-gradient(top, #ee5f5b, #bd362f); + background-image: linear-gradient(top, #ee5f5b, #bd362f); + background-repeat: repeat-x; + border-color: #bd362f #bd362f #802420; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#bd362f', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); +} + +.btn-danger:hover, +.btn-danger:active, +.btn-danger.active, +.btn-danger.disabled, +.btn-danger[disabled] { + background-color: #bd362f; + *background-color: #a9302a; +} + +.btn-danger:active, +.btn-danger.active { + background-color: #942a25 \9; +} + +.btn-success { + background-color: #5bb75b; + *background-color: #51a351; + background-image: -ms-linear-gradient(top, #62c462, #51a351); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#51a351)); + background-image: -webkit-linear-gradient(top, #62c462, #51a351); + background-image: -o-linear-gradient(top, #62c462, #51a351); + background-image: -moz-linear-gradient(top, #62c462, #51a351); + background-image: linear-gradient(top, #62c462, #51a351); + background-repeat: repeat-x; + border-color: #51a351 #51a351 #387038; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#62c462', endColorstr='#51a351', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); +} + +.btn-success:hover, +.btn-success:active, +.btn-success.active, +.btn-success.disabled, +.btn-success[disabled] { + background-color: #51a351; + *background-color: #499249; +} + +.btn-success:active, +.btn-success.active { + background-color: #408140 \9; +} + +.btn-info { + background-color: #49afcd; + *background-color: #2f96b4; + background-image: -ms-linear-gradient(top, #5bc0de, #2f96b4); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#2f96b4)); + background-image: -webkit-linear-gradient(top, #5bc0de, #2f96b4); + background-image: -o-linear-gradient(top, #5bc0de, #2f96b4); + background-image: -moz-linear-gradient(top, #5bc0de, #2f96b4); + background-image: linear-gradient(top, #5bc0de, #2f96b4); + background-repeat: repeat-x; + border-color: #2f96b4 #2f96b4 #1f6377; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#5bc0de', endColorstr='#2f96b4', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); +} + +.btn-info:hover, +.btn-info:active, +.btn-info.active, +.btn-info.disabled, +.btn-info[disabled] { + background-color: #2f96b4; + *background-color: #2a85a0; +} + +.btn-info:active, +.btn-info.active { + background-color: #24748c \9; +} + +.btn-inverse { + background-color: #414141; + *background-color: #414141; + background-image: -ms-linear-gradient(top, #555555, #222222); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#555555), to(#222222)); + background-image: -webkit-linear-gradient(top, #555555, #222222); + background-image: -o-linear-gradient(top, #555555, #222222); + background-image: -moz-linear-gradient(top, #555555, #222222); + background-image: linear-gradient(top, #555555, #222222); + background-repeat: repeat-x; + border-color: #222222 #222222 #000000; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + filter: progid:dximagetransform.microsoft.gradient(startColorstr='#555555', endColorstr='#222222', GradientType=0); + filter: progid:dximagetransform.microsoft.gradient(enabled=false); + vertical-align: middle; +} + +.btn-inverse:hover, +.btn-inverse:active, +.btn-inverse.active, +.btn-inverse.disabled, +.btn-inverse[disabled] { + background-color: #222222; + *background-color: #151515; +} + +.btn-inverse:active, +.btn-inverse.active { + background-color: #080808 \9; +} + +.btn:focus, +.btn:active { + border: 1px solid #4d90fe; + outline: none; + -moz-box-shadow: none; + box-shadow: none; +} +.btn-primary:focus, +.btn-warning:focus, +.btn-danger:focus, +.btn-success:focus, +.btn-info:focus, +.btn-inverse:focus { + border: 1px solid transparent; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.75) inset; +} + +/*button.btn, +input[type="submit"].btn { + *padding-top: 2px; + *padding-bottom: 2px; +}*/ + +/* button.btn::-moz-focus-inner, +input[type="submit"].btn::-moz-focus-inner { + padding: 0; + border: 0; +}*/ + +button.btn.btn-large, +input[type="submit"].btn.btn-large { + *padding-top: 7px; + *padding-bottom: 7px; +} + +button.btn.btn-small, +input[type="submit"].btn.btn-small { + *padding-top: 3px; + *padding-bottom: 3px; +} + +button.btn.btn-mini, +input[type="submit"].btn.btn-mini { + *padding-top: 1px; + *padding-bottom: 1px; +} + +.btn-group { + position: relative; + *margin-left: .3em; + *zoom: 1; +} + +.btn-group:before, +.btn-group:after { + display: table; + content: ""; +} + +.btn-group:after { + clear: both; +} + +.btn-group:first-child { + *margin-left: 0; +} + +.btn-group + .btn-group { + margin-left: 5px; +} + +.btn-toolbar { + margin-top: 9px; + margin-bottom: 9px; +} + +.btn-toolbar .btn-group { + display: inline-block; + *display: inline; + /* IE7 inline-block hack */ + + *zoom: 1; +} + +.btn-group > .btn { + position: relative; + float: left; + margin-left: -1px; + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; +} + +.btn-group > .btn:first-child { + margin-left: 0; + -webkit-border-bottom-left-radius: 4px; + border-bottom-left-radius: 4px; + -webkit-border-top-left-radius: 4px; + border-top-left-radius: 4px; + -moz-border-radius-bottomleft: 4px; + -moz-border-radius-topleft: 4px; +} + +.btn-group > .btn:last-child, +.btn-group > .dropdown-toggle { + -webkit-border-top-right-radius: 4px; + border-top-right-radius: 4px; + -webkit-border-bottom-right-radius: 4px; + border-bottom-right-radius: 4px; + -moz-border-radius-topright: 4px; + -moz-border-radius-bottomright: 4px; +} + +.btn-group > .btn.large:first-child { + margin-left: 0; + -webkit-border-bottom-left-radius: 6px; + border-bottom-left-radius: 6px; + -webkit-border-top-left-radius: 6px; + border-top-left-radius: 6px; + -moz-border-radius-bottomleft: 6px; + -moz-border-radius-topleft: 6px; +} + +.btn-group > .btn.large:last-child, +.btn-group > .large.dropdown-toggle { + -webkit-border-top-right-radius: 6px; + border-top-right-radius: 6px; + -webkit-border-bottom-right-radius: 6px; + border-bottom-right-radius: 6px; + -moz-border-radius-topright: 6px; + -moz-border-radius-bottomright: 6px; +} + +.btn-group > .btn:hover, +.btn-group > .btn:focus, +.btn-group > .btn:active, +.btn-group > .btn.active { + z-index: 2; +} + +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} + +.btn-group > .dropdown-toggle { + *padding-top: 4px; + padding-right: 8px; + *padding-bottom: 4px; + padding-left: 8px; + -webkit-box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.btn-group > .btn-mini.dropdown-toggle { + padding-right: 5px; + padding-left: 5px; +} + +.btn-group > .btn-small.dropdown-toggle { + *padding-top: 4px; + *padding-bottom: 4px; +} + +.btn-group > .btn-large.dropdown-toggle { + padding-right: 12px; + padding-left: 12px; +} + +.btn-group.open .dropdown-toggle { + background-image: none; + -webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); + -moz-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.btn-group.open .btn.dropdown-toggle { + background-color: #e6e6e6; +} + +.btn-group.open .btn-primary.dropdown-toggle { + background-color: #0055cc; +} + +.btn-group.open .btn-warning.dropdown-toggle { + background-color: #f89406; +} + +.btn-group.open .btn-danger.dropdown-toggle { + background-color: #bd362f; +} + +.btn-group.open .btn-success.dropdown-toggle { + background-color: #51a351; +} + +.btn-group.open .btn-info.dropdown-toggle { + background-color: #2f96b4; +} + +.btn-group.open .btn-inverse.dropdown-toggle { + background-color: #222222; +} + +.btn .caret { + margin-top: 7px; + margin-left: 0; +} + +.btn:hover .caret, +.open.btn-group .caret { + opacity: 1; + filter: alpha(opacity=100); +} + +.btn-mini .caret { + margin-top: 5px; +} + +.btn-small .caret { + margin-top: 6px; +} + +.btn-large .caret { + margin-top: 6px; + border-top-width: 5px; + border-right-width: 5px; + border-left-width: 5px; +} + +.dropup .btn-large .caret { + border-top: 0; + border-bottom: 5px solid #000000; +} + +.btn-primary .caret, +.btn-warning .caret, +.btn-danger .caret, +.btn-info .caret, +.btn-success .caret, +.btn-inverse .caret { + border-top-color: #ffffff; + border-bottom-color: #ffffff; + opacity: 0.75; + filter: alpha(opacity=75); +} + +#customQuality { + clear: both; + display: block ; + overflow-x: hidden; + overflow-y: hidden; + padding-bottom: 10px; + padding-left: 0; + padding-right: 0; + padding-top: 10px; + font-size: 14px; + padding-left:20px; +} + +.stepDiv > #customQualityWrapper { + overflow-x: hidden; + overflow-y: hidden; +} + +#customQualityWrapper div.component-group-desc { + float: left; + width: 165px; +} +#customQualityWrapper div.component-group-desc p { + color: #666666; +/* font-size: 1.2em;*/ + margin-bottom: 0.8em; + margin-left: 0; + margin-right: 0; + margin-top: 0.8em; + width: 85%; +} + +#SceneException { + height: 180px; + padding-bottom: 10px; + padding-left: 20px; + padding-right: 0; + padding-top: 10px; +} + +#SceneException div.component-group-desc { + float: left; + width: 165px; +} + +#SceneException div.component-group-desc p { + color: #666666; +/* font-size: 1.2em;*/ + margin-bottom: 0.8em; + margin-left: 0; + margin-right: 0; + margin-top: 0.8em; + width: 85%; +} + +.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { + font-size: 14px; +} + +[class^="icon-"], [class*=" icon-"] { + background-image: url("/images/glyphicons-halflings.png"); +} + +.icon-white { + background-image: url("/images/glyphicons-halflings-white.png"); +} + +[class^="icon-"], +[class*=" icon-"] { + display: inline-block; + width: 16px; + height: 16px; +/* margin-left: -21px; + margin-right: 8px; + position: absolute; */ + vertical-align: bottom; + background-repeat: no-repeat; +} + +.icon-question-sign { + background-position: -96px -96px; +} + +.icon-pause { + background-position: -288px -72px; +} + +.icon-check { + background-position: -144px -72px; +} + +.icon-exclamation-sign { + background-position: 0 -120px; +} + +.icon-play { + background-position: -264px -72px; +} + +.icon-play-circle { + background-position: -192px -24px; +} + +.icon-info-sign { + background-position: -120px -96px; +} + +.icon-remove { + background-position: -312px 0; +} + +.icon-calendar { + background-position: -192px -120px; +} + +h3 { + font-size: 18px; + line-height: 27px; +} + +h4.note { + color: #000000; + float: left; + padding-right: 5px; +} +h4 { + font-size: 14px; +} + +blockquote { + padding: 0 0 0 15px; + margin: 0 0 18px; + border-left: 5px solid #838B8B; + opacity: 0.6; +} + +code, +pre { + padding: 0 3px 2px; + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; + font-size: 12px; + color: #333333; + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + border-radius: 3px; +} + +pre { + display: block; + padding: 8.5px; + margin: 10px 0 9px; + font-size: 12.025px; + line-height: 18px; + word-break: break-all; + word-wrap: break-word; + white-space: pre; + white-space: pre-wrap; + background-color: #f5f5f5; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, 0.15); + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} \ No newline at end of file diff --git a/data/css/default.less b/data/css/default.less new file mode 100644 index 0000000000..6969ec2347 --- /dev/null +++ b/data/css/default.less @@ -0,0 +1,542 @@ +// Config +@import "config.less"; + +* { outline: 0;margin:0; } +*:focus { outline: none; } +img { border: 0; vertical-align: middle;} +html { margin:0; padding:0} +body { +text-rendering: optimizeLegibility; +background: none repeat scroll 0 0 #FFFFFF; + color: #343434; + font-family: @base-font-face; + margin: 0; + overflow-y: scroll; + padding: 0; + font-size: 12px; +} + +form { +border:none; +display:inline; +margin:0; +padding:0; +} +a {color: @link-color;} + +/* these are for inc_top.tmpl */ +#upgrade-notification{position: fixed;line-height:0.5em;color:#000;font-size:1em; height:0px;text-align:center;width:100%;z-index:100;margin:0;padding:0;} +#upgrade-notification div{.gradient(#FDF0D5,#fff9ee);border-bottom:1px solid #af986b;padding:7px 0;} +#header-fix{*margin-bottom: -31px; /* IE fix */height:21px;padding:0;} + +#header { + .gradient(#555555, #333333); + border-bottom: 1px solid #CACACA; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + height: 58px; + position: fixed; + width: 100%; + z-index: 999; +} +#header .wrapper { + margin: 0 auto; + position: relative; + width: 960px; +} +#header a:hover { +background:none; +} + +#logo { +float: left; + position: relative; + top: 0px; + left: -5px; +} + +#versiontext { +color: #FFFFFF; + font-family: Arial,Helvetica,sans-serif; + font-size: 11px; + position: relative; + text-transform: lowercase; + top: 7px; +} + +.navShows { margin-top:-25px; margin-bottom: 40px;} + +.tvshowImg { + background: url("../images/loading.gif") no-repeat scroll center center #FFFFFF; + border: 5px solid #FFFFFF; + .shadow(1px 1px 2px 0 #555555); + float: left; + height: auto; + margin-bottom: 30px; + margin-right: 25px; + overflow: hidden; + text-indent: -3000px; + width: 175px; + img { + float: left; + min-width: 100%; + position: relative; + } +} +/* --------------------------------------------- */ + +table { +margin:0; + td a { + color: #555; + &:hover { color: @text-color;} + } +} + +h1 { +text-align:left; +font-size:21px; +line-height:23px; +font-weight:400; +} +h1.title { +padding-bottom:4px; +margin-bottom:12px; +font-weight: bold; +font-family: @alt-font-face; +font-size: 38px; +} +h1.header { +padding-bottom:4px; +margin-bottom:12px; +font-size: 30px; +font-family: @alt-font-face; +font-weight: bold; +} +h1 a { +text-decoration:none; +} + +h2 { +font-size:18px; +font-weight:700; +} + +.h2footer { +margin: -33px 5px 6px 0px; +} +.h2footer select { +margin-top: -6px; +margin-bottom: -6px; +} + +.separator { +font-size:90%; +color:#999; +} + +div select { +font-size:10px; +border:1px solid #d4d0c8; +} + +div select option { +line-height:1.4; +padding:0 10px; +border-bottom: 1px dotted #D7D7D7; +} + +/* --------------- alignment ------------------- */ +.float-left { float:left; } +.float-right { float:right; } +.align-left { text-align:left; } +.align-right { text-align:right; } +.nowrap { white-space: nowrap; } +/* --------------------------------------------- */ + +.footer { +clear:both; +width: 960px; +text-align:center; +padding-top: 5px; +padding-bottom: 5px; +margin: 20px auto; +border-top:1px solid #eee; +line-height: 1.4em; +font-size: 11px; + a { + color: @link-color; + text-decoration: none; + } + ul { + li { + list-style: none; + float: left; + margin-left: 10px; + img { margin-right: 3px; position: relative; top: -2px;} + } + } +} + +.sickbeardTable { + width: 100%; + margin-left:auto; + margin-right:auto; + text-align:left; + color: #343434; + background-color: #fff; + border-spacing: 0; +} +.sickbeardTable th, +.sickbeardTable td { + padding: 4px; + border-top: #fff 1px solid; + vertical-align: middle; +} +.sickbeardTable th:first-child, +.sickbeardTable td:first-child { + border-left: none; +} +.sickbeardTable th{ + border-collapse: collapse; + .gradient(#555555,#333333); + color: #fff; + text-shadow: -1px -1px 0 rgba(0,0,0,0.3); + text-align: left; +} +.sickbeardTable tfoot a { + text-decoration: none; + color: @swatch-green; +} +.sickbeardTable td { + font-size: 12px; + &.title { font-size: 14px; line-height: normal;} + &.status_column{ line-height: normal;} + small { font-size:11px; font-style: italic; line-height: normal;} +} +.row { +clear:left; +} + +.plotInfo { +cursor:help; +font-weight: 700; +position: relative; +} + +#actions { + .selectAll { margin-right: 10px; border-right: 1px solid #eee; padding-right: 10px;} + .clearAll {} +} +#tooltip { +display:none; +z-index:3343434; +border:1px solid #111; +background-color:#eee; +padding:5px; +margin-right:10px; +} + +.progressbarText { +text-shadow: 0 0 0.1em #fff; +position:absolute; +top:0; +font-size: 9px; +width:100%; +height:100%; +overflow:visible; +text-align:center; +vertical-align: middle; +} +.ui-progressbar .ui-widget-header { + .gradient(#A3E532, #90CC2A); + .rounded(3px); +} +tr.seasonheader { +background-color: #FFFFFF; + padding-bottom: 5px; + padding-top: 10px; + text-align: left; + white-space: nowrap; + td { + padding-top: 20px; + padding-bottom: 10px; + } +} +tr.seasonheader h2 { +display:inline; +font-size:22px; +line-height:20px; +letter-spacing:1px; +margin:0; +color:#343434; +} +tr.seasonheader a { +text-decoration:none; +} +tr.seasonheader a:hover { +background-color: #fff; +color:#343434; +} + + +#checkboxControls label { white-space:nowrap; } +tr.unaired,span.unaired { +background-color:#F5F1E4; +padding:5px; +font-size: 13px; +color: #cec198; +font-weight: bold; +b { color:@text-color;} +.rounded(5px); +} + +tr.skipped,span.skipped { +background-color:#E9F7FB; +color: #90D5EC; +font-weight: bold; +font-size: 13px; +padding:5px; +.rounded(5px); +b { color:@text-color;} +} + +tr.good,span.good { +background-color:#E2FFD8; +color: #AAD450; +padding:5px; +font-size: 13px; +font-weight: bold; +.rounded(5px); +b { color:@text-color;} +} + +tr.qual,span.qual { +background-color:#FDF0D5; +padding:5px; +font-size: 13px; +font-weight: bold; +color: #F7B42C; +.rounded(5px); +b { color:@text-color;} +} + +tr.wanted,span.wanted { +background-color:#FDEBF3; +padding:5px; +font-size: 13px; +font-weight: bold; +color: #F49AC1; +.rounded(5px); +b { color:@text-color;} +} + +tr.wanted,tr.qual,tr.good,tr.skipped,tr.unaired { + font-size: 14px; + font-weight: normal; + color: @text-color; +} +.showInfo { + width: 745px; + float: right; + padding-top: 10px; +} +div#summary { +background-color:#f9f9f9; +.rounded(10px); +padding:10px; +border:1px solid #ddd; +margin:10px 0; + .infoTable { + width: 85%; + } +} +div#summary tr { +line-height: 17px; + td { font-size: 14px; line-height: 25px;} +} + +#MainMenu { + float: right; + height: 58px; + margin: 0; + padding: 0 0 0 10px; +} +#SubMenu { + float: right; + margin-top: -30px; + position: relative; + z-index: 99; +} + +#SubMenu a { + font-size: 12px; + text-decoration: none; +} +#SubMenu span b { margin-left: 20px;} +#donate { +line-height:1em; +background: #57442B; +float: right; +display: none; +} +#donate a,#donate a:hover { +background-color:#57442B; +border:0; +padding:4px 15px 0px; +} +#contentWrapper { + background: none; +} +#content { + line-height: 24px; + margin: 0 auto; + padding: 105px 0 0; + width: 960px; +} +.showLegend{ +font-weight:700; +padding-right:10px; +padding-bottom:1px; +} + +/* for the add new/existing show */ +.alt { background-color: #efefef; } +#tabs div.field-pair, .stepDiv div.field-pair{padding:0.75em 0;} +#tabs div.field-pair input, .stepDiv div.field-pair input{float:left;} +#tabs label.nocheck, #tabs div.providerDiv, #tabs div #customQuality, .stepDiv label.nocheck,.stepDiv div.providerDiv,.stepDiv div #customQuality{padding-left:23px;} +#tabs label span.component-title, .stepDiv label span.component-title{font-size:1.1em;font-weight:700;float:left;width:165px; padding-left: 6px; margin-right:10px;} +#tabs label span.component-desc, .stepDiv label span.component-desc{font-size:.9em; float:left;} +#tabs div.field-pair select, .stepDiv div.field-pair select{font-size:1em;border:1px solid #d4d0c8;} +#tabs div.field-pair select option, .stepDiv div.field-pair select option{line-height:1.4;padding:0 10px; border-bottom: 1px dotted #D7D7D7;} + +ul#rootDirStaticList { width: 90%; text-align: left; margin-left: auto; margin-right: auto; padding-left: 0; } +ul#rootDirStaticList li{ list-style: none outside none; margin: 2px; padding: 4px 5px 4px 5px; cursor: pointer; } + +#displayText { +background-color:#efefef; +padding:8px; +border:1px solid #DFDEDE; +font-size:1.1em; +overflow: hidden; +} + +div#addShowPortal { +margin: 50px auto; + width: 100%; +} + +div#addShowPortal button { float: left; + margin-left: 20px; + padding: 10px; + width: 47%;} +div#addShowPortal button div.button img{ position: absolute; display: block; top: 35%; padding-left: 0.4em; text-align: center; } +div#addShowPortal button .buttontext { position: relative; display: block; padding: 0.1em 0.4em 0.1em 4.4em; text-align: left; } + +#rootDirs, #rootDirsControls { width: 50%; min-width: 400px; } + +td.tvShow a {text-decoration: none; font-size:16px; } +.navShow { display: inline; cursor: pointer; vertical-align: top; position:relative;top:0px; .opacity(50); + &:hover { .opacity(100);} +} + +/* for manage_massEdit */ +.optionWrapper { width: 450px; margin-left: auto; margin-right: auto; padding: 6px 12px; } +.optionWrapper span.selectTitle { float: left; font-weight: 700; font-size: 1.2em; text-align: left; width: 225px; } +.optionWrapper div.selectChoices { float: left; width: 175px; margin-left: 25px; } +.optionWrapper br { clear: both; } + +a.whitelink { color: white; } + +/* for displayShow notice */ +#show_message { + border: 1px solid @border-color; + .gradient(#FDF0D5,#fff9ee); + .rounded(7px); + font-size: 14px; + right: 10px; + .shadow(0px 0px 2px #aaa); + padding: 7px 10px; + position: fixed; + text-align: center; + bottom: 10px; + min-height: 22px; + width: 250px; + z-index: 9999; + font-family: @alt-font-face; + line-height: normal; + .opacity(80); + .msg { + font-family: @alt-font-face; + line-height: normal; + padding-left: 20px; + } + .loader { + position: relative; + top: 2px; + } + &.success { + .gradient(@msg-bg-success,#C2EDC6); + padding: 15px 10px; + text-align: left; + } + &.error { + .gradient(@msg-bg-error,#EDC4C4); + padding:15px 10px; + text-align: left; + } + .ui-icon { + display: inline-block; + margin-left: -20px; + top: 2px; + position: relative; + margin-right: 3px; + } +} +div.ui-pnotify { min-width: 340px; max-width: 550px; width: auto !important;} + +/* override for qtip2 */ +.ui-tooltip-sb .ui-tooltip-titlebar a { color: #222222; text-decoration: none; } +.ui-tooltip, .qtip { max-width: 500px !important; } + +option.flag { + padding-left: 35px; + background-color: #fff; + background-repeat: no-repeat; + background-position: 10px 50%; +} + +span.quality { + font: bold 1em/1.2em verdana, sans-serif; + background: none repeat scroll 0 0 #999999; + color: #FFFFFF; + display: inline-block; + padding: 2px 4px; + text-align: center; + -webkit-border-radius: 4px; + font-size:12px; + -moz-border-radius: 4px; + border-radius: 4px; +} +span.Custom { + background: none repeat scroll 0 0 #444499; /* blue */ +} +span.HD,span.WEB-DL,span.BluRay { + background: none repeat scroll 0 0 #449944; /* green */ +} +span.SD { + background: none repeat scroll 0 0 #994444; /* red */ +} +span.Any { + background: none repeat scroll 0 0 #444444; /* black */ +} + +span.false { + color: #993333; /* red */ +} +span.true { + color: #669966; /* green */ +} +.ui-progressbar { + height: 15px !important; + line-height: 17px; +} \ No newline at end of file diff --git a/data/css/formwizard.css b/data/css/formwizard.css new file mode 100644 index 0000000000..4524d56d6b --- /dev/null +++ b/data/css/formwizard.css @@ -0,0 +1,91 @@ +fieldset.sectionwrap{ +text-align: left; +width: 800px; +border-width:0; +padding:5px; +top: -25px !important; +} + +legend.legendStep{ +font:bold 16px Arial; +color: #343434; +display: none; +} + +div.stepsguide{ /*div that contains all the "steps" text located at top of form */ +text-align: left; +width: 800px; /*width of "steps" container*/ +overflow:hidden; +margin-bottom:15px; +cursor:pointer; +} + +div.stepsguide .step{ +width:250px; /*width of each "steps" text*/ +font: bold 24px Arial; +float:left; +} +div.stepsguide .step p { +border-bottom: 4px solid #57442B; +} + +div.stepsguide .disabledstep{ +color:#C4C4C4; +} +div.stepsguide .disabledstep p { +border-bottom: 4px solid #8A775E; +} + +div.stepsguide .step .smalltext{ +font-size: 13px; +font-weight: normal; +} + +div.formpaginate{ +width: 800px; +overflow:auto; +font-weight:bold; +text-align:center; +margin-top:1em; +} + +div.formpaginate .prev, div.formpaginate .next{ +border-radius:6px; +-webkit-border-radius:6px; +-moz-border-radius:6px; +padding:3px 6px; +background:#57442B; +color:white; +cursor:hand; +cursor:pointer; +} + +.stepDiv {padding: 21px 15px 15px 15px;} + +input:not(.btn){float:left;margin-right:6px;margin-top:5px;padding-top:4px;padding-bottom:4px;padding-right:4px;padding-left:4px;border-bottom-left-radius:3px;border-bottom-right-radius:3px;border-top-left-radius:3px;border-top-right-radius:3px;border-top-width: 1px; + border-left-width: 1px; + border-left-style: solid; + border-bottom-color: #CCCCCC; + border-right-color: #CCCCCC; + border-left-color: #CCCCCC; + border-right-style: solid; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + border-bottom-style: solid; + border-bottom-width: 1px; + border-right-width: 1px; + border-right-style: solid; + border-right-width-value: 1px; + border-top-color: #CCCCCC; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + border-top-style: solid; + border-top-width: 1px; +} + +/* step 3 related */ + +#customQualityWrapper {/* height: 190px;*/ overflow: hidden; } +#customQualityWrapper div.component-group-desc{float:left;width:165px;} +#customQualityWrapper div.component-group-desc p{width:85%;font-size:14px;color:#666;margin:.8em 0;} +#customQualityWrapper div.component-group-desc p.note{width:90%;font-size:14px;color:#333;margin:.8em 0;} diff --git a/data/css/imports/config.less b/data/css/imports/config.less new file mode 100644 index 0000000000..a9fba3b2c0 --- /dev/null +++ b/data/css/imports/config.less @@ -0,0 +1,78 @@ +/* Variables */ +@base-font-face: "Helvetica Neue", Helvetica, Arial, Geneva, sans-serif; +@alt-font-face: "Trebuchet MS", Helvetica, Arial, sans-serif; +@base-font-size: 12px; +@text-color: #343434; +@swatch-blue: #4183C4; +@swatch-green: #BDE433; +@swatch-grey: #666666; +@link-color: #555555; +@border-color: #CCCCCC; +@msg-bg: #FFF6A9; +@msg-bg-success: #D3FFD7; +@msg-bg-error: #FFD3D3; + +/* Mixins */ +.rounded(@radius: 5px) { + -moz-border-radius: @radius; + -webkit-border-radius: @radius; + border-radius: @radius; +} +.roundedTop(@radius: 5px) { + -moz-border-radius-topleft: @radius; + -moz-border-radius-topright: @radius; + -webkit-border-top-right-radius: @radius; + -webkit-border-top-left-radius: @radius; + border-top-left-radius: @radius; + border-top-right-radius: @radius; +} +.roundedLeftTop(@radius: 5px) { + -moz-border-radius-topleft: @radius; + -webkit-border-top-left-radius: @radius; + border-top-left-radius: @radius; +} +.roundedRightTop(@radius: 5px) { + -moz-border-radius-topright: @radius; + -webkit-border-top-right-radius: @radius; + border-top-right-radius: @radius; +} +.roundedBottom(@radius: 5px) { + -moz-border-radius-bottomleft: @radius; + -moz-border-radius-bottomright: @radius; + -webkit-border-bottom-right-radius: @radius; + -webkit-border-bottom-left-radius: @radius; + border-bottom-left-radius: @radius; + border-bottom-right-radius: @radius; +} +.roundedLeftBottom(@radius: 5px) { + -moz-border-radius-bottomleft: @radius; + -webkit-border-bottom-left-radius: @radius; + border-bottom-left-radius: @radius; +} +.roundedRightBottom(@radius: 5px) { + -moz-border-radius-bottomright: @radius; + -webkit-border-bottom-right-radius: @radius; + border-bottom-right-radius: @radius; +} +.shadow(@shadow: 0 17px 11px -1px #ced8d9) { + -moz-box-shadow: @shadow; + -webkit-box-shadow: @shadow; + -o-box-shadow: @shadow; + box-shadow: @shadow; +} +.gradient(@gradientFrom: #FFFFFF, @gradientTo: #EEEEEE){ + background-image: -moz-linear-gradient(@gradientFrom, @gradientTo) !important; + background-image: linear-gradient(@gradientFrom, @gradientTo) !important; + background-image: -webkit-linear-gradient(@gradientFrom, @gradientTo) !important; + background-image: -o-linear-gradient(@gradientFrom, @gradientTo) !important; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=@gradientFrom, endColorstr=@gradientTo) !important; + -ms-filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=@gradientFrom, endColorstr=@gradientTo) !important; +} +.opacity(@opacity_percent:85) { + filter: ~"alpha(opacity=85)"; + -moz-opacity: @opacity_percent / 100 !important; + -khtml-opacity:@opacity_percent / 100 !important; + -o-opacity:@opacity_percent / 100 !important; + opacity:@opacity_percent / 100 !important; +} + diff --git a/data/css/iphone.css b/data/css/iphone.css new file mode 100644 index 0000000000..617f16619c --- /dev/null +++ b/data/css/iphone.css @@ -0,0 +1,24 @@ +body { +width: 100%; +padding: 0; +margin: 0; +font-size: 10px; +line-height:10px; +} + +.MainMenu a, .SubMenu a { padding: 2px; font-weight: normal; } +#btnExistingShow, #btnNewShow { min-height: 150px; } + +.sickbeardTable { +margin-left:1%; +margin-right:1%; +width:98%; +} + +table { +margin:0; +font-size: 10px; +} + +#outerWrapper { width: 98%; padding-left: 1%; padding-right: 1%; } + diff --git a/data/css/jquery-ui-1.8.23.custom.css b/data/css/jquery-ui-1.8.23.custom.css new file mode 100644 index 0000000000..a9c737aa33 --- /dev/null +++ b/data/css/jquery-ui-1.8.23.custom.css @@ -0,0 +1,461 @@ +/*! + * jQuery UI CSS Framework 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Theming/API + */ + +/* Layout helpers +----------------------------------*/ +.ui-helper-hidden { display: none; } +.ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px); } +.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; } +.ui-helper-clearfix:before, .ui-helper-clearfix:after { content: ""; display: table; } +.ui-helper-clearfix:after { clear: both; } +.ui-helper-clearfix { zoom: 1; } +.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); } + + +/* Interaction Cues +----------------------------------*/ +.ui-state-disabled { cursor: default !important; } + + +/* Icons +----------------------------------*/ + +/* states and images */ +.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; } + + +/* Misc visuals +----------------------------------*/ + +/* Overlays */ +.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } + + +/*! + * jQuery UI CSS Framework 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Theming/API + * + * To view and modify this theme, visit http://jqueryui.com/themeroller/?ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ctl=themeroller&ffDefault=Verdana,Arial,sans-serif&fwDefault=bold&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=ffffff&bgTextureHeader=01_flat.png&bgImgOpacityHeader=0&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=dcdcdc&bgTextureContent=03_highlight_soft.png&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=efefef&bgTextureDefault=03_highlight_soft.png&bgImgOpacityDefault=75&borderColorDefault=aaaaaa&fcDefault=222222&iconColorDefault=8c291d&bgColorHover=dddddd&bgTextureHover=03_highlight_soft.png&bgImgOpacityHover=75&borderColorHover=999999&fcHover=222222&iconColorHover=222222&bgColorActive=dfdfdf&bgTextureActive=05_inset_soft.png&bgImgOpacityActive=75&borderColorActive=aaaaaa&fcActive=140f06&iconColorActive=8c291d&bgColorHighlight=fbf9ee&bgTextureHighlight=02_glass.png&bgImgOpacityHighlight=55&borderColorHighlight=aaaaaa&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=02_glass.png&bgImgOpacityError=95&borderColorError=aaaaaa&fcError=8c291d&iconColorError=cd0a0a&bgColorOverlay=6e4f1c&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=35&bgColorShadow=000000&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=35&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px + */ + + +/* Component containers +----------------------------------*/ +.ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; } +.ui-widget .ui-widget { font-size: 1em; } +.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; } +/*.ui-widget-content { border: 1px solid #aaaaaa; background: #dcdcdc url(images/ui-bg_highlight-soft_75_dcdcdc_1x100.png) 50% top repeat-x; color: #222222; }*/ +.ui-widget-content a { color: #222222; } +/*.ui-widget-header { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_0_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; font-weight: bold; }*/ +.ui-widget-header a { color: #222222; } + +/* Interaction states +----------------------------------*/ +/*.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #aaaaaa; background: #efefef url(images/ui-bg_highlight-soft_75_efefef_1x100.png) 50% 50% repeat-x; font-weight: bold; color: #222222; }*/ +.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #222222; text-decoration: none; } +/*.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #999999; background: #dddddd url(images/ui-bg_highlight-soft_75_dddddd_1x100.png) 50% 50% repeat-x; font-weight: bold; color: #222222; }*/ +.ui-state-hover a, .ui-state-hover a:hover { color: #222222; text-decoration: none; } +/*.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #aaaaaa; background: #dfdfdf url(images/ui-bg_inset-soft_75_dfdfdf_1x100.png) 50% 50% repeat-x; font-weight: bold; color: #140f06; }*/ +.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #140f06; text-decoration: none; } +.ui-widget :active { outline: none; } + +/* Interaction Cues +----------------------------------*/ +/*.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #aaaaaa; background: #fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x; color: #363636; }*/ +.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; } +/*.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #aaaaaa; background: #fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x; color: #8c291d; }*/ +.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #8c291d; } +/*.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #8c291d; }*/ +.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; } +/*.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }*/ +.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; } + +/* Icons +----------------------------------*/ + +/* states and images */ +/* +.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); } +.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } +.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } +.ui-state-default .ui-icon { background-image: url(images/ui-icons_8c291d_256x240.png); } +.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } +.ui-state-active .ui-icon {background-image: url(images/ui-icons_8c291d_256x240.png); } +.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); } +.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); } +*/ + +/* positioning */ +.ui-icon-carat-1-n { background-position: 0 0; } +.ui-icon-carat-1-ne { background-position: -16px 0; } +.ui-icon-carat-1-e { background-position: -32px 0; } +.ui-icon-carat-1-se { background-position: -48px 0; } +.ui-icon-carat-1-s { background-position: -64px 0; } +.ui-icon-carat-1-sw { background-position: -80px 0; } +.ui-icon-carat-1-w { background-position: -96px 0; } +.ui-icon-carat-1-nw { background-position: -112px 0; } +.ui-icon-carat-2-n-s { background-position: -128px 0; } +.ui-icon-carat-2-e-w { background-position: -144px 0; } +.ui-icon-triangle-1-n { background-position: 0 -16px; } +.ui-icon-triangle-1-ne { background-position: -16px -16px; } +.ui-icon-triangle-1-e { background-position: -32px -16px; } +.ui-icon-triangle-1-se { background-position: -48px -16px; } +.ui-icon-triangle-1-s { background-position: -64px -16px; } +.ui-icon-triangle-1-sw { background-position: -80px -16px; } +.ui-icon-triangle-1-w { background-position: -96px -16px; } +.ui-icon-triangle-1-nw { background-position: -112px -16px; } +.ui-icon-triangle-2-n-s { background-position: -128px -16px; } +.ui-icon-triangle-2-e-w { background-position: -144px -16px; } +.ui-icon-arrow-1-n { background-position: 0 -32px; } +.ui-icon-arrow-1-ne { background-position: -16px -32px; } +.ui-icon-arrow-1-e { background-position: -32px -32px; } +.ui-icon-arrow-1-se { background-position: -48px -32px; } +.ui-icon-arrow-1-s { background-position: -64px -32px; } +.ui-icon-arrow-1-sw { background-position: -80px -32px; } +.ui-icon-arrow-1-w { background-position: -96px -32px; } +.ui-icon-arrow-1-nw { background-position: -112px -32px; } +.ui-icon-arrow-2-n-s { background-position: -128px -32px; } +.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; } +.ui-icon-arrow-2-e-w { background-position: -160px -32px; } +.ui-icon-arrow-2-se-nw { background-position: -176px -32px; } +.ui-icon-arrowstop-1-n { background-position: -192px -32px; } +.ui-icon-arrowstop-1-e { background-position: -208px -32px; } +.ui-icon-arrowstop-1-s { background-position: -224px -32px; } +.ui-icon-arrowstop-1-w { background-position: -240px -32px; } +.ui-icon-arrowthick-1-n { background-position: 0 -48px; } +.ui-icon-arrowthick-1-ne { background-position: -16px -48px; } +.ui-icon-arrowthick-1-e { background-position: -32px -48px; } +.ui-icon-arrowthick-1-se { background-position: -48px -48px; } +.ui-icon-arrowthick-1-s { background-position: -64px -48px; } +.ui-icon-arrowthick-1-sw { background-position: -80px -48px; } +.ui-icon-arrowthick-1-w { background-position: -96px -48px; } +.ui-icon-arrowthick-1-nw { background-position: -112px -48px; } +.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; } +.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; } +.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; } +.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; } +.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; } +.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; } +.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; } +.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; } +.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; } +.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; } +.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; } +.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; } +.ui-icon-arrowreturn-1-w { background-position: -64px -64px; } +.ui-icon-arrowreturn-1-n { background-position: -80px -64px; } +.ui-icon-arrowreturn-1-e { background-position: -96px -64px; } +.ui-icon-arrowreturn-1-s { background-position: -112px -64px; } +.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; } +.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; } +.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; } +.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; } +.ui-icon-arrow-4 { background-position: 0 -80px; } +.ui-icon-arrow-4-diag { background-position: -16px -80px; } +.ui-icon-extlink { background-position: -32px -80px; } +.ui-icon-newwin { background-position: -48px -80px; } +.ui-icon-refresh { background-position: -64px -80px; } +.ui-icon-shuffle { background-position: -80px -80px; } +.ui-icon-transfer-e-w { background-position: -96px -80px; } +.ui-icon-transferthick-e-w { background-position: -112px -80px; } +.ui-icon-folder-collapsed { background-position: 0 -96px; } +.ui-icon-folder-open { background-position: -16px -96px; } +.ui-icon-document { background-position: -32px -96px; } +.ui-icon-document-b { background-position: -48px -96px; } +.ui-icon-note { background-position: -64px -96px; } +.ui-icon-mail-closed { background-position: -80px -96px; } +.ui-icon-mail-open { background-position: -96px -96px; } +.ui-icon-suitcase { background-position: -112px -96px; } +.ui-icon-comment { background-position: -128px -96px; } +.ui-icon-person { background-position: -144px -96px; } +.ui-icon-print { background-position: -160px -96px; } +.ui-icon-trash { background-position: -176px -96px; } +.ui-icon-locked { background-position: -192px -96px; } +.ui-icon-unlocked { background-position: -208px -96px; } +.ui-icon-bookmark { background-position: -224px -96px; } +.ui-icon-tag { background-position: -240px -96px; } +.ui-icon-home { background-position: 0 -112px; } +.ui-icon-flag { background-position: -16px -112px; } +.ui-icon-calendar { background-position: -32px -112px; } +.ui-icon-cart { background-position: -48px -112px; } +.ui-icon-pencil { background-position: -64px -112px; } +.ui-icon-clock { background-position: -80px -112px; } +.ui-icon-disk { background-position: -96px -112px; } +.ui-icon-calculator { background-position: -112px -112px; } +.ui-icon-zoomin { background-position: -128px -112px; } +.ui-icon-zoomout { background-position: -144px -112px; } +.ui-icon-search { background-position: -160px -112px; } +.ui-icon-wrench { background-position: -176px -112px; } +.ui-icon-gear { background-position: -192px -112px; } +.ui-icon-heart { background-position: -208px -112px; } +.ui-icon-star { background-position: -224px -112px; } +.ui-icon-link { background-position: -240px -112px; } +.ui-icon-cancel { background-position: 0 -128px; } +.ui-icon-plus { background-position: -16px -128px; } +.ui-icon-plusthick { background-position: -32px -128px; } +.ui-icon-minus { background-position: -48px -128px; } +.ui-icon-minusthick { background-position: -64px -128px; } +.ui-icon-close { background-position: -80px -128px; } +.ui-icon-closethick { background-position: -96px -128px; } +.ui-icon-key { background-position: -112px -128px; } +.ui-icon-lightbulb { background-position: -128px -128px; } +.ui-icon-scissors { background-position: -144px -128px; } +.ui-icon-clipboard { background-position: -160px -128px; } +.ui-icon-copy { background-position: -176px -128px; } +.ui-icon-contact { background-position: -192px -128px; } +.ui-icon-image { background-position: -208px -128px; } +.ui-icon-video { background-position: -224px -128px; } +.ui-icon-script { background-position: -240px -128px; } +.ui-icon-alert { background-position: 0 -144px; } +.ui-icon-info { background-position: -16px -144px; } +.ui-icon-notice { background-position: -32px -144px; } +.ui-icon-help { background-position: -48px -144px; } +.ui-icon-check { background-position: -64px -144px; } +.ui-icon-bullet { background-position: -80px -144px; } +.ui-icon-radio-off { background-position: -96px -144px; } +.ui-icon-radio-on { background-position: -112px -144px; } +.ui-icon-pin-w { background-position: -128px -144px; } +.ui-icon-pin-s { background-position: -144px -144px; } +.ui-icon-play { background-position: 0 -160px; } +.ui-icon-pause { background-position: -16px -160px; } +.ui-icon-seek-next { background-position: -32px -160px; } +.ui-icon-seek-prev { background-position: -48px -160px; } +.ui-icon-seek-end { background-position: -64px -160px; } +.ui-icon-seek-start { background-position: -80px -160px; } +/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */ +.ui-icon-seek-first { background-position: -80px -160px; } +.ui-icon-stop { background-position: -96px -160px; } +.ui-icon-eject { background-position: -112px -160px; } +.ui-icon-volume-off { background-position: -128px -160px; } +.ui-icon-volume-on { background-position: -144px -160px; } +.ui-icon-power { background-position: 0 -176px; } +.ui-icon-signal-diag { background-position: -16px -176px; } +.ui-icon-signal { background-position: -32px -176px; } +.ui-icon-battery-0 { background-position: -48px -176px; } +.ui-icon-battery-1 { background-position: -64px -176px; } +.ui-icon-battery-2 { background-position: -80px -176px; } +.ui-icon-battery-3 { background-position: -96px -176px; } +.ui-icon-circle-plus { background-position: 0 -192px; } +.ui-icon-circle-minus { background-position: -16px -192px; } +.ui-icon-circle-close { background-position: -32px -192px; } +.ui-icon-circle-triangle-e { background-position: -48px -192px; } +.ui-icon-circle-triangle-s { background-position: -64px -192px; } +.ui-icon-circle-triangle-w { background-position: -80px -192px; } +.ui-icon-circle-triangle-n { background-position: -96px -192px; } +.ui-icon-circle-arrow-e { background-position: -112px -192px; } +.ui-icon-circle-arrow-s { background-position: -128px -192px; } +.ui-icon-circle-arrow-w { background-position: -144px -192px; } +.ui-icon-circle-arrow-n { background-position: -160px -192px; } +.ui-icon-circle-zoomin { background-position: -176px -192px; } +.ui-icon-circle-zoomout { background-position: -192px -192px; } +.ui-icon-circle-check { background-position: -208px -192px; } +.ui-icon-circlesmall-plus { background-position: 0 -208px; } +.ui-icon-circlesmall-minus { background-position: -16px -208px; } +.ui-icon-circlesmall-close { background-position: -32px -208px; } +.ui-icon-squaresmall-plus { background-position: -48px -208px; } +.ui-icon-squaresmall-minus { background-position: -64px -208px; } +.ui-icon-squaresmall-close { background-position: -80px -208px; } +.ui-icon-grip-dotted-vertical { background-position: 0 -224px; } +.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; } +.ui-icon-grip-solid-vertical { background-position: -32px -224px; } +.ui-icon-grip-solid-horizontal { background-position: -48px -224px; } +.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; } +.ui-icon-grip-diagonal-se { background-position: -80px -224px; } + + +/* Misc visuals +----------------------------------*/ + +/* Corner radius */ +.ui-corner-all, .ui-corner-top, .ui-corner-left, .ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -khtml-border-top-left-radius: 4px; border-top-left-radius: 4px; } +.ui-corner-all, .ui-corner-top, .ui-corner-right, .ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; -khtml-border-top-right-radius: 4px; border-top-right-radius: 4px; } +.ui-corner-all, .ui-corner-bottom, .ui-corner-left, .ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; -khtml-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; } +.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; -khtml-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; } + +/* Overlays */ +/* +.ui-widget-overlay { background: #6e4f1c url(images/ui-bg_flat_0_6e4f1c_40x100.png) 50% 50% repeat-x; opacity: .35;filter:Alpha(Opacity=35); } +.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #000000 url(images/ui-bg_flat_0_000000_40x100.png) 50% 50% repeat-x; opacity: .35;filter:Alpha(Opacity=35); -moz-border-radius: 8px; -khtml-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; } +*/ +/*! + * jQuery UI Resizable 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Resizable#theming + */ +.ui-resizable { position: relative;} +.ui-resizable-handle { position: absolute;font-size: 0.1px; display: block; } +.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; } +.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; } +.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; } +.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; } +.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; } +.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; } +.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; } +.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; } +.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/*! + * jQuery UI Selectable 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Selectable#theming + */ +.ui-selectable-helper { position: absolute; z-index: 100; border:1px dotted black; } +/*! + * jQuery UI Autocomplete 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Autocomplete#theming + */ +.ui-autocomplete { position: absolute; cursor: default; } + +/* workarounds */ +* html .ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */ + +/* + * jQuery UI Menu 1.8.23 + * + * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Menu#theming + */ +.ui-menu { + list-style:none; + padding: 2px; + margin: 0; + display:block; + float: left; +} +.ui-menu .ui-menu { + margin-top: -3px; +} +.ui-menu .ui-menu-item { + margin:0; + padding: 0; + zoom: 1; + float: left; + clear: left; + width: 100%; +} +.ui-menu .ui-menu-item a { + text-decoration:none; + display:block; + padding:.2em .4em; + line-height:1.5; + zoom:1; +} +.ui-menu .ui-menu-item a.ui-state-hover, +.ui-menu .ui-menu-item a.ui-state-active { + font-weight: normal; + margin: -1px; +} +/*! + * jQuery UI Button 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Button#theming + */ +.ui-button { display: inline-block; position: relative; padding: 0; margin-right: .1em; text-decoration: none !important; cursor: pointer; text-align: center; zoom: 1; overflow: visible; } /* the overflow property removes extra width in IE */ +.ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */ +button.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */ +.ui-button-icons-only { width: 3.4em; } +button.ui-button-icons-only { width: 3.7em; } + +/*button text element */ +.ui-button .ui-button-text { display: block; line-height: 1.4; } +.ui-button-text-only .ui-button-text { padding: .4em 1em; } +.ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; } +.ui-button-text-icon-primary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; } +.ui-button-text-icon-secondary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 2.1em .4em 1em; } +.ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; } +/* no icon support for input elements, provide padding by default */ +input.ui-button { padding: .4em 1em; } + +/*button icon element(s) */ +.ui-button-icon-only .ui-icon, .ui-button-text-icon-primary .ui-icon, .ui-button-text-icon-secondary .ui-icon, .ui-button-text-icons .ui-icon, .ui-button-icons-only .ui-icon { position: absolute; top: 50%; margin-top: -8px; } +.ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; } +.ui-button-text-icon-primary .ui-button-icon-primary, .ui-button-text-icons .ui-button-icon-primary, .ui-button-icons-only .ui-button-icon-primary { left: .5em; } +.ui-button-text-icon-secondary .ui-button-icon-secondary, .ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; } +.ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; } + +/*button sets*/ +.ui-buttonset { margin-right: 7px; } +.ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; } + +/* workarounds */ +button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */ +/*! + * jQuery UI Dialog 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Dialog#theming + */ +.ui-dialog { position: absolute; padding: .2em; width: 300px; overflow: hidden; } +.ui-dialog .ui-dialog-titlebar { padding: .4em 1em; position: relative; } +.ui-dialog .ui-dialog-title { float: left; margin: .1em 16px .1em 0; } +.ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; } +.ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; } +.ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; } +.ui-dialog .ui-dialog-content { position: relative; border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; } +.ui-dialog .ui-dialog-buttonpane { text-align: left; border-width: 1px 0 0 0; background-image: none; margin: .5em 0 0 0; padding: .3em 1em .5em .4em; } +.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset { float: right; } +.ui-dialog .ui-dialog-buttonpane button { margin: .5em .4em .5em 0; cursor: pointer; } +.ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; } +.ui-draggable .ui-dialog-titlebar { cursor: move; } +/*! + * jQuery UI Tabs 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Tabs#theming + */ +.ui-tabs { position: relative; padding: .2em; zoom: 1; } /* position: relative prevents IE scroll bug (element with position: relative inside container with overflow: auto appear as "fixed") */ +.ui-tabs .ui-tabs-nav { margin: 0; padding: .2em .2em 0; } +.ui-tabs .ui-tabs-nav li { list-style: none; float: left; position: relative; top: 1px; margin: 0 .2em 1px 0; border-bottom: 0 !important; padding: 0; white-space: nowrap; } +.ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; } +.ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; } +.ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; } +.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */ +.ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: none; } +.ui-tabs .ui-tabs-hide { display: none !important; } +/*! + * jQuery UI Progressbar 1.8.23 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Progressbar#theming + */ +.ui-progressbar { height:1.3em; text-align: left; overflow: hidden; } +.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; } \ No newline at end of file diff --git a/data/css/jquery.pnotify.default.css b/data/css/jquery.pnotify.default.css new file mode 100644 index 0000000000..5fc8b5b9d4 --- /dev/null +++ b/data/css/jquery.pnotify.default.css @@ -0,0 +1,101 @@ +/* + Document : jquery.pnotify.default.css + Created on : Nov 23, 2009, 3:14:10 PM + Author : Hunter Perrin + Version : 1.0.0 + Description: + Default styling for Pines Notify jQuery plugin. +*/ + +/* Notice +----------------------------------*/ +.ui-pnotify { + position: fixed; + right: 10px; + bottom: 10px; + /* Ensure that the notices are on top of everything else. */ + z-index: 9999; +} +/* This hides position: fixed from IE6, which doesn't understand it. */ +html > body .ui-pnotify { + position: fixed; +} +.ui-pnotify .ui-widget { + background: none; +} +.ui-pnotify-container { + background-position: 0 0; + border: 1px solid #cccccc; + background-image: -moz-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -webkit-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -o-linear-gradient(#fdf0d5, #fff9ee) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -moz-border-radius: 7px; + -webkit-border-radius: 7px; + border-radius: 7px; + font-size: 14px; + -moz-box-shadow: 0px 0px 2px #aaaaaa; + -webkit-box-shadow: 0px 0px 2px #aaaaaa; + -o-box-shadow: 0px 0px 2px #aaaaaa; + box-shadow: 0px 0px 2px #aaaaaa; + padding: 7px 10px; + text-align: center; + min-height: 22px; + width: 250px; + z-index: 9999; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; + filter: alpha(opacity=85); + -moz-opacity: 0.8 !important; + -khtml-opacity: 0.8 !important; + -o-opacity: 0.8 !important; + opacity: 0.8 !important; +} +.ui-pnotify-closer { + float: right; + margin-left: .2em; +} +.ui-pnotify-title { + display: block; + background: none; + font-size: 14px; + font-weight: bold; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; +} +.ui-pnotify-text { + display: block; + font-size: 14px; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; +} +.ui-pnotify-icon, .ui-pnotify-icon span { + display: block; + float: left; + margin-right: .2em; +} +/* History Pulldown +----------------------------------*/ +.ui-pnotify-history-container { + position: absolute; + top: 0; + right: 18px; + width: 70px; + border-top: none; + /* Ensure that the history container is on top of the notices. */ + z-index: 10000; +} +.ui-pnotify-history-container .ui-pnotify-history-header { + /*padding: 2px;*/ +} +.ui-pnotify-history-container button { + cursor: pointer; + display: block; + width: 100%; +} +.ui-pnotify-history-container .ui-pnotify-history-pulldown { + display: block; + margin: 0 auto; +} diff --git a/data/css/lib/jquery.qtip.css b/data/css/jquery.qtip2.css similarity index 95% rename from data/css/lib/jquery.qtip.css rename to data/css/jquery.qtip2.css index 0b2d42d4fc..173ce4ba2a 100644 --- a/data/css/lib/jquery.qtip.css +++ b/data/css/jquery.qtip2.css @@ -9,7 +9,7 @@ * http://en.wikipedia.org/wiki/MIT_License * http://en.wikipedia.org/wiki/GNU_General_Public_License * -* Date: Thu Apr 26 12:17:04.0000000000 2012 +* Date: Thu Nov 17 12:01:03.0000000000 2011 */ /* Core qTip styles */ @@ -24,6 +24,8 @@ font-size: 10.5px; line-height: 12px; + + z-index: 15000; } /* Fluid class for determining actual width in IE */ @@ -38,9 +40,10 @@ position: relative; padding: 5px 9px; overflow: hidden; - - border: 1px solid #000001; - + + border-width: 1px; + border-style: solid; + text-align: left; word-wrap: break-word; overflow: hidden; @@ -51,9 +54,9 @@ min-height: 14px; padding: 5px 35px 5px 10px; overflow: hidden; - - border: 1px solid #000001; + border-width: 1px 1px 0; + border-style: solid; font-weight: bold; } @@ -157,29 +160,6 @@ .ui-tooltip .ui-tooltip-tip canvas{ top: 0; left: 0; } -/* Modal plugin */ -#qtip-overlay{ - position: fixed; - left: -10000em; - top: -10000em; -} - - /* Applied to modals with show.modal.blur set to true */ - #qtip-overlay.blurs{ cursor: pointer; } - - /* Change opacity of overlay here */ - #qtip-overlay div{ - position: absolute; - left: 0; top: 0; - width: 100%; height: 100%; - - background-color: black; - - opacity: 0.7; - filter:alpha(opacity=70); - -ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=70)"; - } - /*! Light tooltip style */ .ui-tooltip-light .ui-tooltip-titlebar, .ui-tooltip-light .ui-tooltip-content{ @@ -553,5 +533,4 @@ .ui-tooltip:not(.ie9haxors) div.ui-tooltip-titlebar{ filter: none; -ms-filter: none; -} - +} \ No newline at end of file diff --git a/data/css/lib/bootstrap.css b/data/css/lib/bootstrap.css deleted file mode 100644 index 09e2833dcd..0000000000 --- a/data/css/lib/bootstrap.css +++ /dev/null @@ -1,4960 +0,0 @@ -/*! - * Bootstrap v2.0.3 - * - * Copyright 2012 Twitter, Inc - * Licensed under the Apache License v2.0 - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Designed and built with all the love in the world @twitter by @mdo and @fat. - */ - -article, -aside, -details, -figcaption, -figure, -footer, -header, -hgroup, -nav, -section { - display: block; -} - -audio, -canvas, -video { - display: inline-block; - *display: inline; - *zoom: 1; -} - -audio:not([controls]) { - display: none; -} - -html { - font-size: 100%; - -webkit-text-size-adjust: 100%; - -ms-text-size-adjust: 100%; -} - -a:focus { - outline: thin dotted #333; - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; -} - -a:hover, -a:active { - outline: 0; -} - -sub, -sup { - position: relative; - font-size: 75%; - line-height: 0; - vertical-align: baseline; -} - -sup { - top: -0.5em; -} - -sub { - bottom: -0.25em; -} - -img { - max-width: 100%; - vertical-align: middle; - border: 0; - -ms-interpolation-mode: bicubic; -} - -button, -input, -select, -textarea { - margin: 0; - font-size: 100%; - vertical-align: middle; -} - -button, -input { - *overflow: visible; - line-height: normal; -} - -button::-moz-focus-inner, -input::-moz-focus-inner { - padding: 0; - border: 0; -} - -button, -input[type="button"], -input[type="reset"], -input[type="submit"] { - cursor: pointer; - -webkit-appearance: button; -} - -input[type="search"] { - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; - -webkit-appearance: textfield; -} - -input[type="search"]::-webkit-search-decoration, -input[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; -} - -textarea { - overflow: auto; - vertical-align: top; -} - -.clearfix { - *zoom: 1; -} - -.clearfix:before, -.clearfix:after { - display: table; - content: ""; -} - -.clearfix:after { - clear: both; -} - -.hide-text { - font: 0/0 a; - color: transparent; - text-shadow: none; - background-color: transparent; - border: 0; -} - -.input-block-level { - display: block; - width: 100%; - min-height: 28px; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; -} - -body { - margin: 0; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 13px; - line-height: 18px; - color: #333333; - background-color: #ffffff; -} - -a { - color: #0088cc; - text-decoration: none; -} - -a:hover { - color: #005580; - text-decoration: underline; -} - -.row { - margin-left: -20px; - *zoom: 1; -} - -.row:before, -.row:after { - display: table; - content: ""; -} - -.row:after { - clear: both; -} - -[class*="span"] { - float: left; - margin-left: 20px; -} - -.container, -.navbar-fixed-top .container, -.navbar-fixed-bottom .container { - width: 940px; -} - -.span12 { - width: 940px; -} - -.span11 { - width: 860px; -} - -.span10 { - width: 780px; -} - -.span9 { - width: 700px; -} - -.span8 { - width: 620px; -} - -.span7 { - width: 540px; -} - -.span6 { - width: 460px; -} - -.span5 { - width: 380px; -} - -.span4 { - width: 300px; -} - -.span3 { - width: 220px; -} - -.span2 { - width: 140px; -} - -.span1 { - width: 60px; -} - -.offset12 { - margin-left: 980px; -} - -.offset11 { - margin-left: 900px; -} - -.offset10 { - margin-left: 820px; -} - -.offset9 { - margin-left: 740px; -} - -.offset8 { - margin-left: 660px; -} - -.offset7 { - margin-left: 580px; -} - -.offset6 { - margin-left: 500px; -} - -.offset5 { - margin-left: 420px; -} - -.offset4 { - margin-left: 340px; -} - -.offset3 { - margin-left: 260px; -} - -.offset2 { - margin-left: 180px; -} - -.offset1 { - margin-left: 100px; -} - -.row-fluid { - width: 100%; - *zoom: 1; -} - -.row-fluid:before, -.row-fluid:after { - display: table; - content: ""; -} - -.row-fluid:after { - clear: both; -} - -.row-fluid [class*="span"] { - display: block; - float: left; - width: 100%; - min-height: 28px; - margin-left: 2.127659574%; - *margin-left: 2.0744680846382977%; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; -} - -.row-fluid [class*="span"]:first-child { - margin-left: 0; -} - -.row-fluid .span12 { - width: 99.99999998999999%; - *width: 99.94680850063828%; -} - -.row-fluid .span11 { - width: 91.489361693%; - *width: 91.4361702036383%; -} - -.row-fluid .span10 { - width: 82.97872339599999%; - *width: 82.92553190663828%; -} - -.row-fluid .span9 { - width: 74.468085099%; - *width: 74.4148936096383%; -} - -.row-fluid .span8 { - width: 65.95744680199999%; - *width: 65.90425531263828%; -} - -.row-fluid .span7 { - width: 57.446808505%; - *width: 57.3936170156383%; -} - -.row-fluid .span6 { - width: 48.93617020799999%; - *width: 48.88297871863829%; -} - -.row-fluid .span5 { - width: 40.425531911%; - *width: 40.3723404216383%; -} - -.row-fluid .span4 { - width: 31.914893614%; - *width: 31.8617021246383%; -} - -.row-fluid .span3 { - width: 23.404255317%; - *width: 23.3510638276383%; -} - -.row-fluid .span2 { - width: 14.89361702%; - *width: 14.8404255306383%; -} - -.row-fluid .span1 { - width: 6.382978723%; - *width: 6.329787233638298%; -} - -.container { - margin-right: auto; - margin-left: auto; - *zoom: 1; -} - -.container:before, -.container:after { - display: table; - content: ""; -} - -.container:after { - clear: both; -} - -.container-fluid { - padding-right: 20px; - padding-left: 20px; - *zoom: 1; -} - -.container-fluid:before, -.container-fluid:after { - display: table; - content: ""; -} - -.container-fluid:after { - clear: both; -} - -p { - margin: 0 0 9px; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 13px; - line-height: 18px; -} - -p small { - font-size: 11px; - color: #999999; -} - -.lead { - margin-bottom: 18px; - font-size: 20px; - font-weight: 200; - line-height: 27px; -} - -h1, -h2, -h3, -h4, -h5, -h6 { - margin: 0; - font-family: inherit; - font-weight: bold; - color: inherit; - text-rendering: optimizelegibility; -} - -h1 small, -h2 small, -h3 small, -h4 small, -h5 small, -h6 small { - font-weight: normal; - color: #999999; -} - -h1 { - font-size: 30px; - line-height: 36px; -} - -h1 small { - font-size: 18px; -} - -h2 { - font-size: 24px; - line-height: 36px; -} - -h2 small { - font-size: 18px; -} - -h3 { - font-size: 18px; - line-height: 27px; -} - -h3 small { - font-size: 14px; -} - -h4, -h5, -h6 { - line-height: 18px; -} - -h4 { - font-size: 14px; -} - -h4 small { - font-size: 12px; -} - -h5 { - font-size: 12px; -} - -h6 { - font-size: 11px; - color: #999999; - text-transform: uppercase; -} - -.page-header { - padding-bottom: 17px; - margin: 18px 0; - border-bottom: 1px solid #eeeeee; -} - -.page-header h1 { - line-height: 1; -} - -ul, -ol { - padding: 0; - margin: 0 0 9px 25px; -} - -ul ul, -ul ol, -ol ol, -ol ul { - margin-bottom: 0; -} - -ul { - list-style: disc; -} - -ol { - list-style: decimal; -} - -li { - line-height: 18px; -} - -ul.unstyled, -ol.unstyled { - margin-left: 0; - list-style: none; -} - -dl { - margin-bottom: 18px; -} - -dt, -dd { - line-height: 18px; -} - -dt { - font-weight: bold; - line-height: 17px; -} - -dd { - margin-left: 9px; -} - -.dl-horizontal dt { - float: left; - width: 120px; - overflow: hidden; - clear: left; - text-align: right; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dl-horizontal dd { - margin-left: 130px; -} - -hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #eeeeee; - border-bottom: 1px solid #ffffff; -} - -strong { - font-weight: bold; -} - -em { - font-style: italic; -} - -.muted { - color: #999999; -} - -abbr[title] { - cursor: help; - border-bottom: 1px dotted #ddd; -} - -abbr.initialism { - font-size: 90%; - text-transform: uppercase; -} - -blockquote { - padding: 0 0 0 15px; - margin: 0 0 18px; - border-left: 5px solid #eeeeee; -} - -blockquote p { - margin-bottom: 0; - font-size: 16px; - font-weight: 300; - line-height: 22.5px; -} - -blockquote small { - display: block; - line-height: 18px; - color: #999999; -} - -blockquote small:before { - content: '\2014 \00A0'; -} - -blockquote.pull-right { - float: right; - padding-right: 15px; - padding-left: 0; - border-right: 5px solid #eeeeee; - border-left: 0; -} - -blockquote.pull-right p, -blockquote.pull-right small { - text-align: right; -} - -q:before, -q:after, -blockquote:before, -blockquote:after { - content: ""; -} - -address { - display: block; - margin-bottom: 18px; - font-style: normal; - line-height: 18px; -} - -small { - font-size: 100%; -} - -cite { - font-style: normal; -} - -code, -pre { - padding: 0 3px 2px; - font-family: Menlo, Monaco, Consolas, "Courier New", monospace; - font-size: 12px; - color: #333333; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -code { - padding: 2px 4px; - color: #d14; - background-color: #f7f7f9; - border: 1px solid #e1e1e8; -} - -pre { - display: block; - padding: 8.5px; - margin: 0 0 9px; - font-size: 12.025px; - line-height: 18px; - word-break: break-all; - word-wrap: break-word; - white-space: pre; - white-space: pre-wrap; - background-color: #f5f5f5; - border: 1px solid #ccc; - border: 1px solid rgba(0, 0, 0, 0.15); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -pre.prettyprint { - margin-bottom: 18px; -} - -pre code { - padding: 0; - color: inherit; - background-color: transparent; - border: 0; -} - -.pre-scrollable { - max-height: 340px; - overflow-y: scroll; -} - -form { - margin: 0 0 18px; -} - -fieldset { - padding: 0; - margin: 0; - border: 0; -} - -legend { - display: block; - width: 100%; - padding: 0; - margin-bottom: 27px; - font-size: 19.5px; - line-height: 36px; - color: #333333; - border: 0; - border-bottom: 1px solid #eee; -} - -legend small { - font-size: 13.5px; - color: #999999; -} - -label, -input, -button, -select, -textarea { - font-size: 13px; - font-weight: normal; - line-height: 18px; -} - -input, -button, -select, -textarea { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; -} - -label { - display: block; - margin-bottom: 5px; - color: #333333; -} - -input, -textarea, -select, -.uneditable-input { - display: inline-block; - width: 210px; - height: 18px; - padding: 4px; - margin-bottom: 9px; - font-size: 13px; - line-height: 18px; - color: #555555; - background-color: #ffffff; - border: 1px solid #cccccc; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -.uneditable-textarea { - width: auto; - height: auto; -} - -label input, -label textarea, -label select { - display: block; -} - -input[type="image"], -input[type="checkbox"], -input[type="radio"] { - width: auto; - height: auto; - padding: 0; - margin: 3px 0; - *margin-top: 0; - /* IE7 */ - - line-height: normal; - cursor: pointer; - background-color: transparent; - border: 0 \9; - /* IE9 and down */ - - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} - -input[type="image"] { - border: 0; -} - -input[type="file"] { - width: auto; - padding: initial; - line-height: initial; - background-color: #ffffff; - background-color: initial; - border: initial; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; -} - -input[type="button"], -input[type="reset"], -input[type="submit"] { - width: auto; - height: auto; -} - -select, -input[type="file"] { - height: 28px; - /* In IE7, the height of the select element cannot be changed by height, only font-size */ - - *margin-top: 4px; - /* For IE7, add top margin to align select with labels */ - - line-height: 28px; -} - -input[type="file"] { - line-height: 18px \9; -} - -select { - width: 220px; - background-color: #ffffff; -} - -select[multiple], -select[size] { - height: auto; -} - -input[type="image"] { - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; -} - -textarea { - height: auto; -} - -input[type="hidden"] { - display: none; -} - -.radio, -.checkbox { - min-height: 18px; - padding-left: 18px; -} - -.radio input[type="radio"], -.checkbox input[type="checkbox"] { - float: left; - margin-left: -18px; -} - -.controls > .radio:first-child, -.controls > .checkbox:first-child { - padding-top: 5px; -} - -.radio.inline, -.checkbox.inline { - display: inline-block; - padding-top: 5px; - margin-bottom: 0; - vertical-align: middle; -} - -.radio.inline + .radio.inline, -.checkbox.inline + .checkbox.inline { - margin-left: 10px; -} - -input, -textarea { - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); - -webkit-transition: border linear 0.2s, box-shadow linear 0.2s; - -moz-transition: border linear 0.2s, box-shadow linear 0.2s; - -ms-transition: border linear 0.2s, box-shadow linear 0.2s; - -o-transition: border linear 0.2s, box-shadow linear 0.2s; - transition: border linear 0.2s, box-shadow linear 0.2s; -} - -input:focus, -textarea:focus { - border-color: rgba(82, 168, 236, 0.8); - outline: 0; - outline: thin dotted \9; - /* IE6-9 */ - - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); - -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(82, 168, 236, 0.6); -} - -input[type="file"]:focus, -input[type="radio"]:focus, -input[type="checkbox"]:focus, -select:focus { - outline: thin dotted #333; - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; -} - -.input-mini { - width: 60px; -} - -.input-small { - width: 90px; -} - -.input-medium { - width: 150px; -} - -.input-large { - width: 210px; -} - -.input-xlarge { - width: 270px; -} - -.input-xxlarge { - width: 530px; -} - -input[class*="span"], -select[class*="span"], -textarea[class*="span"], -.uneditable-input[class*="span"], -.row-fluid input[class*="span"], -.row-fluid select[class*="span"], -.row-fluid textarea[class*="span"], -.row-fluid .uneditable-input[class*="span"] { - float: none; - margin-left: 0; -} - -input, -textarea, -.uneditable-input { - margin-left: 0; -} - -input.span12, -textarea.span12, -.uneditable-input.span12 { - width: 930px; -} - -input.span11, -textarea.span11, -.uneditable-input.span11 { - width: 850px; -} - -input.span10, -textarea.span10, -.uneditable-input.span10 { - width: 770px; -} - -input.span9, -textarea.span9, -.uneditable-input.span9 { - width: 690px; -} - -input.span8, -textarea.span8, -.uneditable-input.span8 { - width: 610px; -} - -input.span7, -textarea.span7, -.uneditable-input.span7 { - width: 530px; -} - -input.span6, -textarea.span6, -.uneditable-input.span6 { - width: 450px; -} - -input.span5, -textarea.span5, -.uneditable-input.span5 { - width: 370px; -} - -input.span4, -textarea.span4, -.uneditable-input.span4 { - width: 290px; -} - -input.span3, -textarea.span3, -.uneditable-input.span3 { - width: 210px; -} - -input.span2, -textarea.span2, -.uneditable-input.span2 { - width: 130px; -} - -input.span1, -textarea.span1, -.uneditable-input.span1 { - width: 50px; -} - -input[disabled], -select[disabled], -textarea[disabled], -input[readonly], -select[readonly], -textarea[readonly] { - cursor: not-allowed; - background-color: #eeeeee; - border-color: #ddd; -} - -input[type="radio"][disabled], -input[type="checkbox"][disabled], -input[type="radio"][readonly], -input[type="checkbox"][readonly] { - background-color: transparent; -} - -.control-group.warning > label, -.control-group.warning .help-block, -.control-group.warning .help-inline { - color: #c09853; -} - -.control-group.warning input, -.control-group.warning select, -.control-group.warning textarea { - color: #c09853; - border-color: #c09853; -} - -.control-group.warning input:focus, -.control-group.warning select:focus, -.control-group.warning textarea:focus { - border-color: #a47e3c; - -webkit-box-shadow: 0 0 6px #dbc59e; - -moz-box-shadow: 0 0 6px #dbc59e; - box-shadow: 0 0 6px #dbc59e; -} - -.control-group.warning .input-prepend .add-on, -.control-group.warning .input-append .add-on { - color: #c09853; - background-color: #fcf8e3; - border-color: #c09853; -} - -.control-group.error > label, -.control-group.error .help-block, -.control-group.error .help-inline { - color: #b94a48; -} - -.control-group.error input, -.control-group.error select, -.control-group.error textarea { - color: #b94a48; - border-color: #b94a48; -} - -.control-group.error input:focus, -.control-group.error select:focus, -.control-group.error textarea:focus { - border-color: #953b39; - -webkit-box-shadow: 0 0 6px #d59392; - -moz-box-shadow: 0 0 6px #d59392; - box-shadow: 0 0 6px #d59392; -} - -.control-group.error .input-prepend .add-on, -.control-group.error .input-append .add-on { - color: #b94a48; - background-color: #f2dede; - border-color: #b94a48; -} - -.control-group.success > label, -.control-group.success .help-block, -.control-group.success .help-inline { - color: #468847; -} - -.control-group.success input, -.control-group.success select, -.control-group.success textarea { - color: #468847; - border-color: #468847; -} - -.control-group.success input:focus, -.control-group.success select:focus, -.control-group.success textarea:focus { - border-color: #356635; - -webkit-box-shadow: 0 0 6px #7aba7b; - -moz-box-shadow: 0 0 6px #7aba7b; - box-shadow: 0 0 6px #7aba7b; -} - -.control-group.success .input-prepend .add-on, -.control-group.success .input-append .add-on { - color: #468847; - background-color: #dff0d8; - border-color: #468847; -} - -input:focus:required:invalid, -textarea:focus:required:invalid, -select:focus:required:invalid { - color: #b94a48; - border-color: #ee5f5b; -} - -input:focus:required:invalid:focus, -textarea:focus:required:invalid:focus, -select:focus:required:invalid:focus { - border-color: #e9322d; - -webkit-box-shadow: 0 0 6px #f8b9b7; - -moz-box-shadow: 0 0 6px #f8b9b7; - box-shadow: 0 0 6px #f8b9b7; -} - -.form-actions { - padding: 17px 20px 18px; - margin-top: 18px; - margin-bottom: 18px; - background-color: #f5f5f5; - border-top: 1px solid #ddd; - *zoom: 1; -} - -.form-actions:before, -.form-actions:after { - display: table; - content: ""; -} - -.form-actions:after { - clear: both; -} - -.uneditable-input { - overflow: hidden; - white-space: nowrap; - cursor: not-allowed; - background-color: #ffffff; - border-color: #eee; - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.025); -} - -:-moz-placeholder { - color: #999999; -} - -::-webkit-input-placeholder { - color: #999999; -} - -.help-block, -.help-inline { - color: #555555; -} - -.help-block { - display: block; - margin-bottom: 9px; -} - -.help-inline { - display: inline-block; - *display: inline; - padding-left: 5px; - vertical-align: middle; - *zoom: 1; -} - -.input-prepend, -.input-append { - margin-bottom: 5px; -} - -.input-prepend input, -.input-append input, -.input-prepend select, -.input-append select, -.input-prepend .uneditable-input, -.input-append .uneditable-input { - position: relative; - margin-bottom: 0; - *margin-left: 0; - vertical-align: middle; - -webkit-border-radius: 0 3px 3px 0; - -moz-border-radius: 0 3px 3px 0; - border-radius: 0 3px 3px 0; -} - -.input-prepend input:focus, -.input-append input:focus, -.input-prepend select:focus, -.input-append select:focus, -.input-prepend .uneditable-input:focus, -.input-append .uneditable-input:focus { - z-index: 2; -} - -.input-prepend .uneditable-input, -.input-append .uneditable-input { - border-left-color: #ccc; -} - -.input-prepend .add-on, -.input-append .add-on { - display: inline-block; - width: auto; - height: 18px; - min-width: 16px; - padding: 4px 5px; - font-weight: normal; - line-height: 18px; - text-align: center; - text-shadow: 0 1px 0 #ffffff; - vertical-align: middle; - background-color: #eeeeee; - border: 1px solid #ccc; -} - -.input-prepend .add-on, -.input-append .add-on, -.input-prepend .btn, -.input-append .btn { - margin-left: -1px; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} - -.input-prepend .active, -.input-append .active { - background-color: #a9dba9; - border-color: #46a546; -} - -.input-prepend .add-on, -.input-prepend .btn { - margin-right: -1px; -} - -.input-prepend .add-on:first-child, -.input-prepend .btn:first-child { - -webkit-border-radius: 3px 0 0 3px; - -moz-border-radius: 3px 0 0 3px; - border-radius: 3px 0 0 3px; -} - -.input-append input, -.input-append select, -.input-append .uneditable-input { - -webkit-border-radius: 3px 0 0 3px; - -moz-border-radius: 3px 0 0 3px; - border-radius: 3px 0 0 3px; -} - -.input-append .uneditable-input { - border-right-color: #ccc; - border-left-color: #eee; -} - -.input-append .add-on:last-child, -.input-append .btn:last-child { - -webkit-border-radius: 0 3px 3px 0; - -moz-border-radius: 0 3px 3px 0; - border-radius: 0 3px 3px 0; -} - -.input-prepend.input-append input, -.input-prepend.input-append select, -.input-prepend.input-append .uneditable-input { - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} - -.input-prepend.input-append .add-on:first-child, -.input-prepend.input-append .btn:first-child { - margin-right: -1px; - -webkit-border-radius: 3px 0 0 3px; - -moz-border-radius: 3px 0 0 3px; - border-radius: 3px 0 0 3px; -} - -.input-prepend.input-append .add-on:last-child, -.input-prepend.input-append .btn:last-child { - margin-left: -1px; - -webkit-border-radius: 0 3px 3px 0; - -moz-border-radius: 0 3px 3px 0; - border-radius: 0 3px 3px 0; -} - -.search-query { - padding-right: 14px; - padding-right: 4px \9; - padding-left: 14px; - padding-left: 4px \9; - /* IE7-8 doesn't have border-radius, so don't indent the padding */ - - margin-bottom: 0; - -webkit-border-radius: 14px; - -moz-border-radius: 14px; - border-radius: 14px; -} - -.form-search input, -.form-inline input, -.form-horizontal input, -.form-search textarea, -.form-inline textarea, -.form-horizontal textarea, -.form-search select, -.form-inline select, -.form-horizontal select, -.form-search .help-inline, -.form-inline .help-inline, -.form-horizontal .help-inline, -.form-search .uneditable-input, -.form-inline .uneditable-input, -.form-horizontal .uneditable-input, -.form-search .input-prepend, -.form-inline .input-prepend, -.form-horizontal .input-prepend, -.form-search .input-append, -.form-inline .input-append, -.form-horizontal .input-append { - display: inline-block; - *display: inline; - margin-bottom: 0; - *zoom: 1; -} - -.form-search .hide, -.form-inline .hide, -.form-horizontal .hide { - display: none; -} - -.form-search label, -.form-inline label { - display: inline-block; -} - -.form-search .input-append, -.form-inline .input-append, -.form-search .input-prepend, -.form-inline .input-prepend { - margin-bottom: 0; -} - -.form-search .radio, -.form-search .checkbox, -.form-inline .radio, -.form-inline .checkbox { - padding-left: 0; - margin-bottom: 0; - vertical-align: middle; -} - -.form-search .radio input[type="radio"], -.form-search .checkbox input[type="checkbox"], -.form-inline .radio input[type="radio"], -.form-inline .checkbox input[type="checkbox"] { - float: left; - margin-right: 3px; - margin-left: 0; -} - -.control-group { - margin-bottom: 9px; -} - -legend + .control-group { - margin-top: 18px; - -webkit-margin-top-collapse: separate; -} - -.form-horizontal .control-group { - margin-bottom: 18px; - *zoom: 1; -} - -.form-horizontal .control-group:before, -.form-horizontal .control-group:after { - display: table; - content: ""; -} - -.form-horizontal .control-group:after { - clear: both; -} - -.form-horizontal .control-label { - float: left; - width: 140px; - padding-top: 5px; - text-align: right; -} - -.form-horizontal .controls { - *display: inline-block; - *padding-left: 20px; - margin-left: 160px; - *margin-left: 0; -} - -.form-horizontal .controls:first-child { - *padding-left: 160px; -} - -.form-horizontal .help-block { - margin-top: 9px; - margin-bottom: 0; -} - -.form-horizontal .form-actions { - padding-left: 160px; -} - -table { - max-width: 100%; - background-color: transparent; - border-collapse: collapse; - border-spacing: 0; -} - -.table { - width: 100%; - margin-bottom: 18px; -} - -.table th, -.table td { - padding: 8px; - line-height: 18px; - text-align: left; - vertical-align: top; - border-top: 1px solid #dddddd; -} - -.table th { - font-weight: bold; -} - -.table thead th { - vertical-align: bottom; -} - -.table caption + thead tr:first-child th, -.table caption + thead tr:first-child td, -.table colgroup + thead tr:first-child th, -.table colgroup + thead tr:first-child td, -.table thead:first-child tr:first-child th, -.table thead:first-child tr:first-child td { - border-top: 0; -} - -.table tbody + tbody { - border-top: 2px solid #dddddd; -} - -.table-condensed th, -.table-condensed td { - padding: 4px 5px; -} - -.table-bordered { - border: 1px solid #dddddd; - border-collapse: separate; - *border-collapse: collapsed; - border-left: 0; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -.table-bordered th, -.table-bordered td { - border-left: 1px solid #dddddd; -} - -.table-bordered caption + thead tr:first-child th, -.table-bordered caption + tbody tr:first-child th, -.table-bordered caption + tbody tr:first-child td, -.table-bordered colgroup + thead tr:first-child th, -.table-bordered colgroup + tbody tr:first-child th, -.table-bordered colgroup + tbody tr:first-child td, -.table-bordered thead:first-child tr:first-child th, -.table-bordered tbody:first-child tr:first-child th, -.table-bordered tbody:first-child tr:first-child td { - border-top: 0; -} - -.table-bordered thead:first-child tr:first-child th:first-child, -.table-bordered tbody:first-child tr:first-child td:first-child { - -webkit-border-top-left-radius: 4px; - border-top-left-radius: 4px; - -moz-border-radius-topleft: 4px; -} - -.table-bordered thead:first-child tr:first-child th:last-child, -.table-bordered tbody:first-child tr:first-child td:last-child { - -webkit-border-top-right-radius: 4px; - border-top-right-radius: 4px; - -moz-border-radius-topright: 4px; -} - -.table-bordered thead:last-child tr:last-child th:first-child, -.table-bordered tbody:last-child tr:last-child td:first-child { - -webkit-border-radius: 0 0 0 4px; - -moz-border-radius: 0 0 0 4px; - border-radius: 0 0 0 4px; - -webkit-border-bottom-left-radius: 4px; - border-bottom-left-radius: 4px; - -moz-border-radius-bottomleft: 4px; -} - -.table-bordered thead:last-child tr:last-child th:last-child, -.table-bordered tbody:last-child tr:last-child td:last-child { - -webkit-border-bottom-right-radius: 4px; - border-bottom-right-radius: 4px; - -moz-border-radius-bottomright: 4px; -} - -.table-striped tbody tr:nth-child(odd) td, -.table-striped tbody tr:nth-child(odd) th { - background-color: #f9f9f9; -} - -.table tbody tr:hover td, -.table tbody tr:hover th { - background-color: #f5f5f5; -} - -table .span1 { - float: none; - width: 44px; - margin-left: 0; -} - -table .span2 { - float: none; - width: 124px; - margin-left: 0; -} - -table .span3 { - float: none; - width: 204px; - margin-left: 0; -} - -table .span4 { - float: none; - width: 284px; - margin-left: 0; -} - -table .span5 { - float: none; - width: 364px; - margin-left: 0; -} - -table .span6 { - float: none; - width: 444px; - margin-left: 0; -} - -table .span7 { - float: none; - width: 524px; - margin-left: 0; -} - -table .span8 { - float: none; - width: 604px; - margin-left: 0; -} - -table .span9 { - float: none; - width: 684px; - margin-left: 0; -} - -table .span10 { - float: none; - width: 764px; - margin-left: 0; -} - -table .span11 { - float: none; - width: 844px; - margin-left: 0; -} - -table .span12 { - float: none; - width: 924px; - margin-left: 0; -} - -table .span13 { - float: none; - width: 1004px; - margin-left: 0; -} - -table .span14 { - float: none; - width: 1084px; - margin-left: 0; -} - -table .span15 { - float: none; - width: 1164px; - margin-left: 0; -} - -table .span16 { - float: none; - width: 1244px; - margin-left: 0; -} - -table .span17 { - float: none; - width: 1324px; - margin-left: 0; -} - -table .span18 { - float: none; - width: 1404px; - margin-left: 0; -} - -table .span19 { - float: none; - width: 1484px; - margin-left: 0; -} - -table .span20 { - float: none; - width: 1564px; - margin-left: 0; -} - -table .span21 { - float: none; - width: 1644px; - margin-left: 0; -} - -table .span22 { - float: none; - width: 1724px; - margin-left: 0; -} - -table .span23 { - float: none; - width: 1804px; - margin-left: 0; -} - -table .span24 { - float: none; - width: 1884px; - margin-left: 0; -} - -[class^="icon-"], -[class*=" icon-"] { - display: inline-block; - width: 14px; - height: 14px; - *margin-right: .3em; - line-height: 14px; - vertical-align: text-top; - background-image: url("../img/glyphicons-halflings.png"); - background-position: 14px 14px; - background-repeat: no-repeat; -} - -[class^="icon-"]:last-child, -[class*=" icon-"]:last-child { - *margin-left: 0; -} - -.icon-white { - background-image: url("../img/glyphicons-halflings-white.png"); -} - -.icon-glass { - background-position: 0 0; -} - -.icon-music { - background-position: -24px 0; -} - -.icon-search { - background-position: -48px 0; -} - -.icon-envelope { - background-position: -72px 0; -} - -.icon-heart { - background-position: -96px 0; -} - -.icon-star { - background-position: -120px 0; -} - -.icon-star-empty { - background-position: -144px 0; -} - -.icon-user { - background-position: -168px 0; -} - -.icon-film { - background-position: -192px 0; -} - -.icon-th-large { - background-position: -216px 0; -} - -.icon-th { - background-position: -240px 0; -} - -.icon-th-list { - background-position: -264px 0; -} - -.icon-ok { - background-position: -288px 0; -} - -.icon-remove { - background-position: -312px 0; -} - -.icon-zoom-in { - background-position: -336px 0; -} - -.icon-zoom-out { - background-position: -360px 0; -} - -.icon-off { - background-position: -384px 0; -} - -.icon-signal { - background-position: -408px 0; -} - -.icon-cog { - background-position: -432px 0; -} - -.icon-trash { - background-position: -456px 0; -} - -.icon-home { - background-position: 0 -24px; -} - -.icon-file { - background-position: -24px -24px; -} - -.icon-time { - background-position: -48px -24px; -} - -.icon-road { - background-position: -72px -24px; -} - -.icon-download-alt { - background-position: -96px -24px; -} - -.icon-download { - background-position: -120px -24px; -} - -.icon-upload { - background-position: -144px -24px; -} - -.icon-inbox { - background-position: -168px -24px; -} - -.icon-play-circle { - background-position: -192px -24px; -} - -.icon-repeat { - background-position: -216px -24px; -} - -.icon-refresh { - background-position: -240px -24px; -} - -.icon-list-alt { - background-position: -264px -24px; -} - -.icon-lock { - background-position: -287px -24px; -} - -.icon-flag { - background-position: -312px -24px; -} - -.icon-headphones { - background-position: -336px -24px; -} - -.icon-volume-off { - background-position: -360px -24px; -} - -.icon-volume-down { - background-position: -384px -24px; -} - -.icon-volume-up { - background-position: -408px -24px; -} - -.icon-qrcode { - background-position: -432px -24px; -} - -.icon-barcode { - background-position: -456px -24px; -} - -.icon-tag { - background-position: 0 -48px; -} - -.icon-tags { - background-position: -25px -48px; -} - -.icon-book { - background-position: -48px -48px; -} - -.icon-bookmark { - background-position: -72px -48px; -} - -.icon-print { - background-position: -96px -48px; -} - -.icon-camera { - background-position: -120px -48px; -} - -.icon-font { - background-position: -144px -48px; -} - -.icon-bold { - background-position: -167px -48px; -} - -.icon-italic { - background-position: -192px -48px; -} - -.icon-text-height { - background-position: -216px -48px; -} - -.icon-text-width { - background-position: -240px -48px; -} - -.icon-align-left { - background-position: -264px -48px; -} - -.icon-align-center { - background-position: -288px -48px; -} - -.icon-align-right { - background-position: -312px -48px; -} - -.icon-align-justify { - background-position: -336px -48px; -} - -.icon-list { - background-position: -360px -48px; -} - -.icon-indent-left { - background-position: -384px -48px; -} - -.icon-indent-right { - background-position: -408px -48px; -} - -.icon-facetime-video { - background-position: -432px -48px; -} - -.icon-picture { - background-position: -456px -48px; -} - -.icon-pencil { - background-position: 0 -72px; -} - -.icon-map-marker { - background-position: -24px -72px; -} - -.icon-adjust { - background-position: -48px -72px; -} - -.icon-tint { - background-position: -72px -72px; -} - -.icon-edit { - background-position: -96px -72px; -} - -.icon-share { - background-position: -120px -72px; -} - -.icon-check { - background-position: -144px -72px; -} - -.icon-move { - background-position: -168px -72px; -} - -.icon-step-backward { - background-position: -192px -72px; -} - -.icon-fast-backward { - background-position: -216px -72px; -} - -.icon-backward { - background-position: -240px -72px; -} - -.icon-play { - background-position: -264px -72px; -} - -.icon-pause { - background-position: -288px -72px; -} - -.icon-stop { - background-position: -312px -72px; -} - -.icon-forward { - background-position: -336px -72px; -} - -.icon-fast-forward { - background-position: -360px -72px; -} - -.icon-step-forward { - background-position: -384px -72px; -} - -.icon-eject { - background-position: -408px -72px; -} - -.icon-chevron-left { - background-position: -432px -72px; -} - -.icon-chevron-right { - background-position: -456px -72px; -} - -.icon-plus-sign { - background-position: 0 -96px; -} - -.icon-minus-sign { - background-position: -24px -96px; -} - -.icon-remove-sign { - background-position: -48px -96px; -} - -.icon-ok-sign { - background-position: -72px -96px; -} - -.icon-question-sign { - background-position: -96px -96px; -} - -.icon-info-sign { - background-position: -120px -96px; -} - -.icon-screenshot { - background-position: -144px -96px; -} - -.icon-remove-circle { - background-position: -168px -96px; -} - -.icon-ok-circle { - background-position: -192px -96px; -} - -.icon-ban-circle { - background-position: -216px -96px; -} - -.icon-arrow-left { - background-position: -240px -96px; -} - -.icon-arrow-right { - background-position: -264px -96px; -} - -.icon-arrow-up { - background-position: -289px -96px; -} - -.icon-arrow-down { - background-position: -312px -96px; -} - -.icon-share-alt { - background-position: -336px -96px; -} - -.icon-resize-full { - background-position: -360px -96px; -} - -.icon-resize-small { - background-position: -384px -96px; -} - -.icon-plus { - background-position: -408px -96px; -} - -.icon-minus { - background-position: -433px -96px; -} - -.icon-asterisk { - background-position: -456px -96px; -} - -.icon-exclamation-sign { - background-position: 0 -120px; -} - -.icon-gift { - background-position: -24px -120px; -} - -.icon-leaf { - background-position: -48px -120px; -} - -.icon-fire { - background-position: -72px -120px; -} - -.icon-eye-open { - background-position: -96px -120px; -} - -.icon-eye-close { - background-position: -120px -120px; -} - -.icon-warning-sign { - background-position: -144px -120px; -} - -.icon-plane { - background-position: -168px -120px; -} - -.icon-calendar { - background-position: -192px -120px; -} - -.icon-random { - background-position: -216px -120px; -} - -.icon-comment { - background-position: -240px -120px; -} - -.icon-magnet { - background-position: -264px -120px; -} - -.icon-chevron-up { - background-position: -288px -120px; -} - -.icon-chevron-down { - background-position: -313px -119px; -} - -.icon-retweet { - background-position: -336px -120px; -} - -.icon-shopping-cart { - background-position: -360px -120px; -} - -.icon-folder-close { - background-position: -384px -120px; -} - -.icon-folder-open { - background-position: -408px -120px; -} - -.icon-resize-vertical { - background-position: -432px -119px; -} - -.icon-resize-horizontal { - background-position: -456px -118px; -} - -.icon-hdd { - background-position: 0 -144px; -} - -.icon-bullhorn { - background-position: -24px -144px; -} - -.icon-bell { - background-position: -48px -144px; -} - -.icon-certificate { - background-position: -72px -144px; -} - -.icon-thumbs-up { - background-position: -96px -144px; -} - -.icon-thumbs-down { - background-position: -120px -144px; -} - -.icon-hand-right { - background-position: -144px -144px; -} - -.icon-hand-left { - background-position: -168px -144px; -} - -.icon-hand-up { - background-position: -192px -144px; -} - -.icon-hand-down { - background-position: -216px -144px; -} - -.icon-circle-arrow-right { - background-position: -240px -144px; -} - -.icon-circle-arrow-left { - background-position: -264px -144px; -} - -.icon-circle-arrow-up { - background-position: -288px -144px; -} - -.icon-circle-arrow-down { - background-position: -312px -144px; -} - -.icon-globe { - background-position: -336px -144px; -} - -.icon-wrench { - background-position: -360px -144px; -} - -.icon-tasks { - background-position: -384px -144px; -} - -.icon-filter { - background-position: -408px -144px; -} - -.icon-briefcase { - background-position: -432px -144px; -} - -.icon-fullscreen { - background-position: -456px -144px; -} - -.dropup, -.dropdown { - position: relative; -} - -.dropdown-toggle { - *margin-bottom: -3px; -} - -.dropdown-toggle:active, -.open .dropdown-toggle { - outline: 0; -} - -.caret { - display: inline-block; - width: 0; - height: 0; - vertical-align: top; - border-top: 4px solid #000000; - border-right: 4px solid transparent; - border-left: 4px solid transparent; - content: ""; - opacity: 0.3; - filter: alpha(opacity=30); -} - -.dropdown .caret { - margin-top: 8px; - margin-left: 2px; -} - -.dropdown:hover .caret, -.open .caret { - opacity: 1; - filter: alpha(opacity=100); -} - -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: 1000; - display: none; - float: left; - min-width: 160px; - padding: 4px 0; - margin: 1px 0 0; - list-style: none; - background-color: #ffffff; - border: 1px solid #ccc; - border: 1px solid rgba(0, 0, 0, 0.2); - *border-right-width: 2px; - *border-bottom-width: 2px; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; - -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - -moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); - -webkit-background-clip: padding-box; - -moz-background-clip: padding; - background-clip: padding-box; -} - -.dropdown-menu.pull-right { - right: 0; - left: auto; -} - -.dropdown-menu .divider { - *width: 100%; - height: 1px; - margin: 8px 1px; - *margin: -5px 0 5px; - overflow: hidden; - background-color: #e5e5e5; - border-bottom: 1px solid #ffffff; -} - -.dropdown-menu a { - display: block; - padding: 3px 15px; - clear: both; - font-weight: normal; - line-height: 18px; - color: #333333; - white-space: nowrap; -} - -.dropdown-menu li > a:hover, -.dropdown-menu .active > a, -.dropdown-menu .active > a:hover { - color: #ffffff; - text-decoration: none; - background-color: #0088cc; -} - -.open { - *z-index: 1000; -} - -.open .dropdown-menu { - display: block; -} - -.pull-right .dropdown-menu { - right: 0; - left: auto; -} - -.dropup .caret, -.navbar-fixed-bottom .dropdown .caret { - border-top: 0; - border-bottom: 4px solid #000000; - content: "\2191"; -} - -.dropup .dropdown-menu, -.navbar-fixed-bottom .dropdown .dropdown-menu { - top: auto; - bottom: 100%; - margin-bottom: 1px; -} - -.typeahead { - margin-top: 2px; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -.well { - min-height: 20px; - padding: 19px; - margin-bottom: 20px; - background-color: #f5f5f5; - border: 1px solid #eee; - border: 1px solid rgba(0, 0, 0, 0.05); - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); - -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); -} - -.well blockquote { - border-color: #ddd; - border-color: rgba(0, 0, 0, 0.15); -} - -.well-large { - padding: 24px; - -webkit-border-radius: 6px; - -moz-border-radius: 6px; - border-radius: 6px; -} - -.well-small { - padding: 9px; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -.fade { - opacity: 0; - filter: alpha(opacity=0); - -webkit-transition: opacity 0.15s linear; - -moz-transition: opacity 0.15s linear; - -ms-transition: opacity 0.15s linear; - -o-transition: opacity 0.15s linear; - transition: opacity 0.15s linear; -} - -.fade.in { - opacity: 1; - filter: alpha(opacity=100); -} - -.collapse { - position: relative; - height: 0; - overflow: hidden; - -webkit-transition: height 0.35s ease; - -moz-transition: height 0.35s ease; - -ms-transition: height 0.35s ease; - -o-transition: height 0.35s ease; - transition: height 0.35s ease; -} - -.collapse.in { - height: auto; -} - -.close { - float: right; - font-size: 20px; - font-weight: bold; - line-height: 18px; - color: #000000; - text-shadow: 0 1px 0 #ffffff; - opacity: 0.2; - filter: alpha(opacity=20); -} - -.close:hover { - color: #000000; - text-decoration: none; - cursor: pointer; - opacity: 0.4; - filter: alpha(opacity=40); -} - -button.close { - padding: 0; - cursor: pointer; - background: transparent; - border: 0; - -webkit-appearance: none; -} - -.btn { - display: inline-block; - *display: inline; - padding: 4px 10px 4px; - margin-bottom: 0; - *margin-left: .3em; - font-size: 13px; - line-height: 18px; - *line-height: 20px; - color: #333333; - text-align: center; - text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); - vertical-align: middle; - cursor: pointer; - background-color: #f5f5f5; - *background-color: #e6e6e6; - background-image: -ms-linear-gradient(top, #ffffff, #e6e6e6); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); - background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); - background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); - background-image: linear-gradient(top, #ffffff, #e6e6e6); - background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); - background-repeat: repeat-x; - border: 1px solid #cccccc; - *border: 0; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - border-color: #e6e6e6 #e6e6e6 #bfbfbf; - border-bottom-color: #b3b3b3; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ffffff', endColorstr='#e6e6e6', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); - *zoom: 1; - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); - -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.btn:hover, -.btn:active, -.btn.active, -.btn.disabled, -.btn[disabled] { - background-color: #e6e6e6; - *background-color: #d9d9d9; -} - -.btn:active, -.btn.active { - background-color: #cccccc \9; -} - -.btn:first-child { - *margin-left: 0; -} - -.btn:hover { - color: #333333; - text-decoration: none; - background-color: #e6e6e6; - *background-color: #d9d9d9; - /* Buttons in IE7 don't get borders, so darken on hover */ - - background-position: 0 -15px; - -webkit-transition: background-position 0.1s linear; - -moz-transition: background-position 0.1s linear; - -ms-transition: background-position 0.1s linear; - -o-transition: background-position 0.1s linear; - transition: background-position 0.1s linear; -} - -.btn:focus { - outline: thin dotted #333; - outline: 5px auto -webkit-focus-ring-color; - outline-offset: -2px; -} - -.btn.active, -.btn:active { - background-color: #e6e6e6; - background-color: #d9d9d9 \9; - background-image: none; - outline: 0; - -webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); - -moz-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.btn.disabled, -.btn[disabled] { - cursor: default; - background-color: #e6e6e6; - background-image: none; - opacity: 0.65; - filter: alpha(opacity=65); - -webkit-box-shadow: none; - -moz-box-shadow: none; - box-shadow: none; -} - -.btn-large { - padding: 9px 14px; - font-size: 15px; - line-height: normal; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; -} - -.btn-large [class^="icon-"] { - margin-top: 1px; -} - -.btn-small { - padding: 5px 9px; - font-size: 11px; - line-height: 16px; -} - -.btn-small [class^="icon-"] { - margin-top: -1px; -} - -.btn-mini { - padding: 2px 6px; - font-size: 11px; - line-height: 14px; -} - -.btn-primary, -.btn-primary:hover, -.btn-warning, -.btn-warning:hover, -.btn-danger, -.btn-danger:hover, -.btn-success, -.btn-success:hover, -.btn-info, -.btn-info:hover, -.btn-inverse, -.btn-inverse:hover { - color: #ffffff; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); -} - -.btn-primary.active, -.btn-warning.active, -.btn-danger.active, -.btn-success.active, -.btn-info.active, -.btn-inverse.active { - color: rgba(255, 255, 255, 0.75); -} - -.btn { - border-color: #ccc; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); -} - -.btn-primary { - background-color: #0074cc; - *background-color: #0055cc; - background-image: -ms-linear-gradient(top, #0088cc, #0055cc); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0055cc)); - background-image: -webkit-linear-gradient(top, #0088cc, #0055cc); - background-image: -o-linear-gradient(top, #0088cc, #0055cc); - background-image: -moz-linear-gradient(top, #0088cc, #0055cc); - background-image: linear-gradient(top, #0088cc, #0055cc); - background-repeat: repeat-x; - border-color: #0055cc #0055cc #003580; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#0088cc', endColorstr='#0055cc', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); -} - -.btn-primary:hover, -.btn-primary:active, -.btn-primary.active, -.btn-primary.disabled, -.btn-primary[disabled] { - background-color: #0055cc; - *background-color: #004ab3; -} - -.btn-primary:active, -.btn-primary.active { - background-color: #004099 \9; -} - -.btn-warning { - background-color: #faa732; - *background-color: #f89406; - background-image: -ms-linear-gradient(top, #fbb450, #f89406); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406)); - background-image: -webkit-linear-gradient(top, #fbb450, #f89406); - background-image: -o-linear-gradient(top, #fbb450, #f89406); - background-image: -moz-linear-gradient(top, #fbb450, #f89406); - background-image: linear-gradient(top, #fbb450, #f89406); - background-repeat: repeat-x; - border-color: #f89406 #f89406 #ad6704; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#fbb450', endColorstr='#f89406', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); -} - -.btn-warning:hover, -.btn-warning:active, -.btn-warning.active, -.btn-warning.disabled, -.btn-warning[disabled] { - background-color: #f89406; - *background-color: #df8505; -} - -.btn-warning:active, -.btn-warning.active { - background-color: #c67605 \9; -} - -.btn-danger { - background-color: #da4f49; - *background-color: #bd362f; - background-image: -ms-linear-gradient(top, #ee5f5b, #bd362f); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#bd362f)); - background-image: -webkit-linear-gradient(top, #ee5f5b, #bd362f); - background-image: -o-linear-gradient(top, #ee5f5b, #bd362f); - background-image: -moz-linear-gradient(top, #ee5f5b, #bd362f); - background-image: linear-gradient(top, #ee5f5b, #bd362f); - background-repeat: repeat-x; - border-color: #bd362f #bd362f #802420; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#bd362f', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); -} - -.btn-danger:hover, -.btn-danger:active, -.btn-danger.active, -.btn-danger.disabled, -.btn-danger[disabled] { - background-color: #bd362f; - *background-color: #a9302a; -} - -.btn-danger:active, -.btn-danger.active { - background-color: #942a25 \9; -} - -.btn-success { - background-color: #5bb75b; - *background-color: #51a351; - background-image: -ms-linear-gradient(top, #62c462, #51a351); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#51a351)); - background-image: -webkit-linear-gradient(top, #62c462, #51a351); - background-image: -o-linear-gradient(top, #62c462, #51a351); - background-image: -moz-linear-gradient(top, #62c462, #51a351); - background-image: linear-gradient(top, #62c462, #51a351); - background-repeat: repeat-x; - border-color: #51a351 #51a351 #387038; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#62c462', endColorstr='#51a351', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); -} - -.btn-success:hover, -.btn-success:active, -.btn-success.active, -.btn-success.disabled, -.btn-success[disabled] { - background-color: #51a351; - *background-color: #499249; -} - -.btn-success:active, -.btn-success.active { - background-color: #408140 \9; -} - -.btn-info { - background-color: #49afcd; - *background-color: #2f96b4; - background-image: -ms-linear-gradient(top, #5bc0de, #2f96b4); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#2f96b4)); - background-image: -webkit-linear-gradient(top, #5bc0de, #2f96b4); - background-image: -o-linear-gradient(top, #5bc0de, #2f96b4); - background-image: -moz-linear-gradient(top, #5bc0de, #2f96b4); - background-image: linear-gradient(top, #5bc0de, #2f96b4); - background-repeat: repeat-x; - border-color: #2f96b4 #2f96b4 #1f6377; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#5bc0de', endColorstr='#2f96b4', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); -} - -.btn-info:hover, -.btn-info:active, -.btn-info.active, -.btn-info.disabled, -.btn-info[disabled] { - background-color: #2f96b4; - *background-color: #2a85a0; -} - -.btn-info:active, -.btn-info.active { - background-color: #24748c \9; -} - -.btn-inverse { - background-color: #414141; - *background-color: #222222; - background-image: -ms-linear-gradient(top, #555555, #222222); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#555555), to(#222222)); - background-image: -webkit-linear-gradient(top, #555555, #222222); - background-image: -o-linear-gradient(top, #555555, #222222); - background-image: -moz-linear-gradient(top, #555555, #222222); - background-image: linear-gradient(top, #555555, #222222); - background-repeat: repeat-x; - border-color: #222222 #222222 #000000; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#555555', endColorstr='#222222', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); -} - -.btn-inverse:hover, -.btn-inverse:active, -.btn-inverse.active, -.btn-inverse.disabled, -.btn-inverse[disabled] { - background-color: #222222; - *background-color: #151515; -} - -.btn-inverse:active, -.btn-inverse.active { - background-color: #080808 \9; -} - -button.btn, -input[type="submit"].btn { - *padding-top: 2px; - *padding-bottom: 2px; -} - -button.btn::-moz-focus-inner, -input[type="submit"].btn::-moz-focus-inner { - padding: 0; - border: 0; -} - -button.btn.btn-large, -input[type="submit"].btn.btn-large { - *padding-top: 7px; - *padding-bottom: 7px; -} - -button.btn.btn-small, -input[type="submit"].btn.btn-small { - *padding-top: 3px; - *padding-bottom: 3px; -} - -button.btn.btn-mini, -input[type="submit"].btn.btn-mini { - *padding-top: 1px; - *padding-bottom: 1px; -} - -.btn-group { - position: relative; - *margin-left: .3em; - *zoom: 1; -} - -.btn-group:before, -.btn-group:after { - display: table; - content: ""; -} - -.btn-group:after { - clear: both; -} - -.btn-group:first-child { - *margin-left: 0; -} - -.btn-group + .btn-group { - margin-left: 5px; -} - -.btn-toolbar { - margin-top: 9px; - margin-bottom: 9px; -} - -.btn-toolbar .btn-group { - display: inline-block; - *display: inline; - /* IE7 inline-block hack */ - - *zoom: 1; -} - -.btn-group > .btn { - position: relative; - float: left; - margin-left: -1px; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} - -.btn-group > .btn:first-child { - margin-left: 0; - -webkit-border-bottom-left-radius: 4px; - border-bottom-left-radius: 4px; - -webkit-border-top-left-radius: 4px; - border-top-left-radius: 4px; - -moz-border-radius-bottomleft: 4px; - -moz-border-radius-topleft: 4px; -} - -.btn-group > .btn:last-child, -.btn-group > .dropdown-toggle { - -webkit-border-top-right-radius: 4px; - border-top-right-radius: 4px; - -webkit-border-bottom-right-radius: 4px; - border-bottom-right-radius: 4px; - -moz-border-radius-topright: 4px; - -moz-border-radius-bottomright: 4px; -} - -.btn-group > .btn.large:first-child { - margin-left: 0; - -webkit-border-bottom-left-radius: 6px; - border-bottom-left-radius: 6px; - -webkit-border-top-left-radius: 6px; - border-top-left-radius: 6px; - -moz-border-radius-bottomleft: 6px; - -moz-border-radius-topleft: 6px; -} - -.btn-group > .btn.large:last-child, -.btn-group > .large.dropdown-toggle { - -webkit-border-top-right-radius: 6px; - border-top-right-radius: 6px; - -webkit-border-bottom-right-radius: 6px; - border-bottom-right-radius: 6px; - -moz-border-radius-topright: 6px; - -moz-border-radius-bottomright: 6px; -} - -.btn-group > .btn:hover, -.btn-group > .btn:focus, -.btn-group > .btn:active, -.btn-group > .btn.active { - z-index: 2; -} - -.btn-group .dropdown-toggle:active, -.btn-group.open .dropdown-toggle { - outline: 0; -} - -.btn-group > .dropdown-toggle { - *padding-top: 4px; - padding-right: 8px; - *padding-bottom: 4px; - padding-left: 8px; - -webkit-box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); - -moz-box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); - box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.125), inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.btn-group > .btn-mini.dropdown-toggle { - padding-right: 5px; - padding-left: 5px; -} - -.btn-group > .btn-small.dropdown-toggle { - *padding-top: 4px; - *padding-bottom: 4px; -} - -.btn-group > .btn-large.dropdown-toggle { - padding-right: 12px; - padding-left: 12px; -} - -.btn-group.open .dropdown-toggle { - background-image: none; - -webkit-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); - -moz-box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.btn-group.open .btn.dropdown-toggle { - background-color: #e6e6e6; -} - -.btn-group.open .btn-primary.dropdown-toggle { - background-color: #0055cc; -} - -.btn-group.open .btn-warning.dropdown-toggle { - background-color: #f89406; -} - -.btn-group.open .btn-danger.dropdown-toggle { - background-color: #bd362f; -} - -.btn-group.open .btn-success.dropdown-toggle { - background-color: #51a351; -} - -.btn-group.open .btn-info.dropdown-toggle { - background-color: #2f96b4; -} - -.btn-group.open .btn-inverse.dropdown-toggle { - background-color: #222222; -} - -.btn .caret { - margin-top: 7px; - margin-left: 0; -} - -.btn:hover .caret, -.open.btn-group .caret { - opacity: 1; - filter: alpha(opacity=100); -} - -.btn-mini .caret { - margin-top: 5px; -} - -.btn-small .caret { - margin-top: 6px; -} - -.btn-large .caret { - margin-top: 6px; - border-top-width: 5px; - border-right-width: 5px; - border-left-width: 5px; -} - -.dropup .btn-large .caret { - border-top: 0; - border-bottom: 5px solid #000000; -} - -.btn-primary .caret, -.btn-warning .caret, -.btn-danger .caret, -.btn-info .caret, -.btn-success .caret, -.btn-inverse .caret { - border-top-color: #ffffff; - border-bottom-color: #ffffff; - opacity: 0.75; - filter: alpha(opacity=75); -} - -.alert { - padding: 8px 35px 8px 14px; - margin-bottom: 18px; - color: #c09853; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); - background-color: #fcf8e3; - border: 1px solid #fbeed5; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -.alert-heading { - color: inherit; -} - -.alert .close { - position: relative; - top: -2px; - right: -21px; - line-height: 18px; -} - -.alert-success { - color: #468847; - background-color: #dff0d8; - border-color: #d6e9c6; -} - -.alert-danger, -.alert-error { - color: #b94a48; - background-color: #f2dede; - border-color: #eed3d7; -} - -.alert-info { - color: #3a87ad; - background-color: #d9edf7; - border-color: #bce8f1; -} - -.alert-block { - padding-top: 14px; - padding-bottom: 14px; -} - -.alert-block > p, -.alert-block > ul { - margin-bottom: 0; -} - -.alert-block p + p { - margin-top: 5px; -} - -.nav { - margin-bottom: 18px; - margin-left: 0; - list-style: none; -} - -.nav > li > a { - display: block; -} - -.nav > li > a:hover { - text-decoration: none; - background-color: #eeeeee; -} - -.nav > .pull-right { - float: right; -} - -.nav .nav-header { - display: block; - padding: 3px 15px; - font-size: 11px; - font-weight: bold; - line-height: 18px; - color: #999999; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); - text-transform: uppercase; -} - -.nav li + .nav-header { - margin-top: 9px; -} - -.nav-list { - padding-right: 15px; - padding-left: 15px; - margin-bottom: 0; -} - -.nav-list > li > a, -.nav-list .nav-header { - margin-right: -15px; - margin-left: -15px; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); -} - -.nav-list > li > a { - padding: 3px 15px; -} - -.nav-list > .active > a, -.nav-list > .active > a:hover { - color: #ffffff; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.2); - background-color: #0088cc; -} - -.nav-list [class^="icon-"] { - margin-right: 2px; -} - -.nav-list .divider { - *width: 100%; - height: 1px; - margin: 8px 1px; - *margin: -5px 0 5px; - overflow: hidden; - background-color: #e5e5e5; - border-bottom: 1px solid #ffffff; -} - -.nav-tabs, -.nav-pills { - *zoom: 1; -} - -.nav-tabs:before, -.nav-pills:before, -.nav-tabs:after, -.nav-pills:after { - display: table; - content: ""; -} - -.nav-tabs:after, -.nav-pills:after { - clear: both; -} - -.nav-tabs > li, -.nav-pills > li { - float: left; -} - -.nav-tabs > li > a, -.nav-pills > li > a { - padding-right: 12px; - padding-left: 12px; - margin-right: 2px; - line-height: 14px; -} - -.nav-tabs { - border-bottom: 1px solid #ddd; -} - -.nav-tabs > li { - margin-bottom: -1px; -} - -.nav-tabs > li > a { - padding-top: 8px; - padding-bottom: 8px; - line-height: 18px; - border: 1px solid transparent; - -webkit-border-radius: 4px 4px 0 0; - -moz-border-radius: 4px 4px 0 0; - border-radius: 4px 4px 0 0; -} - -.nav-tabs > li > a:hover { - border-color: #eeeeee #eeeeee #dddddd; -} - -.nav-tabs > .active > a, -.nav-tabs > .active > a:hover { - color: #555555; - cursor: default; - background-color: #ffffff; - border: 1px solid #ddd; - border-bottom-color: transparent; -} - -.nav-pills > li > a { - padding-top: 8px; - padding-bottom: 8px; - margin-top: 2px; - margin-bottom: 2px; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; -} - -.nav-pills > .active > a, -.nav-pills > .active > a:hover { - color: #ffffff; - background-color: #0088cc; -} - -.nav-stacked > li { - float: none; -} - -.nav-stacked > li > a { - margin-right: 0; -} - -.nav-tabs.nav-stacked { - border-bottom: 0; -} - -.nav-tabs.nav-stacked > li > a { - border: 1px solid #ddd; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} - -.nav-tabs.nav-stacked > li:first-child > a { - -webkit-border-radius: 4px 4px 0 0; - -moz-border-radius: 4px 4px 0 0; - border-radius: 4px 4px 0 0; -} - -.nav-tabs.nav-stacked > li:last-child > a { - -webkit-border-radius: 0 0 4px 4px; - -moz-border-radius: 0 0 4px 4px; - border-radius: 0 0 4px 4px; -} - -.nav-tabs.nav-stacked > li > a:hover { - z-index: 2; - border-color: #ddd; -} - -.nav-pills.nav-stacked > li > a { - margin-bottom: 3px; -} - -.nav-pills.nav-stacked > li:last-child > a { - margin-bottom: 1px; -} - -.nav-tabs .dropdown-menu { - -webkit-border-radius: 0 0 5px 5px; - -moz-border-radius: 0 0 5px 5px; - border-radius: 0 0 5px 5px; -} - -.nav-pills .dropdown-menu { - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -.nav-tabs .dropdown-toggle .caret, -.nav-pills .dropdown-toggle .caret { - margin-top: 6px; - border-top-color: #0088cc; - border-bottom-color: #0088cc; -} - -.nav-tabs .dropdown-toggle:hover .caret, -.nav-pills .dropdown-toggle:hover .caret { - border-top-color: #005580; - border-bottom-color: #005580; -} - -.nav-tabs .active .dropdown-toggle .caret, -.nav-pills .active .dropdown-toggle .caret { - border-top-color: #333333; - border-bottom-color: #333333; -} - -.nav > .dropdown.active > a:hover { - color: #000000; - cursor: pointer; -} - -.nav-tabs .open .dropdown-toggle, -.nav-pills .open .dropdown-toggle, -.nav > li.dropdown.open.active > a:hover { - color: #ffffff; - background-color: #999999; - border-color: #999999; -} - -.nav li.dropdown.open .caret, -.nav li.dropdown.open.active .caret, -.nav li.dropdown.open a:hover .caret { - border-top-color: #ffffff; - border-bottom-color: #ffffff; - opacity: 1; - filter: alpha(opacity=100); -} - -.tabs-stacked .open > a:hover { - border-color: #999999; -} - -.tabbable { - *zoom: 1; -} - -.tabbable:before, -.tabbable:after { - display: table; - content: ""; -} - -.tabbable:after { - clear: both; -} - -.tab-content { - overflow: auto; -} - -.tabs-below > .nav-tabs, -.tabs-right > .nav-tabs, -.tabs-left > .nav-tabs { - border-bottom: 0; -} - -.tab-content > .tab-pane, -.pill-content > .pill-pane { - display: none; -} - -.tab-content > .active, -.pill-content > .active { - display: block; -} - -.tabs-below > .nav-tabs { - border-top: 1px solid #ddd; -} - -.tabs-below > .nav-tabs > li { - margin-top: -1px; - margin-bottom: 0; -} - -.tabs-below > .nav-tabs > li > a { - -webkit-border-radius: 0 0 4px 4px; - -moz-border-radius: 0 0 4px 4px; - border-radius: 0 0 4px 4px; -} - -.tabs-below > .nav-tabs > li > a:hover { - border-top-color: #ddd; - border-bottom-color: transparent; -} - -.tabs-below > .nav-tabs > .active > a, -.tabs-below > .nav-tabs > .active > a:hover { - border-color: transparent #ddd #ddd #ddd; -} - -.tabs-left > .nav-tabs > li, -.tabs-right > .nav-tabs > li { - float: none; -} - -.tabs-left > .nav-tabs > li > a, -.tabs-right > .nav-tabs > li > a { - min-width: 74px; - margin-right: 0; - margin-bottom: 3px; -} - -.tabs-left > .nav-tabs { - float: left; - margin-right: 19px; - border-right: 1px solid #ddd; -} - -.tabs-left > .nav-tabs > li > a { - margin-right: -1px; - -webkit-border-radius: 4px 0 0 4px; - -moz-border-radius: 4px 0 0 4px; - border-radius: 4px 0 0 4px; -} - -.tabs-left > .nav-tabs > li > a:hover { - border-color: #eeeeee #dddddd #eeeeee #eeeeee; -} - -.tabs-left > .nav-tabs .active > a, -.tabs-left > .nav-tabs .active > a:hover { - border-color: #ddd transparent #ddd #ddd; - *border-right-color: #ffffff; -} - -.tabs-right > .nav-tabs { - float: right; - margin-left: 19px; - border-left: 1px solid #ddd; -} - -.tabs-right > .nav-tabs > li > a { - margin-left: -1px; - -webkit-border-radius: 0 4px 4px 0; - -moz-border-radius: 0 4px 4px 0; - border-radius: 0 4px 4px 0; -} - -.tabs-right > .nav-tabs > li > a:hover { - border-color: #eeeeee #eeeeee #eeeeee #dddddd; -} - -.tabs-right > .nav-tabs .active > a, -.tabs-right > .nav-tabs .active > a:hover { - border-color: #ddd #ddd #ddd transparent; - *border-left-color: #ffffff; -} - -.navbar { - *position: relative; - *z-index: 2; - margin-bottom: 18px; - overflow: visible; -} - -.navbar-inner { - min-height: 40px; - padding-right: 20px; - padding-left: 20px; - background-color: #2c2c2c; - background-image: -moz-linear-gradient(top, #333333, #222222); - background-image: -ms-linear-gradient(top, #333333, #222222); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222)); - background-image: -webkit-linear-gradient(top, #333333, #222222); - background-image: -o-linear-gradient(top, #333333, #222222); - background-image: linear-gradient(top, #333333, #222222); - background-repeat: repeat-x; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0); - -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.1); - -moz-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25), inset 0 -1px 0 rgba(0, 0, 0, 0.1); -} - -.navbar .container { - width: auto; -} - -.nav-collapse.collapse { - height: auto; -} - -.navbar { - color: #999999; -} - -.navbar .brand:hover { - text-decoration: none; -} - -.navbar .brand { - display: block; - float: left; - padding: 8px 20px 12px; - margin-left: -20px; - font-size: 20px; - font-weight: 200; - line-height: 1; - color: #999999; -} - -.navbar .navbar-text { - margin-bottom: 0; - line-height: 40px; -} - -.navbar .navbar-link { - color: #999999; -} - -.navbar .navbar-link:hover { - color: #ffffff; -} - -.navbar .btn, -.navbar .btn-group { - margin-top: 5px; -} - -.navbar .btn-group .btn { - margin: 0; -} - -.navbar-form { - margin-bottom: 0; - *zoom: 1; -} - -.navbar-form:before, -.navbar-form:after { - display: table; - content: ""; -} - -.navbar-form:after { - clear: both; -} - -.navbar-form input, -.navbar-form select, -.navbar-form .radio, -.navbar-form .checkbox { - margin-top: 5px; -} - -.navbar-form input, -.navbar-form select { - display: inline-block; - margin-bottom: 0; -} - -.navbar-form input[type="image"], -.navbar-form input[type="checkbox"], -.navbar-form input[type="radio"] { - margin-top: 3px; -} - -.navbar-form .input-append, -.navbar-form .input-prepend { - margin-top: 6px; - white-space: nowrap; -} - -.navbar-form .input-append input, -.navbar-form .input-prepend input { - margin-top: 0; -} - -.navbar-search { - position: relative; - float: left; - margin-top: 6px; - margin-bottom: 0; -} - -.navbar-search .search-query { - padding: 4px 9px; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 13px; - font-weight: normal; - line-height: 1; - color: #ffffff; - background-color: #626262; - border: 1px solid #151515; - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(255, 255, 255, 0.15); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(255, 255, 255, 0.15); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 0 rgba(255, 255, 255, 0.15); - -webkit-transition: none; - -moz-transition: none; - -ms-transition: none; - -o-transition: none; - transition: none; -} - -.navbar-search .search-query:-moz-placeholder { - color: #cccccc; -} - -.navbar-search .search-query::-webkit-input-placeholder { - color: #cccccc; -} - -.navbar-search .search-query:focus, -.navbar-search .search-query.focused { - padding: 5px 10px; - color: #333333; - text-shadow: 0 1px 0 #ffffff; - background-color: #ffffff; - border: 0; - outline: 0; - -webkit-box-shadow: 0 0 3px rgba(0, 0, 0, 0.15); - -moz-box-shadow: 0 0 3px rgba(0, 0, 0, 0.15); - box-shadow: 0 0 3px rgba(0, 0, 0, 0.15); -} - -.navbar-fixed-top, -.navbar-fixed-bottom { - position: fixed; - right: 0; - left: 0; - z-index: 1030; - margin-bottom: 0; -} - -.navbar-fixed-top .navbar-inner, -.navbar-fixed-bottom .navbar-inner { - padding-right: 0; - padding-left: 0; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; -} - -.navbar-fixed-top .container, -.navbar-fixed-bottom .container { - width: 940px; -} - -.navbar-fixed-top { - top: 0; -} - -.navbar-fixed-bottom { - bottom: 0; -} - -.navbar .nav { - position: relative; - left: 0; - display: block; - float: left; - margin: 0 10px 0 0; -} - -.navbar .nav.pull-right { - float: right; -} - -.navbar .nav > li { - display: block; - float: left; -} - -.navbar .nav > li > a { - float: none; - padding: 9px 10px 11px; - line-height: 19px; - color: #999999; - text-decoration: none; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); -} - -.navbar .btn { - display: inline-block; - padding: 4px 10px 4px; - margin: 5px 5px 6px; - line-height: 18px; -} - -.navbar .btn-group { - padding: 5px 5px 6px; - margin: 0; -} - -.navbar .nav > li > a:hover { - color: #ffffff; - text-decoration: none; - background-color: transparent; -} - -.navbar .nav .active > a, -.navbar .nav .active > a:hover { - color: #ffffff; - text-decoration: none; - background-color: #222222; -} - -.navbar .divider-vertical { - width: 1px; - height: 40px; - margin: 0 9px; - overflow: hidden; - background-color: #222222; - border-right: 1px solid #333333; -} - -.navbar .nav.pull-right { - margin-right: 0; - margin-left: 10px; -} - -.navbar .btn-navbar { - display: none; - float: right; - padding: 7px 10px; - margin-right: 5px; - margin-left: 5px; - background-color: #2c2c2c; - *background-color: #222222; - background-image: -ms-linear-gradient(top, #333333, #222222); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#333333), to(#222222)); - background-image: -webkit-linear-gradient(top, #333333, #222222); - background-image: -o-linear-gradient(top, #333333, #222222); - background-image: linear-gradient(top, #333333, #222222); - background-image: -moz-linear-gradient(top, #333333, #222222); - background-repeat: repeat-x; - border-color: #222222 #222222 #000000; - border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#333333', endColorstr='#222222', GradientType=0); - filter: progid:dximagetransform.microsoft.gradient(enabled=false); - -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.075); - -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.075); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 1px 0 rgba(255, 255, 255, 0.075); -} - -.navbar .btn-navbar:hover, -.navbar .btn-navbar:active, -.navbar .btn-navbar.active, -.navbar .btn-navbar.disabled, -.navbar .btn-navbar[disabled] { - background-color: #222222; - *background-color: #151515; -} - -.navbar .btn-navbar:active, -.navbar .btn-navbar.active { - background-color: #080808 \9; -} - -.navbar .btn-navbar .icon-bar { - display: block; - width: 18px; - height: 2px; - background-color: #f5f5f5; - -webkit-border-radius: 1px; - -moz-border-radius: 1px; - border-radius: 1px; - -webkit-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); - -moz-box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); -} - -.btn-navbar .icon-bar + .icon-bar { - margin-top: 3px; -} - -.navbar .dropdown-menu:before { - position: absolute; - top: -7px; - left: 9px; - display: inline-block; - border-right: 7px solid transparent; - border-bottom: 7px solid #ccc; - border-left: 7px solid transparent; - border-bottom-color: rgba(0, 0, 0, 0.2); - content: ''; -} - -.navbar .dropdown-menu:after { - position: absolute; - top: -6px; - left: 10px; - display: inline-block; - border-right: 6px solid transparent; - border-bottom: 6px solid #ffffff; - border-left: 6px solid transparent; - content: ''; -} - -.navbar-fixed-bottom .dropdown-menu:before { - top: auto; - bottom: -7px; - border-top: 7px solid #ccc; - border-bottom: 0; - border-top-color: rgba(0, 0, 0, 0.2); -} - -.navbar-fixed-bottom .dropdown-menu:after { - top: auto; - bottom: -6px; - border-top: 6px solid #ffffff; - border-bottom: 0; -} - -.navbar .nav li.dropdown .dropdown-toggle .caret, -.navbar .nav li.dropdown.open .caret { - border-top-color: #ffffff; - border-bottom-color: #ffffff; -} - -.navbar .nav li.dropdown.active .caret { - opacity: 1; - filter: alpha(opacity=100); -} - -.navbar .nav li.dropdown.open > .dropdown-toggle, -.navbar .nav li.dropdown.active > .dropdown-toggle, -.navbar .nav li.dropdown.open.active > .dropdown-toggle { - background-color: transparent; -} - -.navbar .nav li.dropdown.active > .dropdown-toggle:hover { - color: #ffffff; -} - -.navbar .pull-right .dropdown-menu, -.navbar .dropdown-menu.pull-right { - right: 0; - left: auto; -} - -.navbar .pull-right .dropdown-menu:before, -.navbar .dropdown-menu.pull-right:before { - right: 12px; - left: auto; -} - -.navbar .pull-right .dropdown-menu:after, -.navbar .dropdown-menu.pull-right:after { - right: 13px; - left: auto; -} - -.breadcrumb { - padding: 7px 14px; - margin: 0 0 18px; - list-style: none; - background-color: #fbfbfb; - background-image: -moz-linear-gradient(top, #ffffff, #f5f5f5); - background-image: -ms-linear-gradient(top, #ffffff, #f5f5f5); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#f5f5f5)); - background-image: -webkit-linear-gradient(top, #ffffff, #f5f5f5); - background-image: -o-linear-gradient(top, #ffffff, #f5f5f5); - background-image: linear-gradient(top, #ffffff, #f5f5f5); - background-repeat: repeat-x; - border: 1px solid #ddd; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ffffff', endColorstr='#f5f5f5', GradientType=0); - -webkit-box-shadow: inset 0 1px 0 #ffffff; - -moz-box-shadow: inset 0 1px 0 #ffffff; - box-shadow: inset 0 1px 0 #ffffff; -} - -.breadcrumb li { - display: inline-block; - *display: inline; - text-shadow: 0 1px 0 #ffffff; - *zoom: 1; -} - -.breadcrumb .divider { - padding: 0 5px; - color: #999999; -} - -.breadcrumb .active a { - color: #333333; -} - -.pagination { - height: 36px; - margin: 18px 0; -} - -.pagination ul { - display: inline-block; - *display: inline; - margin-bottom: 0; - margin-left: 0; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; - *zoom: 1; - -webkit-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - -moz-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.pagination li { - display: inline; -} - -.pagination a { - float: left; - padding: 0 14px; - line-height: 34px; - text-decoration: none; - border: 1px solid #ddd; - border-left-width: 0; -} - -.pagination a:hover, -.pagination .active a { - background-color: #f5f5f5; -} - -.pagination .active a { - color: #999999; - cursor: default; -} - -.pagination .disabled span, -.pagination .disabled a, -.pagination .disabled a:hover { - color: #999999; - cursor: default; - background-color: transparent; -} - -.pagination li:first-child a { - border-left-width: 1px; - -webkit-border-radius: 3px 0 0 3px; - -moz-border-radius: 3px 0 0 3px; - border-radius: 3px 0 0 3px; -} - -.pagination li:last-child a { - -webkit-border-radius: 0 3px 3px 0; - -moz-border-radius: 0 3px 3px 0; - border-radius: 0 3px 3px 0; -} - -.pagination-centered { - text-align: center; -} - -.pagination-right { - text-align: right; -} - -.pager { - margin-bottom: 18px; - margin-left: 0; - text-align: center; - list-style: none; - *zoom: 1; -} - -.pager:before, -.pager:after { - display: table; - content: ""; -} - -.pager:after { - clear: both; -} - -.pager li { - display: inline; -} - -.pager a { - display: inline-block; - padding: 5px 14px; - background-color: #fff; - border: 1px solid #ddd; - -webkit-border-radius: 15px; - -moz-border-radius: 15px; - border-radius: 15px; -} - -.pager a:hover { - text-decoration: none; - background-color: #f5f5f5; -} - -.pager .next a { - float: right; -} - -.pager .previous a { - float: left; -} - -.pager .disabled a, -.pager .disabled a:hover { - color: #999999; - cursor: default; - background-color: #fff; -} - -.modal-open .dropdown-menu { - z-index: 2050; -} - -.modal-open .dropdown.open { - *z-index: 2050; -} - -.modal-open .popover { - z-index: 2060; -} - -.modal-open .tooltip { - z-index: 2070; -} - -.modal-backdrop { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: 1040; - background-color: #000000; -} - -.modal-backdrop.fade { - opacity: 0; -} - -.modal-backdrop, -.modal-backdrop.fade.in { - opacity: 0.8; - filter: alpha(opacity=80); -} - -.modal { - position: fixed; - top: 50%; - left: 50%; - z-index: 1050; - width: 560px; - margin: -250px 0 0 -280px; - overflow: auto; - background-color: #ffffff; - border: 1px solid #999; - border: 1px solid rgba(0, 0, 0, 0.3); - *border: 1px solid #999; - -webkit-border-radius: 6px; - -moz-border-radius: 6px; - border-radius: 6px; - -webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); - -moz-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); - box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); - -webkit-background-clip: padding-box; - -moz-background-clip: padding-box; - background-clip: padding-box; -} - -.modal.fade { - top: -25%; - -webkit-transition: opacity 0.3s linear, top 0.3s ease-out; - -moz-transition: opacity 0.3s linear, top 0.3s ease-out; - -ms-transition: opacity 0.3s linear, top 0.3s ease-out; - -o-transition: opacity 0.3s linear, top 0.3s ease-out; - transition: opacity 0.3s linear, top 0.3s ease-out; -} - -.modal.fade.in { - top: 50%; -} - -.modal-header { - padding: 9px 15px; - border-bottom: 1px solid #eee; -} - -.modal-header .close { - margin-top: 2px; -} - -.modal-body { - max-height: 400px; - padding: 15px; - overflow-y: auto; -} - -.modal-form { - margin-bottom: 0; -} - -.modal-footer { - padding: 14px 15px 15px; - margin-bottom: 0; - text-align: right; - background-color: #f5f5f5; - border-top: 1px solid #ddd; - -webkit-border-radius: 0 0 6px 6px; - -moz-border-radius: 0 0 6px 6px; - border-radius: 0 0 6px 6px; - *zoom: 1; - -webkit-box-shadow: inset 0 1px 0 #ffffff; - -moz-box-shadow: inset 0 1px 0 #ffffff; - box-shadow: inset 0 1px 0 #ffffff; -} - -.modal-footer:before, -.modal-footer:after { - display: table; - content: ""; -} - -.modal-footer:after { - clear: both; -} - -.modal-footer .btn + .btn { - margin-bottom: 0; - margin-left: 5px; -} - -.modal-footer .btn-group .btn + .btn { - margin-left: -1px; -} - -.tooltip { - position: absolute; - z-index: 1020; - display: block; - padding: 5px; - font-size: 11px; - opacity: 0; - filter: alpha(opacity=0); - visibility: visible; -} - -.tooltip.in { - opacity: 0.8; - filter: alpha(opacity=80); -} - -.tooltip.top { - margin-top: -2px; -} - -.tooltip.right { - margin-left: 2px; -} - -.tooltip.bottom { - margin-top: 2px; -} - -.tooltip.left { - margin-left: -2px; -} - -.tooltip.top .tooltip-arrow { - bottom: 0; - left: 50%; - margin-left: -5px; - border-top: 5px solid #000000; - border-right: 5px solid transparent; - border-left: 5px solid transparent; -} - -.tooltip.left .tooltip-arrow { - top: 50%; - right: 0; - margin-top: -5px; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-left: 5px solid #000000; -} - -.tooltip.bottom .tooltip-arrow { - top: 0; - left: 50%; - margin-left: -5px; - border-right: 5px solid transparent; - border-bottom: 5px solid #000000; - border-left: 5px solid transparent; -} - -.tooltip.right .tooltip-arrow { - top: 50%; - left: 0; - margin-top: -5px; - border-top: 5px solid transparent; - border-right: 5px solid #000000; - border-bottom: 5px solid transparent; -} - -.tooltip-inner { - max-width: 200px; - padding: 3px 8px; - color: #ffffff; - text-align: center; - text-decoration: none; - background-color: #000000; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -.tooltip-arrow { - position: absolute; - width: 0; - height: 0; -} - -.popover { - position: absolute; - top: 0; - left: 0; - z-index: 1010; - display: none; - padding: 5px; -} - -.popover.top { - margin-top: -5px; -} - -.popover.right { - margin-left: 5px; -} - -.popover.bottom { - margin-top: 5px; -} - -.popover.left { - margin-left: -5px; -} - -.popover.top .arrow { - bottom: 0; - left: 50%; - margin-left: -5px; - border-top: 5px solid #000000; - border-right: 5px solid transparent; - border-left: 5px solid transparent; -} - -.popover.right .arrow { - top: 50%; - left: 0; - margin-top: -5px; - border-top: 5px solid transparent; - border-right: 5px solid #000000; - border-bottom: 5px solid transparent; -} - -.popover.bottom .arrow { - top: 0; - left: 50%; - margin-left: -5px; - border-right: 5px solid transparent; - border-bottom: 5px solid #000000; - border-left: 5px solid transparent; -} - -.popover.left .arrow { - top: 50%; - right: 0; - margin-top: -5px; - border-top: 5px solid transparent; - border-bottom: 5px solid transparent; - border-left: 5px solid #000000; -} - -.popover .arrow { - position: absolute; - width: 0; - height: 0; -} - -.popover-inner { - width: 280px; - padding: 3px; - overflow: hidden; - background: #000000; - background: rgba(0, 0, 0, 0.8); - -webkit-border-radius: 6px; - -moz-border-radius: 6px; - border-radius: 6px; - -webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); - -moz-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); - box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3); -} - -.popover-title { - padding: 9px 15px; - line-height: 1; - background-color: #f5f5f5; - border-bottom: 1px solid #eee; - -webkit-border-radius: 3px 3px 0 0; - -moz-border-radius: 3px 3px 0 0; - border-radius: 3px 3px 0 0; -} - -.popover-content { - padding: 14px; - background-color: #ffffff; - -webkit-border-radius: 0 0 3px 3px; - -moz-border-radius: 0 0 3px 3px; - border-radius: 0 0 3px 3px; - -webkit-background-clip: padding-box; - -moz-background-clip: padding-box; - background-clip: padding-box; -} - -.popover-content p, -.popover-content ul, -.popover-content ol { - margin-bottom: 0; -} - -.thumbnails { - margin-left: -20px; - list-style: none; - *zoom: 1; -} - -.thumbnails:before, -.thumbnails:after { - display: table; - content: ""; -} - -.thumbnails:after { - clear: both; -} - -.row-fluid .thumbnails { - margin-left: 0; -} - -.thumbnails > li { - float: left; - margin-bottom: 18px; - margin-left: 20px; -} - -.thumbnail { - display: block; - padding: 4px; - line-height: 1; - border: 1px solid #ddd; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); - -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075); -} - -a.thumbnail:hover { - border-color: #0088cc; - -webkit-box-shadow: 0 1px 4px rgba(0, 105, 214, 0.25); - -moz-box-shadow: 0 1px 4px rgba(0, 105, 214, 0.25); - box-shadow: 0 1px 4px rgba(0, 105, 214, 0.25); -} - -.thumbnail > img { - display: block; - max-width: 100%; - margin-right: auto; - margin-left: auto; -} - -.thumbnail .caption { - padding: 9px; -} - -.label, -.badge { - font-size: 10.998px; - font-weight: bold; - line-height: 14px; - color: #ffffff; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); - white-space: nowrap; - vertical-align: baseline; - background-color: #999999; -} - -.label { - padding: 1px 4px 2px; - -webkit-border-radius: 3px; - -moz-border-radius: 3px; - border-radius: 3px; -} - -.badge { - padding: 1px 9px 2px; - -webkit-border-radius: 9px; - -moz-border-radius: 9px; - border-radius: 9px; -} - -a.label:hover, -a.badge:hover { - color: #ffffff; - text-decoration: none; - cursor: pointer; -} - -.label-important, -.badge-important { - background-color: #b94a48; -} - -.label-important[href], -.badge-important[href] { - background-color: #953b39; -} - -.label-warning, -.badge-warning { - background-color: #f89406; -} - -.label-warning[href], -.badge-warning[href] { - background-color: #c67605; -} - -.label-success, -.badge-success { - background-color: #468847; -} - -.label-success[href], -.badge-success[href] { - background-color: #356635; -} - -.label-info, -.badge-info { - background-color: #3a87ad; -} - -.label-info[href], -.badge-info[href] { - background-color: #2d6987; -} - -.label-inverse, -.badge-inverse { - background-color: #333333; -} - -.label-inverse[href], -.badge-inverse[href] { - background-color: #1a1a1a; -} - -@-webkit-keyframes progress-bar-stripes { - from { - background-position: 40px 0; - } - to { - background-position: 0 0; - } -} - -@-moz-keyframes progress-bar-stripes { - from { - background-position: 40px 0; - } - to { - background-position: 0 0; - } -} - -@-ms-keyframes progress-bar-stripes { - from { - background-position: 40px 0; - } - to { - background-position: 0 0; - } -} - -@-o-keyframes progress-bar-stripes { - from { - background-position: 0 0; - } - to { - background-position: 40px 0; - } -} - -@keyframes progress-bar-stripes { - from { - background-position: 40px 0; - } - to { - background-position: 0 0; - } -} - -.progress { - height: 18px; - margin-bottom: 18px; - overflow: hidden; - background-color: #f7f7f7; - background-image: -moz-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: -ms-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f5f5f5), to(#f9f9f9)); - background-image: -webkit-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: -o-linear-gradient(top, #f5f5f5, #f9f9f9); - background-image: linear-gradient(top, #f5f5f5, #f9f9f9); - background-repeat: repeat-x; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#f5f5f5', endColorstr='#f9f9f9', GradientType=0); - -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - -moz-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); - box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); -} - -.progress .bar { - width: 0; - height: 18px; - font-size: 12px; - color: #ffffff; - text-align: center; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); - background-color: #0e90d2; - background-image: -moz-linear-gradient(top, #149bdf, #0480be); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#149bdf), to(#0480be)); - background-image: -webkit-linear-gradient(top, #149bdf, #0480be); - background-image: -o-linear-gradient(top, #149bdf, #0480be); - background-image: linear-gradient(top, #149bdf, #0480be); - background-image: -ms-linear-gradient(top, #149bdf, #0480be); - background-repeat: repeat-x; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#149bdf', endColorstr='#0480be', GradientType=0); - -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); - -moz-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); - box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - -ms-box-sizing: border-box; - box-sizing: border-box; - -webkit-transition: width 0.6s ease; - -moz-transition: width 0.6s ease; - -ms-transition: width 0.6s ease; - -o-transition: width 0.6s ease; - transition: width 0.6s ease; -} - -.progress-striped .bar { - background-color: #149bdf; - background-image: -o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent)); - background-image: linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - -webkit-background-size: 40px 40px; - -moz-background-size: 40px 40px; - -o-background-size: 40px 40px; - background-size: 40px 40px; -} - -.progress.active .bar { - -webkit-animation: progress-bar-stripes 2s linear infinite; - -moz-animation: progress-bar-stripes 2s linear infinite; - -ms-animation: progress-bar-stripes 2s linear infinite; - -o-animation: progress-bar-stripes 2s linear infinite; - animation: progress-bar-stripes 2s linear infinite; -} - -.progress-danger .bar { - background-color: #dd514c; - background-image: -moz-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -ms-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ee5f5b), to(#c43c35)); - background-image: -webkit-linear-gradient(top, #ee5f5b, #c43c35); - background-image: -o-linear-gradient(top, #ee5f5b, #c43c35); - background-image: linear-gradient(top, #ee5f5b, #c43c35); - background-repeat: repeat-x; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#ee5f5b', endColorstr='#c43c35', GradientType=0); -} - -.progress-danger.progress-striped .bar { - background-color: #ee5f5b; - background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent)); - background-image: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); -} - -.progress-success .bar { - background-color: #5eb95e; - background-image: -moz-linear-gradient(top, #62c462, #57a957); - background-image: -ms-linear-gradient(top, #62c462, #57a957); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#62c462), to(#57a957)); - background-image: -webkit-linear-gradient(top, #62c462, #57a957); - background-image: -o-linear-gradient(top, #62c462, #57a957); - background-image: linear-gradient(top, #62c462, #57a957); - background-repeat: repeat-x; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#62c462', endColorstr='#57a957', GradientType=0); -} - -.progress-success.progress-striped .bar { - background-color: #62c462; - background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent)); - background-image: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); -} - -.progress-info .bar { - background-color: #4bb1cf; - background-image: -moz-linear-gradient(top, #5bc0de, #339bb9); - background-image: -ms-linear-gradient(top, #5bc0de, #339bb9); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#5bc0de), to(#339bb9)); - background-image: -webkit-linear-gradient(top, #5bc0de, #339bb9); - background-image: -o-linear-gradient(top, #5bc0de, #339bb9); - background-image: linear-gradient(top, #5bc0de, #339bb9); - background-repeat: repeat-x; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#5bc0de', endColorstr='#339bb9', GradientType=0); -} - -.progress-info.progress-striped .bar { - background-color: #5bc0de; - background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent)); - background-image: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); -} - -.progress-warning .bar { - background-color: #faa732; - background-image: -moz-linear-gradient(top, #fbb450, #f89406); - background-image: -ms-linear-gradient(top, #fbb450, #f89406); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#fbb450), to(#f89406)); - background-image: -webkit-linear-gradient(top, #fbb450, #f89406); - background-image: -o-linear-gradient(top, #fbb450, #f89406); - background-image: linear-gradient(top, #fbb450, #f89406); - background-repeat: repeat-x; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#fbb450', endColorstr='#f89406', GradientType=0); -} - -.progress-warning.progress-striped .bar { - background-color: #fbb450; - background-image: -webkit-gradient(linear, 0 100%, 100% 0, color-stop(0.25, rgba(255, 255, 255, 0.15)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.15)), color-stop(0.75, rgba(255, 255, 255, 0.15)), color-stop(0.75, transparent), to(transparent)); - background-image: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: -o-linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); - background-image: linear-gradient(-45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); -} - -.accordion { - margin-bottom: 18px; -} - -.accordion-group { - margin-bottom: 2px; - border: 1px solid #e5e5e5; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} - -.accordion-heading { - border-bottom: 0; -} - -.accordion-heading .accordion-toggle { - display: block; - padding: 8px 15px; -} - -.accordion-toggle { - cursor: pointer; -} - -.accordion-inner { - padding: 9px 15px; - border-top: 1px solid #e5e5e5; -} - -.carousel { - position: relative; - margin-bottom: 18px; - line-height: 1; -} - -.carousel-inner { - position: relative; - width: 100%; - overflow: hidden; -} - -.carousel .item { - position: relative; - display: none; - -webkit-transition: 0.6s ease-in-out left; - -moz-transition: 0.6s ease-in-out left; - -ms-transition: 0.6s ease-in-out left; - -o-transition: 0.6s ease-in-out left; - transition: 0.6s ease-in-out left; -} - -.carousel .item > img { - display: block; - line-height: 1; -} - -.carousel .active, -.carousel .next, -.carousel .prev { - display: block; -} - -.carousel .active { - left: 0; -} - -.carousel .next, -.carousel .prev { - position: absolute; - top: 0; - width: 100%; -} - -.carousel .next { - left: 100%; -} - -.carousel .prev { - left: -100%; -} - -.carousel .next.left, -.carousel .prev.right { - left: 0; -} - -.carousel .active.left { - left: -100%; -} - -.carousel .active.right { - left: 100%; -} - -.carousel-control { - position: absolute; - top: 40%; - left: 15px; - width: 40px; - height: 40px; - margin-top: -20px; - font-size: 60px; - font-weight: 100; - line-height: 30px; - color: #ffffff; - text-align: center; - background: #222222; - border: 3px solid #ffffff; - -webkit-border-radius: 23px; - -moz-border-radius: 23px; - border-radius: 23px; - opacity: 0.5; - filter: alpha(opacity=50); -} - -.carousel-control.right { - right: 15px; - left: auto; -} - -.carousel-control:hover { - color: #ffffff; - text-decoration: none; - opacity: 0.9; - filter: alpha(opacity=90); -} - -.carousel-caption { - position: absolute; - right: 0; - bottom: 0; - left: 0; - padding: 10px 15px 5px; - background: #333333; - background: rgba(0, 0, 0, 0.75); -} - -.carousel-caption h4, -.carousel-caption p { - color: #ffffff; -} - -.hero-unit { - padding: 60px; - margin-bottom: 30px; - background-color: #eeeeee; - -webkit-border-radius: 6px; - -moz-border-radius: 6px; - border-radius: 6px; -} - -.hero-unit h1 { - margin-bottom: 0; - font-size: 60px; - line-height: 1; - letter-spacing: -1px; - color: inherit; -} - -.hero-unit p { - font-size: 18px; - font-weight: 200; - line-height: 27px; - color: inherit; -} - -.pull-right { - float: right; -} - -.pull-left { - float: left; -} - -.hide { - display: none; -} - -.show { - display: block; -} - -.invisible { - visibility: hidden; -} diff --git a/data/css/lib/images/tablesorter/asc.gif b/data/css/lib/images/tablesorter/asc.gif new file mode 100644 index 0000000000000000000000000000000000000000..fa3fe40113a4482d2d3ff9b39c9056067477999e GIT binary patch literal 54 zcmZ?wbhEHb6lGvxXkcXc|NlP&1B2pE7DfgJMg|=qn*k)lz{J}lzkvJ2`CGHoFJ`Og HGFSruQVtDe literal 0 HcmV?d00001 diff --git a/data/css/lib/images/tablesorter/bg.gif b/data/css/lib/images/tablesorter/bg.gif new file mode 100644 index 0000000000000000000000000000000000000000..40c6a65aa2862b0939e2707b4d9c1174225f21c1 GIT binary patch literal 64 zcmZ?wbhEHb6lLIKXkcXc|NlP&1B2pE7DfgJMg|=qn*k)lz$D(&-*B4qR#HWXQP<jg QY~gmP3Ez7<Lm3#X0rl7sf&c&j literal 0 HcmV?d00001 diff --git a/data/css/lib/images/tablesorter/desc.gif b/data/css/lib/images/tablesorter/desc.gif new file mode 100644 index 0000000000000000000000000000000000000000..88c6971d61ea3eecd468cd38c296346ef8b21bd1 GIT binary patch literal 54 zcmZ?wbhEHb6lGvxXkcXc|NlP&1B2pE7DfgJMg|=qn*k)lz{K0r&v<g}hB?h4MqQDN G4AuZo0t~tU literal 0 HcmV?d00001 diff --git a/data/css/lib/images/ui-bg_fine-grain_10_eceadf_60x60.png b/data/css/lib/images/ui-bg_fine-grain_10_eceadf_60x60.png deleted file mode 100644 index 46567420785a91503f30de00eec3a331cd4a969e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4429 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 T(^GCZ00000NkvXXu0mjfj|q=Y diff --git a/data/css/lib/images/ui-bg_flat_0_000000_40x100.png b/data/css/lib/images/ui-bg_flat_0_000000_40x100.png deleted file mode 100644 index abdc01082bf3534eafecc5819d28c9574d44ea89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178 zcmeAS@N?(olHy`uVBq!ia0vp^8bF-F!3HG1q!d*FsY*{5$B>N1x91EQ4=4yQY-ImG zFPf9b{J;c_6SHRK%WcbN_hZpM=(Ry;4Rxv2@@2Y=$K57eF$X$=!PC{xWt~$(69B)$ BI)4BF diff --git a/data/css/lib/images/ui-bg_flat_0_ffffff_40x100.png b/data/css/lib/images/ui-bg_flat_75_ffffff_40x100.png similarity index 100% rename from data/css/lib/images/ui-bg_flat_0_ffffff_40x100.png rename to data/css/lib/images/ui-bg_flat_75_ffffff_40x100.png diff --git a/data/css/lib/images/ui-bg_glass_55_fbf9ee_1x400.png b/data/css/lib/images/ui-bg_glass_55_fbf9ee_1x400.png index b39a6fb27ffbb1f3712e6cfa09e32d8ac084469b..ad3d6346e00f246102f72f2e026ed0491988b394 100644 GIT binary patch delta 98 zcmV-o0G<Dk0eFxcTmWxa3flkx06$4YK~y-6?awg^z%T$p(XaQvY}e9Ymo`J7bD+39 z1YP7hj~Jko@>`PeCcr`%f<YJw6P~1HH`{FgQA+vb2F08Z22okaDgXcg07*qoM6N<$ Ef}%MmcK`qY delta 122 zcmV-=0EPc}kO7b!bO9J&I!yoo09Q#wK~y-6?UXSNfG`XL&qn<Jt*nR=h9X{2)k9fM zQ5MIFWv=rm10+z@FxoJ9vN(C?@{X%!i)WojaaY5VF({F`6c+yZy6E-VgU&li`gXz< cKk~;&JP}tRwLELNuK)l507*qoM6N<$g47H%y#N3J diff --git a/data/css/lib/images/ui-bg_glass_65_ffffff_1x400.png b/data/css/lib/images/ui-bg_glass_65_ffffff_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..42ccba269b6e91bef12ad0fa18be651b5ef0ee68 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouqzpV=978O6-=0?FV^9z|eBtf= z|7WztIJ;WT>{+tN>ySr~=F{k$>;_x^_y?afmf9pRKH0)6?eSP?3s5hEr>mdKI;Vst E0O<Z9>;M1& literal 0 HcmV?d00001 diff --git a/data/css/lib/images/ui-bg_glass_75_dadada_1x400.png b/data/css/lib/images/ui-bg_glass_75_dadada_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..5a46b47cb16631068aee9e0bd61269fc4e95e5cd GIT binary patch literal 111 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouq|7{B978O6lPf+wIa#m9#>Unb zm^4K~wN3Zq+uP<E-4iDYHYX${Ii)G?xY5!0{fvg8SC7yQ4u<2&oOc%dd<Zm-fx*+& K&t;ucLK6Ud-y?JY literal 0 HcmV?d00001 diff --git a/data/css/lib/images/ui-bg_glass_75_e6e6e6_1x400.png b/data/css/lib/images/ui-bg_glass_75_e6e6e6_1x400.png new file mode 100644 index 0000000000000000000000000000000000000000..86c2baa655eac8539db34f8d9adb69ec1226201c GIT binary patch literal 110 zcmeAS@N?(olHy`uVBq!ia0vp^j6gJjgAK^akKnouq)a_s978O6-<~$)Vo(rZKDhVK z|J9WTLT^QIG;Q^ml{ow8HvtEZhta#LLqp$|vO7%bGjI7IBizcw`SFI!bT^;@44$rj JF6*2UngEbSBdP!Z literal 0 HcmV?d00001 diff --git a/data/css/lib/images/ui-bg_highlight-soft_75_cccccc_1x100.png b/data/css/lib/images/ui-bg_highlight-soft_75_cccccc_1x100.png new file mode 100644 index 0000000000000000000000000000000000000000..7c9fa6c6edcfcdd3e5b77e6f547b719e6fc66e30 GIT binary patch literal 101 zcmeAS@N?(olHy`uVBq!ia0vp^j6j^i!3HGVb)pi0l#Zv1V~E7m<ccpZF4n8Dv9Yx& zy8QY7U*2m$;l+;n|NjK_PainIAnKgVYt6(keT9{lbLSy{pgsmqS3j3^P6<r_2PGZu literal 0 HcmV?d00001 diff --git a/data/css/lib/images/ui-bg_highlight-soft_75_dcdcdc_1x100.png b/data/css/lib/images/ui-bg_highlight-soft_75_dcdcdc_1x100.png deleted file mode 100644 index 66acd27c22878fbd9c68d3e8b88ba292d595c118..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^j6j^i!3HGVb)pi0l%}VPV~E7m<chz)zSgU;v9Yx& wy8QY7U%t6pz`5We$DB<TzLFK|FEueREVeR?I<VjPI#3gXr>mdKI;Vst0Gd%8>;M1& diff --git a/data/css/lib/images/ui-bg_highlight-soft_75_dddddd_1x100.png b/data/css/lib/images/ui-bg_highlight-soft_75_dddddd_1x100.png deleted file mode 100644 index 8392ac785d9d71c0a05b2af8d1c516421bc03de0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^j6j^i!3HGVb)pi0l)9&jV~E7m<ciPF&ep53v9Yx& uy8QY7U*1b$M~09}=R=dM2YbD^nHUt78^(GsaoYvd!r<xZ=d#Wzp$Pysc^aYs diff --git a/data/css/lib/images/ui-bg_highlight-soft_75_efefef_1x100.png b/data/css/lib/images/ui-bg_highlight-soft_75_efefef_1x100.png deleted file mode 100644 index 077a5024dbd1dd8e8724f41a10bd8314eb57c9b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 128 zcmeAS@N?(olHy`uVBq!ia0vp^j6j^i!3Ja)XlH!`a@;*#9780gZtZjAI$*%T(*EcF z-g_m*ni@x&7Ctdr@nXk}nPLZ|1rHW|U;ZkL;RVa2RSuW_d=u838ghH(gY%!2Q}#K| d-mD!dmBZ8R@lo%<Pc@*C44$rjF6*2UngA`OFR%ar diff --git a/data/css/lib/images/ui-bg_inset-soft_75_dfdfdf_1x100.png b/data/css/lib/images/ui-bg_inset-soft_75_dfdfdf_1x100.png deleted file mode 100644 index c33667aee3d67c6620bdcf7b8a6a7b1c40d4a9ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 98 zcmeAS@N?(olHy`uVBq!ia0vp^j6j^i!3HGVb)pi0l%}VPV~E7m<Q?_@|JAFpv9Yx! vdKefPC8aBJm>A}G2?$yAN>!|%<ix=6W;RQ&_=eIZpe6=SS3j3^P6<r__}&;; diff --git a/data/css/lib/images/ui-icons_8c291d_256x240.png b/data/css/lib/images/ui-icons_454545_256x240.png similarity index 92% rename from data/css/lib/images/ui-icons_8c291d_256x240.png rename to data/css/lib/images/ui-icons_454545_256x240.png index a90f0cec755b4a555e4d505ed45a02893c305dd5..59bd45b907c4fd965697774ce8c5fc6b2fd9c105 100644 GIT binary patch delta 267 tcmbQJG*M}SX1$A>%Rr)f`dTla``?Ozfx)jN$S;^dLcoY+vpVA|egI&^NL&B_ delta 267 tcmbQJG*M}SW_^#A%s`@g`dYsx?r9kV1A|{lkY6x^gn$vtW_89_`~W|$N;m)j diff --git a/data/css/lib/images/ui-icons_888888_256x240.png b/data/css/lib/images/ui-icons_888888_256x240.png new file mode 100644 index 0000000000000000000000000000000000000000..6d02426c114be4b57aabc0a80b8a63d9e56b9eb6 GIT binary patch literal 4369 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 literal 0 HcmV?d00001 diff --git a/data/css/lib/jquery-ui-1.8.17.custom.css b/data/css/lib/jquery-ui-1.8.17.custom.css new file mode 100644 index 0000000000..620035d160 --- /dev/null +++ b/data/css/lib/jquery-ui-1.8.17.custom.css @@ -0,0 +1,567 @@ +/*! + * jQuery UI CSS Framework 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Theming/API + */ + +/* Layout helpers +----------------------------------*/ +.ui-helper-hidden { display: none; } +.ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px); } +.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; } +.ui-helper-clearfix:before, .ui-helper-clearfix:after { content: ""; display: table; } +.ui-helper-clearfix:after { clear: both; } +.ui-helper-clearfix { zoom: 1; } +.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); } + + +/* Interaction Cues +----------------------------------*/ +.ui-state-disabled { cursor: default !important; } + + +/* Icons +----------------------------------*/ + +/* states and images */ +.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; } + + +/* Misc visuals +----------------------------------*/ + +/* Overlays */ +.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } + + +/*! + * jQuery UI CSS Framework 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Theming/API + * + * To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Verdana,Arial,sans-serif&fwDefault=normal&fsDefault=1.1em&cornerRadius=4px&bgColorHeader=cccccc&bgTextureHeader=03_highlight_soft.png&bgImgOpacityHeader=75&borderColorHeader=aaaaaa&fcHeader=222222&iconColorHeader=222222&bgColorContent=ffffff&bgTextureContent=01_flat.png&bgImgOpacityContent=75&borderColorContent=aaaaaa&fcContent=222222&iconColorContent=222222&bgColorDefault=e6e6e6&bgTextureDefault=02_glass.png&bgImgOpacityDefault=75&borderColorDefault=d3d3d3&fcDefault=555555&iconColorDefault=888888&bgColorHover=dadada&bgTextureHover=02_glass.png&bgImgOpacityHover=75&borderColorHover=999999&fcHover=212121&iconColorHover=454545&bgColorActive=ffffff&bgTextureActive=02_glass.png&bgImgOpacityActive=65&borderColorActive=aaaaaa&fcActive=212121&iconColorActive=454545&bgColorHighlight=fbf9ee&bgTextureHighlight=02_glass.png&bgImgOpacityHighlight=55&borderColorHighlight=fcefa1&fcHighlight=363636&iconColorHighlight=2e83ff&bgColorError=fef1ec&bgTextureError=02_glass.png&bgImgOpacityError=95&borderColorError=cd0a0a&fcError=cd0a0a&iconColorError=cd0a0a&bgColorOverlay=aaaaaa&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px + */ + + +/* Component containers +----------------------------------*/ +.ui-widget { font-family: Verdana,Arial,sans-serif; font-size: 1.1em; } +.ui-widget .ui-widget { font-size: 1em; } +.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Verdana,Arial,sans-serif; font-size: 1em; } +.ui-widget-content { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_flat_75_ffffff_40x100.png) 50% 50% repeat-x; color: #222222; } +.ui-widget-content a { color: #222222; } +.ui-widget-header { border: 1px solid #aaaaaa; background: #cccccc url(images/ui-bg_highlight-soft_75_cccccc_1x100.png) 50% 50% repeat-x; color: #222222; font-weight: bold; } +.ui-widget-header a { color: #222222; } + +/* Interaction states +----------------------------------*/ +.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #d3d3d3; background: #e6e6e6 url(images/ui-bg_glass_75_e6e6e6_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #555555; } +.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #555555; text-decoration: none; } +.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #999999; background: #dadada url(images/ui-bg_glass_75_dadada_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; } +.ui-state-hover a, .ui-state-hover a:hover { color: #212121; text-decoration: none; } +.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #aaaaaa; background: #ffffff url(images/ui-bg_glass_65_ffffff_1x400.png) 50% 50% repeat-x; font-weight: normal; color: #212121; } +.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #212121; text-decoration: none; } +.ui-widget :active { outline: none; } + +/* Interaction Cues +----------------------------------*/ +.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #fcefa1; background: #fbf9ee url(images/ui-bg_glass_55_fbf9ee_1x400.png) 50% 50% repeat-x; color: #363636; } +.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #363636; } +.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a; background: #fef1ec url(images/ui-bg_glass_95_fef1ec_1x400.png) 50% 50% repeat-x; color: #cd0a0a; } +.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #cd0a0a; } +.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #cd0a0a; } +.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; } +.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; } +.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; } + +/* Icons +----------------------------------*/ + +/* states and images */ +.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_222222_256x240.png); } +.ui-widget-content .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } +.ui-widget-header .ui-icon {background-image: url(images/ui-icons_222222_256x240.png); } +.ui-state-default .ui-icon { background-image: url(images/ui-icons_888888_256x240.png); } +.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); } +.ui-state-active .ui-icon {background-image: url(images/ui-icons_454545_256x240.png); } +.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_2e83ff_256x240.png); } +.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_cd0a0a_256x240.png); } +.ui-pnotify-icon span { background-image: url(images/ui-icons_222222_256x240.png) !important;} + +/* positioning */ +.ui-icon-carat-1-n { background-position: 0 0; } +.ui-icon-carat-1-ne { background-position: -16px 0; } +.ui-icon-carat-1-e { background-position: -32px 0; } +.ui-icon-carat-1-se { background-position: -48px 0; } +.ui-icon-carat-1-s { background-position: -64px 0; } +.ui-icon-carat-1-sw { background-position: -80px 0; } +.ui-icon-carat-1-w { background-position: -96px 0; } +.ui-icon-carat-1-nw { background-position: -112px 0; } +.ui-icon-carat-2-n-s { background-position: -128px 0; } +.ui-icon-carat-2-e-w { background-position: -144px 0; } +.ui-icon-triangle-1-n { background-position: 0 -16px; } +.ui-icon-triangle-1-ne { background-position: -16px -16px; } +.ui-icon-triangle-1-e { background-position: -32px -16px; } +.ui-icon-triangle-1-se { background-position: -48px -16px; } +.ui-icon-triangle-1-s { background-position: -64px -16px; } +.ui-icon-triangle-1-sw { background-position: -80px -16px; } +.ui-icon-triangle-1-w { background-position: -96px -16px; } +.ui-icon-triangle-1-nw { background-position: -112px -16px; } +.ui-icon-triangle-2-n-s { background-position: -128px -16px; } +.ui-icon-triangle-2-e-w { background-position: -144px -16px; } +.ui-icon-arrow-1-n { background-position: 0 -32px; } +.ui-icon-arrow-1-ne { background-position: -16px -32px; } +.ui-icon-arrow-1-e { background-position: -32px -32px; } +.ui-icon-arrow-1-se { background-position: -48px -32px; } +.ui-icon-arrow-1-s { background-position: -64px -32px; } +.ui-icon-arrow-1-sw { background-position: -80px -32px; } +.ui-icon-arrow-1-w { background-position: -96px -32px; } +.ui-icon-arrow-1-nw { background-position: -112px -32px; } +.ui-icon-arrow-2-n-s { background-position: -128px -32px; } +.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; } +.ui-icon-arrow-2-e-w { background-position: -160px -32px; } +.ui-icon-arrow-2-se-nw { background-position: -176px -32px; } +.ui-icon-arrowstop-1-n { background-position: -192px -32px; } +.ui-icon-arrowstop-1-e { background-position: -208px -32px; } +.ui-icon-arrowstop-1-s { background-position: -224px -32px; } +.ui-icon-arrowstop-1-w { background-position: -240px -32px; } +.ui-icon-arrowthick-1-n { background-position: 0 -48px; } +.ui-icon-arrowthick-1-ne { background-position: -16px -48px; } +.ui-icon-arrowthick-1-e { background-position: -32px -48px; } +.ui-icon-arrowthick-1-se { background-position: -48px -48px; } +.ui-icon-arrowthick-1-s { background-position: -64px -48px; } +.ui-icon-arrowthick-1-sw { background-position: -80px -48px; } +.ui-icon-arrowthick-1-w { background-position: -96px -48px; } +.ui-icon-arrowthick-1-nw { background-position: -112px -48px; } +.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; } +.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; } +.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; } +.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; } +.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; } +.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; } +.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; } +.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; } +.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; } +.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; } +.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; } +.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; } +.ui-icon-arrowreturn-1-w { background-position: -64px -64px; } +.ui-icon-arrowreturn-1-n { background-position: -80px -64px; } +.ui-icon-arrowreturn-1-e { background-position: -96px -64px; } +.ui-icon-arrowreturn-1-s { background-position: -112px -64px; } +.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; } +.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; } +.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; } +.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; } +.ui-icon-arrow-4 { background-position: 0 -80px; } +.ui-icon-arrow-4-diag { background-position: -16px -80px; } +.ui-icon-extlink { background-position: -32px -80px; } +.ui-icon-newwin { background-position: -48px -80px; } +.ui-icon-refresh { background-position: -64px -80px; } +.ui-icon-shuffle { background-position: -80px -80px; } +.ui-icon-transfer-e-w { background-position: -96px -80px; } +.ui-icon-transferthick-e-w { background-position: -112px -80px; } +.ui-icon-folder-collapsed { background-position: 0 -96px; } +.ui-icon-folder-open { background-position: -16px -96px; } +.ui-icon-document { background-position: -32px -96px; } +.ui-icon-document-b { background-position: -48px -96px; } +.ui-icon-note { background-position: -64px -96px; } +.ui-icon-mail-closed { background-position: -80px -96px; } +.ui-icon-mail-open { background-position: -96px -96px; } +.ui-icon-suitcase { background-position: -112px -96px; } +.ui-icon-comment { background-position: -128px -96px; } +.ui-icon-person { background-position: -144px -96px; } +.ui-icon-print { background-position: -160px -96px; } +.ui-icon-trash { background-position: -176px -96px; } +.ui-icon-locked { background-position: -192px -96px; } +.ui-icon-unlocked { background-position: -208px -96px; } +.ui-icon-bookmark { background-position: -224px -96px; } +.ui-icon-tag { background-position: -240px -96px; } +.ui-icon-home { background-position: 0 -112px; } +.ui-icon-flag { background-position: -16px -112px; } +.ui-icon-calendar { background-position: -32px -112px; } +.ui-icon-cart { background-position: -48px -112px; } +.ui-icon-pencil { background-position: -64px -112px; } +.ui-icon-clock { background-position: -80px -112px; } +.ui-icon-disk { background-position: -96px -112px; } +.ui-icon-calculator { background-position: -112px -112px; } +.ui-icon-zoomin { background-position: -128px -112px; } +.ui-icon-zoomout { background-position: -144px -112px; } +.ui-icon-search { background-position: -160px -112px; } +.ui-icon-wrench { background-position: -176px -112px; } +.ui-icon-gear { background-position: -192px -112px; } +.ui-icon-heart { background-position: -208px -112px; } +.ui-icon-star { background-position: -224px -112px; } +.ui-icon-link { background-position: -240px -112px; } +.ui-icon-cancel { background-position: 0 -128px; } +.ui-icon-plus { background-position: -16px -128px; } +.ui-icon-plusthick { background-position: -32px -128px; } +.ui-icon-minus { background-position: -48px -128px; } +.ui-icon-minusthick { background-position: -64px -128px; } +.ui-icon-close { background-position: -80px -128px; } +.ui-icon-closethick { background-position: -96px -128px; } +.ui-icon-key { background-position: -112px -128px; } +.ui-icon-lightbulb { background-position: -128px -128px; } +.ui-icon-scissors { background-position: -144px -128px; } +.ui-icon-clipboard { background-position: -160px -128px; } +.ui-icon-copy { background-position: -176px -128px; } +.ui-icon-contact { background-position: -192px -128px; } +.ui-icon-image { background-position: -208px -128px; } +.ui-icon-video { background-position: -224px -128px; } +.ui-icon-script { background-position: -240px -128px; } +.ui-icon-alert { background-position: 0 -144px; } +.ui-icon-info { background-position: -16px -144px; } +.ui-icon-notice { background-position: -32px -144px; } +.ui-icon-help { background-position: -48px -144px; } +.ui-icon-check { background-position: -64px -144px; } +.ui-icon-bullet { background-position: -80px -144px; } +.ui-icon-radio-off { background-position: -96px -144px; } +.ui-icon-radio-on { background-position: -112px -144px; } +.ui-icon-pin-w { background-position: -128px -144px; } +.ui-icon-pin-s { background-position: -144px -144px; } +.ui-icon-play { background-position: 0 -160px; } +.ui-icon-pause { background-position: -16px -160px; } +.ui-icon-seek-next { background-position: -32px -160px; } +.ui-icon-seek-prev { background-position: -48px -160px; } +.ui-icon-seek-end { background-position: -64px -160px; } +.ui-icon-seek-start { background-position: -80px -160px; } +/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */ +.ui-icon-seek-first { background-position: -80px -160px; } +.ui-icon-stop { background-position: -96px -160px; } +.ui-icon-eject { background-position: -112px -160px; } +.ui-icon-volume-off { background-position: -128px -160px; } +.ui-icon-volume-on { background-position: -144px -160px; } +.ui-icon-power { background-position: 0 -176px; } +.ui-icon-signal-diag { background-position: -16px -176px; } +.ui-icon-signal { background-position: -32px -176px; } +.ui-icon-battery-0 { background-position: -48px -176px; } +.ui-icon-battery-1 { background-position: -64px -176px; } +.ui-icon-battery-2 { background-position: -80px -176px; } +.ui-icon-battery-3 { background-position: -96px -176px; } +.ui-icon-circle-plus { background-position: 0 -192px; } +.ui-icon-circle-minus { background-position: -16px -192px; } +.ui-icon-circle-close { background-position: -32px -192px; } +.ui-icon-circle-triangle-e { background-position: -48px -192px; } +.ui-icon-circle-triangle-s { background-position: -64px -192px; } +.ui-icon-circle-triangle-w { background-position: -80px -192px; } +.ui-icon-circle-triangle-n { background-position: -96px -192px; } +.ui-icon-circle-arrow-e { background-position: -112px -192px; } +.ui-icon-circle-arrow-s { background-position: -128px -192px; } +.ui-icon-circle-arrow-w { background-position: -144px -192px; } +.ui-icon-circle-arrow-n { background-position: -160px -192px; } +.ui-icon-circle-zoomin { background-position: -176px -192px; } +.ui-icon-circle-zoomout { background-position: -192px -192px; } +.ui-icon-circle-check { background-position: -208px -192px; } +.ui-icon-circlesmall-plus { background-position: 0 -208px; } +.ui-icon-circlesmall-minus { background-position: -16px -208px; } +.ui-icon-circlesmall-close { background-position: -32px -208px; } +.ui-icon-squaresmall-plus { background-position: -48px -208px; } +.ui-icon-squaresmall-minus { background-position: -64px -208px; } +.ui-icon-squaresmall-close { background-position: -80px -208px; } +.ui-icon-grip-dotted-vertical { background-position: 0 -224px; } +.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; } +.ui-icon-grip-solid-vertical { background-position: -32px -224px; } +.ui-icon-grip-solid-horizontal { background-position: -48px -224px; } +.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; } +.ui-icon-grip-diagonal-se { background-position: -80px -224px; } + + +/* Misc visuals +----------------------------------*/ + +/* Corner radius */ +.ui-corner-all, .ui-corner-top, .ui-corner-left, .ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -khtml-border-top-left-radius: 4px; border-top-left-radius: 4px; } +.ui-corner-all, .ui-corner-top, .ui-corner-right, .ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; -khtml-border-top-right-radius: 4px; border-top-right-radius: 4px; } +.ui-corner-all, .ui-corner-bottom, .ui-corner-left, .ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; -khtml-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; } +.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; -khtml-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; } + +/* Overlays */ +.ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); } +.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); -moz-border-radius: 8px; -khtml-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; }/*! + * jQuery UI Resizable 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Resizable#theming + */ +.ui-resizable { position: relative;} +.ui-resizable-handle { position: absolute;font-size: 0.1px; display: block; } +.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; } +.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; } +.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; } +.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; } +.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; } +.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; } +.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; } +.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; } +.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/*! + * jQuery UI Selectable 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Selectable#theming + */ +.ui-selectable-helper { position: absolute; z-index: 100; border:1px dotted black; } +/*! + * jQuery UI Accordion 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Accordion#theming + */ +/* IE/Win - Fix animation bug - #4615 */ +.ui-accordion { width: 100%; } +.ui-accordion .ui-accordion-header { cursor: pointer; position: relative; margin-top: 1px; zoom: 1; } +.ui-accordion .ui-accordion-li-fix { display: inline; } +.ui-accordion .ui-accordion-header-active { border-bottom: 0 !important; } +.ui-accordion .ui-accordion-header a { display: block; font-size: 1em; padding: .5em .5em .5em .7em; } +.ui-accordion-icons .ui-accordion-header a { padding-left: 2.2em; } +.ui-accordion .ui-accordion-header .ui-icon { position: absolute; left: .5em; top: 50%; margin-top: -8px; } +.ui-accordion .ui-accordion-content { padding: 1em 2.2em; border-top: 0; margin-top: -2px; position: relative; top: 1px; margin-bottom: 2px; overflow: auto; display: none; zoom: 1; } +.ui-accordion .ui-accordion-content-active { display: block; } +/*! + * jQuery UI Autocomplete 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Autocomplete#theming + */ +.ui-autocomplete { position: absolute; cursor: default; } + +/* workarounds */ +* html .ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */ + +/* + * jQuery UI Menu 1.8.20 + * + * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Menu#theming + */ +.ui-menu { + list-style:none; + padding: 2px; + margin: 0; + display:block; + float: left; +} +.ui-menu .ui-menu { + margin-top: -3px; +} +.ui-menu .ui-menu-item { + margin:0; + padding: 0; + zoom: 1; + float: left; + clear: left; + width: 100%; +} +.ui-menu .ui-menu-item a { + text-decoration:none; + display:block; + padding:.2em .4em; + line-height:1.5; + zoom:1; +} +.ui-menu .ui-menu-item a.ui-state-hover, +.ui-menu .ui-menu-item a.ui-state-active { + font-weight: normal; + margin: -1px; +} +/*! + * jQuery UI Button 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Button#theming + */ +.ui-button { display: inline-block; position: relative; padding: 0; margin-right: .1em; text-decoration: none !important; cursor: pointer; text-align: center; zoom: 1; overflow: visible; } /* the overflow property removes extra width in IE */ +.ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */ +button.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */ +.ui-button-icons-only { width: 3.4em; } +button.ui-button-icons-only { width: 3.7em; } + +/*button text element */ +.ui-button .ui-button-text { display: block; line-height: 1.4; } +.ui-button-text-only .ui-button-text { padding: .4em 1em; } +.ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; } +.ui-button-text-icon-primary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; } +.ui-button-text-icon-secondary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 2.1em .4em 1em; } +.ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; } +/* no icon support for input elements, provide padding by default */ +input.ui-button { padding: .4em 1em; } + +/*button icon element(s) */ +.ui-button-icon-only .ui-icon, .ui-button-text-icon-primary .ui-icon, .ui-button-text-icon-secondary .ui-icon, .ui-button-text-icons .ui-icon, .ui-button-icons-only .ui-icon { position: absolute; top: 50%; margin-top: -8px; } +.ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; } +.ui-button-text-icon-primary .ui-button-icon-primary, .ui-button-text-icons .ui-button-icon-primary, .ui-button-icons-only .ui-button-icon-primary { left: .5em; } +.ui-button-text-icon-secondary .ui-button-icon-secondary, .ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; } +.ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; } + +/*button sets*/ +.ui-buttonset { margin-right: 7px; } +.ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; } + +/* workarounds */ +button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */ +/*! + * jQuery UI Dialog 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Dialog#theming + */ +.ui-dialog { position: absolute; padding: .2em; width: 300px; overflow: hidden; } +.ui-dialog .ui-dialog-titlebar { padding: .4em 1em; position: relative; } +.ui-dialog .ui-dialog-title { float: left; margin: .1em 16px .1em 0; } +.ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; } +.ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; } +.ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; } +.ui-dialog .ui-dialog-content { position: relative; border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; } +.ui-dialog .ui-dialog-buttonpane { text-align: left; border-width: 1px 0 0 0; background-image: none; margin: .5em 0 0 0; padding: .3em 1em .5em .4em; } +.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset { float: right; } +.ui-dialog .ui-dialog-buttonpane button { margin: .5em .4em .5em 0; cursor: pointer; } +.ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; } +.ui-draggable .ui-dialog-titlebar { cursor: move; } +/*! + * jQuery UI Slider 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Slider#theming + */ +.ui-slider { position: relative; text-align: left; } +.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; } +.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; } + +.ui-slider-horizontal { height: .8em; } +.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; } +.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; } +.ui-slider-horizontal .ui-slider-range-min { left: 0; } +.ui-slider-horizontal .ui-slider-range-max { right: 0; } + +.ui-slider-vertical { width: .8em; height: 100px; } +.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; } +.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; } +.ui-slider-vertical .ui-slider-range-min { bottom: 0; } +.ui-slider-vertical .ui-slider-range-max { top: 0; }/*! + * jQuery UI Tabs 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Tabs#theming + */ +.ui-tabs { position: relative; padding: 0em; zoom: 1; background: transparent; border: none;} /* position: relative prevents IE scroll bug (element with position: relative inside container with overflow: auto appear as "fixed") */ +.ui-tabs .ui-tabs-nav { margin: 0; padding: 0; background: none; border:none;} +.ui-tabs .ui-tabs-nav li { list-style: none; float: left; position: relative; top: 1px; margin: 0 .2em 1px 0; border-bottom: 0 !important; padding: 0; white-space: nowrap;background: #FFF; } +.ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; font-weight: normal; } +.ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; background-color: #f7f7f7; border-color: #CCC; } +.ui-tabs .ui-tabs-nav li.ui-tabs-selected a { color: #454545; } +.ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; } +.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */ +.ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: #f7f7f7; border: 1px solid #CCC; } +.ui-tabs .ui-tabs-hide { display: none !important; } +/*! + * jQuery UI Datepicker 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Datepicker#theming + */ +.ui-datepicker { width: 17em; padding: .2em .2em 0; display: none; } +.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; } +.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; } +.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; } +.ui-datepicker .ui-datepicker-prev { left:2px; } +.ui-datepicker .ui-datepicker-next { right:2px; } +.ui-datepicker .ui-datepicker-prev-hover { left:1px; } +.ui-datepicker .ui-datepicker-next-hover { right:1px; } +.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px; } +.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; } +.ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; } +.ui-datepicker select.ui-datepicker-month-year {width: 100%;} +.ui-datepicker select.ui-datepicker-month, +.ui-datepicker select.ui-datepicker-year { width: 49%;} +.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; } +.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0; } +.ui-datepicker td { border: 0; padding: 1px; } +.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; } +.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; } +.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; } +.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; } + +/* with multiple calendars */ +.ui-datepicker.ui-datepicker-multi { width:auto; } +.ui-datepicker-multi .ui-datepicker-group { float:left; } +.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; } +.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; } +.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; } +.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; } +.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; } +.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; } +.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; } +.ui-datepicker-row-break { clear:both; width:100%; font-size:0em; } + +/* RTL support */ +.ui-datepicker-rtl { direction: rtl; } +.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; } +.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; } +.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; } +.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; } +.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; } +.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; } +.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; } +.ui-datepicker-rtl .ui-datepicker-group { float:right; } +.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; } +.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; } + +/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */ +.ui-datepicker-cover { + display: none; /*sorry for IE5*/ + display/**/: block; /*sorry for IE5*/ + position: absolute; /*must have*/ + z-index: -1; /*must have*/ + filter: mask(); /*must have*/ + top: -4px; /*must have*/ + left: -4px; /*must have*/ + width: 200px; /*must have*/ + height: 200px; /*must have*/ +}/*! + * jQuery UI Progressbar 1.8.20 + * + * Copyright 2012, AUTHORS.txt (http://jqueryui.com/about) + * Dual licensed under the MIT or GPL Version 2 licenses. + * http://jquery.org/license + * + * http://docs.jquery.com/UI/Progressbar#theming + */ +.ui-progressbar { height:2em; text-align: left; overflow: hidden; } +.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; } \ No newline at end of file diff --git a/data/css/lib/jquery.pnotify.default.css b/data/css/lib/jquery.pnotify.default.css index 896fcb8afb..5fc8b5b9d4 100644 --- a/data/css/lib/jquery.pnotify.default.css +++ b/data/css/lib/jquery.pnotify.default.css @@ -1,83 +1,101 @@ -/* -Document : jquery.pnotify.default.css -Created on : Nov 23, 2009, 3:14:10 PM -Author : Hunter Perrin -Version : 1.2.0 -Link : http://pinesframework.org/pnotify/ -Description: - Default styling for Pines Notify jQuery plugin. +/* + Document : jquery.pnotify.default.css + Created on : Nov 23, 2009, 3:14:10 PM + Author : Hunter Perrin + Version : 1.0.0 + Description: + Default styling for Pines Notify jQuery plugin. */ -/* -- Notice */ + +/* Notice +----------------------------------*/ .ui-pnotify { -top: 10px; -right: 15px; -position: absolute; -height: auto; -/* Ensures notices are above everything */ -z-index: 9999; + position: fixed; + right: 10px; + bottom: 10px; + /* Ensure that the notices are on top of everything else. */ + z-index: 9999; } -/* Hides position: fixed from IE6 */ +/* This hides position: fixed from IE6, which doesn't understand it. */ html > body .ui-pnotify { -position: fixed; + position: fixed; } -.ui-pnotify .ui-pnotify-shadow { --webkit-box-shadow: 0px 2px 10px rgba(50, 50, 50, 0.5); --moz-box-shadow: 0px 2px 10px rgba(50, 50, 50, 0.5); -box-shadow: 0px 2px 10px rgba(50, 50, 50, 0.5); +.ui-pnotify .ui-widget { + background: none; } .ui-pnotify-container { -background-position: 0 0; -padding: .8em; -height: 100%; -margin: 0; + background-position: 0 0; + border: 1px solid #cccccc; + background-image: -moz-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -webkit-linear-gradient(#fdf0d5, #fff9ee) !important; + background-image: -o-linear-gradient(#fdf0d5, #fff9ee) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -moz-border-radius: 7px; + -webkit-border-radius: 7px; + border-radius: 7px; + font-size: 14px; + -moz-box-shadow: 0px 0px 2px #aaaaaa; + -webkit-box-shadow: 0px 0px 2px #aaaaaa; + -o-box-shadow: 0px 0px 2px #aaaaaa; + box-shadow: 0px 0px 2px #aaaaaa; + padding: 7px 10px; + text-align: center; + min-height: 22px; + width: 250px; + z-index: 9999; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; + filter: alpha(opacity=85); + -moz-opacity: 0.8 !important; + -khtml-opacity: 0.8 !important; + -o-opacity: 0.8 !important; + opacity: 0.8 !important; } -.ui-pnotify-sharp { --webkit-border-radius: 0; --moz-border-radius: 0; -border-radius: 0; -} -.ui-pnotify-closer, .ui-pnotify-sticker { -float: right; -margin-left: .2em; +.ui-pnotify-closer { + float: right; + margin-left: .2em; } .ui-pnotify-title { -display: block; -margin-bottom: .4em; + display: block; + background: none; + font-size: 14px; + font-weight: bold; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; } .ui-pnotify-text { -display: block; + display: block; + font-size: 14px; + font-family: "Trebuchet MS", Helvetica, Arial, sans-serif; + line-height: normal; } .ui-pnotify-icon, .ui-pnotify-icon span { -display: block; -float: left; -margin-right: .2em; + display: block; + float: left; + margin-right: .2em; } -/* -- History Pulldown */ +/* History Pulldown +----------------------------------*/ .ui-pnotify-history-container { -position: absolute; -top: 0; -right: 18px; -width: 70px; -border-top: none; -padding: 0; --webkit-border-top-left-radius: 0; --moz-border-top-left-radius: 0; -border-top-left-radius: 0; --webkit-border-top-right-radius: 0; --moz-border-top-right-radius: 0; -border-top-right-radius: 0; -/* Ensures history container is above notices. */ -z-index: 10000; + position: absolute; + top: 0; + right: 18px; + width: 70px; + border-top: none; + /* Ensure that the history container is on top of the notices. */ + z-index: 10000; } .ui-pnotify-history-container .ui-pnotify-history-header { -padding: 2px; + /*padding: 2px;*/ } .ui-pnotify-history-container button { -cursor: pointer; -display: block; -width: 100%; + cursor: pointer; + display: block; + width: 100%; } .ui-pnotify-history-container .ui-pnotify-history-pulldown { -display: block; -margin: 0 auto; -} \ No newline at end of file + display: block; + margin: 0 auto; +} diff --git a/data/css/lib/jquery.qtip2.css b/data/css/lib/jquery.qtip2.css new file mode 100644 index 0000000000..173ce4ba2a --- /dev/null +++ b/data/css/lib/jquery.qtip2.css @@ -0,0 +1,536 @@ +/* +* qTip2 - Pretty powerful tooltips +* http://craigsworks.com/projects/qtip2/ +* +* Version: nightly +* Copyright 2009-2010 Craig Michael Thompson - http://craigsworks.com +* +* Dual licensed under MIT or GPLv2 licenses +* http://en.wikipedia.org/wiki/MIT_License +* http://en.wikipedia.org/wiki/GNU_General_Public_License +* +* Date: Thu Nov 17 12:01:03.0000000000 2011 +*/ + +/* Core qTip styles */ +.ui-tooltip, .qtip{ + position: absolute; + left: -28000px; + top: -28000px; + display: none; + + max-width: 280px; + min-width: 50px; + + font-size: 10.5px; + line-height: 12px; + + z-index: 15000; +} + + /* Fluid class for determining actual width in IE */ + .ui-tooltip-fluid{ + display: block; + visibility: hidden; + position: static !important; + float: left !important; + } + + .ui-tooltip-content{ + position: relative; + padding: 5px 9px; + overflow: hidden; + + border-width: 1px; + border-style: solid; + + text-align: left; + word-wrap: break-word; + overflow: hidden; + } + + .ui-tooltip-titlebar{ + position: relative; + min-height: 14px; + padding: 5px 35px 5px 10px; + overflow: hidden; + + border-width: 1px 1px 0; + border-style: solid; + + font-weight: bold; + } + + .ui-tooltip-titlebar + .ui-tooltip-content{ border-top-width: 0px !important; } + + /*! Default close button class */ + .ui-tooltip-titlebar .ui-state-default{ + position: absolute; + right: 4px; + top: 50%; + margin-top: -9px; + + cursor: pointer; + outline: medium none; + + border-width: 1px; + border-style: solid; + } + + * html .ui-tooltip-titlebar .ui-state-default{ top: 16px; } /* IE fix */ + + .ui-tooltip-titlebar .ui-icon, + .ui-tooltip-icon .ui-icon{ + display: block; + text-indent: -1000em; + } + + .ui-tooltip-icon, .ui-tooltip-icon .ui-icon{ + -moz-border-radius: 3px; + -webkit-border-radius: 3px; + border-radius: 3px; + } + + .ui-tooltip-icon .ui-icon{ + width: 18px; + height: 14px; + + text-align: center; + text-indent: 0; + font: normal bold 10px/13px Tahoma,sans-serif; + + color: inherit; + background: transparent none no-repeat -100em -100em; + } + + +/* Applied to 'focused' tooltips e.g. most recently displayed/interacted with */ +.ui-tooltip-focus{ + +} + +/* Applied on hover of tooltips i.e. added/removed on mouseenter/mouseleave respectively */ +.ui-tooltip-hover{ + +} + + +/*! Default tooltip style */ +.ui-tooltip-default .ui-tooltip-titlebar, +.ui-tooltip-default .ui-tooltip-content{ + border-color: #F1D031; + background-color: #FFFFA3; + color: #555; +} + + .ui-tooltip-default .ui-tooltip-titlebar{ + background-color: #FFEF93; + } + + .ui-tooltip-default .ui-tooltip-icon{ + border-color: #CCC; + background: #F1F1F1; + color: #777; + } + + .ui-tooltip-default .ui-tooltip-titlebar .ui-state-hover{ + border-color: #AAA; + color: #111; + } + +/* Tips plugin */ +.ui-tooltip .ui-tooltip-tip{ + margin: 0 auto; + overflow: hidden; + z-index: 10; +} + + .ui-tooltip .ui-tooltip-tip, + .ui-tooltip .ui-tooltip-tip *{ + position: absolute; + + line-height: 0.1px !important; + font-size: 0.1px !important; + color: #123456; + + background: transparent; + border: 0px dashed transparent; + } + + .ui-tooltip .ui-tooltip-tip canvas{ top: 0; left: 0; } + + +/*! Light tooltip style */ +.ui-tooltip-light .ui-tooltip-titlebar, +.ui-tooltip-light .ui-tooltip-content{ + border-color: #E2E2E2; + color: #454545; +} + + .ui-tooltip-light .ui-tooltip-content{ + background-color: white; + } + + .ui-tooltip-light .ui-tooltip-titlebar{ + background-color: #f1f1f1; + } + + +/*! Dark tooltip style */ +.ui-tooltip-dark .ui-tooltip-titlebar, +.ui-tooltip-dark .ui-tooltip-content{ + border-color: #303030; + color: #f3f3f3; +} + + .ui-tooltip-dark .ui-tooltip-content{ + background-color: #505050; + } + + .ui-tooltip-dark .ui-tooltip-titlebar{ + background-color: #404040; + } + + .ui-tooltip-dark .ui-tooltip-icon{ + border-color: #444; + } + + .ui-tooltip-dark .ui-tooltip-titlebar .ui-state-hover{ + border-color: #303030; + } + + +/*! Cream tooltip style */ +.ui-tooltip-cream .ui-tooltip-titlebar, +.ui-tooltip-cream .ui-tooltip-content{ + border-color: #F9E98E; + color: #A27D35; +} + + .ui-tooltip-cream .ui-tooltip-content{ + background-color: #FBF7AA; + } + + .ui-tooltip-cream .ui-tooltip-titlebar{ + background-color: #F0DE7D; + } + + .ui-tooltip-cream .ui-state-default .ui-tooltip-icon{ + background-position: -82px 0; + } + + +/*! Red tooltip style */ +.ui-tooltip-red .ui-tooltip-titlebar, +.ui-tooltip-red .ui-tooltip-content{ + border-color: #D95252; + color: #912323; +} + + .ui-tooltip-red .ui-tooltip-content{ + background-color: #F78B83; + } + + .ui-tooltip-red .ui-tooltip-titlebar{ + background-color: #F06D65; + } + + .ui-tooltip-red .ui-state-default .ui-tooltip-icon{ + background-position: -102px 0; + } + + .ui-tooltip-red .ui-tooltip-icon{ + border-color: #D95252; + } + + .ui-tooltip-red .ui-tooltip-titlebar .ui-state-hover{ + border-color: #D95252; + } + + +/*! Green tooltip style */ +.ui-tooltip-green .ui-tooltip-titlebar, +.ui-tooltip-green .ui-tooltip-content{ + border-color: #90D93F; + color: #3F6219; +} + + .ui-tooltip-green .ui-tooltip-content{ + background-color: #CAED9E; + } + + .ui-tooltip-green .ui-tooltip-titlebar{ + background-color: #B0DE78; + } + + .ui-tooltip-green .ui-state-default .ui-tooltip-icon{ + background-position: -42px 0; + } + + +/*! Blue tooltip style */ +.ui-tooltip-blue .ui-tooltip-titlebar, +.ui-tooltip-blue .ui-tooltip-content{ + border-color: #ADD9ED; + color: #5E99BD; +} + + .ui-tooltip-blue .ui-tooltip-content{ + background-color: #E5F6FE; + } + + .ui-tooltip-blue .ui-tooltip-titlebar{ + background-color: #D0E9F5; + } + + .ui-tooltip-blue .ui-state-default .ui-tooltip-icon{ + background-position: -2px 0; + } + +/*! Add shadows to your tooltips in: FF3+, Chrome 2+, Opera 10.6+, IE6+, Safari 2+ */ +.ui-tooltip-shadow{ + -webkit-box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.15); + -moz-box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.15); + box-shadow: 1px 1px 3px 1px rgba(0, 0, 0, 0.15); +} + + .ui-tooltip-shadow .ui-tooltip-titlebar, + .ui-tooltip-shadow .ui-tooltip-content{ + filter: progid:DXImageTransform.Microsoft.Shadow(Color='gray', Direction=135, Strength=3); + -ms-filter:"progid:DXImageTransform.Microsoft.Shadow(Color='gray', Direction=135, Strength=3)"; + + _margin-bottom: -3px; /* IE6 */ + .margin-bottom: -3px; /* IE7 */ + } + + +/*! Add rounded corners to your tooltips in: FF3+, Chrome 2+, Opera 10.6+, IE9+, Safari 2+ */ +.ui-tooltip-rounded, +.ui-tooltip-rounded .ui-tooltip-content, +.ui-tooltip-tipsy, +.ui-tooltip-tipsy .ui-tooltip-content, +.ui-tooltip-youtube, +.ui-tooltip-youtube .ui-tooltip-content{ + -moz-border-radius: 4px; + -webkit-border-radius: 4px; + border-radius: 4px; +} + +.ui-tooltip-rounded .ui-tooltip-titlebar, +.ui-tooltip-tipsy .ui-tooltip-titlebar, +.ui-tooltip-youtube .ui-tooltip-titlebar{ + -moz-border-radius: 5px 5px 0 0; + -webkit-border-radius: 5px 5px 0 0; + border-radius: 5px 5px 0 0; +} + +.ui-tooltip-rounded .ui-tooltip-titlebar + .ui-tooltip-content, +.ui-tooltip-tipsy .ui-tooltip-titlebar + .ui-tooltip-content, +.ui-tooltip-youtube .ui-tooltip-titlebar + .ui-tooltip-content{ + -moz-border-radius: 0 0 5px 5px; + -webkit-border-radius: 0 0 5px 5px; + border-radius: 0 0 5px 5px; +} + + +/*! Youtube tooltip style */ +.ui-tooltip-youtube{ + -webkit-box-shadow: 0 0 3px #333; + -moz-box-shadow: 0 0 3px #333; + box-shadow: 0 0 3px #333; +} + + .ui-tooltip-youtube .ui-tooltip-titlebar, + .ui-tooltip-youtube .ui-tooltip-content{ + _margin-bottom: 0; /* IE6 */ + .margin-bottom: 0; /* IE7 */ + + background: transparent; + background: rgba(0, 0, 0, 0.85); + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#D9000000,endColorstr=#D9000000); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#D9000000,endColorstr=#D9000000)"; + + color: white; + border-color: #CCCCCC; + } + + .ui-tooltip-youtube .ui-tooltip-icon{ + border-color: #222; + } + + .ui-tooltip-youtube .ui-tooltip-titlebar .ui-state-hover{ + border-color: #303030; + } + + +/* jQuery TOOLS Tooltip style */ +.ui-tooltip-jtools{ + background: #232323; + background: rgba(0, 0, 0, 0.7); + background-image: -moz-linear-gradient(top, #717171, #232323); + background-image: -webkit-gradient(linear, left top, left bottom, from(#717171), to(#232323)); + + border: 2px solid #ddd; + border: 2px solid rgba(241,241,241,1); + + -moz-border-radius: 2px; + -webkit-border-radius: 2px; + border-radius: 2px; + + -webkit-box-shadow: 0 0 12px #333; + -moz-box-shadow: 0 0 12px #333; + box-shadow: 0 0 12px #333; +} + + /* IE Specific */ + .ui-tooltip-jtools .ui-tooltip-titlebar{ + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#717171,endColorstr=#4A4A4A); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#717171,endColorstr=#4A4A4A)"; + } + .ui-tooltip-jtools .ui-tooltip-content{ + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#4A4A4A,endColorstr=#232323); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#4A4A4A,endColorstr=#232323)"; + } + + .ui-tooltip-jtools .ui-tooltip-titlebar, + .ui-tooltip-jtools .ui-tooltip-content{ + background: transparent; + color: white; + border: 0 dashed transparent; + } + + .ui-tooltip-jtools .ui-tooltip-icon{ + border-color: #555; + } + + .ui-tooltip-jtools .ui-tooltip-titlebar .ui-state-hover{ + border-color: #333; + } + + +/* Cluetip style */ +.ui-tooltip-cluetip{ + -webkit-box-shadow: 4px 4px 5px rgba(0, 0, 0, 0.4); + -moz-box-shadow: 4px 4px 5px rgba(0, 0, 0, 0.4); + box-shadow: 4px 4px 5px rgba(0, 0, 0, 0.4); +} + + .ui-tooltip-cluetip .ui-tooltip-titlebar{ + background-color: #87876A; + color: white; + border: 0 dashed transparent; + } + + .ui-tooltip-cluetip .ui-tooltip-content{ + background-color: #D9D9C2; + color: #111; + border: 0 dashed transparent; + } + + .ui-tooltip-cluetip .ui-tooltip-icon{ + border-color: #808064; + } + + .ui-tooltip-cluetip .ui-tooltip-titlebar .ui-state-hover{ + border-color: #696952; + color: #696952; + } + + +/* Tipsy style */ +.ui-tooltip-tipsy{ + border: 0; +} + + .ui-tooltip-tipsy .ui-tooltip-titlebar, + .ui-tooltip-tipsy .ui-tooltip-content{ + _margin-bottom: 0; /* IE6 */ + .margin-bottom: 0; /* IE7 */ + + background: transparent; + background: rgba(0, 0, 0, .87); + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#D9000000,endColorstr=#D9000000); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#D9000000,endColorstr=#D9000000)"; + + color: white; + border: 0px transparent; + + font-size: 11px; + font-family: 'Lucida Grande', sans-serif; + font-weight: bold; + line-height: 16px; + text-shadow: 0 1px black; + } + + .ui-tooltip-tipsy .ui-tooltip-titlebar{ + padding: 6px 35px 0 10; + } + + .ui-tooltip-tipsy .ui-tooltip-content{ + padding: 6px 10; + } + + .ui-tooltip-tipsy .ui-tooltip-icon{ + border-color: #222; + text-shadow: none; + } + + .ui-tooltip-tipsy .ui-tooltip-titlebar .ui-state-hover{ + border-color: #303030; + } + + +/* Tipped style */ +.ui-tooltip-tipped{ + +} + + .ui-tooltip-tipped .ui-tooltip-titlebar, + .ui-tooltip-tipped .ui-tooltip-content{ + border: 3px solid #959FA9; + + filter: none; -ms-filter: none; + } + + .ui-tooltip-tipped .ui-tooltip-titlebar{ + background: #3A79B8; + background-image: -moz-linear-gradient(top, #3A79B8, #2E629D); + background-image: -webkit-gradient(linear, left top, left bottom, from(#3A79B8), to(#2E629D)); + filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#3A79B8,endColorstr=#2E629D); + -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#3A79B8,endColorstr=#2E629D)"; + + color: white; + font-weight: normal; + font-family: serif; + + border-bottom-width: 0; + -moz-border-radius: 3px 3px 0 0; + -webkit-border-radius: 3px 3px 0 0; + border-radius: 3px 3px 0 0; + } + + .ui-tooltip-tipped .ui-tooltip-content{ + background-color: #F9F9F9; + color: #454545; + + -moz-border-radius: 0 0 3px 3px; + -webkit-border-radius: 0 0 3px 3px; + border-radius: 0 0 3px 3px; + } + + .ui-tooltip-tipped .ui-tooltip-icon{ + border: 2px solid #285589; + background: #285589; + } + + .ui-tooltip-tipped .ui-tooltip-icon .ui-icon{ + background-color: #FBFBFB; + color: #555; + } + +/* IE9 fix - removes all filters */ +.ui-tooltip:not(.ie9haxors) div.ui-tooltip-content, +.ui-tooltip:not(.ie9haxors) div.ui-tooltip-titlebar{ + filter: none; + -ms-filter: none; +} \ No newline at end of file diff --git a/data/css/lib/tablesorter.css b/data/css/lib/tablesorter.css new file mode 100644 index 0000000000..591dc7a84d --- /dev/null +++ b/data/css/lib/tablesorter.css @@ -0,0 +1,100 @@ +/* Variables *//* Mixins */ +/* SB Theme */ +table.tablesorter { + width: 100%; + margin-left: auto; + margin-right: auto; + text-align: left; + color: #000; + background-color: #fff; + border-spacing: 0; +} +table.tablesorter td { + font-size: 14px; + padding: 8px 10px; +} +/* remove extra border from left edge */ +table.tablesorter th:first-child, +table.tablesorter td:first-child { + border-left: none; +} +table.tablesorter th { + border-collapse: collapse; + background-image: -moz-linear-gradient(#555555, #333333) !important; + background-image: linear-gradient(#555555, #333333) !important; + background-image: -webkit-linear-gradient(#555555, #333333) !important; + background-image: -o-linear-gradient(#555555, #333333) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + color: #fff; +} +table.tablesorter .tablesorter-header { + /* background-image: url(../images/tablesorter/bg.gif); */ + + background-repeat: no-repeat; + background-position: center right; + cursor: pointer; +} +table.tablesorter .tablesorter-header-inner { + padding: 0px 15px 0px 4px; +} +table.tablesorter th.tablesorter-headerSortUp .tablesorter-header-inner { + background: url(../lib/images/tablesorter/asc.gif) no-repeat right center; +} +table.tablesorter th.tablesorter-headerSortDown .tablesorter-header-inner { + background: url(../lib/images/tablesorter/desc.gif) no-repeat right center; +} +table.tablesorter th.tablesorter-headerSortUp { + background-image: -moz-linear-gradient(#777777, #555555) !important; + background-image: linear-gradient(#777777, #555555) !important; + background-image: -webkit-linear-gradient(#777777, #555555) !important; + background-image: -o-linear-gradient(#777777, #555555) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + color: #FFFFFF; + /* background-image: url(../images/tablesorter/asc.gif); */ + +} +table.tablesorter th.tablesorter-headerSortDown { + background-image: -moz-linear-gradient(#777777, #555555) !important; + background-image: linear-gradient(#777777, #555555) !important; + background-image: -webkit-linear-gradient(#777777, #555555) !important; + background-image: -o-linear-gradient(#777777, #555555) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#555555, endColorstr=#333333) !important; + color: #FFFFFF; + /* background-image: url(../images/tablesorter/desc.gif); */ + +} +/* Zebra Widget - row alternating colors */ +table.tablesorter tr.odd td { + background-color: #F5F1E4; +} +table.tablesorter tr.even td { + background-color: #fbf9f3; +} +/* filter widget */ +table.tablesorter input.tablesorter-filter { + width: 98%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +table.tablesorter tr.tablesorter-filter, +table.tablesorter tr.tablesorter-filter td { + text-align: center; + background: #eee; +} +/* optional disabled input styling */table.tablesorter input.tablesorter-filter.disabled { + display: none; +} +/* xtra css for sb */ +.tablesorter-header-inner { + text-align: center; + white-space: nowrap; + padding: 0 2px; +} +tr.tablesorter-stickyHeader { + background-color: #fff; + padding: 2px 0; +} diff --git a/data/css/style.css b/data/css/style.css deleted file mode 100644 index b283536388..0000000000 --- a/data/css/style.css +++ /dev/null @@ -1,1280 +0,0 @@ -/* ======================================================================= -browser.css -========================================================================== */ -#fileBrowserDialog { - overflow-y: auto; -} -#fileBrowserDialog ul { - padding: 0; - margin: 0; -} -#fileBrowserDialog ul li { - margin: 2px 0; - list-style-type: none; - cursor: pointer; -} -#fileBrowserDialog ul li a { - display: block; - padding: 4px 0; -} -#fileBrowserDialog ul li a:hover { - color: #00f; - background: none; -} -#fileBrowserDialog ul li a span.ui-icon { - float: left; - margin: 0 4px; -} -#fileBrowserDialog h2 { - font-size: 20px; -} -/* -.browserDialog.busy .ui-dialog-buttonpane { - background: url("/images/loading.gif") 10px 50% no-repeat; -} -*/ -.ui-autocomplete { - max-height: 180px; - overflow-x: hidden; - overflow-y: auto; -} -/* IE6 hack since it doesn't support max-height */ -* html .ui-autocomplete { - height: 180px; - padding-right: 20px; -} -.ui-menu .ui-menu-item { - background-color: #eee; -} -.ui-menu .ui-menu-item-alternate{ - background-color: #fff; -} -.ui-menu a.ui-state-hover{ - color: #fff; - background: none; - background-color: #0a246a; -} - -/* ======================================================================= -formWizard.css -========================================================================== */ -fieldset.sectionwrap { - width: 800px; - padding: 5px; - text-align: left; - border-width: 0; -} -legend.legendStep { - font: bold 16px Arial; - color: #57442b; -} -div.stepsguide { - margin-bottom: 15px; - overflow: hidden; - text-align: left; - cursor: pointer; -} -div.stepsguide .step { - float: left; - width: 250px; - font: bold 24px Arial; -} -div.stepsguide .step p { - margin: 12px 0; - border-bottom: 4px solid #57442b; -} -div.stepsguide .disabledstep { - color: #c4c4c4; -} -div.stepsguide .disabledstep p { - border-bottom: 4px solid #8a775e; -} -div.stepsguide .step .smalltext { - font-size: 13px; - font-weight: normal; -} -div.formpaginate { - width: 800px; - margin-top: 1em; - overflow: auto; - font-weight: bold; - text-align: center; -} -div.formpaginate .prev, div.formpaginate .next { - padding: 3px 6px; - color: #fff; - cursor: hand; - cursor: pointer; - background: #57442b; - -webkit-border-radius: 6px; - -moz-border-radius: 6px; - border-radius: 6px; -} -.stepDiv { - padding: 15px; -} -/* step 3 related */ -#customQuality { - display: block; - padding: 10px 0; - overflow: hidden; - clear: both; -} -#customQualityWrapper div.component-group-desc { - float: left; - width: 165px; -} -#customQualityWrapper div.component-group-desc p { - width: 85%; - margin: .8em 0; - font-size: 1.2em; - color: #666; -} - -/* ======================================================================= -tablesorter.css -- custom -========================================================================== */ -table.tablesorter { - width: 100%; - margin-right: auto; - margin-left: auto; - color: #000; - text-align: left; - background-color: #fff; - border-spacing: 0; -} -table.tablesorter th, -table.tablesorter td { - padding: 4px; - border-top: #fff 1px solid; - border-left: #fff 1px solid; -} -/* remove extra border from left edge */ -table.tablesorter th:first-child, -table.tablesorter td:first-child { - border-left: none; -} -table.tablesorter th { - color: #fff; - text-align: center; - text-shadow: -1px -1px 0 rgba(0,0,0,0.3); - background-color: #333; - border-collapse: collapse; -} -table.tablesorter .tablesorter-header { - padding: 4px 18px 4px 4px; - cursor: pointer; - background-image: url(data:image/gif;base64,R0lGODlhFQAJAIAAAP///////yH5BAEAAAEALAAAAAAVAAkAAAIXjI+AywnaYnhUMoqt3gZXPmVg94yJVQAAOw==); - background-position: center right; - background-repeat: no-repeat; - /* background-image: url(../images/tablesorter/bg.gif); */ -} -table.tablesorter th.tablesorter-headerSortUp { - background-color: #57442b; - background-image: url(data:image/gif;base64,R0lGODlhFQAEAIAAAP///////yH5BAEAAAEALAAAAAAVAAQAAAINjB+gC+jP2ptn0WskLQA7); - /* background-image: url(../images/tablesorter/asc.gif); */ -} -table.tablesorter th.tablesorter-headerSortDown { - background-color: #57442b; - background-image: url(data:image/gif;base64,R0lGODlhFQAEAIAAAP///////yH5BAEAAAEALAAAAAAVAAQAAAINjI8Bya2wnINUMopZAQA7); - /* background-image: url(../images/tablesorter/desc.gif); */ -} -/* Zebra Widget - row alternating colors */ -table.tablesorter tr.odd, .sickbeardTable tr.odd { - background-color: #f5f1e4; -} -table.tablesorter tr.even, .sickbeardTable tr.even { - background-color: #dfdacf; -} -/* filter widget */ -table.tablesorter input.tablesorter-filter { - width: 98%; - height: inherit; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} -table.tablesorter tr.tablesorter-filter, -table.tablesorter tr.tablesorter-filter td { - text-align: center; - background: #eee; -} -/* optional disabled input styling */ -table.tablesorter input.tablesorter-filter.disabled { - display: none; -} -.tablesorter-header-inner { - padding: 0 2px; - text-align: center; -} -tr.tablesorter-stickyHeader { - padding: 2px 0; - background-color: #fff; -} -table.tablesorter tfoot tr { - color: #fff; - text-align: center; - text-shadow: -1px -1px 0 rgba(0,0,0,0.3); - background-color: #333; - border-collapse: collapse; -} -table.tablesorter tfoot a { - color:#fff; - text-decoration: none; -} - -/* ======================================================================= -inc_top.tmpl -========================================================================== */ -body { - font-family: Verdana, "Helvetica", sans-serif; - font-size: 12px; - color: #000; - background-color: #f5f1e4; -} -#upgrade-notification { - width: 100%; - height: 0; - padding: 0; - margin: 0; - font-size: 12px; - font-weight: bold; - line-height: 6px; - color: #57442b; - text-align: center; -} -#upgrade-notification div { - padding: 7px 0; - background-color: #c6b695; - border-bottom: 1px solid #af986b; -} -#header-fix { - height: 21px; - padding: 0; - *margin-bottom: -31px; - /* IE fix */ -} -#header { - padding: 5px 0; - background-color: #fff; -} -#header a:hover { - background: none; -} -#logo { - padding: 0 5px 0 15px; - font-family: Arial, Helvetica, sans-serif; - font-size: 33px; - font-weight: bold; - text-transform: uppercase; -} -#versiontext { - font-family: Arial, Helvetica, sans-serif; - font-size: 10px; - font-style: italic; - color: #57442b; - text-rendering: optimizelegibility; -} -#versiontext a { - font-weight: bold; - color: #57442b; -} -#navDonate { - padding: 5px 10px 4px; -} -/* submenu related */ -#SubMenu { - padding-left: 20px; - clear: both; - font-size: 12px; - color: #333; - background-color: #f5f1e4; - border-top: 1px solid #333; - border-bottom: 1px solid #b3b3b3; -} -#SubMenu span { - padding: 2px 6px 2px 8px; -} -#SubMenu span a { - padding: 2px 6px 2px; - font-weight: bold; - line-height: 24px; - color: #333; - text-decoration: none; -} -#SubMenu span a:hover { - color: #fff; - background-color: #000; -} -/* for comingEpisode submenu */ -#SubMenu span a.inner { - padding: 2px; - font-weight: normal; -} -#content { - width: 90%; - min-width: 875px; - padding: 15px; - background: #fff; - border-right: 1px solid #b3b3b3; - border-left: 1px solid #b3b3b3; - margin-left: auto; - margin-right: auto; - clear: both; -} - -/* ======================================================================= -inc_bottom.tmpl -========================================================================== */ -.footer { - width: 100%; - padding: 6px 0; - color: #4e4e4e; - text-align: center; - background-color: #f5f1e4; - border-top: 1px solid #b3b3b3; -} - -/* ======================================================================= -home.tmpl -========================================================================== */ -.ui-progressbar { - height: 20px; - line-height: 18px; -} -.progressbarText { - position: absolute; - top: 0; - width: 100%; - height: 100%; - overflow: visible; - text-align: center; - text-shadow: 0 0 0.1em #fff; - vertical-align: middle; -} - -/* ======================================================================= -home_postprocess.tmpl -========================================================================== */ -#episodeDir, #newRootDir { - margin-right: 6px; -} - -/* ======================================================================= -editShow.tmpl -========================================================================== */ -/* only works on FF :( */ -option.flag { - padding-left: 35px; - background-color: #fff; - background-position: 10px 50%; - background-repeat: no-repeat; -} - -/* ======================================================================= -manage_massEdit.tmpl -========================================================================== */ -.optionWrapper { - width: 450px; - padding: 6px 12px; - margin-right: auto; - margin-left: auto; -} -.optionWrapper span.selectTitle { - float: left; - width: 225px; - font-size: 14px; - font-weight: bold; - line-height: 22px; - text-align: left; -} -.optionWrapper div.selectChoices { - float: left; - width: 175px; - margin-left: 25px; -} - -/* ======================================================================= -manage_episodeStatuses.tmpl -========================================================================== */ -a.whitelink { - color: #fff; -} - -/* ======================================================================= -home_addShows.tmpl -========================================================================== */ -#addShowPortal { - width: 480px; - padding: 10px 0; - margin-right: auto; - margin-left: auto; -} -#addShowPortal a { - padding: 10px; -} -div.button img { - display: block; - float: left; - padding: 25px 5px; -} -div.buttontext { - display: block; - margin-left: 55px; - text-align: left; -} - -/* ======================================================================= -home_addExistingShow.tmpl + inc_addShowOptions.tmpl -========================================================================== */ -input.cb { - float: left; - margin: 3px 5px 3px 4px; -} -ul#rootDirStaticList { - width: 90%; - margin-right: auto; - margin-left: auto; - text-align: left; -} -ul#rootDirStaticList li { - padding: 4px 5px 4px 5px; - margin: 2px; - list-style: none outside none; - cursor: pointer; -} -/* for tabs: customize options */ -#tabs div.field-pair, -.stepDiv div.field-pair { - padding: 0.75em 0; -} -#tabs div.field-pair input, -.stepDiv div.field-pair input { - float: left; -} -#tabs label.nocheck, -#tabs div.providerDiv, -#tabs div #customQuality, -.stepDiv label.nocheck, -.stepDiv div.providerDiv, -.stepDiv div #customQuality { - padding-left: 23px; -} -#tabs label span.component-title, -.stepDiv label span.component-title { - float: left; - width: 165px; - padding-left: 6px; - margin-right: 10px; - font-size: 1.1em; - font-weight: bold; -} -#tabs label span.component-desc, -.stepDiv label span.component-desc { - float: left; - font-size: .9em; -} -#tabs div.field-pair select, -.stepDiv div.field-pair select { - font-size: 1em; - border: 1px solid #d4d0c8; -} -#tabs div.field-pair select option, -.stepDiv div.field-pair select option { - padding: 0 10px; - line-height: 1.4; - border-bottom: 1px dotted #d7d7d7; -} - -/* ======================================================================= -home_newShow.tmpl -========================================================================== */ -#displayText { - padding: 8px; - overflow: hidden; - font-size: 13px; - background-color: #efefef; - border: 1px solid #dfdede; -} - -/* ======================================================================= -displayShow.tmpl + manage_backlogOverview.tmpl -========================================================================== */ -.navShow { - display: inline; - cursor: pointer; -} -.showLegend { - padding-right: 10px; - padding-bottom: 1px; - font-weight: bold; -} -.checkbox.inline { - padding: 0 5px; -} -.checkbox.inline > input { - margin-right: 5px; - margin-left: 0; -} -.unaired { - background-color: #f5f1e4; -} -.skipped { - background-color: #bedeed; -} -.good { - background-color: #c3e3c8; -} -.qual { - background-color: #ffda8a; -} -.wanted { - background-color: #ffb0b0; -} -.snatched { - background-color: #ebc1ea; -} -/* ======================================================================= -manage_backlogOverview.tmpl -========================================================================== */ -.forceBacklog { - margin-left: 10px; -} -h2.backlogShow { - display: inline; - margin: 0; - font-size: 18px; - line-height: 18px; - letter-spacing: 1px; - color: #000; -} - -/* ======================================================================= -config*.tmpl -========================================================================== */ -#config-content { - display: block; - width: 875px; - padding: 0 0 40px; - margin: 0 auto; - clear: both; - text-align: left; - background: #fff; -} -.component-group { - padding: 15px 15px 25px; - border-bottom: 1px dotted #666; -} -/* .component-group-desc :width - + .component-group-list :width - + .component-group :padding - ------------------------------- - must be less than config-content -*/ -.component-group-desc{ - float: left; - width: 250px; -} -.component-group-desc p { - width: 90%; - margin: 10px 0; - color: #666; -} -.component-group-list { - float: left; - width: 590px; -} -#config div.field-pair { - padding: 8px 4px; -} -#config div.field-pair input { - float: left; - margin-right: 6px; -} -#config .nocheck, #config div #customQuality, .metadataDiv, .providerDiv { - padding-left: 20px; -} -#config label span.component-title { - float: left; - width: 172px; - margin-right: 10px; - font-size: 13px; - font-weight: bold; -} -#config label span.component-desc { - display: block; - padding-bottom: 2px; - overflow: hidden; - font-size: 12px; -} -.component-group-save { - float: right; - padding-top: 10px; -} -select .selected { - font-weight: 700; -} -.jumbo { - font-size: 15px !important; - line-height: 24px; -} - -/* ======================================================================= -comingEpisodes.tmpl -========================================================================== */ -.tvshowDiv { - display: block; - width: 606px; - padding: 0; - margin: auto; - clear: both; - text-align: left; - border-right: 1px solid #ccc; - border-bottom: 1px solid #ccc; - border-left: 1px solid #ccc; -} -.tvshowDiv a, .tvshowDiv a:link, .tvshowDiv a:visited, .tvshowDiv a:hover { - text-decoration: none; - background: none; -} -.tvshowTitle a { - padding-left: 8px; - font-size: 13px; - line-height: 23px; - color: #fff; - text-shadow: -1px -1px 0 rgba(0,0,0,0.3); -} -.tvshowTitleIcons { - float: right; - padding: 3px 5px; -} -.tvshowDiv .title { - font-weight: 900; - color: #333; -} -.posterThumb { - height: 200px; - vertical-align: top; - -ms-interpolation-mode: bicubic; -} -.bannerThumb { - height: 112px; - vertical-align: top; - border-bottom: 1px solid #d2ebe8; - -ms-interpolation-mode: bicubic; -} -.tvshowDiv th { - letter-spacing: 1px; - color: #000; - text-align: left; - background-color: #333; -} -.tvshowDiv th.nobg { - text-align: center; - background: #efefef; - border-top: 1px solid #666; -} -.tvshowDiv td { - padding: 10px; - color: #000; - background: #fff; -} -.tvshowDiv td.nextEp { - width: 100%; - color: #000; - background: #f5fafa; - border-bottom: 1px solid #d2ebe8; -} -h2.day { - margin: 10px 0; - letter-spacing: 1px; - color: #000; - text-align: center; - background-color: #9cb5cf; -} -h2.network { - margin: 10px 0; - letter-spacing: 1px; - color: #000; - text-align: center; - background-color: #8fbfaf; -} -.epListing { - width: auto; - padding: 10px; - margin-bottom: 10px; - border: 1px solid #ccc; -} -.listing-default { - background-color: #f5f1e4; -} -.listing-current { - background-color: #dfd; -} -.listing-waiting { - background-color: #9f9; -} -.listing-overdue { - background-color: #fdd; -} -.listing-toofar { - background-color: #ddf; -} -.listing-unknown { - background-color: #ffdc89; -} -span.pause { - font-size: 12px; - color: #f00; -} -.epSummaryTrigger { - float: left; - padding-top: 5px; -} -.epSummary { - padding-top: 5px; - margin-left: 25px; -} - -/* ======================================================================= -config.tmpl -========================================================================== */ -.infoTable td { - padding: 5px; -} -.infoTableHeader { - font-weight: bold; -} -.infoTableSeperator { - border-top: 1px dotted #666; -} -[class^="icon16-"], [class*=" icon16-"] { - display: inline-block; - width: 16px; - height: 16px; - line-height: 16px; - vertical-align: text-top; - /* background-image: url("../images/glyphicons-config.png"); */ - background-position: -40px 0; - background-repeat: no-repeat; -} -.icon16-github { - background-position: 0 0; -} -.icon16-mirc { - background-position: -20px 0; -} -.icon16-sb { - background-position: -40px 0; -} -.icon16-web { - background-position: -60px 0; -} -.icon16-win { - background-position: -80px 0; -} - -/* ======================================================================= -config_notifications.tmpl -========================================================================== */ -.notifier-icon { - float: left; - margin: 2px 10px 0 0; -} -.testNotification { - padding: 5px; - margin-bottom: 10px; - line-height: 20px; - border: 1px dotted #ccc; -} - -/* ======================================================================= -config_postProcessing.tmpl -========================================================================== */ -#config div.example { - padding: 10px; background-color: #efefef; -} -.Key { - width: 100%; - padding: 6px; - font-family: sans-serif; - font-size: 13px; - background-color: #f4f4f4; - border: 1px solid #ccc; - border-collapse: collapse; - border-spacing: 0; -} -.Key th, .tableHeader { - padding: 3px 9px; - margin: 0; - color: #fff; - text-align: center; - background: none repeat scroll 0 0 #666; -} -.Key td { - padding: 1px 5px !important; -} -.Key tr { - border-bottom: 1px solid #ccc; -} -.Key tr.even { - background-color: #dfdede; -} - -/* ======================================================================= -config_providers.tmpl -========================================================================== */ -#config-components > h2 { - border-bottom: 4px solid #ddd; -} -#providerOrderList, #service_order_list { - width: 250px; - padding-left: 20px; - list-style-type: none; -} -#providerOrderList li, #service_order_list li { - padding: 5px; - margin: 5px 0; - font-size: 14px; -} -#providerOrderList input, #service_order_list input { - margin: 0 2px; -} -.imgLink img { - padding: 0 2px 2px; -} -/* fix drop target height */ -#providerOrderList.ui-state-highlight, #service_order_list .ui-state-highlight { - height: 20px; - line-height: 18px; -} -h4.note { - float: left; - padding-right: 5px; - color: #000; -} -p.note { - color: #333; -} - -/* ======================================================================= -config_search.tmpl -========================================================================== */ -#no-torrents { - margin-top: 15px; - margin-bottom: 0; -} -.title-group { - position: relative; - padding: 15px; - border-bottom: 1px dotted #666; -} - -/* ======================================================================= -config_notifications.tmpl -========================================================================== */ -div.metadata-options-wrapper { - float: left; - width: 190px; -} -div.metadata-example-wrapper { - float: right; - width: 325px; -} -div.metadata-options { - padding: 7px; - overflow: auto; - background: #f5f1e4; - border: 1px solid #ccc; -} -div.metadata-options label { - display: block; - padding-left: 7px; - line-height: 20px; - color: #036; -} -div.metadata-options label:hover { - color: #fff; - background-color: #57442b; -} -div.metadata-example { - padding: 7px; -} -div.metadata-example label { - display: block; - line-height: 21px; - color: #000; -} -div.metadataDiv .disabled { - color: #ccc; -} - -/* ======================================================================= -global -- used all over -========================================================================== */ -.sickbeardTable { - width: 100%; - margin-right: auto; - margin-left: auto; - color: #000; - text-align: left; - background-color: #fff; - border-spacing: 0; -} -.sickbeardTable th, -.sickbeardTable td { - padding: 4px; - border-top: #fff 1px solid; - border-left: #fff 1px solid; -} -.sickbeardTable th:first-child, -.sickbeardTable td:first-child { - border-left: none; -} -.sickbeardTable th{ - color: #fff; - text-align: center; - text-shadow: -1px -1px 0 rgba(0,0,0,0.3); - background-color: #333; - border-collapse: collapse; -} -.sickbeardTable tfoot a { - color: #fff; - text-decoration: none; -} -tr.seasonheader { - padding: 0; - text-align: center; - background-color: #fff; -} -tr.seasonheader h2 { - display: inline; - margin: 0; - font-size: 22px; - line-height: 20px; - letter-spacing: 1px; - color: #000; -} -tr.seasonheader a { - text-decoration:none; -} -td.tvShow { - font-weight: bold; -} -td.tvShow a { - font-size: 14px; - color: #333; - text-decoration: none; -} -td.tvShow:hover a { - color:#000; -} -td.tvShow:hover { - cursor: pointer; - background-color: #cfcfcf !important; -} -/* TODO: convert float-* to pull-* */ -.float-left { - float: left; -} -.float-right { - float: right; -} -.align-left { - text-align: left; -} -.align-right { - text-align: right; -} -.nowrap { - white-space: nowrap; -} -.padding { - padding: 5px; -} -.alt { - background-color: #efefef; -} -div#summary { - padding: 10px; - margin: 10px; - background-color: #efefef; - border: 1px solid #dfdede; -} -h1 a:hover, h2 a:hover, span a:hover, div.h2footer a:hover, .notify-text a:hover { - color: #fff; - text-decoration: none; - background-color: #000; -} -/* used on displayShow and comingEp list */ -.plotInfo { - position: relative; - float: right; - margin-left: 8px; - font-weight: bold; - cursor: help; -} -div select option { - padding: 2px 10px; -} -span.path { - padding: 3px 6px; - color: #8b0000; - background-color: #f5f1e4; -} -h1.title { - padding-bottom: 4px; - margin-bottom: 15px; - font-family: "Helvetica", Verdana, sans-serif; - font-size: 24px; - font-weight: 400; - line-height: 30px; - text-align: left; - text-rendering: optimizeLegibility; - border-bottom: 1px solid #888; -} -.h2footer { - margin: -45px 5px 8px 0; - line-height: 18px; -} -.h2footer select { - margin-top: -6px; - margin-bottom: -6px; -} -.h2footer span { - padding: 3px 5px; -} -/* quality tags */ -span.quality { - display: inline-block; - padding: 2px 4px; - font: bold 1em/1.2em verdana, sans-serif; - color: #fff; - text-align: center; - background: none repeat scroll 0 0 #999; - -webkit-border-radius: 4px; - -moz-border-radius: 4px; - border-radius: 4px; -} -span.Custom { - background: none repeat scroll 0 0 #449; - /* purplish blue */ -} -span.HD { - background: none repeat scroll 0 0 #008fbb; - /* greenish blue */ -} -span.HD720p { - background: none repeat scroll 0 0 #494; - /* green */ -} -span.HD1080p { - background: none repeat scroll 0 0 #499; - /* blue */ -} -span.SD { - background: none repeat scroll 0 0 #944; - /* red */ -} -span.Any { - background: none repeat scroll 0 0 #444; - /* black */ -} -span.RawHD { - background: none repeat scroll 0 0 #999944; - /* dark orange */ -} -/* unused boolean tags */ -span.false { - color: #933; - /* red */ -} -span.true { - color: #696; - /* green */ -} - -/* ======================================================================= -Lib overrides -- global (except jui/bootstrap) -========================================================================== */ -/* pines notify related */ -div.ui-pnotify { - width: auto !important; - max-width: 550px; - min-width: 340px; -} -/* qTip2 related */ -.ui-tooltip-sb .ui-tooltip-titlebar a { - color: #222; - text-decoration: none; -} -.ui-tooltip, .qtip { - max-width: 500px !important; -} - -/* ======================================================================= -bootstrap overrides -========================================================================== */ -a { - color: #333; -} -img { - max-width: none; - /* fixes IE8 */ -} -input, textarea, select, .uneditable-input { - width: auto; - color: #000; -} -select:focus, -.btn:focus { - outline: none; -} -input[disabled], select[disabled], textarea[disabled], input[readonly], select[readonly], textarea[readonly] { - background-color: #efefef; - border-color: #ccc; -} -.btn:focus, -.btn:active { - border: 1px solid #4d90fe; - outline: none; - -moz-box-shadow: none; - box-shadow: none; -} -.btn-primary:focus, -.btn-warning:focus, -.btn-danger:focus, -.btn-success:focus, -.btn-info:focus, -.btn-inverse:focus { - border: 1px solid transparent; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.75) inset; -} -.alert { - font-size: 15px; - text-align: center; -} -.navbar, label, legend, form, input, textarea, select, .uneditable-input { - margin-bottom: 0; -} -.navbar .nav .active > a, -.navbar .nav .active > a:hover { - background-color: transparent; -} -.navbar .nav > li > a { - padding: 7px 10px 9px; - color: #ddd; -} -.nav > li.active > a { - padding: 3px 10px 6px; - margin-top: 4px; - margin-bottom: 3px; - color: #fff; - background-color: #4E3D27 !important; - -webkit-border-radius: 5px; - -moz-border-radius: 5px; - border-radius: 5px; -} -.navbar-inner { - min-height: 36px; - background-color: #614e35; - background-image: -ms-linear-gradient(top, #67543b, #57442b); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#67543b), to(#57442b)); - background-image: -webkit-linear-gradient(top, #67543b, #57442b); - background-image: -o-linear-gradient(top, #67543b, #57442b); - background-image: linear-gradient(top, #67543b, #57442b); - background-image: -moz-linear-gradient(top, #67543b, #57442b); - background-repeat: repeat-x; - -webkit-border-radius: 0; - -moz-border-radius: 0; - border-radius: 0; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#67543b', endColorstr='#57442b', GradientType=0); - -moz-box-shadow: none; - box-shadow: none; -} -.navbar .navbar-text { - line-height: 36px; -} -.navbar .divider-vertical { - height: 36px; - margin: 0 5px; - background-color: #57442b; - border-right: 1px solid #614e35; -} -.dropdown-menu li > a:hover, -.dropdown-menu .active > a, -.dropdown-menu .active > a:hover { - color: #fff; - text-decoration: none; - background-color: #333; -} -ul.nav .dropdown-menu .divider { - margin: 5px 1px; -} -/* -.dropdown-menu li > a:hover > [class^="icon-"], -.dropdown-menu li > a:hover > [class*=" icon-"] { - background-image: url("../images/glyphicons-halflings-white.png"); -} -*/ -/* ------------------------------------------ */ - -/* bootstrap mod to show menu on hover instead of click */ -.dropdown-menu .sub-menu { - position: absolute; - top: 0; - left: 100%; - margin-top: -1px; - visibility: hidden; -} -.dropdown-menu li:hover .sub-menu { - visibility: visible; -} -.dropdown:hover .dropdown-menu { - display: block; -} -.nav-tabs .dropdown-menu, .nav-pills .dropdown-menu, .navbar .dropdown-menu { - margin-top: 0; -} -.navbar .sub-menu:before { - top: 10px; - left: -7px; - border-top: 7px solid transparent; - border-right: 7px solid rgba(0, 0, 0, 0.2); - border-bottom: 7px solid transparent; - border-left: none; -} -.navbar .sub-menu:after { - top: 11px; - left: -6px; - border-top: 6px solid transparent; - border-right: 6px solid #fff; - border-bottom: 6px solid transparent; - border-left: none; -} - -/* bootstrap-progressbar -- unused currently -.progress { - border: 1px solid #aaa; - background-color: #fcfcfc; - background-image: -moz-linear-gradient(top, #f9f9f9, #ffffff); - background-image: -ms-linear-gradient(top, #f9f9f9, #ffffff); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#f9f9f9), to(#ffffff)); - background-image: -webkit-linear-gradient(top, #f9f9f9, #ffffff); - background-image: -o-linear-gradient(top, #f9f9f9, #ffffff); - background-image: linear-gradient(top, #f9f9f9, #ffffff); - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#f9f9f9', endColorstr='#ffffff', GradientType=0); - margin-bottom: 0; -} -.progress-sb .bar { - background-color: #419ab5; - background-image: -moz-linear-gradient(top, #51a9c4, #2d88a1); - background-image: -ms-linear-gradient(top, #51a9c4, #2d88a1); - background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#51a9c4), to(#2d88a1)); - background-image: -webkit-linear-gradient(top, #51a9c4, #2d88a1); - background-image: -o-linear-gradient(top, #51a9c4, #2d88a1); - background-image: linear-gradient(top, #51a9c4, #2d88a1); - background-repeat: repeat-x; - filter: progid:dximagetransform.microsoft.gradient(startColorstr='#51a9c4', endColorstr='#2d88a1', GradientType=0); -} -*/ diff --git a/data/css/superfish.css b/data/css/superfish.css new file mode 100644 index 0000000000..90a7564a59 --- /dev/null +++ b/data/css/superfish.css @@ -0,0 +1,285 @@ +/* Variables *//* Mixins */ +/*** ESSENTIAL STYLES ***/ +.sf-menu ul { + background: #F5F1E4; + position: absolute; + top: -999em; + padding: 0; + -moz-border-radius-bottomleft: 5px; + -moz-border-radius-bottomright: 5px; + -webkit-border-bottom-right-radius: 5px; + -webkit-border-bottom-left-radius: 5px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + border: 1px solid #ccc; + width: 10em; + /* left offset of submenus need to match (see below) */ + +} +.sf-menu ul li a { + padding-left: 28px; +} +.sf-menu ul li a img { + position: absolute; + margin-top: -1px; + margin-left: -22px; + z-index: 99; +} +.sf-menu ul li { + width: 100%; +} +.sf-menu li.spacer, +.sf-menu li.spacer:hover { + background-color: #57442B; + width: 15px; +} +.sf-menu .first { + margin-left: 0px; +} +.sf-menu .navIcon { + padding: 0.6em 1em 0.55em; +} +.sf-menu li:hover { + visibility: inherit; + /* fixes IE7 'sticky bug' */ + +} +.sf-menu li { + float: left; + position: relative; +} +.sf-menu a { + display: block; + position: relative; +} +.sf-menu li:hover ul, +.sf-menu li.sfHover ul { + left: 0; + top: 3.2em; + /* match top ul list item height */ + + z-index: 99; +} +ul.sf-menu li:hover li ul, +ul.sf-menu li.sfHover li ul { + top: -999em; +} +ul.sf-menu li li:hover ul, +ul.sf-menu li li.sfHover ul { + left: 10em; + /* match ul width */ + + top: 0; +} +ul.sf-menu li li:hover li ul, +ul.sf-menu li li.sfHover li ul { + top: -999em; +} +ul.sf-menu li li li:hover ul, +ul.sf-menu li li li.sfHover ul { + left: 10em; + /* match ul width */ + + top: 0; +} +.sf-menu li.current > a { + color: #bde433; +} +.sf-menu { + float: left; + /*margin-bottom: 1em;*/ + + line-height: 1em; +} +.sf-menu a { + border-right: 1px solid #ccc; + padding: .75em 1em; + text-decoration: none; +} +.sf-menu li a { + border: 1px solid transparent; + color: #FFFFFF; + display: block; + padding-bottom: 12px; + padding-top: 12px; + padding-left: 10px; + padding-right: 10px; + font-size: 15px; + font-weight: normal; + text-shadow: 1px 1px 0 #000; + text-transform: capitalize; +} +.sf-menu li a.log { + font-size: 11px; + padding-top: 10px; + padding-left: 15px; + padding-bottom: 11px; + line-height: 19px; + padding-right: 23px; +} +.sf-menu li a.config { + height: 28px; + width: 10px; +} +.sf-menu li a.config img { + left: -7px; + position: relative; + top: -14px; +} +.sf-menu li li a, +.sf-menu li li li a { + text-shadow: none; +} +.sf-menu a, +.sf-menu a:visited { + /* visited pseudo selector so IE6 applies text colour*/ + + color: #FFFFFF; +} +.sf-menu li { + display: block; + float: left; + margin: 8px 0 0; + text-align: center; +} +.sf-menu li li { + padding: 0; + margin: 0; + text-align: left; + /* alt row light brown */ +} +.sf-menu li li li { + background: #F5F1E4; + /* even row tan */ +} +.sf-menu li li a, +.sf-menu li li a:visited { + color: #000; +} +.sf-menu li li a:hover { + color: #343434; +} +.sf-menu li li li a, +.sf-menu li li li a:visited { + color: #000; +} +.sf-menu li li li a:hover { + color: #343434; +} +.sf-menu li:hover, +.sf-menu li.sfHover, +.sf-menu a:focus, +.sf-menu a:hover, +.sf-menu a:active { + outline: 0; +} +.sf-menu li a:hover { + background-image: -moz-linear-gradient(#777777, #555555) !important; + background-image: linear-gradient(#777777, #555555) !important; + background-image: -webkit-linear-gradient(#777777, #555555) !important; + background-image: -o-linear-gradient(#777777, #555555) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#777777, endColorstr=#555555) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#777777, endColorstr=#555555) !important; + border: 1px solid #777777; + border-radius: 3px 3px 3px 3px; + -moz-box-shadow: 0 1px 0 #888888 inset; + -webkit-box-shadow: 0 1px 0 #888888 inset; + -o-box-shadow: 0 1px 0 #888888 inset; + box-shadow: 0 1px 0 #888888 inset; +} +.sf-menu li ul li a { + font-size: 14px; + font-weight: normal; +} +.sf-menu li ul li a:hover { + background-image: -moz-linear-gradient(#555555, #333333) !important; + background-image: linear-gradient(#555555, #333333) !important; + background-image: -webkit-linear-gradient(#555555, #333333) !important; + background-image: -o-linear-gradient(#555555, #333333) !important; + filter: progid:dximagetransform.microsoft.gradient(startColorstr=#777777, endColorstr=#555555) !important; + -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#777777, endColorstr=#555555) !important; + color: #FFF !important; + text-shadow: none; + border: 1px solid transparent; + -moz-border-radius: 0; + -webkit-border-radius: 0; + border-radius: 0; + -moz-box-shadow: none; + -webkit-box-shadow: none; + -o-box-shadow: none; + box-shadow: none; +} +/*** arrows **/ +.sf-menu a.sf-with-ul { + padding-right: 1.8em; + min-width: 1px; + /* trigger IE7 hasLayout so spans position accurately */ + +} +.sf-sub-indicator { + position: absolute; + display: block; + right: .75em; + top: 1.05em; + /* IE6 only */ + + width: 10px; + height: 10px; + text-indent: -999em; + overflow: hidden; + /* + background: url('/images/arrows.png') no-repeat -10px -100px; /* 8-bit indexed alpha png. IE6 gets solid image only */ + +} +a > .sf-sub-indicator { + /* give all except IE6 the correct values */ + + top: 14px; + background-position: 0 -100px; + /* use translucent arrow for modern browsers*/ + +} +/* apply hovers to modern browsers */ +a:focus > .sf-sub-indicator, +a:hover > .sf-sub-indicator, +a:active > .sf-sub-indicator, +li:hover > a > .sf-sub-indicator, +li.sfHover > a > .sf-sub-indicator { + background-position: -10px -100px; + /* arrow hovers for modern browsers*/ + +} +/* point right for anchors in subs */ +.sf-menu ul .sf-sub-indicator { + background-position: -10px 0; +} +.sf-menu ul a > .sf-sub-indicator { + background-position: 0 0; +} +/* apply hovers to modern browsers */ +.sf-menu ul a:focus > .sf-sub-indicator, +.sf-menu ul a:hover > .sf-sub-indicator, +.sf-menu ul a:active > .sf-sub-indicator, +.sf-menu ul li:hover > a > .sf-sub-indicator, +.sf-menu ul li.sfHover > a > .sf-sub-indicator { + background-position: -10px 0; + /* arrow hovers for modern browsers*/ + +} +/*** shadows for all but IE6 ***/ +.sf-shadow ul { + /* + background: url('/images/shadow.png') no-repeat bottom right; +*/ + + padding: 0 8px 9px 0; + -moz-border-radius-bottomleft: 17px; + -moz-border-radius-topright: 17px; + -webkit-border-top-right-radius: 17px; + -webkit-border-bottom-left-radius: 17px; +} +.sf-shadow ul.sf-shadow-off { + background: transparent; +} diff --git a/data/css/superfish.less b/data/css/superfish.less new file mode 100644 index 0000000000..9000efed77 --- /dev/null +++ b/data/css/superfish.less @@ -0,0 +1,211 @@ +// Config +@import "config.less"; + +/*** ESSENTIAL STYLES ***/ + +.sf-menu ul { + background: #F5F1E4; + position: absolute; + top: -999em; + padding: 0; + border: 1px solid #ccc; + width: 10em; /* left offset of submenus need to match (see below) */ +} +.sf-menu ul li a {padding-left: 28px;} +.sf-menu ul li a img {position:absolute;margin-top:-2px;margin-left:-22px;z-index:99;} + +.sf-menu ul li { + width: 100%; +} +.sf-menu li.spacer,.sf-menu li.spacer:hover { + background-color:#57442B; + width:15px; +} +.sf-menu .first { +margin-left:0px; +} +.sf-menu .navIcon { + padding: 0.6em 1em 0.55em; +} +.sf-menu li:hover { + visibility: inherit; /* fixes IE7 'sticky bug' */ +} +.sf-menu li { + float: left; + position: relative; +} +.sf-menu a { + display: block; + position: relative; +} +.sf-menu li:hover ul, +.sf-menu li.sfHover ul { + left: 0; + top: 3.2em; /* match top ul list item height */ + z-index: 99; +} +ul.sf-menu li:hover li ul, +ul.sf-menu li.sfHover li ul { + top: -999em; +} +ul.sf-menu li li:hover ul, +ul.sf-menu li li.sfHover ul { + left: 10em; /* match ul width */ + top: 0; +} +ul.sf-menu li li:hover li ul, +ul.sf-menu li li.sfHover li ul { + top: -999em; +} +ul.sf-menu li li li:hover ul, +ul.sf-menu li li li.sfHover ul { + left: 10em; /* match ul width */ + top: 0; +} + +.sf-menu li.current > a { + color: @swatch-green; +} + + +.sf-menu { + float: left; + /*margin-bottom: 1em;*/ + line-height: 1em; +} +.sf-menu a { + border-right: 1px solid #ccc; + padding: .75em 1em; + text-decoration:none; +} +.sf-menu li a { + border: 1px solid transparent; + color: #FFFFFF; + display: block; + padding-bottom: 12px; + padding-top: 12px; + padding-left: 10px; + padding-right: 10px; + font-size: 15px; + font-weight: normal; + text-shadow: 1px 1px 0 #000; + text-transform: capitalize; +} +.sf-menu li a.log { + font-size: 11px; + padding-top: 10px; + padding-left: 15px; + padding-bottom: 11px; + line-height: 19px; + padding-right: 23px; +} + +.sf-menu li a.config { + height: 28px; + width: 10px; +} +.sf-menu li a.config img { + left: -7px; + position: relative; + top: -14px; +} +.sf-menu li li a, .sf-menu li li li a { + text-shadow: none; +} +.sf-menu a, .sf-menu a:visited { /* visited pseudo selector so IE6 applies text colour*/ + color: #FFFFFF; +} +.sf-menu li { display: block; + float: left; + margin: 8px 0 0; + text-align: center;} +.sf-menu li li { padding: 0; margin: 0; text-align: left; /* alt row light brown */ } +.sf-menu li li li { background: #F5F1E4; /* even row tan */ } + +.sf-menu li li a,.sf-menu li li a:visited { color: #000; } +.sf-menu li li a:hover { color: #343434; } + +.sf-menu li li li a,.sf-menu li li li a:visited { color: #000; } +.sf-menu li li li a:hover { color: #343434; } + + +.sf-menu li:hover, .sf-menu li.sfHover, +.sf-menu a:focus, .sf-menu a:hover, .sf-menu a:active { + outline: 0; +} +.sf-menu li a:hover { + //-moz-transition: color 0.2s ease-in 0s; + .gradient(#777777, #555555); + border: 1px solid #777777; + border-radius: 3px 3px 3px 3px; + .shadow(0 1px 0 #888888 inset); +} +.sf-menu li ul li a { + font-size: 14px; + font-weight: normal; +} +.sf-menu li ul li a:hover { + .gradient(#555555, #333333); + color: #FFF !important; + text-shadow: none; + border: 1px solid transparent; + .rounded(0); + .shadow(none); +} +/*** arrows **/ +.sf-menu a.sf-with-ul { + padding-right: 1.8em; + min-width: 1px; /* trigger IE7 hasLayout so spans position accurately */ +} +.sf-sub-indicator { + position: absolute; + display: block; + right: .75em; + top: 1.05em; /* IE6 only */ + width: 10px; + height: 10px; + text-indent: -999em; + overflow: hidden; +/* + background: url('/images/arrows.png') no-repeat -10px -100px; /* 8-bit indexed alpha png. IE6 gets solid image only */ + +} +a > .sf-sub-indicator { /* give all except IE6 the correct values */ + top: 14px; + background-position: 0 -100px; /* use translucent arrow for modern browsers*/ +} +/* apply hovers to modern browsers */ +a:focus > .sf-sub-indicator, +a:hover > .sf-sub-indicator, +a:active > .sf-sub-indicator, +li:hover > a > .sf-sub-indicator, +li.sfHover > a > .sf-sub-indicator { + background-position: -10px -100px; /* arrow hovers for modern browsers*/ +} + +/* point right for anchors in subs */ +.sf-menu ul .sf-sub-indicator { background-position: -10px 0; } +.sf-menu ul a > .sf-sub-indicator { background-position: 0 0; } +/* apply hovers to modern browsers */ +.sf-menu ul a:focus > .sf-sub-indicator, +.sf-menu ul a:hover > .sf-sub-indicator, +.sf-menu ul a:active > .sf-sub-indicator, +.sf-menu ul li:hover > a > .sf-sub-indicator, +.sf-menu ul li.sfHover > a > .sf-sub-indicator { + background-position: -10px 0; /* arrow hovers for modern browsers*/ +} + +/*** shadows for all but IE6 ***/ +.sf-shadow ul { +/* + background: url('/images/shadow.png') no-repeat bottom right; +*/ + padding: 0 8px 9px 0; + -moz-border-radius-bottomleft: 17px; + -moz-border-radius-topright: 17px; + -webkit-border-top-right-radius: 17px; + -webkit-border-bottom-left-radius: 17px; +} +.sf-shadow ul.sf-shadow-off { + background: transparent; +} \ No newline at end of file diff --git a/data/css/tablesorter.less b/data/css/tablesorter.less new file mode 100644 index 0000000000..da90e01e3e --- /dev/null +++ b/data/css/tablesorter.less @@ -0,0 +1,91 @@ +// Config +@import "config.less"; + +/* SB Theme */ +table.tablesorter { + width: 100%; + margin-left:auto; + margin-right:auto; + text-align:left; + color: #000; + background-color: #fff; + border-spacing: 0; +} +table.tablesorter td { + font-size: 12px; + padding: 8px 10px; +} +/* remove extra border from left edge */ +table.tablesorter th:first-child, +table.tablesorter td:first-child { + border-left: none; +} +table.tablesorter th { + border-collapse: collapse; + .gradient(#555555,#333333); + color: #fff; +} +table.tablesorter .tablesorter-header { +/* background-image: url(../images/tablesorter/bg.gif); */ + background-repeat: no-repeat; + background-position: center right; + //padding: 4px 18px 4px 4px; + cursor: pointer; +} +table.tablesorter .tablesorter-header-inner { + //background: url(../images/tablesorter/bg.gif) no-repeat right center; + padding: 0px 18px 0px 4px; +} +table.tablesorter th.tablesorter-headerSortUp .tablesorter-header-inner { + background: url(../images/tablesorter/asc.gif) no-repeat right center; +} +table.tablesorter th.tablesorter-headerSortDown .tablesorter-header-inner { + background: url(../images/tablesorter/desc.gif) no-repeat right center; +} +table.tablesorter th.tablesorter-headerSortUp { + .gradient(#777777, #555555); + color: #FFFFFF; +/* background-image: url(../images/tablesorter/asc.gif); */ +} +table.tablesorter th.tablesorter-headerSortDown { + .gradient(#777777, #555555); + color: #FFFFFF; +/* background-image: url(../images/tablesorter/desc.gif); */ +} + +/* Zebra Widget - row alternating colors */ +table.tablesorter tr.odd td { + background-color: #F5F1E4; +} +table.tablesorter tr.even td { + background-color: #fbf9f3; +} +/* filter widget */ +table.tablesorter input.tablesorter-filter { + width: 98%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +table.tablesorter tr.tablesorter-filter, +table.tablesorter tr.tablesorter-filter td { + text-align: center; + background: #eee; +} +/* optional disabled input styling */ +table.tablesorter input.tablesorter-filter.disabled { + display: none; +} + + +/* xtra css for sb */ +.tablesorter-header-inner { + text-align: left; + white-space: nowrap; + padding: 0 2px; +} +tr.tablesorter-stickyHeader { + background-color: #fff; + padding: 2px 0; +} + diff --git a/data/css/token-input-facebook.css b/data/css/token-input-facebook.css new file mode 100644 index 0000000000..a166be82b3 --- /dev/null +++ b/data/css/token-input-facebook.css @@ -0,0 +1,122 @@ +/* Example tokeninput style #2: Facebook style */ +ul.token-input-list-facebook { + overflow: hidden; + height: auto !important; + height: 1%; + width: auto; + border: 1px solid #8496ba; + cursor: text; + font-size: 12px; + font-family: Verdana !important; + min-height: 1px; + z-index: 999; + margin: 0 !important; + padding: 0 !important; + background-color: #fff; + list-style-type: none; +/* clear: left; */ +} + +ul.token-input-list-facebook li input { + border: 0 !important; + width: 100px !important; + padding: 3px 8px !important; + background-color: white; + margin: 2px 0 !important; + -webkit-appearance: caret; +} + +li.token-input-token-facebook { + overflow: hidden; + height: auto !important; + height: 15px; + margin: 3px !important; + padding: 1px 3px !important; + background-color: #eff2f7; + color: #000; + cursor: default; + border: 1px solid #ccd5e4; + font-size: 11px !important; + border-radius: 5px; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + float: left; + white-space: nowrap; +} + +li.token-input-token-facebook p { + display: inline; + padding: 0 !important; + margin: 0 !important; +} + +li.token-input-token-facebook span { + color: #a6b3cf; + margin-left: 5px; + font-weight: bold; + cursor: pointer; +} + +li.token-input-selected-token-facebook { + background-color: #5670a6; + border: 1px solid #3b5998; + color: #fff; +} + +li.token-input-input-token-facebook { + float: left; + margin: 0; + padding: 0; + list-style-type: none; +} + +div.token-input-dropdown-facebook { + position: absolute; + width: auto; + background-color: #fff; + overflow: hidden; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; + border-bottom: 1px solid #ccc; + cursor: default; + font-size: 11px; + font-family: Verdana; + z-index: 1; +} + +div.token-input-dropdown-facebook p { + margin: 0; + padding: 5px; + font-weight: bold; + color: #777; +} + +div.token-input-dropdown-facebook ul { + margin: 0; + padding: 0; +} + +div.token-input-dropdown-facebook ul li { + background-color: #fff; + padding: 3px; + margin: 0; + list-style-type: none; +} + +div.token-input-dropdown-facebook ul li.token-input-dropdown-item-facebook { + background-color: #fff; +} + +div.token-input-dropdown-facebook ul li.token-input-dropdown-item2-facebook { + background-color: #fff; +} + +div.token-input-dropdown-facebook ul li em { + font-weight: bold; + font-style: normal; +} + +div.token-input-dropdown-facebook ul li.token-input-selected-dropdown-item-facebook { + background-color: #3b5998; + color: #fff; +} \ No newline at end of file diff --git a/data/css/token-input-mac.css b/data/css/token-input-mac.css new file mode 100644 index 0000000000..18522f05f3 --- /dev/null +++ b/data/css/token-input-mac.css @@ -0,0 +1,204 @@ +/* Example tokeninput style #2: Mac Style */ +fieldset.token-input-mac { + position: relative; + padding: 0; + margin: 5px 0; + background: #fff; + width: 400px; + border: 1px solid #A4BDEC; + border-radius: 10px; + -moz-border-radius: 10px; + -webkit-border-radius: 10px; +} + +fieldset.token-input-mac.token-input-dropdown-mac { + border-radius: 10px 10px 0 0; + -moz-border-radius: 10px 10px 0 0; + -webkit-border-radius: 10px 10px 0 0; + box-shadow: 0 5px 20px 0 rgba(0,0,0,0.25); + -moz-box-shadow: 0 5px 20px 0 rgba(0,0,0,0.25); + -webkit-box-shadow: 0 5px 20px 0 rgba(0,0,0,0.25); +} + +ul.token-input-list-mac { + overflow: hidden; + height: auto !important; + height: 1%; + cursor: text; + font-size: 12px; + font-family: Verdana; + min-height: 1px; + z-index: 999; + margin: 0; + padding: 3px; + background: transparent; +} + +ul.token-input-list-mac.error { + border: 1px solid #C52020; +} + +ul.token-input-list-mac li { + list-style-type: none; +} + +li.token-input-token-mac p { + display: inline; + padding: 0; + margin: 0; +} + +li.token-input-token-mac span { + color: #a6b3cf; + margin-left: 5px; + font-weight: bold; + cursor: pointer; +} + +/* TOKENS */ + +li.token-input-token-mac { + font-family: "Lucida Grande", Arial, serif; + font-size: 9pt; + line-height: 12pt; + overflow: hidden; + height: 16px; + margin: 3px; + padding: 0 10px; + background: none; + background-color: #dee7f8; + color: #000; + cursor: default; + border: 1px solid #a4bdec; + border-radius: 15px; + -moz-border-radius: 15px; + -webkit-border-radius: 15px; + float: left; +} + +li.token-input-highlighted-token-mac { + background-color: #bbcef1; + border: 1px solid #598bec; + color: #000; +} + +li.token-input-selected-token-mac { + background-color: #598bec; + border: 1px solid transparent; + color: #fff; +} + +li.token-input-highlighted-token-mac span.token-input-delete-token-mac { + color: #000; +} + +li.token-input-selected-token-mac span.token-input-delete-token-mac { + color: #fff; +} + +li.token-input-input-token-mac { + border: none; + background: transparent; + float: left; + padding: 0; + margin: 0; +} + +li.token-input-input-token-mac input { + border: 0; + width: 100px; + padding: 3px; + background-color: transparent; + margin: 0; +} + +div.token-input-dropdown-mac { + position: absolute; + border: 1px solid #A4BDEC; + border-top: none; + left: -1px; + right: -1px; + background-color: #fff; + overflow: hidden; + cursor: default; + font-size: 10pt; + font-family: "Lucida Grande", Arial, serif; + padding: 5px; + border-radius: 0 0 10px 10px; + -moz-border-radius: 0 0 10px 10px; + -webkit-border-radius: 0 0 10px 10px; + box-shadow: 0 5px 20px 0 rgba(0,0,0,0.25); + -moz-box-shadow: 0 5px 20px 0 rgba(0,0,0,0.25); + -webkit-box-shadow: 0 5px 20px 0 rgba(0,0,0,0.25); + clip:rect(0px, 1000px, 1000px, -10px); +} + +div.token-input-dropdown-mac p { + font-size: 8pt; + margin: 0; + padding: 0 5px; + font-style: italic; + color: #aaa; +} + +div.token-input-dropdown-mac h3.token-input-dropdown-category-mac { + font-family: "Lucida Grande", Arial, serif; + font-size: 10pt; + font-weight: bold; + border: none; + padding: 0 5px; + margin: 0; +} + +div.token-input-dropdown-mac ul { + margin: 0; + padding: 0; +} + +div.token-input-dropdown-mac ul li { + list-style-type: none; + cursor: pointer; + background: none; + background-color: #fff; + margin: 0; + padding: 0 0 0 25px; +} + +div.token-input-dropdown-mac ul li.token-input-dropdown-item-mac { + background-color: #fff; +} + +div.token-input-dropdown-mac ul li.token-input-dropdown-item-mac.odd { + background-color: #ECF4F9; + border-radius: 15px; + -moz-border-radius: 15px; + -webkit-border-radius: 15px; +} + +div.token-input-dropdown-mac ul li.token-input-dropdown-item-mac span.token-input-dropdown-item-description-mac { + float: right; + font-size: 8pt; + font-style: italic; + padding: 0 10px 0 0; + color: #999; +} + +div.token-input-dropdown-mac ul li strong { + font-weight: bold; + text-decoration: underline; + font-style: none; +} + +div.token-input-dropdown-mac ul li.token-input-selected-dropdown-item-mac, +div.token-input-dropdown-mac ul li.token-input-selected-dropdown-item-mac.odd { + background-color: #598bec; + color: #fff; + border-radius: 15px; + -moz-border-radius: 15px; + -webkit-border-radius: 15px; +} + +div.token-input-dropdown-mac ul li.token-input-selected-dropdown-item-mac span.token-input-dropdown-item-description-mac, +div.token-input-dropdown-mac ul li.token-input-selected-dropdown-item-mac.odd span.token-input-dropdown-item-description-mac { + color: #fff; +} diff --git a/data/images/bg.gif b/data/images/bg.gif new file mode 100644 index 0000000000000000000000000000000000000000..53f4abc902ed0178d4b83f95b984eab7c8781609 GIT binary patch literal 48 xcmZ?wbhEHbWMyDwXkcJ?_wL=@yLT0TvM_*v4u}BBFfg&V2u|O&f}M-O8URFb3&#Ke literal 0 HcmV?d00001 diff --git a/data/images/changelog16.png b/data/images/changelog16.png new file mode 100644 index 0000000000000000000000000000000000000000..3f6807c06dc27b8d5a162160339010dff54fbb7b GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5XoIPC}Ln>}1{rUgjo>{el^;kkmipoiq1xI@{*b*3fetb#Z z(J;X&hE3S0Vd4t|iPD1|WsJhitqrX%&y%etUV6hLkibyTAZQ*sUGpK(I0jEwKbLh* G2~7ane>CU- literal 0 HcmV?d00001 diff --git a/data/images/flags/ad.png b/data/images/flags/ad.png new file mode 100644 index 0000000000000000000000000000000000000000..625ca84f9ec596848d4b967b5556fda897ca7183 GIT binary patch literal 643 zcmV-}0(||6P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!6-h)vRCwBA zOs~HF<HtV+28MtC7=HX>S}{rU-#^xW|9%5S{`_G8k=zVG=|5luWB>#JF#yj01oZa& zTu&JQ008~}|NQ&{0055x0sR95`v3p?1_t~7{`v!N`v3d-{`&g=`T6amqXIBA$0h)T zKokWRkpoMx|Go@O4FvhR>O1p+i7`B6t^3)y2dJ<#?4I?d4x-E}Az98Z`2`TmzkmP! z{`(J9{pauh-+zJD{JOYLhW+QSzkh#x`Y&wke*WLjpZ~u9`2XuC<FB7UCjkTy%b!0$ zE&u-f{rBh3|9}7f|Ns9}<imU6E1$oGePO)F`S0J)f4_eG{|+J<fByQ#zyJ_HOh0}B zRRdlB@Asd7zkUOC{P@B83#gjA^A*r3|A6TG_kTaX11$kr#lQd%KtK(DfDQqw{{0(- z9$w>r@{^OJ|MkZb_UGR@Z=V<Z|MSORU^xEz3G_7327mwpY4`(H{p<JNA3y&~t3CYl z`{xmB)zc>>fB*iaq<8P{*B}2u-uMnu#J~U$KrBDL0bT$1_irEqiZcM6WEF7g<|*+% zzkjHi-TwRc=f7_t9|K*?`1?07lmG&V<<qwxzyJK_<zWHpXJZA1us8$5Pj$0jz<~Jo zPlk!<7o$ATmmt+3k1{X-1Q4UX=l)l(eth}y>)V$<e?KuiybuhGa$p?8(LY0IOoNgG d0|P*S0RVW+clR-Rn4bUu002ovPDHLkV1izoK(7D* literal 0 HcmV?d00001 diff --git a/data/images/flags/ae.png b/data/images/flags/ae.png new file mode 100644 index 0000000000000000000000000000000000000000..ef3a1ecfccdfe9cf7e9fc086a2c6c010f7977ed8 GIT binary patch literal 408 zcmV;J0cZY+P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzDoI2^RCwBA z{Lg>@{}>pUYZ(~+fDw@M3(WY1!T<=sp}f-o0K!lJ3<>Jc!&VtMslE3ph2^H3FGe(F z4|^}slF1@l1Nxc}^5hjjU=0la|37)k@b@neJ^1|l@87?_{{z(l6@kcKe}Db@_Y267 z<&g#201!YdV6*>2R0EZ*bYA%%2Vi7me5m>mAb?naPGtnD20MY__n-f`0mzg8{s9CK z*hzmtW)s%H$oS{aAAkS?JLxaT`2W9u;n%<jwge!6z)k`h|LYgXRlk1WaSxCLItd_v zKpL1q#{Xvc4GJ4;YwKUXe*OOa8%ly%fByXb^XIRMips@{7XbnYq=D)89|lQDke)w( zW&Zqz0HAXrBoO?C0iZnq0mS$f9H($3%s>DD1Q-B%#%TuGpFSS|0000<MNUMnLSTaC C)2r10 literal 0 HcmV?d00001 diff --git a/data/images/flags/af.png b/data/images/flags/af.png new file mode 100644 index 0000000000000000000000000000000000000000..a4742e299f517aee16c248e40eccad39ac34c9e9 GIT binary patch literal 604 zcmV-i0;BzjP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz?ny*JRCwBA z)YR1c^yw1=82tM6>&5HW|5#c7{r(N){s9YCi84$~U|{$Glm>AD0*K|^yLU}ZO+eP) ze;~;4`#0mCzs$dX{r~-&?f370zkUTM{{OB1_1E8DKmYz*JbN)f0I>i8Q1#1Kum1f0 z{pZggpt}FRe*ORX>;JEx|9}4c_w(m__W!pfKK=atLxM{ZXbC_7vHbh@@9*EgK-ECy zzkh)Y{?EYhFEQc&-#`EN9{7Lq7*OE*@9)2U|NQmmC(vO40VtezBLF}U2%?OI(TG)& zJC>!~;SYmN-)zb6gc(7A-^Q(#z0vHb9!CJ#T#ii{@&pjek8j`pfX)5|H00+GpnLvt za{MtfWBC4qk%R63j~{=2{QLgr`wyTb*am<AV)^>x2hce{<v+my=*z$N?=p!?{^REP zbNvS6w{O4z18Vy9=NCxBF9v`BVqy3XboTEbKYl>;{QL3a|BhWh#6<u9{=;_VIs?#L zpfi8}1nT+y?>A5bKmf5of*%NgdVm^$Zuo!gI_LQd|G=96g51yW<L~!hz;O8s(f|-Z zAPqouLc+ojPc!`d#qjGV<F8+gKYud*{tXNhK5+&`Ug6(=e*gLR2k2>l0Agf71rLE? q3XWqq@&Lw?Iyk0*94-cc00RK~MQKxxUU$F%0000<MNUMnLSTa4q&7_e literal 0 HcmV?d00001 diff --git a/data/images/flags/ag.png b/data/images/flags/ag.png new file mode 100644 index 0000000000000000000000000000000000000000..556d5504dc28d89be22ec1883f12e8d8c07d5f41 GIT binary patch literal 591 zcmV-V0<iswP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz;Ymb6RCwBA zuw)<&00a=@b_Ry))zxnq82*6)Bg0=F&cB}4fBygbvti$#m+yXm|NiIC@87@w{Nelk zTVc~CfB*n70M7peE&u=*0RNWc<@o*m|Ni}eXZzON{TU7W{{8zhA^R~%`&)YY_xSq% z`}=_%9r^$O0st`p&i?}X0012R|3Cfx{Qds_{`;}M{W&fA6&L#!8T&ji`k=7+|Ni>= z`}|7!`YQYT5C8xIh~*yx1DgQ1_jkSr_QgN{Gn)!7=zkRT=l(yS{y%^Jd$>%yr<u<9 z^JmK192QXlpkjak0%`d7=imQ7f4o+;&vq`J^(OK6k3YZvG66OG`t$$l^E{Scp9@zO zF!KEV_vg<a1_potVgh;`=s%#h<hg#i9-J*Hz#t{YC?(1OL^5IwGQ!M$yZbbGz5#vx z@Aq$@=>P!)vVq~xpZ~xA{{HjZ>hB|)Z~OQoBLBm{yCqA0{k;C`&+mV~|1$jj15^wU zKukakbkF}kzkmk({q_6zmUTZaUWBOraqa5!?(V-pgMR}R{04?0&`AIR#PSE^hrdi> z;*7t4gLVFSJ$J5DTibsorq@%ai5h7B2l^1G@*gM=fUW@uAjYc<48V{DX@ijvE(11_ dg@FMezyQzuBHsGv+i3s*002ovPDHLkV1jTADB=JB literal 0 HcmV?d00001 diff --git a/data/images/flags/ai.png b/data/images/flags/ai.png new file mode 100644 index 0000000000000000000000000000000000000000..74ed29d92616c86757d3c0ec04378301c8f591b4 GIT binary patch literal 643 zcmV-}0(||6P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!6-h)vRCw9| zF#yj00bOXewDSY+0|Ke|#s>ocPy#thD++(+#W%SS0Qvp^`1%0+_yGC)0QUL-|M&m^ z`T_tk0M7pe&iDc7fh@K74M*k&0{8f*88BG;;GY6ve?;&4{{Q>_{{8&^`u+g@|Ns5} z|C?Nv0*Gb#mu+n4z6ZW~#qjy_|6ji&ZeIEMk16oyzH)i@Pk$MNxLARR?a#kwpMS72 zF)}cG2M8dRUN*~81wCz%$A2$f`_IL|A|w6j^RHtc7{2`lYG-KkQ{(*knSqh%3kzr7 zs%s1kKL7#%F#yj00-^B^D;pD^^AqC)4C(?1m>@q26ceiU4*2{4`u_d~@vQ*ykp&A1 z3h$o${r~^~`vQo?Pm-bY@cm79zVQG5%l!B6)Riap%L`bGemnT<@2|iAfl64!%YWS~ z`p0YY_dig_cYpw5WYAmG*lPLk!;jy8|NZ#~MF0Q%{rCG1ko^Di`(Fhkd3F{?py0<J ze}6xGu_3DxAb^+{e*XIN|KBf&>c9Vg{{gA~^#>F<|Nj5KapBR^@Bf~CXSi|t;lF=C z8-4)<5DUX^V9+rDCI3RuUy#}V{s46X!}R~}@4ug&fA|+@;n!b(1zCZ900<x!pbbFt z#Ch5Nf^C2}2^i$RLF|8j|1yBMKsf>KKMX({{sIILBZE*UQbc|SngJt$+`m9Kz!<;5 dBm+Qz0RWL%NC2%sdjkLf002ovPDHLkV1lARHI@JX literal 0 HcmV?d00001 diff --git a/data/images/flags/al.png b/data/images/flags/al.png new file mode 100644 index 0000000000000000000000000000000000000000..92354cb6e257be2cade71cb825027ce8d9efc06d GIT binary patch literal 600 zcmV-e0;m0nP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz>PbXFRCwBA zyv)hK@{fUmi2(>${xJOc&A`II@PmN?BKC`c;SU)710$d~Kmf4-r6niy16BY3`}g0! ze=L9h&Uybegp-Ns@87v^zGQMU|NZla@z1Y+zkdVKmFbHB0*HkH4F0@)&iwE1;&<Od zIhl07{-}HR_0O+g5uA*_-u&x*{i%SP3CLxWkOW!?5I`*d{(;N}p+A3&ng3V5`1IrF z&;P%FasBx->GRLuzkX(OGXMMa``@o$|Ni_2x(y(Jn3#TnHT?el|IhC;U%pHK`}6Df z@2@|9J^lXU=Z~KvfBzi*^!4A*U;lv`e*Fge3Lt=(82$q_`~m9z|ND;$!(WEqze~6o ze}X|JFO$H(U+VvX#{c{avi3L7@c;n?3L&6l7=Hix`}emN<6i~#|7+iUWBL8-&##}m zKKx5zXJr5LA80nvkwCM6egFs{po<uQs)77JKYw!m`PK3AV=(*w4?lhaQ5*+j>9Y?& zL6B;olm7ns#=rm&Kp-3b{RIXY(1{??Ha=$NKfkS+|9@us$Mfg+93j@fzkY#y2y!;i z&kO(o1PWH5>zPHwn7|>;0Mz;WPl13iP!Y(WKYy4&4hBXMSPugOKmaiw2gk!dq!|4J mj$lZ<LSqRm!T=Tl2rvL;`a(cWc8Wa!0000<MNUMnLSTZ1lqpI8 literal 0 HcmV?d00001 diff --git a/data/images/flags/am.png b/data/images/flags/am.png new file mode 100644 index 0000000000000000000000000000000000000000..344a2a86c43d52f490455d0fe582da93e07175b2 GIT binary patch literal 497 zcmV<N0S^9&P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzgGod|RCwBA z{H?0W@Zkdk1pNE=@7J$?fBrE1`o-|~??0db7%{*YKm-tggfYee00e;;QJ>E0R~eis zC&uN{cDQ8`!@ZNLOqwtoG6nhzPx}SV-d6yzFfcIz75{(rngJyDkKrFw8iaoThHCr$ zo8k9wMhS^O3=9AP!~%5B|9}4(ASQtkLN$m1CV&5705X362095KfLKJhW;OjWdGY7l zzrX*$DuLu*pcFC#NdEr)SL!$SY=)lz0mSm_*B_u)|AM4J<Zl#pfByhY0jq{^85n*5 z1P}{2JpRL-4RH(DIsd?3fEW+)KPb2W0*LYZR0bBCuK!=3{{R2y|39GcZv^@8_dgKh zFT+0&mx){E#hj%80mSkT7)U@lkZPcTa6P}ls=?fUe;B~zzyJRjSQr2Th~?iWhX23* zGBArUFaQJX4<q9r21cj~My9`v|H0t~3=p8~&p-b@FaQJ)<HueGhHni2eldUyf{?&4 ngfSQ(+&`d5264F<00ImE80Lq~rp{NU00000NkvXXu0mjfmZj6F literal 0 HcmV?d00001 diff --git a/data/images/flags/an.png b/data/images/flags/an.png new file mode 100644 index 0000000000000000000000000000000000000000..633e4b89fded98256a8d142dfb60a8058f7e6b67 GIT binary patch literal 488 zcmV<E0T=#>P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzdPzh<RCwBA z{P^)B0}}Z6kAXpu{~v@6M9-gtNF)FdKrBEBK0ZDm`Tzfah@Srig$#fH{QvWZ;m@D{ z!ovT-QV{U$*)xCuVu31xs|GS2KKTFpHxwZOMn=XzfBpaj5DU6$Ao=ST(D?tqfEs=w zYXCd=A3y*AF#yj00RRAbc6LtU;`99c{`~v?009B^`2BTsF8==ieSOOT0RQy#`TF|& z`}_VM9}U65h60EQs10Zw*#3V&)ZT9P_s_qE`qQ;Fr+)wWUte$Z>(}33zy1Lc&_o7? zKL7y)bkd){fBym<_v`mxpbeRs`+WRVot-5A{r$IX$CJFg13<HYdcem0WnlOL5I~G? z-@fJN=ZCr;0<5i}J_dT`!UebofS$f{=MF#svHSytJOhdbpgM?Zq)>u{3eX_{0R++j zR}GCkSy{&4zk!krKoTg(2n-k?g31B}5J<zTSFe!c^3T?-keL1h43OKm|1mKEgY(Zn eh!_JvfB^u;X->W!NMq0d0000<MNUMnLSTY9z1-gb literal 0 HcmV?d00001 diff --git a/data/images/flags/ao.png b/data/images/flags/ao.png new file mode 100644 index 0000000000000000000000000000000000000000..bcbd1d6d40d8665ed9b1001c490ce48befabb258 GIT binary patch literal 428 zcmV;d0aN~oP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzPDw;TRCwB4QL#z`K@i<NFLwtP!On18 zaRGw??ZtmcrM;vR1rZ_m2?77Xe1Lv}Wg3fMk?Ifx5t1&A5H7nryW`AmID*46yqS43 z^X5)|^!rYTNU$kH6oNwZAxKFH@EJ=X7e!3L@o*?8aE=}VFt8T2CNBU2U~kOZY!>q< zM7LZb@rX?|r)2RP-+aO3(7q?c3+$-Vv0E)PxM3SDV%@s`#GkZvw_x^WBa~uq3^l}t zWdf(j(=(>^SgETc#5#EZT(4ObRkfxbzP9G;yza0;Ygc8-_*?EP(ca#`l6-Z6D0{tL zQ4|~MCSi!9Q9YkW=V$ix#EqZ!rc?e<p_FCWYPE{2HvkBrs^nl`+6V4=B5$vuDS<a* zjO=#1w4tu+W>WA0TwdVID+3aqrYUXUhCI)Ad5!(cP!BhhW$Ayb2&r8kK!bz*2`~UE W(Ndrek0Jg50000<MNUMnLSTY9OSlOD literal 0 HcmV?d00001 diff --git a/data/images/flags/ar.png b/data/images/flags/ar.png new file mode 100644 index 0000000000000000000000000000000000000000..e5ef8f1fcddb9fa0b89c353430e9640c122445cc GIT binary patch literal 506 zcmV<W0R{evP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzj7da6RCwBA zj6DAT%O3^^_{Z??7ZCqp01Ev2^Y72Ue?R^JML-C|`16mAi2)#hSU&w>NL7TW{{R0! z<G=qv2E)I9|Ns7H{QHmjF9XxxfB(T;5XwFF1t5S}e*9th{g2`O@BbjB{~16MKuiAr z|ML%wKwKzWn4OW~*FS&&VyTyXqbVi~)bk%`%YTqIAOso-0$@Ib2_#t=8AP?;00a<= zI`2PWHU_X_uv#Gfeed5dn}LQhaeMyfaQhDfjEoFs!VCZb#02p!R18A@*z@n-{r`XO z{r|T8KQaLM?H@n@u^_8vfaqYeWBBm~7*78=oRQTt0v-16A3y-HT>JP>i}(MpzaSkj z=eqvqa`^|6{{QbQRQ11q3~Vg_kG}Z}5I`(hw}ncX8D4$|h64jUK>or*=^r$dfI%n0 z$<TR35Fmh9nEw4^WMC3yhX&|hV90`WG6ETYK;a8?Hv>3MKz;!F0w91GjkkV%_X`@4 wKR^hm`X5LwD9*v?4;TUC6Bz$&3;+QJ0QLcJ=WT{TU;qFB07*qoM6N<$f>3bX%K!iX literal 0 HcmV?d00001 diff --git a/data/images/flags/as.png b/data/images/flags/as.png new file mode 100644 index 0000000000000000000000000000000000000000..32f30e4ce4eedd22d4f09c4f3a46c52dd064f113 GIT binary patch literal 647 zcmV;20(kw2P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!8A(JzRCwBA zR8;)`;R6E%{QC9p#}9_Te}T+@U<3g`#<FsTG#7@yYzzzl0RS-o&i@4L>;Rmc0RI30 z|Nj5|{Q>>`0R8>{{QLs@`vM01^D6+%Ap+Y10Qw3Sr3ED)s=c}b05Jg0{{#dC0RR60 z)z$y}`~dv>0Q&p?0Q>ne0Khr|#}EMN1`-ttJvaFq6VTDg`OMJS*4F}vg#m0fQ1$QM z42u6QTCg3K<haMo!O3Ny$Lf^Bz|Mbd$K1zD=iR;ZNIy74*}@YbfLQ+g`p5O}vp35w zMfPi~jK4V)<k_8b7{%oNgTbx6%bs68$|5As#e9pM;q$8xYybho67hAX?WcSH*q^bg zt8%&pGIDYMhk>t8+Li1J#Y_zUd>4Lw{URe1tEjLbKmY(S0M7pd(g2qsJ0|z;{R`~d z`n<6E*3bb41p*Zr{{R344LkeqyaMyV01O!W0PZ0+TjlEQ0st`p&i?@b0Q{Gi0m{n% z`T6-B``1JQx+4kM0{rp_FfRy7P6Qq%_1|&<`|JPx`Uew8u)+%h2<Yj*zkad&{0Y>+ zdiAI7xt}_}e|$CmbI9$<Ho2X7ESzjywx+D1F~3=u*;)QFF+BhXAfOFDySi9@{`&v( z$NwK-^z$b$20@JfKfii0A2DD*%+Gv}O<IQC!FkJzmjD3-)bJe^uYX_+WG*D84>2>u h*D)|iF)#oG7yty8`q2#I8zBGy002ovPDHLkV1j5VEF}N{ literal 0 HcmV?d00001 diff --git a/data/images/flags/at.png b/data/images/flags/at.png new file mode 100644 index 0000000000000000000000000000000000000000..0f15f34f2883c4b4360fc871d7105309f1533282 GIT binary patch literal 403 zcmV;E0c`$>P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzB}qg<RCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`&prl$WjwHQRjfmQ&G0jUOA z@&|<Aj6Z*V{rbfwF8*)(c7On4eEIUFsHiB|mj4W3hz5X4AtX09_uIE`0Rjl(B&aGl z13LiA0t65XLJ?N;k=6ePhRZ*I0Al>Dug_Rm`2Y251|$~)1M2@@6mI}!8O6olw6y^Q z5X--d7nzS8`+x5q12kBmVFD!~j6c5_fMKno0(1^Q0I>i=is|<s21!X!fC5E<a=;J- xvHvhYNU-q`1XK(VK#X5eBN9&>LjXX40RRttS6cG0UZ?;7002ovPDHLkV1fxUnjZiF literal 0 HcmV?d00001 diff --git a/data/images/flags/au.png b/data/images/flags/au.png new file mode 100644 index 0000000000000000000000000000000000000000..a01389a745d51e16b01a9dc0a707572564a17625 GIT binary patch literal 673 zcmV;S0$%-zP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!Gf6~2RCwBA zJYL>>fJ3En$GhGS>sbE%%m3$AD)q?8M9y>88-}kR7#RKlk!P~Y_PLuF7~U~3`~nC7 zF#yj00ZUDdpLsm{7ajP|&HwoK0Usg|6%f4L_{`Mi{rvv-`ukf=Ed&Gs-sA7L!Q7*a zj{*QO0M7pb%?Sw^g@yy{>ihEY{`vU@3=8@G0rvO$i3mOL`~mv-`W+b$Mmr&io5dg< z5v!7q0*L95jt`TzK8Kd(Utv)OSp_aLv){6ccV+Z`{Q2+asKUU&aO3`Kpz6wW8wp`< z28M3{0mSqnB#A*-c*8%1=RD#sSOwMznKA3=e&iEzwo{cA=PgXK`2OQ}gqId83!|%* zA_Kz@fB*n70M7pdECCwp4H&@R`1|(w-}M5x*74i)0}%fAt;XafA{48))#>Z>?C<vx z4+yBW)ZEa+0st`p&i@0;-YG3aF7@#A-unsO`UeF3`snNQ&*=O8|Nq9#<LT}6|NsA2 zODmwR(Fq6z-qF4ShzS&MfByac^DoF=sW!#@*YCf7{{Cb5_xJa&KmY#y`~Ua<&!2yK z<{bO@>D#}*e}Ret0tl$#*RMZ7<Nli)NXtp`1-NPa{Pp|S?>}Jl7Z|M45`5*URzH9L z{rmSnPy;{!u>dsyO%meg+<D}#zMAO4lMiGi_<sHb%KrQF=l}N~zlsvgwUotv{`$LS z=QW_G0Ro7Tfx#CXoj-s&k<cGxuBv0%?fp#*4F7<k3=m)d3iunO7I*l>00000NkvXX Hu0mjfN{&}S literal 0 HcmV?d00001 diff --git a/data/images/flags/aw.png b/data/images/flags/aw.png new file mode 100644 index 0000000000000000000000000000000000000000..a3579c2d621069c8128d7cf16440d5e45a3ab3cd GIT binary patch literal 524 zcmV+n0`vWeP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzo=HSORCwAf z#=!{yAP@t=D2IJmvQ^oT4>^}0h@W9fA9JOn#opKkr#VO<{LPm{QLg}EdH3Vb1Zzpp zqiZ+XNBm%5{`ViKi{bCTe}Dh~2a+HyfB%6Q|9}7eKVjxEfB<6Q<6r}-{{7;G=hB{; zLB+p+|Ni^;|DXT={s75;AoBO$f4@PDf8sps4FCQC1Q64wUw^h*hy4HZXO3U)@85qw zD*pci8U|DWQu_BVi2MUWKoOv;00M{w=;|*lY_o%kegGx^{`&*A{SSok_usF-P)oof z|A86+0*K|;FQAM6fB*Ft<dT2?A#VK(Cc*ZAodjWkeGd>oEI&bZG5mq328#as{}<$F zWDQ{Ffz1MH00<z)-?teUg{%JmdBgy8JlOpR62biolx1X*e{*FQKmf6@PTJZWr1j*} zPl!XoX8!|Q@((Hkc0JHb^86gDYwQ66i1GVX1{R4TU@!trVgQCGC`iE!pa{bsus#1k zHZc4FhUx1|2LJ+yks*IS!>1phxCBNdC_2H6U$AI~GQbEJqg*Ti0R{kQ(yH?TRrCY^ O0000<MNUMnLSTYH7wQxM literal 0 HcmV?d00001 diff --git a/data/images/flags/ax.png b/data/images/flags/ax.png new file mode 100644 index 0000000000000000000000000000000000000000..1eea80a7b739bea4a249dd10a3457010525f60da GIT binary patch literal 663 zcmV;I0%-k-P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!DM>^@RCwBA zRL|V|<JUh12Bv@i7%n9g@Wnj(H;GMP$&{}jm>7QjV)*ul;Rlceqc6W0{`~_8AeMg& z|LUXE8U8c=`}dziv6P|mEu%JjM}pqh@65md|Ni~w->=_)fBgZXe?Nc!ot?WGAOHX{ z0M7pe`uG9>{{ZXo`u+d?{`u+u=j#9V2j%Sc^797z|N8p+{rLO+`TP9&`u!FW2mk>3 z0#G;xDFDDQ2vg|)-@5Zw?JVTrAxNs~5&|AZ;5uWfQ`vUsMGm`>aGWoI7=P|oW>%~E z|Kk<I|6dHhfBm2N_J0KXUv8i~e*XXa^WR@E`RCW)zdx8cq#mtW1`t3jED_KC*MDSu z_6F$S|KET7|Mu(OjE@X|elq_2!SLfJBT(Cq?~FfxGX40;AjibAjR7D405Jg0{{;G; z2n8wz{`Uy}{s95|0REEm|6T|E3<3Z80{;C1{rv*}`~&{{1pfU60}BiI4*&v)g=^N7 zzEtC<Z@&Eg^S9Az5$oUY|2Y^UcVzte^9ShHAHP84FOa|g%8PRES<V0ufFXLh0RRMn zAPSL_{dejQTopH&3f@@mIzrkkeiH!;e_L_TTOqqs0m9<Qf^ydvKr9R&J^`hL_*s7c zVPIktW)S_!z|J8d!2XSs;nzR5-+%vtqv8)Ry#D<C$-wXfAb=PdjOR1F`^NAA6qo1Q x%7uy<{^T<#PfQ0I29yQ{JPiE-BQ}5l0|0RyOf8qXn?V2o002ovPDHLkV1inUNG|{Y literal 0 HcmV?d00001 diff --git a/data/images/flags/az.png b/data/images/flags/az.png new file mode 100644 index 0000000000000000000000000000000000000000..4ee9fe5ced2610a60ff99e6cc4fbe80d7d53c624 GIT binary patch literal 589 zcmV-T0<!&yP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz-$_J4RCwBA zWSDrI;m02a2>8dq@aG@HzdsB>fj@shYzX-a%=q<(frSYmfLIv5{Hb<T1S$RxMF0Q( z{|{8~_us!iAjUr+`}bcU`S16?|G)qK-!$_WKmf5YaB(p(vpo3<6lVac{{8RY-@ib) zzrX)8fE4}v|LgC+UqB6i|H*Q*F|hFf1Q6q4Mn<JclYYH;`TyTvhQC15{`~**=N}mT z|NR@p0Fu9d0|nV6Bo57-2@pUm3`|TQ_W@1(`<LPOpT9r8i$z7TN=bj*e}Gk9_T%Bh z->zQ${~M_P_y6C&m>3uU0*DFd0+8#0#Gk)^|NLQBRAyCBeB9H+Wn{=Mr@*PJ_ZKV< zB>w@GFfafF5Ks>TBP0ZV{bBt5_ve*sKkwd`OiO#eX6=u=cfX!E#rX3NBT(umNC7Yu z0Ro8S7X#4ce?Xr63$phg)Bk64X8r&5``_O`AFf^jc>p5y?+^38KVKLa00M}`wU!~x z=I_1Fe}Df4D*yZI@1I|PfBgnRptt`0`2{iq<e)$Q{;Kf*yZDd+Ab?mHJ~90M{YR2b z<o922(15i4`OW$VY#rGBAkP7V`tRSLe;M8}00a;tLn#AL4=8GX!O$Nh#vd5tADHA} b00=MuuH0o0e<Ph&00000NkvXXu0mjf=xZ5r literal 0 HcmV?d00001 diff --git a/data/images/flags/ba.png b/data/images/flags/ba.png new file mode 100644 index 0000000000000000000000000000000000000000..c77499249c9c54700885c84465bc9039a433a2c9 GIT binary patch literal 593 zcmV-X0<QguP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<4Ht8RCwBA zWMDY0>MpRNPo|?TW>MPOFW(r1_!xlT-yep5|G*@e%^SFxf#DC32@n7=0M7pe00011 zNe=7o{FIuhzQ(sjF~t7>`T+s_0sj00{`>*}`~Uy^|Ni?^Z7Tcx|FW0O0tlpm;s5{T z+df{s^E1rDZN;v4VP>0|8GkY`{`&t1s2YfV{r~st=KUYCl59Xr00M{!Xv@F<41fPH zoWA^R$>xvtF5yd$xc~X{7o-}f=ig7DY9RXc``_>1K-c{N2q2&ahQEIq{`~z1RCDs; zw*~7zIJ!sAKj8J}&!7K)enC|K{|nUc|Mwq|27mwp*#K1f8;Jh=2byv8+}GKw-@AGz z%-rMu^XK3HKtBLA{Qvdq*I%FsKn(x^1k&&qY(LP_Uw?q)|3jz0PF?=a(?4bU?qHzN z5I+E=z&88?2q2IKpz*){`~&I%+VJc5-=Dw#?LYB#!lHLRL7BZ<BEb&({pa_;zhD~x z0tl!9=-Pi^r9gAQBpB^J_N9B?+ko(bwhi$>8-NZ5x*TXaKmf6TZ20qEQj`^F;~x-W z05U+*fBxNj@_W(7kJ(ujGnWgh$g%(Z2hso#K#U9wI~hQ+`3Dk_7z`j1tL#*2FTcgW f@C+EF009O7>dRn2w6d?H00000NkvXXu0mjfueTzu literal 0 HcmV?d00001 diff --git a/data/images/flags/bb.png b/data/images/flags/bb.png new file mode 100644 index 0000000000000000000000000000000000000000..0df19c71d20d7fdc06e1cba01028983439b2bdae GIT binary patch literal 585 zcmV-P0=E5$P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz+et)0RCwBA zWMFP&fP=sP{+<VO-!c6A!|;!b;U5zNr*%Gv&+vzV;TI4A1P}{QwtA8$ko@=W|Gz&> zj51&U|NhG$_=oZTA7<u1Of0`+s{H@_`~CYb2ydD&86bdIz$X8H^6J~)zyJUKV*LN> z0mJ{l|Ns2{|Nr-Y#^3*c{=WU_!>_-;fvROCxPdkR1P~M0Xa=C_KR`47{sXG+pY!Yf zqu=)*{%)W0>;M0szkdJy_507SKYxDz`3KYh5I`&-cY{^``2$3Z|NsB`^ZWO@y}$qd z{r%_nFQBC$4Is{6hz5WF0@(mI8^j0N`~Tl>LzQ1Ye}a&q>MtPo*RQ`|OMu4x1!@2Y zAfSdne}TsT{`c$8-(P?J0c~)w`~?J`zx?#Dg*yp^z&88=2q2IKpt(TRKniTbum8-y z1bBWi0e#F0wgIH{FVJ|Pxj-8L0tgsNfByUdJMs7b-@h3^8h-!E3i%CU{QCW$1*GB6 ze~`2PfHeF82q2&aU>N+96yg32bmdP5238q{|Gydk0=52OVEN6!@<&OA7Z~7wL16>b z@DCt>7#SFn85mf=5eY;LZ#OUe_l@D-2Zn#Y82<4v{9|E|jkRQ8_`$&N0jL_N03g5s XX0T^_9W~6o00000NkvXXu0mjfXpthO literal 0 HcmV?d00001 diff --git a/data/images/flags/bd.png b/data/images/flags/bd.png new file mode 100644 index 0000000000000000000000000000000000000000..076a8bf87c0cedcce47099c6b74b59f2c9d1dbce GIT binary patch literal 504 zcmV<U0SEqxP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzib+I4RCwBA zWN>CcfPV}OzZe+)fYC1)`hmpw%>WR9L@~Dk00x0r!@5ksR;+hV0>ZLfIcBS@orh;x zv}95za5W)x_7^}bV3YqpdH?h;NZnuSC%?V#{+0gl`~RQckJx{&l>NO~;@7XgzkdJz zEh{VwR0<G4EI?-fO@<h3^W=BhrN2zSe*ORRhvC<6xi7y8U;g@`{d>LSuiyWGR{aKQ z00<x^pmYEJ{|mDH&u@z-e?Y2#|Najo!HDU{FQ<FIe*O9N>-R5+27mwp+3@G@Um)kt zzdstU{y<d&Nf<-_IY`6rKfi%S0yO{x5EIb*ATR#=0~Ec&12*_KnEd$@LZ0CR+wc!+ z13&<QGyqvZA)u@FivIfZ2dL=Rf0)C7?%X5=@&M5IzkfkC00a;VFm(TbgA8ctR*By~ zHGhR)|0Vwor1}o$?>Wl9SIB_0ft3II3)BD*KrA2|{``^Tm;4PDy`=a1tTre>fEI#8 u{{2z<2NVVaum*qtVq}m+iAaI~Aiw~?reC_kVQV=60000<MNUMnLSTXd@#O9R literal 0 HcmV?d00001 diff --git a/data/images/flags/be.png b/data/images/flags/be.png new file mode 100644 index 0000000000000000000000000000000000000000..d86ebc800a673e6cf357c33d00edef93e2df0787 GIT binary patch literal 449 zcmV;y0Y3hTP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzQ%OWYRCwBA zWIzQU-!t(10x|ymWB4b)@b44DKXHbC3<v}eKrBGH>gsB+3J~Dn`pdxgmx19A<NrU* z41bt-fB%p9^Z)nn|9?Q_mra`h0*D1{?*AuGp8f@cfB$|nF#P@h|2Gu<{r&&;um8V( z0aY`~$^vZw2p|@)+5dq`{{Rutr2jzGzyAOK1J>{hgns>oXkhsL8>j&wfIu2Rsv+R_ zAB5T1GyoL?1Q1BWpFe+p|Nf1n;TJ-~Kd@?uhChFRHUI<=#0HS!U%!8$YJib28yLY( z0tg_G2B7i3e*FSj@aq@44gY_`jRywQ69xu=0D?FP=vyd&x*M*V6|CV8P{Uu4OBfgc z0tjNmA4y3`us8lPG6H?}8%zRafgb(Cp!5f*6oP<G0tg^Rw3xnqm*E!+1JHN>{xAq} rF#LPR@IoIX4%E%Yz;K6w0U*EtuUcjA`-_J300000NkvXXu0mjf+>pV; literal 0 HcmV?d00001 diff --git a/data/images/flags/bf.png b/data/images/flags/bf.png new file mode 100644 index 0000000000000000000000000000000000000000..ab5ce8fe1237a18d6809a5570024eb108cb14a3e GIT binary patch literal 497 zcmV<N0S^9&P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzgGod|RCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`(ov;U*021-Lr1*!W5(gW7O z@cTDV13&<=05Ky_HBiN$KMcQt<nRCget*vgk?ifi{{Q?1(+Sc5G#wy-Sk5sp$a4Pu z#RGBWpMOB<_FufhK)K(~7yM$4|NZaRuYar{dsw;tTw-7V2p|^IT84c0KMy|t`StJD zpTB>819e>gHP`g_|Np;&&;0(C^$RHS>o<s0;`@E-2?Ia?3dr0B02l^BF0x~$^<L3E zR?Z!wLY#U<uhNJHoKoCoqzV*aERSsE>lZ*Q41X9v27}ar6#W9}0h{^{OajIK0?h(y z_zOe~zZd`lhy{p$|NA4!EeST}?;nUxkP49VfMx;1;V%&U|N9rH;SU2q05LM8GXV8K n(H~UyA0);fa6B_H00bBSQ>$p&<A*}N00000NkvXXu0mjfu#eXe literal 0 HcmV?d00001 diff --git a/data/images/flags/bg.png b/data/images/flags/bg.png new file mode 100644 index 0000000000000000000000000000000000000000..0469f0607dc76eb60327c29e04d9585f3ef25dc7 GIT binary patch literal 462 zcmV;<0WtoGP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzU`a$lRCwBA z{QC7P0}}Z6?;nIjX28LpfBygihy^Ih#l;0A|Ns9F(F3Oc|A$jZ;Khp<00G1TQ}iF1 z@gEytWMl+d0uVqfcRt;bVUqd#|1Uy@;r~A%1cQGt2t=$*EVsVh0tg^RhGvE`m$FBn zAN~3F2Z(<E`wc>WfB*UmB!B(-`wPhZ^#?@C^U5EbbPyl_g=3Hc01Sh${{P9=HB%4| z34*<3-m=C?^;L%miR{xQv2*hU;8+Y&0Dxf-W~KjsusWFAC4@j0#9_j;X5z6SjRhH> zd}sd(7FPhVFdSi!*Zj@;_Sc`kfByUdk|3A-`STmZ_yb~qxIp9o{E-p)bLt=iKmf7) z+|Ix(`{&<1pm88mK&}8g1WG~}e||Ik`OB#Cw~m1UAb?na(Zlrn4}+v6P!1@{@CT$5 z<gPz|7$77N{rw9;K*az7#0ZS%e@KV{m5U|?1ONdB06frThAL}}IsgCw07*qoM6N<$ Eg5Z+INB{r; literal 0 HcmV?d00001 diff --git a/data/images/flags/bh.png b/data/images/flags/bh.png new file mode 100644 index 0000000000000000000000000000000000000000..ea8ce68761bbd8ee06f80c487c24d4493abfb52d GIT binary patch literal 457 zcmV;)0XF`LP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzTS-JgRCwBA z{PgJ)0}SwP-~KNuisA2H2=^Zt{edwc2q1tMff{&uc_AwP|NG1E`ZdG6`Tt+NX88N( z|DQh$fByXY{{8>&-#|8y{IY2iKmf5omHz(^qAp)ym^JhN+jk7Vet{H$(Z8=>|AV=V zva&!M00M{!p&AHub^kXuF^G#pRQ&(-8^nMkpk@XJfB<6o_wOIv5C1P;X6Wwu|KUB< zc%X*g2-OTg#J~U$KumBafq;(A|A`a+OH08N{X($;=pP0KfB<5Fs|GR7oM!0i{{QYB zSm|$=!=N@Wf}I2qKuka<{f7YtBjf)QCj1u{`~UM7nrdKh0c`*VF+c!8{Q&j%sZ;-Z zd;dRshENS5{{S`og~Sg)05QIL^-54s5b7kL(8GuS8yfz9{>%Ui`+q-vfW*P*^_43C z0mK4S`s2qBh&}(NP5W0_$$%QQ@R)`GfB*vkck5Jby^kNv00000NkvXXu0mjfy0pt~ literal 0 HcmV?d00001 diff --git a/data/images/flags/bi.png b/data/images/flags/bi.png new file mode 100644 index 0000000000000000000000000000000000000000..5cc2e30cfc47452d5bef949628e955a522d59e50 GIT binary patch literal 675 zcmV;U0$lxxP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!HAzH4RCwBA z{D1W-!!NEI7ApUK{$&vt|M%w)5HbAv_3y`zKmY&#V_*QH9}FO%v-S8tmQMfy#HhVo z@k+!!AphIRlVA4iVHOo-`1|+I=g$m4Z5$l`|Ni~`=n=QO2e+%s|Ns9v+c*IN05Jg0 z{{sR%@c|7A5)2p^AS??rIq<Hl{Os%#U0?_-E%LUt`r+XihmHj)CDr)ae9U>$wbBBJ zMTk#;k4<3e#ePFE;{?TciOx=-NpIdg`t|#dcz^$YFqm+of7AU<7UGr+0t^5F#PsJM z(2)${Jd)SmT+KY5@!|h>po)L5-!r~>3uJux|0`&Jz~QGy6a*CiGyVtq1|Wc#zWw|0 z_y6C2|NnAvaR2)A>-NX%|3Ci{R?!hvH~jzU``ypCK7Rkq&&~h)FDNvCApj5nF#yj0 z0_X_*{{Q{_|NQXv@Av-qBMmR$teo!O*6-%u-J+8r3n~2s`s?uP{Qmp>{`~+900M{! zNC9;}eE(3IS9-C{62AZc<>Jx><Tdyu<P|fEx&Hs(<FsF0NbSa(>%af}0@?r&05Jg0 z{{#T;0Os`NWh`ko4>SM%|Kw(60099Kad7|t0qcf^`tR-^sIP`Fhru7lkI#w#>Hq?W zkzqQ+@08y_|9v}t;{CEE%)%nTc=-MC<G<g(nYg(A|M~Ot(L<s5ByJBkVA$|w^8o}9 z<H7p}Wj=_VGt~UVz`(}9@CO{VK=K!u0bxU=?3S;+CiMy+zyN({CUlyEn$-XR002ov JPDHLkV1lIBS^oe4 literal 0 HcmV?d00001 diff --git a/data/images/flags/bj.png b/data/images/flags/bj.png new file mode 100644 index 0000000000000000000000000000000000000000..1cc8b458a4ca83a29117c1ab9e6cd1e60a717db2 GIT binary patch literal 486 zcmV<C0U7>@P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzcu7P-RCwBA zWSGdn@Ph#g{xLB80nvY}82<gkh8P$C0*Hm-3q!SQHAwOQe?Y{{$PCu{nUV1iGxHxN zrr-bn|M~wPN`CpW2_S%27+8SP|DSw)`uG3ezyJQSGP3?-`1}9=N3bFY`UOV685w1P zmH-403j@fm|3IaG{{Q{+?>7S@!~g$((Ud|+28Q224FCZIGXF0FQ1zcbfB*dX_lJRr z;s3wCNa|2D02KoSAbE^!0Dxf-mZ69ApkCb5fP^9ydGO$cm6w`kGj(t#`M{tFlLo%j z*4%mm2&CaJSn02SzkrtfWBLcO;r%bLy5Gnoqrh)qC;<czNCU{IzrTQbe*gUivH|Eo z4Al&OK&}RcIY0n`o%9!|^!M+7zk%ex|Ns9%G(c2CH2eW-0Q&SV&<_9s!~%@9-~awd za!Z1}@t1{(`QM+vjIumHBN@O3gV5i<5Cn7*Kmai^lrk`U0!Q#KFak2ZF}&Okic~ZN c0RRC803-oxJV3ZB;s5{u07*qoM6N<$g26f1y#N3J literal 0 HcmV?d00001 diff --git a/data/images/flags/bm.png b/data/images/flags/bm.png new file mode 100644 index 0000000000000000000000000000000000000000..c0c7aead8dfdeb942752d40cead84182c94f3c94 GIT binary patch literal 611 zcmV-p0-XJcP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz^+`lQRCwBA zJegj)he@G&`n7t|ODs$bwJ&9|Wxqt(Nqty4lZk=h4+FzLF!}>VKmZT`F#yj00W%wG zw4zx17##T6*Z=hN0Tvey7!|wG?K;V)0{r{_`}_X<{Qmp<|NQ*_`}@YSvI2-@&cAgG z*R?p0?_>V(@&EVlEP?{x|1esA`||VNU68t8zyJRJ_3zg&hTp&0WMzR?0R#}s1|E|% zRV}tRkH6o2#3;(nsVMXI-pgBmh17ms`TzTu!pfBl%*_9R;OEbWDJei#0R#}sGpDd$ z96Spi@~bn7G5z{~^_!ZLy2#s;5B~o8&G`G*|6f0Ap0WS=_rFd2C(ubi4FCZEF#yj0 z1a!1=<P~`V*Xs`t_5b|)_`}iOAr(jq@a_Bg{rdX<{r$yjN@ZJI{!LB&{QUd?00M~R z!PD0opC3y6dGPneuYbS)_yc`;<=5L^zk%)s8aeIxbk`2!yMHhDKfN6QwgDi3n1Epi zlmHqARQ><gFCgRZPmnMFfd!po*0Qs6uyJs>2e0}MwgDi3SU@2S4mePl{012eQ4J-3 z|Mm`933BS6zyJRJ1;ztF0D(08|NVzmQWB*4&)@%$um=Y`lmy1W-@hDx{{qQh3=9AP x#JH7#;TJ3-|G=X54_FW_j)5c_0|P*S0RT0dOSD~(4;cUe002ovPDHLkV1hfJDkcB` literal 0 HcmV?d00001 diff --git a/data/images/flags/bn.png b/data/images/flags/bn.png new file mode 100644 index 0000000000000000000000000000000000000000..8fb09849e9b5712e9cdd8a2c25035da201535cf5 GIT binary patch literal 639 zcmV-_0)YLAP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!5lKWrRCwBA z{Cbmt;THn~69WYN`v=5O#=k!x_P>8n_8%|<AOOQ+Yy&V1!!Y~*H?s7gTu~qyIz|yI zK;S~Ef#~~<(rfSX1y?Jx@=#hhCe)hGFMwDe1~c(QGk~r6_xIJmj}w1>dG+hlCr-g% z|9<~wU<9iE{r}%DMphZ1Jpcj3%*DZBWo-@72DFWlk(-gj@!ngb-$#D&C_i9e{QvLY zw|{?r|NHlgiHY~?^IHG`05Jg0{{(k=dF}7->+9=cXlNG}79t%T{rC3_t(pA#`tSX# z4jKOh|NsB|;rr`({{a5__y7Wk5omBxQSrlv5B>nb??0VZ8qCZrpS+?}gatn>o%-+B zPl=+||6st##PspQ2Y>)#{Cu5(QJ~_^?faD-pMV;E|7MwLubjQ@B-gLso$mVYgECpU zxpfp2fEt*XnBKpC4-fz`0M7pb@zVeZ8g>5r-~k2r`Sbhd;{WmN02I(80QmHEv)Ik{ z>H7Nm`}_MdF)?FhWi>T50*LYBMFw`s0-y$le?J(QfB$Fr_2c8e-`_!j`0PG|ckZ*_ zzkY$-`sc5Tips@{7Xbo@1sGC5IYwrQfB%2{|MTZR!ygX*-%M<O8UFoNwfcSY4ByXB z|DHVi^W^!Ts*bNf4+8`c<J;4qSOiAlpI=Z0kYr+E_yuHt1I08G7tjoFl>cG4cZmTY ZzyK0(F$K@T-Dv;-002ovPDHLkV1la=J3;^e literal 0 HcmV?d00001 diff --git a/data/images/flags/bo.png b/data/images/flags/bo.png new file mode 100644 index 0000000000000000000000000000000000000000..ce7ba522aa7e948d581478432643c230eed1a658 GIT binary patch literal 500 zcmV<Q0So?#P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzhDk(0RCwBA z{4dJP00aO2{AKtBq!<`}F#P)uqA?Lb01o9G2LKR;fnYe^jwlgtrAurRXhvIXf8*SV zs6rSi(17d))Nloa`1%>^3LvnC{|x`%yaFl$ss8ha0V@6XKS=c-5cwO(_{}IL0ki=i zfLOjo{AE-9^-mn=h(G`Tfz|!{{r?|W+uz?{^yeR#!Nm9NlRN`J0I_^YV&Ikj@%Q)F z|Ns9m{0FN3^Z!3k!*8HnFvf4N5x<yNc|Wu;00a;V&?6v&LDGL1K#br2fYMM!fBrH2 z0yBVuf0%&We+&Qt!~(SZKf`aZY7qGkW&_l0h$Udn|ADUj!vGLKENlKS*a98&=jT7L z3LyCx!uStnfVn``z!2bJ{xS0h13&<=<bGnv|NZsBuTQ`K{r>as50L!z?-wXsAPk7$ ze<1l+iQ((fPYeJ7#KQ200VpKFA^{3Nph^G!{9^b86oDW}(Ek1R7ZiL9e}Ret0*H}e qBRB&8AR!3%7c6cef(%d+Aiw~vBYd|xMihYn0000<MNUMnLSTaF<K6uL literal 0 HcmV?d00001 diff --git a/data/images/flags/br.png b/data/images/flags/br.png new file mode 100644 index 0000000000000000000000000000000000000000..9b1a5538b264a295021f4f717d4299bb8ed98d98 GIT binary patch literal 593 zcmV-X0<QguP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<4Ht8RCwBA zWJqQ}fPV}Oe_-S<FoH86=m!Hp0I>j-HAXl7XJGjM{~r+i{r~sxzrQwG-&h#_Y&p;L z=ieV7_s^f-zyJOD{rC5(+EoAn1k&)I;s3Kw&;LT?{{2}P{Pq8T&j0^^J4?LvUd;UK z&+k8ffB*XXONL(tXahh1fi?X94^$0Q>Z$uRRO)Y4)uapmB!2(-ul(c1=C+V!kAF`) z$PCo;`_FHns{jIs3Fu;wy-Z(c2YwAz{&V4aXk?bejFO*Op&u`@i<P#NM5z2anDp@% z$W_0AHUI<=6VO{y{6FUU{$~3B`#;0){+-SR)BjJ|$ol6OM|0|bK^^`7|9=6|$@CBU z(q9<9G5`b+6VR3i@0ilI{`t=Uk}wuL1$4~Cd;i;4vwV2{U!V5`M1#wcUk5J#X5eQ4 z2q2IRfByV=_lYTJDa)3Jzp`T5RX=}y{qq0s&;Jju|Ke_2x8cDr?MbYUUbC`tu`+yQ z00<x!pbdZigMti*ChuqN;{3XAg2Vsc(*J+|{?C3(Z!*j8e=I;B|M?3F6<`Pf1Q1BW z?>~PecqBpL@DIoojb8mb+WHGK%fG2>)a3=iVf_~v+`s?*1;!`?Kmai^I3i*ZIYtpN f{g(k500bBS@<T5{{p$N}00000NkvXXu0mjf4z?hV literal 0 HcmV?d00001 diff --git a/data/images/flags/bs.png b/data/images/flags/bs.png new file mode 100644 index 0000000000000000000000000000000000000000..639fa6cfa9c4792d03fb09fa197faf7ae549bfdf GIT binary patch literal 526 zcmV+p0`dKcP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzph-kQRCwBA zWbpQ8sH$RMV`KR7g8>Ns{bTs^2gCr95H^s6Fo1}O2_S$Nfl}4;<}o-r{^R8Q_xtzX z|Nnu?|Ni^;2gm@D3=F@4<iCHv|NjS~O~;M_1P}|zu)BAj+`aoZB;>EC#P1&<J-`1k z{DzYMe*OCoL_lq_?5qs`cmM*31?-Cde;+^kGk0#b-3u#&zd$uW)Bpeb4QBj;F@7`t zll%B%4?qBcH2nSh_YV-fcrkzdA4Z1X=6YcJA?m<N!HnMw|9=A&0|XEg*n59}gTc#> zzuV{ie)#0qf2Lnh)j(~3VeG$ufPMl9Aczeh3x56n#l!R`HRQLf<gfp~pr#_*!1(Vs zPy;{!fouSp{p;7SKRmptLBH%xfBpaS3v4Qq9-w=`!S?3|13&<=fSvT`x0u-PgoFlJ zg<oHP{Q?FeC}d#de^9Xg`S)MtKl??NV*mjJ((qeOUeeF^H!ts>pFjVwfkN;%I39k( zVgne^e?jDThF>oL0*Hk{T6(^^8pGqq44*zhqZ1OZzkZ>_F(mFeH~<0+0A4S6=>Lb* QN&o-=07*qoM6N<$f&=*Yr2qf` literal 0 HcmV?d00001 diff --git a/data/images/flags/bt.png b/data/images/flags/bt.png new file mode 100644 index 0000000000000000000000000000000000000000..1d512dfff42db1ea3e7c59fa7dd69319e789ee12 GIT binary patch literal 631 zcmV--0*L*IP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!2}wjjRCwAn z$*~ClAP~U7D3AM>?&DC3JV7l?ccjcgB=Qq4l4w|Q;eOZ4z|IjsEI`#PYSloM|AFHF zfy)2>XZQ=$@t;lU|F1tRzpnmg{PUOLH{-8A|F{4CvUU?d0I>k|0TuuM^_1cNUy$nm zzZn?*{^!*ACzSEy$NyhHzB0Wn`TXRU=!%=n%Ci4h82|!^32gR%glZtk^6USnb00o@ z{`LDW>%U)&BK_|-y<imh^`HIszuyc10mKA$AW-!mkm~<`{xkjh_L=F`cd36L_kR5P z>-75RkKVi&O=|xC<=6keK+72b0*D2q;Xlai|Ns9mF#h@f<M$Kp6%4<=Gqe6@Vg9e{ zo5sMv#5*_cKj&|T-+zE=0Ro8S-yfh4{`?2p!1#-S`OjOyvVY%yh_ik8`{Vb!@0`59 zU;WwE`2WMN|J=VB|NaJQ00<zaf1t4W{h#^Qcb=fTES>M(h;aV>^!x1-zNu02XV-n@ zm3imZ@&EI0hChG)|NZlafdL?Zn1I0yH26P<=DUx29QQsc%}izF;QV-S-hYOl|1V4z zX|a-<=Kf#g*Z)7i{{y|szyJ_HEKEEM|C#>$KmJzoZa)M2-~Wm-e7Cl+vi)L|kY`}} z&A<nY)IXpw{0j<3ppyUsi1G7$28IlVe?J-iiGm^(7{~v<f<q1iK>$F20RV*|BkB*O Rz6SsR002ovPDHLkV1m;fPLlut literal 0 HcmV?d00001 diff --git a/data/images/flags/bv.png b/data/images/flags/bv.png new file mode 100644 index 0000000000000000000000000000000000000000..160b6b5b79db15e623fa55e5774e5d160b933180 GIT binary patch literal 512 zcmV+b0{{JqP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl1W5CRCwBA ze8>O=a{vExMP2%`MCSoB^FIcLe_%lf;|~%E5I`(IQNh}3Ao>6Q|DFUXMn*>AqQd`w z|1kXd^B;tM|Njjl{{hM0zwd6?1Q0+hV1xeud-4=Wy?p-%sO`^#2S61Jzk!N?s)6X& zzhA%p|N6}=D+{y%Ab`Lc{sL9~1=0UN4*CD*7s%9KAf+JHKs~=eB-8KTKvw|-5R1&; zzd&a|ob(5%^Z$Q=wHy9p13+aOpFRNu5F>N&`Tk_-7w>=n{RejQzkfh&Kn{rf10?_b z{tFTZibx5v&dxav5I~H7|Ney-|DWN1$%1FyagzUW0464;_wU~W1Q5$TW@eGxtUvee z3vAf*8|<XFcGmxXfqcvW^6qb_H-7(NQC7No`W!$2u>igK9~@*rr66bh|NrkNM8z+V zAV?>O@ek;bKfu6d00<zkhChFpBtb3_<pTv8vy3d$Ur@j^fP<g;&mWNLzkmOM9S;yd zj8|b%sPO*1px%kM7tF2+3;%(|F(iT+U?dO#1Q-DHo?4QnY^9|D0000<MNUMnLSTZ4 C8Rb6! literal 0 HcmV?d00001 diff --git a/data/images/flags/bw.png b/data/images/flags/bw.png new file mode 100644 index 0000000000000000000000000000000000000000..fcb103941523e24b03726fbbd88ef213dd476577 GIT binary patch literal 443 zcmV;s0Yv_ZP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzO-V#SRCwBA zR6Y6s!yg6+`19}IuYW+20Z9J;_mANpnDYnBfUp@D00J;9##R78Koq;fB!*R*%P>k6 zPW&$KB`I@TtA?2x@Q~7pdcWi#1>DDZ2>MuG0I@IumHuaV^&7|soAMvT0IK){RtF@1 zgH;3B;_Qq-34j1%{P^)BFE1~|NkA(gBv!!4$aw$$eSiRB1ga=2D|__l(H|iA4T2Ex z>lc`SQ9x2&UjE?0g8%`<0`lvhzd$}*14J4{IhY2@0~G@V5J<z{zaYiGe*c1*4^n_^ zHrxoHVt@c*eEaq-KR-Wy*F$81ez<k(7C->82%q9<QD=Da0~izx$YBB@K@3Q6{bP{h zWLUjN2q1u1K(Y0oNsJ8=lnmf7W%&J%5yAjQ%U_TnFn<3314k`D05LLb|H|+KDN_G1 lK%@2-S{#F=*cbo;3;<_!ePLeZWljJ9002ovPDHLkV1mwauw?)M literal 0 HcmV?d00001 diff --git a/data/images/flags/by.png b/data/images/flags/by.png new file mode 100644 index 0000000000000000000000000000000000000000..504774ec10efa9fdbedf75295ac88848f23a3908 GIT binary patch literal 514 zcmV+d0{#7oP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzlu1NERCwA< z!a)iE5D);s`KhOSfh28`w63O+5m`#sn%wVUvj`@GPU8u{q#WA-0D~|PbcSakW=n&x zaVXP#ar(0#su1CB%s?^)(Im)4f4!Mc0I@K9{K&9t*Z=%{1|R~e25JM7Q1lB({`&vx z7m&>;EBlXu0U&@_82I`BfBC|Yn~P8llm?myQ3_K18=`^X_ivyEfB<6p_xd%%#*GZ! z-C&_V5IGPDK|g;X*gypg3;+Sd!XPTj0Q7fK5>&$<sCiIraLqv3KYxHW00a;d!|T`o z*RA_MX)??KF!JY51PMkECjkTy%RjGx|NFLo%}9*R`1AYUuU~(E|Ni^y_ut>Y{sHB| zjK9DB00sa3QR4q|tc?L6fSCSm+3>HW`NxbYpP7GsX8sK%KS9yYPfWi)GJ)AFzdp14 z{>t>1fsFwmfLQ*Ci!yxq#8guv=_mQ;-(Rp{AoS<oAE5C-66meJK=2=g7=ADS1Q6rb z=P$oWia&UK+O*1);THo4{b4{b7{H7l3=IDmAOIl10B<*SmRnR}WdHyG07*qoM6N<$ Ef=o)|H2?qr literal 0 HcmV?d00001 diff --git a/data/images/flags/bz.png b/data/images/flags/bz.png new file mode 100644 index 0000000000000000000000000000000000000000..be63ee1c623af897a81d80c8e60506d2c9bc0a43 GIT binary patch literal 600 zcmV-e0;m0nP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz>PbXFRCwBA z{K<d<{(#XBFq;h~_z#AF03d)E8RYMF)C++{|Nr~T@ROZ`{p&Ywj$gn3{dxb1^$Rm6 z<GYW4{xbaj^Z(DEfAhO90|XEYgNVcrF1Bay{{Q{^|Ia@Lrauf0cCW=`e*F0R_wkEg zyEkZm{KyYf4MZ}cObiTa009KVK$98%{QdtMsG9L_MBEWKbzWm8S+jo<KC&Vyc?a28 zfByRO7l?iXEn@fu5I{^oC;$2PAISdu=f9HV3mJ9+o{x-|t{pyi@zDQAKP4D>boB21 z`ppP*BG3|`!vO*asNvt=e_;3gWsp&PA@E<2jYs6ilRy6-zmSku5?~OOP=5aV@4sI_ zSN#QP00<x^hF|~w0{!&&|F6IQUOW-_{QtxEAKyQ*i#}vfee>z<Plm7Wp7Q?w_5b&8 zkf(tf00M}G;TK5tZ?FMpuF4pD9{KZw#nw<6sQ>Q!JD>SpAKYgR_QStF|9~F;2M|Ck zz%U1e+FyoWe;B_0VVclq;}vp_kNy3hKMWrj7*{kqJbMcaQ;_Pve;I)x1Q0+h46j}> zeiN4E1lkA+>|e|*mzJsW^M3)l^W_^hf&c&c`Mv->`48yHFF-%s0|+3-*GLfwiRNz% m3}3(u4lrUsie?}H2rvLBbQv(L;??y40000<MNUMnLSTZ=KNo5M literal 0 HcmV?d00001 diff --git a/data/images/flags/ca.png b/data/images/flags/ca.png new file mode 100644 index 0000000000000000000000000000000000000000..1f204193ae58c87efdd88d46700ccb48c2e1d0d8 GIT binary patch literal 628 zcmV-)0*n2LP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!21!IgRCwBA z{OBG0@52Ws7N#Gso^YQ&&42{{|NjppfBpLPOjwu=$Y5aj!@vL#KrH_rJ>e`b1WGV} ze#gbd^&bp=ef-GykAYc$@BhDltSqdeSy?RXJpcawy+5fJAb?mHcsPJ&{d@EF@4HtJ z)qno||1xd*zaM|Ot4f#|8A1AQ-~Pubz$7IBv;-i4SQvovfB*ge{fFVtAE4aNE7yPi z`^Eg=5yP(^Ur(Q5`TLjM%#`8#_rHuB|9}1hItd_vSpNO`#qjs<|KGp=|NIHm@MXqK z79K7}ZvOwQ9Df%s{`~lX*xI%KzW-!k{Lc96Cr|@G0I@Lq`0?-epMSr9|NHeD<V$7w z|L4yAXXRo1@`LH)GiG&-fB*jd`T3oRg%hX%=p=vuV*2yr$NxXSfj0d6`2(ndCn@Qh zun5r8zkh%EA|%FNUJexe4OH{<*WX_tCjkTy%fByQz^Z?O(7%5S931=&4d3_dVr1tK zGB;=B;P?x4@Xw!rS${G726~hMAb?o@ynPDxz^{KmxBdGEG>?&umBG}6iH(DSogJte zDERNk_kV0Zng09%Itd_vSb&=T{rt@+B*66dJ0my{fpNkvBm!jpV_;xnVgd@XDk?Dx zh=IJyzyJ_HjJJUrz)|>#fr0nRl^;KTz#|yK`2G9$BS%LzureS32rvK^u`=B;c)+&+ O0000<MNUMnLSTZV3pytN literal 0 HcmV?d00001 diff --git a/data/images/flags/cc.png b/data/images/flags/cc.png new file mode 100644 index 0000000000000000000000000000000000000000..aed3d3b4e4467c33717ab3e2f61596e06113f9bb GIT binary patch literal 625 zcmV-%0*?KOP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!14%?dRCwBA zWQb*EfP+7O8U6uD28LfS5`yFy7_NaCKNtW4hy|!@b3vDn=EpB@*jg_0{{8pw57Xbj z|Nj2@`{xgk{P$OZ^LwJ)`}q$z&c0*XR5lSHfIu32WWFY>Vbu`-)NXk-Y`p?2$B#e1 z{(buQ`}e;;zyJNd_UF(2w_nb@VUiUP2igD-Kuin_BL6vl`zgIQV*mR8|8G|2U!T7I zn`-)Ow$bxne}9LGfB5z5*XcJ*K(;aeSD>o^0st`p&i@1e@&c~g4N@7~{}B9V!5sbp z`j|K8<@Nq-&kt=P>@pAZ`T7Q#;RiPl^j;kSgdzX}05Jg0{{#R8=f>y-z~LG0`u_d< z{QLg=2?6_k)DH^)`=sFo8YTUi-v;{r`o8J{6Wj>^)c^vB2^cJY|Nnmc_5b@He}DY@ z_51JdufKoSslWR1>-W9)3`r;XfsXn0`_Fc($H!lT!V@5Xn1Igu{qOIuf50&L{qg7D z?|*(LofMGa_%_k-*{}b<f%<>_0g?9mMSwvE)Bq4bEI<vw5c~}m{r&gPm%qO`7=8yG z68iQJsPxw_pw!>r*1})*zF_+U)&LMdEMOb{Nb-vOh6FrNC(tkd{&4;N%g_D$&)>g> z0^hO}m?z%iIsOJ1mka;_#K>R{i_RZNQ42x8VJSe8f#Ern2@qfa%&RHCyvg>Q00000 LNkvXXu0mjf=TkSf literal 0 HcmV?d00001 diff --git a/data/images/flags/cd.png b/data/images/flags/cd.png new file mode 100644 index 0000000000000000000000000000000000000000..5e489424884d2ec9e429f70d69af00edf242a077 GIT binary patch literal 528 zcmV+r0`L8aP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzvPnciRCwBSkv~k6K^VrL@A}nJf)*T1 z3L#8Y;~xSzAPEDBL&Hq5jf=)VENWO?jf9E6D@-hqG{&JMepOP?gpvvgmH^Sjfyu<l z0^(P`-re`|UfY3%%iZP8d%t_~+&xzCI{~=gwNNNN<^l>JkPqeYl28iLgD=0{><C@= zJiungxto1}cc1TEbAAt6Ff?hOd|fT*N&AO;jnhbVxG|CY^%Eg-iEa~J<+*&ikorN% zJvqE{n%F+cAI*?%rryi{5jn;cirvbxJX><<Ez>0$P44T5yOrT$dE?(KkwMFdoG^-J zGv9P)Kk|i5`lcNgUUAbboca5{hI)v&h!9!~`Yg)Ld}$VwYqqXn@gVLi>3LSVGm1W? z3qnDJAk6chH(<V;E4`d@hN9sB)Pe0wm1uSP!zuIys30W7C5QlfebIoq^6KHMscfS9 z0p<@t(x!nRk~feJY9L_fI{M}K^~5KGqDC|BTTs5eWuXUuP#fn^Trf-yBGzgR9M;gK z{3GtPHKFQ((-20Rc(;g5#MU}rdW!Lpu}m>u7f~FohUBCxfQDx8?5BQsCcprAnfVhO SHC~zk0000<MNUMnLSTZ!v-Dd4 literal 0 HcmV?d00001 diff --git a/data/images/flags/cf.png b/data/images/flags/cf.png new file mode 100644 index 0000000000000000000000000000000000000000..da687bdce928e32e4a2bcbed2f3d2d97f42d340c GIT binary patch literal 614 zcmV-s0-61ZP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz_(?=TRCwBA z<kY{x%rCgUR?@%mIRg{J?|=XPeqs1{LGmvHgMi9o2G)NJfBrK3`p57WO#b=<5CAa% z&i@1h4-nX<up1kF<1(hI$ZP%n0RQ;_3c+pv{QQbM75fAK{r&&_{{Q>@|NZ~|iEi8i zh~?j3h8^dV0y&$fY|i?}`2XK;hJSzm|G$6#|L@;V)&KqZ3sU;$?>`_ZF38C6g#jRd z7@s_ODJ3Nev=9gw8UKSQhX4P)z5oCE{eQ>y|1j|H-+x9%#`EXT0R#{W1H*5i5@u$g zs{agN2m-%;fixfipeaCQpFe*F2q2cU!)f`}`5%6M06O*WzrTN({{8*;CkCkD_wPBk z=llWket~@P=ieVecEOTuB>(}$vTge|RYlbYUmpDW_v_E!KfnL|{{8n?G|>28zowm@ z_UrGj-+zAtrGTUoztWctUjPD#h2amw@BhDn5<oM6N`L(Wu|e+n{Tr$pNV5F?0}Nf} zKMZUP00G2OlEe_D{O9ZM-@u^z_wP3-wEu%O{QA}J_!}5J|Nj00N&WxJ!}_-~hXEjf zSOPd0bOrzX|1bIfKhXEU5d95e$o^*d^T$aN$o>OHz(52cF<S<J0Af6Jh=GZTfs>Pg zot=T34<!B%7_G_-{}>qlT?57Mzkg5?0ssOG0Ftp>paW8OyZ`_I07*qoM6N<$f+2k} AjsO4v literal 0 HcmV?d00001 diff --git a/data/images/flags/cg.png b/data/images/flags/cg.png new file mode 100644 index 0000000000000000000000000000000000000000..a859792ef32a02b41503b5ab5f216191af397e02 GIT binary patch literal 521 zcmV+k0`~ohP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzn@L1LRCwBA zWH`>i@P`2b{=v9^K<w{Q;{X2q`uFb#2>tp0uZ001fLMSM)r!?%mH+<z|M&O*-#-k0 z|Ni~M$nYmd_zw%`@Bjb*{QnOoxBdRIeG@<cv4Bnc|K#^mpwhqp{{H^|8%P3G<V*ZE z;Q0OTA5iHp5L)@0aiuKKdVm080-OCGWH3zipZ~v7M1NazL6rUkk?Vf_@A&oq_ivyE zfB<3vDhI3n^XLB`Ao=&-?=-RBZoI$2&H$Ue`S<^(U;lspX88RZXgWXufi(R24>B91 z=ii?+(ckX8zrd=&8g~5t-|*}I50LQ;KzRlRfB=Hn08;wv->)Q*Kc0LLgTdsU-~VfV z{r~#=|1XdRMzE6r0tlo5X#B6gzhXrG`18Y*0!g6iieLY~Kvgq<oeT_WfB*so>z}`W zLWO>Z3Lv@r7+CdZpiy8Ae}EeP0z(_<2Y>)#Vfe-1#`njMUlORAfdMG=o8iiD#%aG9 z#Q!h|feZ$rzkeYJ=p=vuV)VVru<RQrG$O&#{Lc{--Ixdh00ImEs)1&9CQ|Xr00000 LNkvXXu0mjfrU(Kl literal 0 HcmV?d00001 diff --git a/data/images/flags/ch.png b/data/images/flags/ch.png new file mode 100644 index 0000000000000000000000000000000000000000..242ec01aaf5ad351cb978a4eb650ad801a438b09 GIT binary patch literal 367 zcmeAS@N?(olHy`uVBq!ia0vp^+#t-s1SHkYJtzcHEa{HEjtmUzPnffIy#(?lOI#yL zg7ec#$`gxH85~pclTsBta}(23gHjVyDhp4h+AuIMGJ3i=hE&{2`g6X4;eWtQ0TuxV z9fi13(+NNRzn525R_^%t+x&39d{6y9ga1E2r+>?7U|?FE@Z<mglfiBK>m|i>fBlzF zc~M_qKf%h=bAcoS;}NE7f8|yFe%B9?;;8%o@BeG_!|(4qhyo=(h-XBmKHXpc{~y!A z`THH3fsDVeudko)ARzm9UL&JI!+~uEM*rBES1=kd6zV%LH0J*N$gIQAc0y}k9qTFv z4h1oVG?rB#zNY^8{QUp5wE>>R#S4NZQd1i@F)*?OF@6y}@zmk^!Gr7L9asuAf!ae1 z{{CbBBH<BO!*oHaZN7cOF3AJ88i0zA{1Dar@o!<98Ve(f1jD+O$NQIep1%kTF$Pap KKbLh*2~7ZUpO<I= literal 0 HcmV?d00001 diff --git a/data/images/flags/ci.png b/data/images/flags/ci.png new file mode 100644 index 0000000000000000000000000000000000000000..3f2c62eb4d7dc192036af889b593b782dbe8abac GIT binary patch literal 453 zcmV;$0XqJPP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzS4l)cRCwBA z{6CR_;Rgc<{A2j{hv8GsM<5Fb7$DTYf8r;^fl>^Az=(+fAb?mHzA!MjRs*H~|NF=I z{~td;@BjZG-hUVsqZ#w(|L@=b|NQ>{d(*K^00G3pzycQk`jp|{UxvSb;p*Vv_V?Sr z{{Q;@@3$<wEYK=|0Ac~+|Ns7jRR8<?|L<=!)j;s;-!G5`pe6r)16>6WKrBG#F*5vR z`2XiW&<Fqi{Ko)L4gY|y0%-sUAQq5Uz-IsdOF+XPpn^XP009Kj05ltnelh&TYXjIx z00G1TbO_^rpz&bUe}7@s0Cdcse}De|2iX7+Kp+jwAf>+<K(6|YRl^^U29P3PkN^Y_ z%fBBCOh8TCk{~^Q{(_^Bfsv7s0S1&flz?9R`wtl8e;Ix;00a=@*HVUmpFnX8)Coi% vy6*n@^9K^s5QhE^eNap@fMc4C0U*Et7^HTbL!piS00000NkvXXu0mjfoMp*{ literal 0 HcmV?d00001 diff --git a/data/images/flags/ck.png b/data/images/flags/ck.png new file mode 100644 index 0000000000000000000000000000000000000000..746d3d6f758858c749523ac27c05c85930d10667 GIT binary patch literal 586 zcmV-Q0=4~#P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz+(|@1RCwBA z+?`&%<%7t?OD{UVoMB=3SNumSoaLRLnMm~7Z43-D3=BUQ7=9rmfB<4NH*Z|u<M~}c z<nN7Z|9<}ZBPJyx!n5bzw=<WoGqZC2`Sb73-@hOd1pY1TF9rwzF#yj01f1lU0@4)^ z@a_5T@BaDu2N4hW`~Nie`m6F71P2ZL`~Ca;{jHc~NM4g07!Uve_yUM&hnUSj6-^dZ z)!#y*|D@#D^bNmD$X(}A`VCb2`_Ia$$-jR82C4>Xo6{KsbP_-SF+DJF`pL$*;0gaz z7NKVhyo<lc{FYXD%Eb5U*Y97y|9U4*1(Lsh|M~UnPgMRq28O=?0mSrf-?8OqUSEFs zZvPX8-H-ntI(23Gr4PS<{s1akKQryuACNsD+kjs9{qOMM!vF!qvi#vYWyXI%vFnU1 zzZn^U;*XxZz4QAI3p<}%%%tCcLB<1p^5@TApzm#MZ2<y^3Fxf9e?bQP1{nr2?-$5O z5D9e5o+Wt@)j+A$(^4217ytqYqyZRSzkvv*2ttBg^2ai$3rHScUkbF!FJ(GV13&<= z05t&378l_KSqd@i?>`U;4*0+SK#>rfF<U`K<nP~qKn(x^#K^#4h#HqaVB}v0Yycp@ Y0CuxTZrJ%C5&!@I07*qoM6N<$f^Me?fdBvi literal 0 HcmV?d00001 diff --git a/data/images/flags/cl.png b/data/images/flags/cl.png new file mode 100644 index 0000000000000000000000000000000000000000..29c6d61bd4f16075228cdc6e526aafc3443029d7 GIT binary patch literal 450 zcmV;z0X_bSP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzR7pfZRCwBA zWJo{G@a+%79|i^{28JL17`T}JF0f)?VEBg>FfafF5DUYXKh;r+@n*scPQUy6`!63S zGXn!71H*qtMn-04W+o=4|487=moEST#KiEAA;m(3;Xgy1iO{3BKc9a12~-UM7^Z`) z1qdLZI~bN-c=z}J|D88K{QmR*&tHcBxB*ZDKmf5Ya0q<-!`is-{h$8~f7m(x{QZSX zH3Jv`1P}|uy*o^wIGI2G`1kie)4zYe_&EQ6|NftW0ohon1}3I|Z{7d|5aVB<uKfJ} z4<7vg{fhzUrr*DTjQ_uW|Nr$1#6}{Sl$73|It36wEI_|7f`u6V{6SHKsv0Ew=MOOO z0Rjl5f%y-}U<SA%Ol^OF8vZi;1-S+wfIu3Ue*a;Rlmr{|m*EdcCnMNM1_%j6fB!-d sP%%INF@6PxE)rru<)TRe0YHEO0EUfd201@Dy#N3J07*qoM6N<$g8h8DJ^%m! literal 0 HcmV?d00001 diff --git a/data/images/flags/cm.png b/data/images/flags/cm.png new file mode 100644 index 0000000000000000000000000000000000000000..f65c5bd5a79e2885060515be55f03a3ea4a15d95 GIT binary patch literal 525 zcmV+o0`mQdP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzpGibPRCwBA zWSGdn@Ph#gSQs7_Gsu9M|KR8=!@qwp#J~U$fI>Mp0ssVo0Ek~;W>Z#0SJsA+2j`G% zv|UVeYYs-#Sn6_J90h1VosR?LBU7{U1rQ6+R0f9sPrg3=`~UA>#=n0a|7Q67_y6zT zP{j57|G!`V{{zu)Mn+knB>(|98e;<hU=Rj5wEzDbqg1=F0VnoFb(zT~E+lL;g1Gwm zWkinf1Q5u4kPCoH{`~>E17s>tTLaMG-~Y>h{r`!g0jL-tfS4HmF#P%V_xIo5zyJLP zk|2W%e*G8w^<VPW|Egag?*Cs<1XTEs0U&@t8vgzR8UO3=uV4Ru{R29q{@4HKzy3e{ z`M>1X|KC618W{eAoCFX+EI<t)$Nv2V)bpG17syFKXJmtY4D<{e+&#ch`t#=x13&<Q zH2nGd2jnE65(coMU%#NLApr%|@E43200M{w7;C@({gLFB1o{E!Iwq;#3_QOXfqH?a z0ZE=e42*wZs{aC=1Q0-s45bVVpMVyE5d*`?g$zo_(F{Znq0tNh009O75XfYM2l-It P00000NkvXXu0mjfKy2q> literal 0 HcmV?d00001 diff --git a/data/images/flags/cn.png b/data/images/flags/cn.png new file mode 100644 index 0000000000000000000000000000000000000000..89144146219e6fbec7eaa89e1bf4b073d299569e GIT binary patch literal 472 zcmV;}0Vn>6P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzYDq*vRCwBA zyw1Sz4~&=?KtRBi;ni*s10whbEcgeB4G=&qK$b*(HE%4#=N12075}lD{uOxo_w$B7 z48Q*T|MTbn@8AExB!qo!(<Xob0%_ol`_C-)lsE40+p53+zWru1`OB*Q`^(1PAa%ch z<gb6fegWAmva&!+00Ic)kS}Zg|NH#+>$bmtfBa_T{rmsVufH!rO2O)W0!b+P{TrwO zAb?mv&i(NBb<rP&|9?15|FEe1{(j{5_rpNd2($l#RsRQT00<zElm7ku3vuiBlYbaE ze}CQpR`mNf#8d{D@jwMY4FCZI((wN;$bSsKfBpUT=hHfn!4UWSgPRS}07MK7009Kn z01*O)1Cj&KRRc`~+W-(iAPqnk#EJjF&VZ_bxf^U2Fj)Wo|N9rH79fCFK#}tM53{5s zP>u;G`sX*)cv$d*%>MiL-(QdhpkjakVmyu%k;sfcNRj;yhJaxT5MTg0u5&QfH8#Bf O0000<MNUMnLSTX%SKDF$ literal 0 HcmV?d00001 diff --git a/data/images/flags/co.png b/data/images/flags/co.png new file mode 100644 index 0000000000000000000000000000000000000000..a118ff4a146fbd40ce865ea3c93b9d20ab3f14a0 GIT binary patch literal 483 zcmV<90UZ8`P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzbxA})RCwBA ze7~LH-#-Q@_y=NxxDdu448*_y5I`(I2_gMzpyK~P?tdWq`=8-2Q1btOkdprlzyAZt zf4_m;|NnpA+p-BDfLOpL1KCf3N*VtB1+qYTKq`L2$X{Uen~_}>XbC_7v4G9~kE;3? z$h?1GrT>4y7{3|*{r>Zd0U&@_82$jY{AFPH^Plk#hy=QU5o|Ds0oDLyFn~2M0QE2c z1P~*G;PKK9g@-SH{rUU<_aC4k|G@~v`1Kn|{`&=C|M{yZ!G37cNq_)iVfexD=MNA8 zh5p0Hf4_c1v;p<}|Ak}_F!%rh2&CcXZ-(!`82<cb`2C0B7a08pYXC7IT#$*3zkY$j z2Oxl0uK%|byOHzz-u?f-e}PT@{SPPuB!OI@KCrVuf`9+8DJ!36I0X<uENl!6%zu6` zNs53S^b16Sq=D}E4PgTf|M&Ob|G$6!|NX(kzyJ_HjCUEp;l#l33lzT~^aqJVLqGr! ZU;wayYr!vk`O5$R002ovPDHLkV1m^H+|&R7 literal 0 HcmV?d00001 diff --git a/data/images/flags/cr.png b/data/images/flags/cr.png new file mode 100644 index 0000000000000000000000000000000000000000..c7a3731794031667843f05ad3897a85c7c434877 GIT binary patch literal 477 zcmV<30V4j1P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzZ%IT!RCwBA zWZ-XQ_{9JP|Nb%j`or-59|OZ5B=ieL0?|K!02IWz4FDhv1VIM`h{ouMJ_wx9D))d_ zqir#Ip6LiXWQ&F>&@8$XA^gF6<r6@RKmY${W&kS(5?}^cGYWw485tQreEI|sKr9T4 z7BLtb|Ns8&|39F_zyJSW=r5QAIuC3Skj==&{cH6~fB+Q6K@k8T2n0YJ3)p~`^rMAe ziNPT#pPe0tXo+02ibzBS3Lqf;$J4#~?PT7nPXMt1J;(rbFHjWdMu-NmG+Z5s{PpYK z@83*76B!r)0tlo5>?xq}zd#y5N`WeVBbyB+frx<tAb?mz+FLmso&SCL3~?xg{0DP4 zkOcYx?3cexe0(yKCjkTy<M*FGS(uqYatw&j`G*-w3=F_fdh_-zKmai^Fci1Wivl_d zXwV-p0);)03#9*m;sj*DzyCmSpsuCuWdH#L)Q}E~MR;_=xrhh`vw<WNK!5=N7J+%w T8XW5G00000NkvXXu0mjf>ZH0- literal 0 HcmV?d00001 diff --git a/data/images/flags/cu.png b/data/images/flags/cu.png new file mode 100644 index 0000000000000000000000000000000000000000..083f1d611c94a535e02954711486da244ec3c5d0 GIT binary patch literal 563 zcmV-30?hr1P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz#Ysd#RCwBA zyv@L%>bRVNjfdgKH->)<41fMG{Q1Z53yA)}NHF>VM86o=*Z=~EMTLRklg;&|8vY-+ zh5rBh|L@=bKM?ZoKOp1JpMQV<{rmmr|L=eQfFkpA_5cJB<7Wm2?&_*f&z|!oCH%Iq zVPN<V)b{^B0~CX)e_)!Ck@5Y<j{pJ00(8aifB!ju|NcCE2G7^846g3~85y7oVTcJ# zGBPr-F);uH5aUM%2KM%LW;3(jvuFSO{$n4X)^_tSpyPl4`3G_ako*fJr35*a6mA6w zAdn3#uC5Hay3D`-uuhs}_wvlAU0>&@1^xa7gnxhi`TP6#zh6KNfByZH;9_972M|CY z4S#m*VEXjw_teQhfB)LctFzlGTKpda#8eRa1F`|=V+j!s1_l{`0AhU0z`&K2_5c3; z?|=UACdB;@3Hy%?Ffai9!1(6P8-M@;JL%7#4}bn}Wu*W1^8Nqk|9@zxfJk8Yg2Rge z5=!5G`~e6c79IwMkAMHSMfCl8%kXF6O^EHl82Alg`~}6s-@pI={`>#;Kf}J}IRF8~ zc$$I1RCFdZQi0L={}0epF!T#d{sWT?V3G+SzyRi{Q|!NXWpDrh002ovPDHLkV1m+z B7Bv6> literal 0 HcmV?d00001 diff --git a/data/images/flags/cv.png b/data/images/flags/cv.png new file mode 100644 index 0000000000000000000000000000000000000000..a63f7eaf63c028615b2ded5878b5e14a7dbe962f GIT binary patch literal 529 zcmV+s0`C2ZP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzqe(<TRCwBA zWJvta00;m6G5i6N3=F>*82<cY`1KD;0?`i$g8?7_g>p^=00=`tlmIeOf&y&dk@6oT z&CV8YCOfMzZ7b-;WHc4ffO<aUDt-6+0*D1-^s`Su6<`ZMav%-AVCsGY(H{_3nx7G9 z4?qBcH2nPsQVq1+T<ScK7pK2&&RM@-e;M}NH3lmE{TGgaPW=ZEKukb|V1pT~WX?wE zZhH7ZBzjHrgZE;|hN~RpkNpB$_3IA^0rdd=01!ZofB*hvWMupg^1x#TMw$P(0V5OB z#}6L>0*K`UGc#9pHKW{n=1`s=Y4`sB0UGuD|F2(+4xFrs{6A+r|Fh*S$P>SRGs(%l z+O-QHfEa)O{tdAK=ovi517(5A-n@AO5I`)f0sl(Vm><0RZzq2;(QtLuuFT6X6;y@p zb-8u)9gSFj-3}5^z@SqUX4qfA01!aT3<}j|#!Osn3@^V(zxysO&hx#~ZN`f)vS*$d zpLlA?$HBnM&cM&k$j1o^^oKjY0t65vgZ)>ehy*ei5K#*ZyWju7F%Ll?01#jRixNU5 T4U6zw00000NkvXXu0mjf>cZT~ literal 0 HcmV?d00001 diff --git a/data/images/flags/cx.png b/data/images/flags/cx.png new file mode 100644 index 0000000000000000000000000000000000000000..48e31adbf4cc0074f40e95f87c1f103b91fe270e GIT binary patch literal 608 zcmV-m0-ybfP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz@<~KNRCwBA zWDt*M=vm0X!vF>Uz~~Q*L}q{yKmai^Fs!d`Rbj9@@=xl~KZbvQ|NZ^*|L@=bfBpc` zKOp-38^rz{DEepVv)`L`Yyt=%76t}3hHqR?<~aN{I`!9n&u`}MKs~?z|Ni|Cihlk3 z1w?Q7fI4M?HUI<=3&<7!82<kK_veK1pKJ1e16Kc%d;m22_ut>Y{{Q;@?^mDN?|=V) z{r~^ll=rtu5CcE}F#**BRkyY(adR>Mc_H{~T5^XRV~oVFSgBv#s=t2z{grn0SJIVV z|Ni~D^5u8)5(a<(0%_pmV<{@S{pr)+Nt0E6|KyV7{&xHKulY}Z0S*2Ic2cIyuhSoY z&3*a@=p=vu0%~ALNs$10lZEA9e*W!WzkWV{%9A7hYlh}ewm<)V{rd@2{rBIm&Idr} z{RV0P2mmnv&i?}d08{q%2kh+oUtbt0DGA2L@2{yn^Xv%Q%_4hjZ2Q<D!x_=S`1Ab! z{162X=hEi_2pFu()6xtcKmPvn=f596{(?f}7ch)iK(6`y4P-3K*FOm}|5zPV;ZnJ{ z;vzr*0YmB6@85rQ^>{KfBuh*0fWjOcoPYi>{Qk`ejtlm`TPy!Q@c0|K1n4Ay00P>u ul!4(JQe+}W>@P40kp+Sq42=5$0t^6?P(4CrvcmZQ0000<MNUMnLSTY0E+*pu literal 0 HcmV?d00001 diff --git a/data/images/flags/cy.png b/data/images/flags/cy.png new file mode 100644 index 0000000000000000000000000000000000000000..5b1ad6c07886e6963db439afff55d7056e3c5cd4 GIT binary patch literal 428 zcmV;d0aN~oP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzK1oDDRCwBA z{P^)B0}}Z64@|%*3;+;7EI`1;#RZi9|NlQk519V{A5I~G7cX7_1P}{M(SKwHs<MAT zF(Adr$OyCnAb^-qRsUo7cJ2SK=l>s1`2Y9I{~ve$KWO@Yukru?KTxgz{s9CK3*7Y} zZC_6Qf4TJkuQ&gHzW)Di+kXz$|9k=eg(Cm|XMz~|=g%L20Ahjo4`KiVr|y5|U;qEV z`p@?NKb!P_9;^R?@o<+hfSm*oKp+h;f&YIQ*bg)L>I;A4<JP<O`StbBZvzAnUSR|~ z2_S%25XOW3@c7%8Gq2vr^WD7i>WVP82*P-nR{;Wu@%{Vvyu7@~4txIf`R>OD+-01E zIfRil07L2S-Mat*#Q65@TRuKMWMi;}EJy%|ff@h;2;_%%@7_UT@edf{0+7H22rvMY Ws9gjvbyTka0000<MNUMnLSTZV#l#o@ literal 0 HcmV?d00001 diff --git a/data/images/flags/cz.png b/data/images/flags/cz.png new file mode 100644 index 0000000000000000000000000000000000000000..c8403dd21fd15f46d501a766a7a97733462f3b22 GIT binary patch literal 476 zcmV<20VDp2P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzZb?KzRCwBA zWQaf89UzkBCc*#)|Ni}hkjM-;`19`{Kmf4_{`<nlD|_?BUjs!Rh@Sud!33N_0xw>? z00<x^AmIN0XZ3|IN3MT_sDLX&Gah0gKmf4-8Grx%=lc6|<CTAZ|NXPm68Mh;Kr{dZ z5X&E+yMTuM|IhvR=emo3|NQ;upe=w)!=FEY00M~R*B`KN{{H{__dn;~pDWJ(72m#E zNt6x9|M%w)kYxA^mizmcQ9ytpFApGqfHnXP1qlN6{QmpT_re;Pa~ppC`u+dcufM-R z<p1Bl|A9#sC8ZBPe*y#$%iljhr-PINHQ1fsXm@TSRMGF>48MLcfb{@Lpld*gfdL?Z zSQvhRtN^J#x6%GQNHxSfxHgc;AD{-1tAIKH0tl!9q}uD*5!1Kl8Kk6va!f$;fJ%WL z`2Cv^NdEc54D$xi27mx!VfgXqT8ME7!~2)OPy-`SXu#NiAkhzFFflLy1Q-A_8F>@M S6G{sJ0000<MNUMnLSTYdwbYLQ literal 0 HcmV?d00001 diff --git a/data/images/flags/dj.png b/data/images/flags/dj.png new file mode 100644 index 0000000000000000000000000000000000000000..582af364f8a9cb680628beae33cc9a2dbe0559f4 GIT binary patch literal 572 zcmV-C0>k}@P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz&PhZ;RCwBA zJbeBCj6Do5-Z5~nF)%Rv`}K$6-#>;we;9uK`S<6~zaM{qBL9B<0yBR7V`E_e2q4D) z|Nnpa!Ep64!{fLA8NdLj;oraifB*mg`;Xx-kn#6Fhzn-q&in!pKrBENJRA&WD*ySo z7*5@0`26EP69WTC_22)0z>5C-g{l_hVFa245I`UeKudt6h7^Mc@BgDW81BCO4-x~L z`sXhc{R3+I%fRsKA3y*x{R6rHsA1>M|6jifb2E4w{<qcm&&l|o;n#mA7-IhQpAn20 zzAyj;5Xc6GvuFQr-~Rv8Cx+l)M#le!3je=x-r02F>z{xB{`~z5CPC;Y7bk<j4uAjx zJIToC|BDy@?d`!1`M2rArn#SIK9_&}`~UA>|9<`c2PXgjR$^CT@Hzz$KrBEfF#?VC z^aSdB^z+g5SJMxCJOGqNsQwQkfr0#&=?~CJ009Kjz|71H^!MIRd#Ajb^76;aUyQ$y z%m(TN#spBq-#`C>zGeUjAdrR+|30kwu=eoBL!3-pGMq9%bs!`E|ACMovw<P>4;Zk2 z8GbPU1Q5%#7t@Nb6*GKbU;u{yA29j{CVzn$|6qa)V3LCYAiw~8(_SNKujRx50000< KMNUMnLSTY(1rd4x literal 0 HcmV?d00001 diff --git a/data/images/flags/dk.png b/data/images/flags/dk.png new file mode 100644 index 0000000000000000000000000000000000000000..e2993d3c59ae78855f777c158a6aae6c1fb5c843 GIT binary patch literal 495 zcmV<L0TBL)P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzfk{L`RCwBA z{K&xYj{ypR41vFY|Gj$kK~nM`n2U`5fYks55DSpSQC$sI{tt*485x;)dAUo9|9$=T z|IeTQzkf4;Ng(^*@82&sZ2|}&7O=Me|DHT$`1|)i5COs8zklyP{P*QENZqgB|9}7b z|LYe}HIu9?&~$(REX_d<06+`@K>h!ZNvLM`<}kPiIA3?K?Zl!VJuS0ABN12uI2v;s z000mK68GQM4oDR3?|C6;zBc4LR82Q1eETXSa+3nD0Ad8%4|Ml`Fn}2U{~ypshW{9V zk%{T!hYtV&#KHiVV*o?2zW>+&Bgm+K00G4EikX==E9>w5`yf~S`o*<t*Z+?nzxew7 z`}_&w9*75kvMfqUPtKhK2q3Txkbnca6-YuX`}LFI=TE30P{SW!;Qj@A73c?m00MdX z|L;Fcl9FI!{(=ITnHd;@3}3!5LW2L#AEv)x)qg><00G4K6dZ++hy)_Bw{QPEdi2K7 l5H1Kw2asrHVqgFWFaQRwS@oh;XP^K8002ovPDHLkV1foV*8Tth literal 0 HcmV?d00001 diff --git a/data/images/flags/dm.png b/data/images/flags/dm.png new file mode 100644 index 0000000000000000000000000000000000000000..5fbffcba3cb0f20016c9717614127b89db4c9664 GIT binary patch literal 620 zcmV-y0+aoTP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz{z*hZRCwBA zWawl-fPV}Oe?a8lC<bO#hMzxwvRAN!M1C<q$zKcr0mK4S*6h*@B>yt}{r~U(pZ|aU z{rk@#!dhJXj+vP`D<JFlzu$lU{r>&$&!2yPR?Jud5I`&tqo00z_V3?cpb$_)%#~li z5Bw?p&G@T5&5h~tB<;UIJ-`3_mgbQL+5iwhOdtm^{QZlh+Tg>NcGo{qR#UR9ewx4g zlzHy^uRp(j{rmOj?;oHBfB*n70M7pb{ow!s5r+W$=Kufw0RQ~_o$a1N^GPoTT-yiw z&XGVBSbe$r0&xrf|M~#~9P-zx0st`p&i?@b004Y@cH`sX`~3X;`}>j`1PlZ1WGkKd zF<hCP_XYd=@$d;w*Yf)M`X3)4xw^mti1F{=zd$|zfq(%7|8%A=GP@nM_XkVX|AhPB zWIz9{-6_k|z8)yg#KiRe{d<4_Vq~ahDE2OX`1#@Qzo5`aIse1--RHH=zY=!-Zgl^f zz3#^$ZssPV|3D8Y@+lr_KLij!OuzsJa(?~&1=R5C@9&A)|BiAoCGBQ<|B|DAyEH^K zPzo5Ve}SI-!vGLKEI^O{{{LHoQvw)7K%Ky_T4Vj&&ww*8{l)UG^~-kp%LA1H!GBOl z0yO{x5F<k_N<>1~FN+w&9T}cJeJULy4V8r?0tN<v00RI`oh*#KILw0p0000<MNUMn GLSTXg<||78 literal 0 HcmV?d00001 diff --git a/data/images/flags/do.png b/data/images/flags/do.png new file mode 100644 index 0000000000000000000000000000000000000000..5a04932d87963bcb063497b1179cee12f407e18a GIT binary patch literal 508 zcmV<Y0R#StP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzj!8s8RCwBA zWY%2F00V#j{A2j_kKy0Hx7QN*{(QJ6DEyCs0f_z}kpKb20#rAt#`He}!~g#b|Nj2} z_wWCofB(4H7#LZ&L{n4$|Ni~|&mSQ94@}<Ov<V=9KpKEbU%mVB7oz&_KOp1p|Nkui z{{Fsu_y4co|9}7b_v;r>HM6WN&<2126ppbC05A-~q$vMCOd&N)3^rn&3WaiZo>@dB zxpL5=L>h@#UjVT%{Q39iA58Ths0J3s|NohoLF&Lt!8(8c18V>XAjZFc|1vT%{s#lF z^Kb%2CZ>-cJ^%y|<6Q;@;r#qR4;}z*|Nr|B$h_ab1b6QI%fu2>dIV_O?>~RR<Ubj4 z?xlST00M{!=#T$UOaFj82Sh)C=*KUx+kmQngH`|e19S=~Yykp@1*8EIa3HgPL);Da zGBAXoW&;)f0fh=k13&<QHT?d=Bq_-V3UZ*we*+neOw3G-jFMtpe?g%P3W<OJKqN5g y00M~dDr!VRNVTgMKX8euE7l+hLl_JI0R{lXvUlH4Njp~n0000<MNUMnLSTZz0^Iii literal 0 HcmV?d00001 diff --git a/data/images/flags/dz.png b/data/images/flags/dz.png new file mode 100644 index 0000000000000000000000000000000000000000..335c2391d39090d6b40a409870a74326665589c2 GIT binary patch literal 582 zcmV-M0=fN(P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz*hxe|RCwBA zRIK#<@Z$>u82tM4=f|&Ke}8;oX5+eBw}y?G6)5=s|Nnnr@aNATAPEpaEN{Mj=m^OD z&%p5S|G&Tg{{H#<7bL;LEGj9<&cFmz{_j5mJbLs9Ab?m{m{|TZ{D1lB9Z2clKfnI{ z`ThHs^2c9)q;CF`l>Eoa3N#g>nv07INCE^93j<gHsQUNcKR_h=`CH(f*POq8e*3+h z$K97fRFr|48EykmEkFP<{rK|>WH3<mpWna!|8afrmh0Eg22C+$Pv0+F*D^9OLA(di z0CX)t05Ji52{QiAub+Q^|NQk!<m>lKe}28-VENu!{qxMpm&svFOiawo%#4hT5U&FS z5Yzu(KY%Xz`RDh~-yl6N|NT~b@qz9A2lm1$Hf43G`D-AmnZXVNY5)izCWas1fbRMA z8?52ykLBVV-&q+tkKFpbbOC=r`2SzOfQo=l0_p*hfB*gk2q2IKpzD7Eo%H+Hk6%A8 za57|Q3rv;f;d1r)FDv_xg9F*eKs^8f#KQ37)2~0jMR){${rwHH2k3S7pO3`Z{#jf7 z{|`16Y&=9YkOT-IMh0&|hF9+yelRe6V}O#tcxPkSx96}BCoe=1&?OKCkOT-Y05IoG U$(*n^qyPW_07*qoM6N<$f?|9Y@c;k- literal 0 HcmV?d00001 diff --git a/data/images/flags/ec.png b/data/images/flags/ec.png new file mode 100644 index 0000000000000000000000000000000000000000..0caa0b1e785295d003869330fc4e073dce07e7f6 GIT binary patch literal 500 zcmV<Q0So?#P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzhDk(0RCwBA z{CQKI;om0)2>1sCzZm}g`N!}J$oTi?|G$4gL7*^D3`7C}Kmf5Y{CmeN)&f@km*M|^ zrvE_l-~a!AA&BAspa1{=fXIJ9!9O2vbOQts3j+fX{b%^|8m0my0Yd-4N`WN9@BjaR z=no^SIM8~400P<oatc<}Aax+)e}lQdftrDC1qdJ(7Oqc>%s(0a|A0FHhM?*o#se9Q zEZ;b|7ytr@MWm#zEz$bb`!9d~{{Q>$@1MW_!07MqKOpw+zkh)g(B$8L|49h*Ov!x= z5I`*NZ%IA<!1wqK%Wsf0$kyL~{`~^00P6Yu=RZvF@8`c<3=B#D0mQ=a;~&^0kh<T0 z;EcZz#;-sB|3Iwz^Y8CpP#6OQ5aW3U2C1T=Uk@Jq|NZ;l@83}J*DoLmHW?)H{|^xT zWmQr-d+;DY0I?`AFmQbRz#zyC5(OCxQo#UX{{e~o1z7=9&G7f{|1WQK7#IKoi18r< q!yiy=qM$!W3<e|y69WT4fB^vfY=I+7gOt1g0000<MNUMnLSTYPIp9kG literal 0 HcmV?d00001 diff --git a/data/images/flags/ee.png b/data/images/flags/ee.png new file mode 100644 index 0000000000000000000000000000000000000000..0c82efb7dde983e6ab0f6bebb3b2eb326ce3874a GIT binary patch literal 429 zcmV;e0aE^nP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzKS@MERCwBA zWN_Zk00;jV7=Ha@_yb12{(#s(^y3eh{fFVlKZf6b00K}b#{>X?Fbu$UU<G+ofGafM zs%y*k<jU%Ha!*#srreoQ(Zx0}3Nq-M?+YLnh|y2p1I0nAfz;o>AQCA3`wxix_2=I& zAouq_SzðCcuS!~%52Kai0?gF&VORsRAR2~rJG2PFT1^!)w@)C_d-AAkU2Vc75Z z*R<@<qep*$;5P_Dz^`9m0!D#^<mDL{4gv%a$Vq?x`~`C18lck1%E2^H9;g@~fIu4l z{sk%i_4^mhe2@ZUv*AVn6$1ni<I|^4#l^+{|Njr<{KpMIN*Nh1T(|%bKrH`&UIr^A zr~w@G{{R9AqyerP9)Uo^7*GI^`yZkKAb>y`9z1vevIh)-p7{p`5C+7f|6l;f1_&?! X)GmJPc-xs)00000NkvXXu0mjfGFPrC literal 0 HcmV?d00001 diff --git a/data/images/flags/eg.png b/data/images/flags/eg.png new file mode 100644 index 0000000000000000000000000000000000000000..8a3f7a10b5757b006948ea4436fb242d02dc9a4e GIT binary patch literal 465 zcmV;?0WSWDP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzV@X6oRCwBA ze9OS_j{yq)p^|X)2T2eh0LNi&0{{pEu@sbSoUIs&CGI1luw}1dchAHwJ5s2KA&U$! z;qL<OIZptwfRr-)|NG=A15oMTzd%ut9w7PqH%Q$tAmi7+U%!CjOtP{-s{jIsh0WBI zNmGkKR19i7P|x4LzkmK_w*AJmpY`AGUqG{&{(vlG6cuILz8xTd82|qL%gD$GvgiMQ z1~B~h@9&S-^B8_VVU?(0_|Nhm27t;wefk6tKui$tLdC%Jua{kaet!D*_vf!?E&q`L z(9{3^0R#{enrblk^ZU>LAHVtk{r`=k{y)(2e*gi*sIRYISXlV_^=qKp{(!;n-+xj9 zUjemETFMXP0$m6sfwJP_;%#kh009JYeOg-Dy?gh5gTXH_fG|KLm<2Qhs6|CZ<>JMQ z009IFR-loRl9E6vpeV=!FaTTi8)D~Q7yv2;2q2OXK!5=N{?|@pNV(X=00000NkvXX Hu0mjfG@sA` literal 0 HcmV?d00001 diff --git a/data/images/flags/eh.png b/data/images/flags/eh.png new file mode 100644 index 0000000000000000000000000000000000000000..90a1195b47a6f12c70d06cb0bd0e4ea88d7bfb03 GIT binary patch literal 508 zcmV<Y0R#StP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzj!8s8RCwBA zU?2r-WMBXYAQm81UtbR-|NZ;-7Yu-mKYxDz`2%AA{{8z87y^Y_zJKT4xDg<LSim~} zKYjWPDE$Wxe*gIe)B+^H;NP!bbH9C)l#%(wzyJ_HEcfr<7ZMTzN`myE0-#m@fr7t( zfARK~{q+mT_{YEi5I`&<QX<@}+z{0aVEF&cng1tG{{Qgde|b4j(L+m1*`Ggw3<e-# zU;qdprhg3o5M~4ED_8!{n)Tn!?f>`hKmn*~pz%QT=MPXRKmaj;?1ifa0xhloNlE|L zuK&--1mwx?-uoV+`qwW8u#*4+i1F_GyFwg7fByafIr{%Uh)$p>Ae%w(|Nq~=m~|`# z{`_H7QhIal96$iEC^sml1*F~kc<(nb4FCQ91q_kDz!3TkBLDsd`Sky<|4qNomi`s~ z`xoeVfB<3v8uI)9A4wibp!A=AfB%5B0nwj-e?TNKsQ>@@`|oeijK3Q@{{o!^5I~F! y*$fQd7#Mzm(H|HCnf(WhfND7yc3x%x2rvM-AWsdQI)rrq0000<MNUMnLSTaaA?2q4 literal 0 HcmV?d00001 diff --git a/data/images/flags/er.png b/data/images/flags/er.png new file mode 100644 index 0000000000000000000000000000000000000000..13065ae99ccace42df97be8b594049f9f40dcc4f GIT binary patch literal 653 zcmXw1T}V@582&uljzg!GcrmAG5$1(Rvt>&(jx%j7OGE_~DVuFcQkgj@33fJv($pjj zgoNxWFM>pG#K4X+%S_Ys!f>f$mib36%ekHNec$;y5njB{!+Y`YzVGwA&4mTVh%ikU z03gD2Hn&LPeNu%hDG7PUvzrphs|^<n0Q7#)D;}*&eXK37%vM}`)po~x+X@Vp+A8Zo z(+zXAwa99=G}Mh-vjCtHO}U1WyY8y2$#Jdmp}8&ARhmK5k@SN~?M`UxXSGTNLPmqn zoz9*&TbVkSZi~^sBa1N*wvy?}9}Fut&Tt-%ILA5GPT<hBxg!ox#g7aRzyH;~zM@NN zW<GWjcb_<jy@7I`xtDGqx`O>(-as$IIo1LmPya%Hc0Qn*6qc4XX3oKoa+Z)_XBQk8 znPA)<qL6^<^L|g8<~_+4mRW}WuI+%2a83=NKpL1LauEQ95eb7ZWIv*k(QX@3+5|z3 zl?yO)3ua8CO$D0L;L$^HRSUuHp6s7-G)7->XelBh#6J<)fj|w>7X+~Yun^@Bp4$+N z6L8rb{%QnJN{fql*fJH1L*2YjUlB~CXS&&LY)1V3h&68|x1_5-(4l3HUgs~3JvLXI z$_D=zL{dTnq9RK`-w~w|sCYqqA;@OoAE0!{9Gi+cF%zA>5*8OAiX<fj!2NF$x5>Ws z!A~!@Tb_6WJ;mn(q~>CYJ~Oq(|Mc`miY)G1d$)?S_lf*=dz3nd-8+hwz5w#U=!7L- z+<aupZ2obdJPL{-a)jftDB>Ve0W8Werm#o=KvYxRVVNtM9!poHk%m;Y{gxKdXC|Y{ fc0^aUlspXz7vm>S7OoCUZUIwXLGJ6E%Z+~lY(hhH literal 0 HcmV?d00001 diff --git a/data/images/flags/et.png b/data/images/flags/et.png new file mode 100644 index 0000000000000000000000000000000000000000..2e893fa056c3d27448b6b9b6579486439ac6e490 GIT binary patch literal 592 zcmV-W0<ZmvP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz;z>k7RCwBA zWQbH``0|MX0{;DB`1Ob3-!Fz=zZw28fY1*HhF@R=VQ?@21P}|ur+3w<Z6L+}{{Q>` z_wT=dfByab`{x&s{PXYspTB>919^Y{{Qd`I{N9v10U&@_7=ExZ{APUe{`KE~Al1MB z{rb!Jhml3<_uqeCzux)%<M%I+t-palRzw15E<gaWFetqi5%~F==^M~Upy@w<Gw_KO zF*CCPIbVMKc>bv7|NkFAvw@aK@N+YWG5`bsF#yj00sZ{|0ReUZ0OJ4u`~d&_lgxzd z_7grGtKje;)ax=32IqJ>VE_O6|Nr{|0Uz@6!2*a0?AgCSJ_s@V{`!jztXlEUzkg2h zOW%AK0ILQg2A~)NKmdU>0L=y=29PKt(~s@<LjL{#CB<`N%k4-`*7tA?4F7)vH2?$v zF#yj00s8;|0fcJ*<=+7O{Qvy=`uPBZQJC=u4Ez572=niRpHu|@^#A+&0Q~#`9U|<j z&H{+#9|HsPqd))e{bu<68z{l@>sMWK)O87MU|4G0ImPt*+y7s`{{I1L_{;G9FVHyv z0R+;(^!pEkq$JpwzYKqVGyVRp{reTr#sB4{{{Q&{G@Ah!GGGK$3=lw!Ux87Egcwk{ eXi`7`5MTfy3O%OUuKb?>0000<MNUMnLSTZeEg+i! literal 0 HcmV?d00001 diff --git a/data/images/flags/fam.png b/data/images/flags/fam.png new file mode 100644 index 0000000000000000000000000000000000000000..cf50c759eb28b5962720aa1ce0617a29003e477d GIT binary patch literal 532 zcmV+v0_**WP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzrb$FWRCwBA zWH|hv0S=hH{I#}WVEDzr@aG@HuYU|bfaIFb@BaY>SU^I57#IKohy|#uQJDd#=|98& zUrgMLRb~JG{$u$2m+|*M=0AU#{`@s%|H#1h=ih%I`g`z86F>lp<roP72nGV+iNXu~ z|8EMF(HSNQhko~mr>k-UOTf}FCMD80)oDIYC+&q4vMR0s0&DpH|L=c>-#`S^3sJ`m zRPz_88i@Y=|MmYr*an6_AO0`^1P}|uzkh%JGXi;k8UFtPIt#4m|G)nb<AK`#{r?Tt z0CMQJUkm^N1k}I)Wc~q~2=v20AcouU2cid|f$<N}Tz~)qdLCppPz;2aAU6C4IuS_& z&@`Y!fmVSu00a=!NkC8i1_md{CBOgw1lj;|+iyttfSe4p1V{k{5GZ*6{9^_h|LZ@) zFGjG_;Z6ek`!C3DkO%$)_55Q12q2IRzyC8yumVHr|2IYk1}Py1ehx-vW`^H?fguJ& zK+b=Le?Zj?4F8yz7ytr@kzwOka2)<)0BQgAHdOo{PzDLLT?fWHhyhgc;RgdifB^tu WyJe~`g5k>m0000<MNUMnLSTZDXyK*+ literal 0 HcmV?d00001 diff --git a/data/images/flags/fj.png b/data/images/flags/fj.png new file mode 100644 index 0000000000000000000000000000000000000000..cee998892eb316c3293ef2d52afec9218bdbbc03 GIT binary patch literal 610 zcmV-o0-gPdP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz^hrcPRCwBA zJepg*=a*Fd%xm?+7r5A%%Ad=mOMVInF!B6U!yxsK;m<#YA7Jv|KZb8W1_M9<F&fI| zZ(S7rM_lU1)hqwMePfi6;ujOxcJ=#15knRZhF=W-|NH}^zaVnbv@ZYw#4_*CD#oj7 zoX7SuzkmPl`*$`$!S8<<EdTsJVr;;``~NRk-S593B*D$d@arEy0I_W0GDuX`<a+<? z`=9@eLZTeX3h(YezxtUSr~;_EcuOJc_s=2`e_qW0%^;&N?N>2C05Lr?a%1G+Sb3M_ z-f!*)-&mJ@lxC7weD@!u;s2li|9<}wjr{Zf&o8mqKR`Cn4*&rGF#yj01QaSLwCD}R z0w(ww8v*|PzTN}jB`Pj8{QK|!{{8;|gOCLd|L9jy6{oELG6Dcq@B)aDq496GGsCmb z5T7wXTnzN$?|=Wl{r{i6vr6{G)xV#={AXc)t!L+QBoiQjSb+Zc`=1dU2n>I~p8E|B z6OfY_{`1$ji1Pn`9_T5yZrhJfj0}g~00a;V(9A!7nZZWFG{8az7^c++|9dI@cmDl* z!Nvb)UorrL86bdIfbsI1fk}{;;V;BV|AE?oY(}v2K-{x07*6Kx`S<ew-+v6i=mQ8K wMuyC<Aemoa1d3M%1os~j0~FZ|KmZV6001~fjJuH!vH$=807*qoM6N<$f}qYJIsgCw literal 0 HcmV?d00001 diff --git a/data/images/flags/fk.png b/data/images/flags/fk.png new file mode 100644 index 0000000000000000000000000000000000000000..ceaeb27decb3f138ab5b385491c092557b79da92 GIT binary patch literal 648 zcmV;30(bq1P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!8c9S!RCwBA zykA(of<vcr$(zMY8-6h`B|oufRJ-S6C1pJK3j@O^28KTj3_lncet{W4k_{jL05Jg0 z{{cHQgSCu7_8A@n*3|y<^ZOVX4H_1(+WXAS<NE#m{QUj<`~Lp@{rdd=`uzX0oreMd zF#yj01j68w|I`r<!MOnP^8ohu{}2xR{sU3=__Ojc@c982JT(9W1oZj+`S<w**3kd} z`~rw&g_Mo2sum9q$KN{-{|k#T%Se2B_WmLp=l8e&fBpFLmz^iup7qZ^hRz*de*Xly z3LpRgF#yj01kOrcNe&IB-W@Xz{Qds_#|<}4E8^(U@Amro`uzR)`~AAl(+dOp_2~x& z{Qdv{`T~fBZP&I%B0-1m|9;2L^844HotG}1Rpz&5{rC6hx8FY**;$yZl;mGuIl;^% z1C#}700<zK_VWz+eoTLUe&7C;=g;qdzyJQceDT@YKfn-S`u*c4*Pr)~j_-f<@&)H_ zo)5o4HUI<=3(z0G{{lncACUU}=kKrI|9<`d{~O5r{WmX^;n&Xtd%r3FVN=liw2#e5 z{M61r009Kj01i7a`t#@4Z=fC!``6FEovZ%E+dk80c=Y?%udjc9Z9OXpv;iQ1fEvF2 z{_|f_j0b2uP$$?`Age$Y{{6T2HuwL(zko*k`OotA?_Zz>fB<4-V2A|A=r2_C101z~ iU~vp#0R6xN5MThlzdwv9U#bcK0000<MNUMnLSTX;Yeanj literal 0 HcmV?d00001 diff --git a/data/images/flags/fm.png b/data/images/flags/fm.png new file mode 100644 index 0000000000000000000000000000000000000000..066bb247389893b9ac33893fe346732ef394d8d6 GIT binary patch literal 552 zcmV+@0@wYCP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzx=BPqRCwAn z#?cJ`5ERAGXEdXk%v#da4L<baq^*xxzobks8X~+07veP=j{sr;s>}CO1*!he@c;k+ zfB*mg{rg{#hxPwIh8G{d0|o#7{Raep|AEAFCm#U_AQp((@4x;AD*Xo({rB(3@4q1y z(m>6<n{F~NGW_`qRQ(UAO^}D3;nyF40AgYI2XYBW>3@(hzyJPYWMEi%>@iRTXyxzU zAo>qT2S^W413&<={Q1WKlmMyz`(Hzv@8AD_R~~+N^7b200Z@m&0zc4{Q@7rNwftxJ z^$Q??fEu9g1Db8CAq;fMkDq@pJa`8*&sI~^TtyHla^%`8uswf)HUI<=3()%@Pl0^! zf8Ui?K<etF4}U<O`M>|l%XeRX{Qd(n@juAHz(D*15I`UefB!>$cK-hRUqIf!|Kj}Y zKt+#Ue>r^ZHOMRf{y`i940V72V*2%m0pg)Q5O4ka4>U8*PA<V-4jB9l44`lWs|LA( z;nzQa0Al$8a?^i4ZdRZ-z`^&Q=P&by)6am85EJ0|4>AEH2(%ZZ;ol#C0AiF(UW*)& q3=s4Oj6m#vP&UxAe?ZIt5MTgFMVEBke8_SD0000<MNUMnLSTZvulI%k literal 0 HcmV?d00001 diff --git a/data/images/flags/fo.png b/data/images/flags/fo.png new file mode 100644 index 0000000000000000000000000000000000000000..cbceb809eb9b96d5d8ae231a53c4f4a98f0fcba9 GIT binary patch literal 474 zcmV<00VV#4P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzY)M2xRCwBA z{QUVd0}SxWy}12eRh9kb`xj#W{`~`r{KEhM0mK3n<>BE0lK=nzFYgc)d0A2*B+AFf z2joHok-@WP&j1351!6Wt`q9fjf1W;g`1ALFY=DuG5oiNI0I|ST{|2JJ|Ni~?`|A$_ zRt*pr0t5gt0M7pe4IJopi4@}M{rvp?{Qds``}+I+|3-D|`uqR;{Qmm<|NHy?`uqO- z{Qn;q1i_Qs0*LV@1A}N|@t-FT{{IC^{`vn0sPGp^)o&2vABgb_!eEtCyu9%!Kmf6* zGhfda5_|CT&#%8A#S0%rhKer*8VNG{57cZ3sU*g7is3Rq0I|G(Bf-nd3vr@r@vHy8 ze*OIQ@9-oMOb-A(eJ@7=Ab?mP;SCW2x*O<#U%#>Y7zqCS`2!F@APw*!ml9!S{vjhP z$_zA&0R;fLP(1(v#Q5^%OL#2G%0Af7VC%@R_vTF*<k$rU=O3^`fB+!C0C+H8=D=x) QdjJ3c07*qoM6N<$g0D^5O#lD@ literal 0 HcmV?d00001 diff --git a/data/images/flags/ga.png b/data/images/flags/ga.png new file mode 100644 index 0000000000000000000000000000000000000000..0e0d434363abd6766f9e8a8c8c9ad7275d23702a GIT binary patch literal 489 zcmV<F0T%v=P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzdr3q=RCwBA zWN2nUfPV}Oe_$jW{emGd7a#zK@=^l;2m?X%f;U7}2S#dde-x6*JI$J{cD*^C6w#8& zkfQ;8O$dGZ3LqAcdH?@E`T7*7^zXmFzyJM)kRbFIgns?~_3Pg+AVZc%mf;5jKmZcQ zAO!#rhGA`;!~cJ`%2amADUbvfW>k)EZ(2O=d>QH$KN3zEi7S9u{+2K>GX4ds`2QcM z=+A$K-~a!^(JwH9Fn%*K{{Cdb01!YdV9)*qi~a#?`wdg{8%Z^Y!NB<E-#-R`00L<M zDg_~+?f?IvszV_e|NjPR00<yP2K(g`atxop{RRq`zkh#&0tk%$g3#~3f5B{^)IVtv zt~r&f0Ro7H;g!JW@9ZDGGW>>w;@|&31~6!UgV<mZp!h$K1|Cih1_mjB0AgYI1vK}c zm;m#iKTKd#|3l3F1Bnr!(tp5s0R=fQmKYd*00a;tgY0^SZ@(CR{R8R*k^ldo#xp$H f8Nflz1Q1{VoFiyorf{!p00000NkvXXu0mjf7x~Kn literal 0 HcmV?d00001 diff --git a/data/images/flags/gb.png b/data/images/flags/gb.png new file mode 100644 index 0000000000000000000000000000000000000000..ff701e19f6d2c0658fb23b1d94124cba4ce60851 GIT binary patch literal 599 zcmV-d0;v6oP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz`AI}URCwBAoHTQvnvLG!L$_YOVPIhR z`}g19iWuE1ZBzdL{i|D>U(k2*|8J(R-+sudaynhucHbwAMTnor{mwqO^w7JHzaBsT z{O^B8RYf5+LvDs&KmRKVd78=o{`1#HTiEo_OolaGleS)G+IQ#sUI`b*<ttPkoQ(BX zdH(z-BO?RD#k&lb?f@B&-u_Q#y`y({1!HORg0(k0n@%tR-SPDK`}#L}kNztCZEmQ{ z)B=hyeE-bw{X3BEzCWGY&E{rcl9ShpD>pv<`1zCJ=H0jd{{2S>p`ri%{LsXJ%FbMS z$#S`6f|?OG!^Jxczkf6Q`UNF{l0Sd`ad7zm>({^EzyAS6{{CgrkluOb3l1A>ZU2~A zK+FZ=zkmP!`TOVhpFbzBzF<mgJlZ$!LUj7htJgj-ynFjAB?0L4|6jiYZ2)<O;r}yd zr$cjg$jHr$OxbL1*Ua?e#~*QFwxu)O<=6CooI5XBFdeAs-_M^w$1wc=Z|$&LWbZyx z#upb{59yi8GyMC*6cei&?XU5pqw<n{Y~wd`A)deg|1&UZN;7Io039Q%E))~L=CG!% z=$tv3r|Yv5ZCRebeml)AZM~}fxupkx|M~vs@885Q)osT={QvXE{ra1g8}~B&`@4GU lArU#j%I>aPmD2$N3;+$pK?>zdet`f0002ovPDHLkV1gy;I?Vt8 literal 0 HcmV?d00001 diff --git a/data/images/flags/gd.png b/data/images/flags/gd.png new file mode 100644 index 0000000000000000000000000000000000000000..9ab57f5489bb9ebb6450cb27f4efe0cfb466144e GIT binary patch literal 637 zcmV-@0)qXCP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!4@pEpRCwBA z{Lg>@|2i2MUNJEAGBA`g{QJZ3ub1IpEW<w`hJO<o{xLuiKmalRWnfset2$l_q@IEC zAH(0j{~3T{fBpk?{QdKv;rD+a`|t06@BXyi_??rm2_S%2fC^u6{cn2zG?eGBCfnct z%)c4_{r&&%H%KY-@BjaQ|Nr~z;*VbwzyD_7kp<cS5I`(U3=IGO{lD|}Z`;eijy%78 z`Tq*D{QA%M2c-1(uQxw_EqU{M#hYJW8Gaiv{05p15I`&-m;L=)BloA~?w>7h{~Z4K zJ3#Qa=Q1XS-;5h0el2+U>(Te$zyAID&HDTIKad810Ad2W{jW5`@9y)AKr^5J{4Kqa z;cw*UKQW)B)-pW&@%z{RUqB7N{{H&&_Ycr?fB*vd;rG8kGw%JlqX`uK!_D~nn&&T_ z*-Q+-7;nUX=6mt$`7e;3-;BTi{QC{m01!YRC;j>JdmqEEKMa4Icz%Tm{+4F_^}h({ z_1{sye%WyRp84|E^4Gur0Kxx1e;6150*D2Oe>41%=Kmef^V^IA7&yOx!2%AYU;o*D z%dq`!;`!w)_PhDb-(PS30!;@9Adn3rpZ_$9NHVegX88Y?;V;N+#{WPzFy?-P;*ar< zJ?CFrnZE^h{{CWM00<z)uMCJ-{1?x_@POgpL56>h;Fvzl@K2fHp9I6dqaaxb00=Mu XLcuQ~?TP?t00000NkvXXu0mjf`7udf literal 0 HcmV?d00001 diff --git a/data/images/flags/ge.png b/data/images/flags/ge.png new file mode 100644 index 0000000000000000000000000000000000000000..728d97078df1d07241ae605dff2f2cac463be72e GIT binary patch literal 594 zcmV-Y0<HatP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<Vi$9RCwBA zeEs@00}TB8_YXp{$w)H%W%%~^@jo#5^XCs-3Lt=3fRu=c$p826{$4oGq;J3|DD?k7 z!>^8x|9^h-OG^F+g7@$L?BC01YQ`Wbb?43<fB<5GnEm_m#dl|p{l0h(sDOdt|KDd% z|2}~l|MT4Gm;1K=JboCc3}_QT0D)`(Dq^*D5a8uu(Km*0fyV#;{rf){u-H2Zv9qz5 zSpnHV*8v0&<Ja7D)_~yuA3rfLG5-Jc>;K=s|G$6#bNnb!1Cx>Qe>QfY2m>?Izi&U8 z1o&Rm*8l_%%a6%3nS?}u4*37)&;Q?l7=Q-<`}?1Z>;K<Be>QLY|KmGQ@ZWEch0Jnt zUmm{%2p|@g=ujpTGX@n^21dqzKYxO4`1a@2NuYivJ4XgKw*UYBFf%g!{qd7YP>5~& zE`R_4F#yj00OjT7{QUg;`}^~|xBB|}`T6<y`1tNwRP9nt_4W1i^z`%d^XHR`^78WY z^Yg>Q!vcs262Lz;t$n|1+qbnVARhhy8{z5C(<Z#xxBF9f6WG)L00M{wh=DA2BQyRt zZ`jSOAfe1CF2V5YCnF;xkj>*C%JTg?tEV3%;s64O@&5h$(1-*>2%Ak`A87BFlP7^( gh&l)WvH=1N0MfQja}g1cO8@`>07*qoM6N<$g4hNuZ2$lO literal 0 HcmV?d00001 diff --git a/data/images/flags/gf.png b/data/images/flags/gf.png new file mode 100644 index 0000000000000000000000000000000000000000..8332c4ec23c853944c29b02d7b32a88033f48a71 GIT binary patch literal 545 zcmV++0^a?JP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzvq?ljRCwBA zES`Fq0S5m4`IqO%_xJDLpFe*B$v=PoFfuYKxfuTY!tm!O!yljm1_potVgafPw37mo z|Ns5}_wT=?B=7(K|3TdUK<+;v$=cHQ|IhD#zkdDy{rhc26F>lgG%);U`26kn-@hOg zU%!6+4+cOs(0HIde9xZz`}Onxub&LUB0x(30+2WcIRJn#2ut|?gWYu1Cf+!-K%B8# zdf?1WA}#uZ8oj7u>$I1i0Al&`=O0k%-@icgAIJnM0xA6maSq6BK-ECw|NZ*S`0Lj% z1_pot6puj;05Ax`F!=umqj7^frO?t|3^&I1kxUq9yECc+jQpY84SWH_0#pxl$?v~F z@*hy-KN0|X07U)z`4{NpU%#2aHUI<=%a31wK(7Du52Oc(|3O^?R1IN+RRjI-n*kVB z3=9AP#PZ|EACPLGJ%9cJNh|>9B%spYzZw7h1%?tp0I_@ndg9MNE>313@6R75NcceF zkr51-#U+7;F#`Sf7i0rK0I_`g_NQ&Z<sUzOef#m}%a32uoRUDN{s!{@0^RcK$B!R+ jljj4~L82K500ImE?yY-fn+$bC00000NkvXXu0mjf0<ihi literal 0 HcmV?d00001 diff --git a/data/images/flags/gh.png b/data/images/flags/gh.png new file mode 100644 index 0000000000000000000000000000000000000000..4e2f8965914ddd3bd6be97674d2e40a9a3f7d26f GIT binary patch literal 490 zcmV<G0Tup<P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzd`Uz>RCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`(ov;U*021*0XgD3^5{teN< z@cTDV13&-@;@|`T5QYI@3O)ok?1DO<2trehc#kXh!0Z4iC6of!=I9L4Jz5Qk(jP`l zJOKo8(qFLXAF#IH8`u5XwDI@PAHNy@|4L4RsD^0x1N0+605O4m05bkR14QCiMDQ;; z>0h$aKjWi;+@CNFzZm}i25JBZAQt8_hOB_!_dovn^Y72^zrTL{{r&6TuiuWpfB*e$ zwD}j1{Ph<^0%eu?|D0`P00<x!hCd8{{sNT(ee@e54N~#%H;e=d0?h(y_zOe~zZd`l zhy{p$|NA4!EeST}?;nUxkcvMb@-HyF{`>`k|9}4iHT+`$2p~p=WCoxfpgkZGj{YEt g{DC2GLI4Ob02tU}a;hkw5&!@I07*qoM6N<$g4!w08~^|S literal 0 HcmV?d00001 diff --git a/data/images/flags/gi.png b/data/images/flags/gi.png new file mode 100644 index 0000000000000000000000000000000000000000..e76797f62fedcbfca8c83c51951680d6a6e9081f GIT binary patch literal 463 zcmV;=0WkiFP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzVM#<mRCwBA zeE<GE0}TB8_YXoMGvMIQpFcnnAb?na5&{AOK=S|p{}63p`u~48g#;cvcmNPUOfW?- z)&Kte{r2@=d;Ra1FVJj&*a;9oOsHo6|MUFWzp8?-*RTGo%>VoB37QQ+R{;bN6GSUi z+kb{HA3p-oUOth}-@kwP{0U71P%%INK{Y@H82)oDp3V0Dt?T`39Pi$;RTl%zL?{P4 z2_S%&kW~Z0x6qjPzkeV9`s>}VU!Q7P|1&Wm)PrpR2q2b!5Hle5FfeebsWZ)5s<A&# zz;@nWc6PW^AmIU23=lw!KSf!Y>pT9vc*+3O2}Iw&|Nr{sqx*C2&8&?7c>c4n{QvVC zD9-TjFQbI?+i42`0*K|`>)%Y*uQL4o{r~rGhChE9{`~$Pz`@Ka$@uLp<F5}uQy6}O zRD-0RJqKC@5I~GyfnGsEK=6lw;SB?W5UTh;SZV+WFaVRiQj9A%xvT&H002ovPDHLk FV1grA&&>b; literal 0 HcmV?d00001 diff --git a/data/images/flags/gl.png b/data/images/flags/gl.png new file mode 100644 index 0000000000000000000000000000000000000000..ef12a73bf9628ff5a67b81bd980d9c5d2b2c0f05 GIT binary patch literal 470 zcmV;{0V)28P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzXh}ptRCwBA z{P^)B0}}Z64@|%*3;+;7EI<i9K0YA%|NnoGj`#2X|M<ZmB=nz^6(WcXo;`a85I`&t zgF&jld|_C)@c-%44Bx;12Wp6p{%>J{teTOL@z0+>00G1VRSiT77W_YWkm2*^|KGm- zfAHXcOY8ruSJ7+$Itd_vn4oTd_U!+mLkz$F{Qvdq|L@-*^6S_C%a&nk00<z)zp1H= zAtC=ieE9$8573+c{{qpU|9}6Yqd$Kb1qDA=RssYN3&ZT$|GBt;djI$LG5kmM<^KKu z^YWk;{{GFVr1bCodw>990)`(*=-xesBS%qG0|gf?f~#iu^9N|j9|i`10Ac}ZU<Pu2 z{$!Xq5oC|C@qcFK|1VzrpET+Jv13pTe}EePGW`7u^ejLCfiy7v{=*<C2{LBdbOtd| z1~xXvM~@gjd|;552O11QfB!-dP%%INF@6QcC=y~o<)TRe0YHEO02qo}O31{6<NyEw M07*qoM6N<$g2#H%K>z>% literal 0 HcmV?d00001 diff --git a/data/images/flags/gm.png b/data/images/flags/gm.png new file mode 100644 index 0000000000000000000000000000000000000000..0720b667aff506d7892c5c301af04e6bbf932751 GIT binary patch literal 493 zcmV<J0TTX+P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUze@R3^RCwBA zJkG%IhXDcp!MJ~5?0+x@L>wRhhvIeu00_fCKU~B)yH$s9sXS^B!W{?M(W&}hPbMwO z;*cg65E@7haJ!!XVgYOW|Le(9kkY?@fpY);{sqc`6amR!K*q2CzkUI^Y_hUI(*XjA zMdSH%VNp?r|Ns620Z1<dz#0D_OrSI)Bje92R{;WuQ9S)`Qz+xBj|_hy&iVKM&tE74 z+4Kj*_zmWY3ouTs_yiC@EH7^{ynn~=<{d<W0i^T~NCi;oZy4hbn9cB$k>T|}fB<6Q zlw#oF`Oo_sVk+2%KTsoq3?TP6gz@)3Ki_`_=6?VI#CZSdeQ9y&f57m8xf%uh13`xW zAjrhTbmsgSfB<4-$Y)3kNW1sx-tWJ^f#}!YUqA$5fJva>FJQR-`S({vK;>fVMSuWe z0mcW=Ig;FxKxv@ppTFP`1*!N0BL9M&0|dYz`1hCL7Xv^5F*2kxF#KQuvOqEU3km&! jiTr^fV1zR<00bBS-TrJ5MX@2w00000NkvXXu0mjfGz`_@ literal 0 HcmV?d00001 diff --git a/data/images/flags/gn.png b/data/images/flags/gn.png new file mode 100644 index 0000000000000000000000000000000000000000..ea660b01faefde01ad2527a6abcf7d1a5c1b0526 GIT binary patch literal 480 zcmV<60U!Q}P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUza!Eu%RCwBA z{Lg>@|A6>41A`El{SSoRd}EL<V_^6NMt?vEAb?mvlGW8f;{U&Yj6fw^fB!T51<Eio z{$XbR!^`nIChX6jf4_hK`}6zn?@d!T0R#|O1H=FSPo6UT{R>3@|NdrR`1}9=ZwUJP z@AvHwzkdDu1yn7|BMY<#Ab?oFX8(t({tZ$6>;L~hU=2XVuU~(E|N0Bk07O6y00G1T zbT=bV^`Afg|NLS2{ReI~M8m&-NE-fuGynt;*hzmtW+Q3%1=j#1fvO=I{`~y|)Bq4b zU?=?r84r{KY4``%041R|`~%zYhXEjfz)k`h|LYgXRlk0r+3@c_)IERx{rUUv4^RU@ z0D&|xgN*;p0Mzyy>QQ8EKn=iP{qyfH5CNS85I`UeOuzpyNJ@hA{P_#yFfjaPWc&?| zr{By>f0X$D{QV0G@4r9|{}=!Qi18~pg5ikaD#Jf9Xfy-Svu_Nh0nj)GNi#731Q-A_ W8E1tdJ(&;y0000<MNUMnLSTXiG15H% literal 0 HcmV?d00001 diff --git a/data/images/flags/gp.png b/data/images/flags/gp.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb086d0012637103c0bebca861c10116ed3d527 GIT binary patch literal 488 zcmV<E0T=#>P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzdPzh<RCwBA zlzq$afdLBs{A2jV!0`7UnDY;Y{vfgcF#rVMP>fLk0D%}*I7ff3uKv?i+N*~ULWZ>4 zW5%k%a3T{@*`z6pma6eF$JtK+F@C*&o=d^t|Ns9GOCXH@*Z?CV<FgZ+00M~dKR?6& zmL`S|&lo=a`OgS6=r`DOAo~6PH<0}c!uZGVhfzZM!;}pG0mSr&jp4r%6GIxuUty3- z{(`*l7svo1pr`)-{r3+@{{HtHs1qo{01!Y-3||-+Em-~w{%7=K`~L%C0}TC!GXDLB zut6FC0*K`w69ePj?+lW^{@?%3@b~Bc|9_zBAnpM=2Fzyo3)acN01!YdOkWuo_Wbz& z<UPax-yrAw{>uP#7nB5Y|1kmu|NQ^=?>FP0zrYX%2q2aZH;)P`n*#-K1r9WbfYOYN z??RUX1P~*`M}`*mir*mb{sxCVG>rbhqT(MY2L1y54rHu+wi6(L7#SX-$0bVa{(;3h egu%oB5MTiLH(5{VMZMqv0000<MNUMnLSTX)KHLxh literal 0 HcmV?d00001 diff --git a/data/images/flags/gq.png b/data/images/flags/gq.png new file mode 100644 index 0000000000000000000000000000000000000000..ebe20a28de06f3e6e520cea360cfc57586a5bec3 GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzt4TybRCwBA z6z;tLmO+sL0{$^D{DG0k=ogG+VE_mq7A6Lne#e(@7$*Ju{||`%{{Q>u-`~Ig{`~p> z=MRwl_xtx>F!}G#@4vq{&D;bKKrBFA+{}CzK0Nsg1pog2{{I_D14*DX1pWH^3y6RW zSzcL&Zwvqd#Pa7K10w^wlkmI<oj(l!K@bR-z8(Azw2jjqNdAWi{r}I%$oSU$EkFP< z{bBg`?;pdX_l!S&v9K^g(e%rgCtWzt%)rddzyc<rh=Yj(Ab^;D{bBg=m*MaK|I<&y z&HAs)Xspj*@*fQ_fXoI6Af`Wm7{34h|L5=jL^G)PlNT>ISh(4kI3GTJhN2$mzJLD! z0*D0|DC|s(0(}1j8UFtJ4HA_S5@P=M@9)2VV#30}-~k05FvNkXnV5ck`2-L^EDTKl zn1259n3DG7^QXUm{{H**3#f<zXz!mt_kaEbg(pzYpWi?O*cBCz9XbRMKr9Sj{_sA( z%OWQBPgs~4r~#<rFGw|z!Swg<e{g(&ECG^#|1vQ!00a;V!@C#y(FQj$q8J|O$P8et b0R$KTrKv+Eku_nt00000NkvXXu0mjfo3Z*7 literal 0 HcmV?d00001 diff --git a/data/images/flags/gr.png b/data/images/flags/gr.png new file mode 100644 index 0000000000000000000000000000000000000000..8651ade7cbe030e85efc811a844d8f366c97a50c GIT binary patch literal 487 zcmV<D0T}*?P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzc}YY;RCwBA z<h4J_z`$@~ngEbA$#}=`kMaAjzYM?rfzcl*`2&c+BLDvY1P}{Qos)@xmX_B4|NmV~ zuQUArzot{;|9^)6KnV~9!65MOKf{AZ&j135@zJA4va+%uaWDWfj-G!Hl=}x%@ei!- zAC%3>!E|`n6Mz6>fhdBj29g(UfB*aM-=Dw#|NaG$fByXc1LXhxFC@THKjjKQ05P&# zA9gbr+SsEBRPB^?1!&T?30hEBFhHFGv5AR^>DH}B00G4E=NHV45I6k$@0N4rAH*g9 z{zDN+_&*OP%Y{RC0Ro8e#fvv0A_7PTA~XKMG0?q08}8kE2oOLl>koag&}IJi^WT4% zN&g{c!yE%t3}J9_Fdy0V1t5S}4xV|TB*XjR%dfvcU;YDm6wdeu;Q~GU4<sVQ%i6i= zCO`l&-n{=&REQnq4oL8Uox=e2D8hvdAipr2I(rKsfEXFXH$x-x=RXF9KN#pA82teU dJBS1bFaYZtYfz_b#(n?*002ovPDHLkV1jE`)foT) literal 0 HcmV?d00001 diff --git a/data/images/flags/gs.png b/data/images/flags/gs.png new file mode 100644 index 0000000000000000000000000000000000000000..7ef0bf598d9aa7c12264551d5db06f44307911d1 GIT binary patch literal 630 zcmV-+0*U>JP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!2uVaiRCwBA z{#jYb?^(TjAH#G(#wba}bswDang1V~k=?X~nSp_ifkB9YfuDhai-CcIfdMGO2oOMw z3=FmZPr3gxwSKr|AH#?Dg4*gr`YKh+o_slR^4G7we}4b{{Rcw+`~CaxrcJp30RS-o z&i?`b8vx~Eehu>J`S$qz^!f=A3G?{>83*rF;63vB|NHv-`~3j>`uzF%_#GV;x3_Tu z05Jg0{{&$2KZ#9J4f5RdzRdzC6Ae5p=(o+T{uB7y^ZNSwN=h$cVmJ#62-nx<w6xK; zw*Uf&iLI(bOq8+aFvmL~*^l2DYTsHYM>+ob%l!TKAE1V~I16T$|E1+i#l`u&y!03t z7ytqQF#yj01Tv_Ick58z;rzG}_y7F-%)*RRAv1X@1^f8;`}+eA4+yBm#PKA>w6y_L zQ&a!|00ICp0M7peY0>I$KneW(?7rp&{QCm?`vUdU)NRiG`uYI-`}gbY@;Dai8vP{| z4h7uY=>Px#0*Hx$0pxvffc*LW3+PFp{}_J#0ty11^n2!vLjod>mX_Jy|Cz2{eHy3% zAb?mH7=HZ$2N_TWSP@V&gaITO7A`uvc=2f<_uIFxDk_pd4FCZI)bI_+mz3lOl7E0f z{^u`PGlT&Y`3GeD{rm6lUtS;y)Bq4bz=+dkVE6#Ehk@Y-82x6z3jhKP0OI&0DF;s+ Q-T(jq07*qoM6N<$f)`^cRsaA1 literal 0 HcmV?d00001 diff --git a/data/images/flags/gt.png b/data/images/flags/gt.png new file mode 100644 index 0000000000000000000000000000000000000000..c43a70d36424b66f1627216ad988cd23a4be9285 GIT binary patch literal 493 zcmV<J0TTX+P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUze@R3^RCwBA zWKRG8_Xh(A{9|DF_V4kO4;(E2fB+=$@873y|I{M{82<bNp&$PkI2Zr|h~@7WhV~ec z`oI7F|Ng@$E+WXx1myh(0wBfr`+r8>|9}7f|M~m>&%ghRD!%{(5DNnfNcHoNKp}=7 ze;EG#|IZ9j4hBF)cVB@t{Qmo2T96TF4?qC109^ty;2+Qi2B0xObN{1)Uw^<hfGlAE zx(XnGSb&=T{$m7k{(^<jRR90?{}<2{uzP+3HT+@#2q2&ahQI$A{{H>{8{`KJ4gZ0H zP(J{D2s9NSfWR7{27t^!x8NVhN&g{E`Ueb#e*ggla?+pwj3Cv27=VUhwE^V&zaVoN z82$hR5DUYve}Dck14V%vK+Z)23?LgAK*7ZT@-zbjKmf4-BkT7+CNVw+pd~+kF)%WM zL>VCz0|PT7gS-eZm>Gd?16jcE4<LXT8C<_Iy!!{V2dEQ>PH%g~!@=<9&!2x_@aEGW jS9>vVD)<L60wBNu)c|wN;KJ0}00000NkvXXu0mjfIl9bD literal 0 HcmV?d00001 diff --git a/data/images/flags/gu.png b/data/images/flags/gu.png new file mode 100644 index 0000000000000000000000000000000000000000..92f37c05330243ce2eae41bcd9a368c66d656875 GIT binary patch literal 509 zcmV<Z0RsMsP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzk4Z#9RCwBA z{L9IJ0RI^N{bKm{AHsx^|4`Wg0mK4iO`i`^^6%gOzd#ND7{KKJKYu{nzkmM!`TPGj zPzubbt>6R*AQp(xFW-Wt{{mJ2|NHk};Jc-)O#kPzME?5A`1{ZQ-#|To{!0im{$XGM z2p}eq*?*y`{{RjC%V7Pj<(}hvo^Z>=hrJ-xK=d0#0&M^~2_S%&fR6tAp8=>2$p8C? z0q7F5H=q9h`}OesIT^OczkdG%sRkpU6i@>|0I@Lq1-k@j0La}yZU2}V|JX_T{{Q!% z>EEtDfBydd!vuBJUq+w?fB<6o2X+#W53~WS;s2M<|9d72Yseq)`11cZJHs!aS-+wF z1}X*!ASNLG{ST%Z<XncYUw=vo9`LoCdHR#guiuQn!K#6>e}QTk7ytqY<Y|z1!JY#d z_UC`Q%mmK|+n84|u9PZ*dKxSa)Bscr5I`U&0gV?C1nB{L9q1pX1%lIoip2jg0G$dB zYet}Ipn9NUfB<6rj1j4Skk}BZKMaVt2M90#<CaL~qSA9@00000NkvXXu0mjfPmb#) literal 0 HcmV?d00001 diff --git a/data/images/flags/gw.png b/data/images/flags/gw.png new file mode 100644 index 0000000000000000000000000000000000000000..b37bcf06bf20520555542c58534333e92022d929 GIT binary patch literal 516 zcmV+f0{i`mP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzmPtfGRCwBA z{Lg>@{}>p)on&BOV)*xm;om>72$%sP!HhqD7ytr@1teTu4J7{m`^Wh2AGglm{~UiA z82<eK{|8L|1~TB}i*1_#0tl>u;s5_9PZ|FH1)~3de={)r1*?FgUtsi`kx>?C4?qC1 zfX)8@|L<Q$pz7bh8UFtTOaDPu3LzO7egicC1P~L@so($pE&B84-tXW4fB*T<@C!*P z8u|_N13&<QY?$%q@0;JhyZ-!{`uoqC`@g?&{QCR*_uqd&^y|;R-~ayo`uiWm5M=*T z`Hle~fIu2D|NhDU^C$23FNWWLmfZOLf$i6?Kfizd`}O<p?_Ymm<iFoae81n%V*m&s z7NC>Ze*d2K>lZN0{{I4*^b@QIr~;(tA5`a`e}Ddhy$ldQAPvkwr9kh3`~WiS7Zcc2 zh-#qDKOj^7{QC<;3||-k0tlpm>GvN7NlB2NKYyjTe{-?^h8PLd@aHekus?tQg2Ee! zfN=^CK#X6(5e!Gd)(i|h;JEn(j5jcXFhHq*fkB7?Aiw}&uW^ngBcx#f0000<MNUMn GLSTZq;P@l} literal 0 HcmV?d00001 diff --git a/data/images/flags/gy.png b/data/images/flags/gy.png new file mode 100644 index 0000000000000000000000000000000000000000..22cbe2f5914953f1cdea98b7b0979b327ced9582 GIT binary patch literal 645 zcmX9+T}V@50R471x2bnbf^g1cv(^t4O$o9GQ<pOvk*Osvglysxgc^xZB5X@iXAzRW zC6rKs4}zwNqE^?K1AS<Fm?R}_7*2DMbG5sl``!C}T@ua@=ixjYI9=ssrLq)d3IIT6 zxTvq>J&k9ol;AaCAG*Vvs6lsG2f+AJUecp&K4&zS7@MzJZZ+RCHJO2~-cn~)8*ZB# z%#~(Seaqctb3On>x<RkIcK=)5)Ks5&$vfw43eX=$NWVgXa_tK$O(gXKB9?&&<N`Mq zcmddX+C%EI)tckK<M6F-c9Y)V*f<MV7W06RV_5_tVj%}72PcXDHPJ}`xZqFM<Ihf= zNLQt7I#nKwN;c?dulHr(?>dArM!+zLfe2=iS%3k1HK82<L4Lk!cUQE=ymxQI^iP`1 zMMZMJkX55o7DqYsD;^ESb|90GiDkYeAK?gV%ZsmPIA50*K?<XH-RrRmm%pTnN{ZVs z_9U?Fi>I)yo62#|&;D2*%o~N(LQ$HrxFU=@<#wgQDty7s|5?>qxBTrc>UoBZ!}1le z#)a`Pq~$aEPO=D0fO80I7h5SSMqU=q48*j9Qb*%7#+Pi|ervSf?0bSFwKsAPn1FO| zKH_&kh#AJmvOUSnl~!1AmcaNJM5awz`0DF46>zWZuCh$z(7uBp0to4w2iu-uj<Q(> zV9oc#M;CkJ!OT_8;~(;r&Cw`0K3r=(%@VWyiIA#;S}+n)^}q>|)QZ|IaYyyY!;frq z6mATysX~aM!z!n$rJ$=27fpoIr3iB{q|Gr32uDR<?>a3PcNj==OQG<oT@mfdGbB0E bq5uR5LXtDt;>Hve|07^1DbtUgzuEQ=j%rDF literal 0 HcmV?d00001 diff --git a/data/images/flags/hk.png b/data/images/flags/hk.png new file mode 100644 index 0000000000000000000000000000000000000000..d5c380ca9d84d30674f05b95c2f645b500626c07 GIT binary patch literal 527 zcmV+q0`UEbP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzp-DtRRCwBA ze9OS_j{yq)!ANBE2Q2sp%w}R>00<x!Ad9EE8m#c&e<1q%m*LOf|9}55{Qdj?_iqsT z^9N2o-n0oIfLNG7YXARx@)W2Pq#7vq_b-o?^?z=zPixlv`vuhV>;JD`K-EmLvOuK( z0R&e6?>|)a-@i;iz|8zVIqAQ;I)|_@BNM~FU%wy-s0ZjAfB<3vx(uZH&mV?Ae;64V zIcsYEzkmP#{)7J<oXnFZaX)|lH7o1amoE_K0TlxT5DUmbe?ZO!YWVm2@Bf=O|2sMT z-?aHZC+GivfB)~^{qNg1pz#b~ZvZs_1Q5gqknte5{{HiE+B9CEh>;N}0Ju0muUq%~ z^Jl0Zz)k`PASO^y0(FCg{s2v4<LCdcrw8I5JOH$Wm4^qS8e}}!27mwp*#I{F_dl>J zf4+RlSW)rg;lp2_Kl2?q^5yYkpazCNzyJOD%k=jzP%%INf#Tuc?>~%^l1w0DfWH6z z1E^V4lvz;l%d1x`a&jQQ{ROE8h7C|LKmaj5WMKG(8n4KVKd5of#=rm&U;y%qJ?5>3 RVzdAN002ovPDHLkV1mTk^F06n literal 0 HcmV?d00001 diff --git a/data/images/flags/hm.png b/data/images/flags/hm.png new file mode 100644 index 0000000000000000000000000000000000000000..a01389a745d51e16b01a9dc0a707572564a17625 GIT binary patch literal 673 zcmV;S0$%-zP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!Gf6~2RCwBA zJYL>>fJ3En$GhGS>sbE%%m3$AD)q?8M9y>88-}kR7#RKlk!P~Y_PLuF7~U~3`~nC7 zF#yj00ZUDdpLsm{7ajP|&HwoK0Usg|6%f4L_{`Mi{rvv-`ukf=Ed&Gs-sA7L!Q7*a zj{*QO0M7pb%?Sw^g@yy{>ihEY{`vU@3=8@G0rvO$i3mOL`~mv-`W+b$Mmr&io5dg< z5v!7q0*L95jt`TzK8Kd(Utv)OSp_aLv){6ccV+Z`{Q2+asKUU&aO3`Kpz6wW8wp`< z28M3{0mSqnB#A*-c*8%1=RD#sSOwMznKA3=e&iEzwo{cA=PgXK`2OQ}gqId83!|%* zA_Kz@fB*n70M7pdECCwp4H&@R`1|(w-}M5x*74i)0}%fAt;XafA{48))#>Z>?C<vx z4+yBW)ZEa+0st`p&i@0;-YG3aF7@#A-unsO`UeF3`snNQ&*=O8|Nq9#<LT}6|NsA2 zODmwR(Fq6z-qF4ShzS&MfByac^DoF=sW!#@*YCf7{{Cb5_xJa&KmY#y`~Ua<&!2yK z<{bO@>D#}*e}Ret0tl$#*RMZ7<Nli)NXtp`1-NPa{Pp|S?>}Jl7Z|M45`5*URzH9L z{rmSnPy;{!u>dsyO%meg+<D}#zMAO4lMiGi_<sHb%KrQF=l}N~zlsvgwUotv{`$LS z=QW_G0Ro7Tfx#CXoj-s&k<cGxuBv0%?fp#*4F7<k3=m)d3iunO7I*l>00000NkvXX Hu0mjfN{&}S literal 0 HcmV?d00001 diff --git a/data/images/flags/hn.png b/data/images/flags/hn.png new file mode 100644 index 0000000000000000000000000000000000000000..96f838859fd2aed975f5f4134050fdbc0486ce1e GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzt4TybRCwBA zWRPFV@aYc&1pN8O@aqqV{QD2c!G;(B0*Hm--LJ+jTOjM-KZbw*|NZ?7RQ&JnpZ|aU z{{I6a|AEk-f4~0#8UI)IE(Qo77KUH{e*OOU<mJ!5e?h8Y(*J*h6#>yNpn^YtfB*U? zE6K^g@B<)#SlTBTcsfda`|<bx|Nnpg0rCHT4FCWC1F=Eq_y4~@yMb2!`^U}6^5=^k zKmaj3fByU*82tVF_w$$E(^uU8{rmUNpTB<m{Q2X@kM`L&U%mbO_3PI!U%mjz<HwH! z1P~L%xBtO_osA*TSsch%yXVEK?Jt0g7%vG<Hb#i-e~>@_0R#{e$UZ3l|Ic6l=B#}T zWCl5lg}RFY8S^$g{qgfJOdc2ve*glAv3c@IFK6|y-~NDH^$#cn3{a5k!L9^_5>O@B z$^W^zSlTD;0tg^R28Q0WdbfK|zW)9V43odV{`~*->+kR1AO=tbO#T4}-G3E1?u#4x z0Ro5x7#++k42m+GppXWk{}2W^;6Y*k7i<qu92j&!-vR^>(1vOT1`b$6{=&w9#5#oJ b00=Mu*}Zhb7k&Za00000NkvXXu0mjfKokPk literal 0 HcmV?d00001 diff --git a/data/images/flags/ht.png b/data/images/flags/ht.png new file mode 100644 index 0000000000000000000000000000000000000000..416052af772d719132c152e26649635a97a63a94 GIT binary patch literal 487 zcmV<D0T}*?P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzc}YY;RCwBA zWK}=@kAZ=S0R;a1`S<@11H-R>AoS-S!;e2821p2q{((sbfB+=MK@k8U5Cg$|VB~~? z2XKWZk_lAZtGhi{|56nPieMKY$Bq=4KgZ0muK;2JYWn}5;nka8K-GUCa!{rJenZIL z|9<}gF~mh#ftCOS5DU<%|Ns8~1)2?0{RgZLWF&|Ls)lL+iU2hL1Q5&LKMX(>AUTM^ zNU9+S#0FXN@8@rz^Zx+^5DWL07wmsTIe-5EX@IBzTJ`52%kO`z5F362$-h7b*KaNc zh6exv#P}EJiR%3Sk01R1^NZmZ(C**=fB*Xb3rzn04HN{CU^bJS()(Sf00M~R4FdxY z(0f3MKYtkh0!g5OAQFsz{e$TF`x|H}%fCO*7#IKo2o$W~FaxWA8VofRr202h8w1#j zz=!|{Ah3qte;CCj89_$={rBfLBSbS$5J>(7`GW}-*g)q41Q6q6a2)=FMdm+9l%onl dL?8elzyJ+{hsuy4pm6{I002ovPDHLkV1h<u#(Mw& literal 0 HcmV?d00001 diff --git a/data/images/flags/id.png b/data/images/flags/id.png new file mode 100644 index 0000000000000000000000000000000000000000..c6bc0fafac79403c97c64ba0228d35f250d05b57 GIT binary patch literal 430 zcmV;f0a5;mP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzKuJVFRCwBA zyf4Vi@aZoD82kg%|G)?c{=nG(kk|kL#KQ3YuTWn*Q2PJBe+>Wr{r~&-|6d^ahvDC! z|G)qK`}^nrA0Ybu|2K$nd)6X=0AgWa1{?O`IRi-PU$8V7{r&w9sOb0ae<0Pr|Nr{M zEF}%J0U&@_82&N<jfANF2Uf}eRPpCGi2eJ|zu&(=>|Y=e#y>zg27mx!0Xgp5*S}vr z{r~fq0csvl>92pk!P<U<jQ{uR7f_s$hxZo)13&<=2;`=6*x3L5_z~oue_#Z17?|+~ z><5VDfB!NI2#TFQ3lKnzfB%Al=06ZHfFW+c#KiRe{d<4_V)^&)A0s0pNIe5S)eu>r zF8~6F38(?TQZ#J<0R*xEXct5e0}KG|WIzExE=U%r7$AT^8h-rv@ecwRzz_$3Xaxu` Y0RLik?wUgPu>b%707*qoM6N<$f;0ZTz5oCK literal 0 HcmV?d00001 diff --git a/data/images/flags/ie.png b/data/images/flags/ie.png new file mode 100644 index 0000000000000000000000000000000000000000..26baa31e182ddd14106e67de1ac092a7da8e4899 GIT binary patch literal 481 zcmV<70UrK|P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzb4f%&RCwBA zWSGdn@Ph#a{xLB8VR)MIl!<`}#Djo8KSZ2V|NUe5_X~_T7ytr@h2aZBwQDt0#s7ce zqT-B<Kn?$a04VtX-=F^#G5`Pn1~LBr{*t>1Ab?mHSU}=WzCQi??=KL1`SXRBmG?g! zeE<Ca<?a9ffBpacn^9O6=m>xSVgb4YXfjasA0Ybs`#&c5^XvcLUqDM3{{9AP00<zE z`F|Py{`vps&p!~tQ2y`#?_dA_0TD<8P%%INu>d^^H2e48-+%sM)d02u=%hct8G!N( z3;+QH((o5-_OE}xfO;@2_y=+i*h!3FCjkTyNW*WSt$#tPfB*dj3@CIxKqoQ$2DuvO z1O^6x00KJ+r1UogVe!Ksu!etsL5P6?Ab?navG)7lA4zUWkT?GPWdcP410y3N0|YR! zFo-FE!v&-P=p=vuVq_>~VE6=zV^DnmVAx)=U5ZNz6vaS)0m(NHWW2-wfs+9Q00bBS XO2cxg3=*#z00000NkvXXu0mjf|9Z^l literal 0 HcmV?d00001 diff --git a/data/images/flags/il.png b/data/images/flags/il.png new file mode 100644 index 0000000000000000000000000000000000000000..2ca772d0b79b255872cde2fb29060bbbbad950f2 GIT binary patch literal 431 zcmV;g0Z{&lP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzK}keGRCwBA zeEISv0}}WL25<%t{Q2_-$OV!RE<gY=vb(>WlqUuh`uiUU82<hPDg~1N|Ni|CWdHg5 z{|}J;2gv^a=g<G{^cw&H#0a!OOiT=<77QSa|JVQ{BjdGe*8l>D1+EBLb>EWz|Nj3k zj6%@>aVJ0ku|Ql5RsEk~{?`9D9{=ZO{V&1vKX2lHHJSgJ0SFC1p8y096I?Y|?0<mu z|3AO~=Y;+D^ZWn%_x~7Y6f1zH0|XEg5W`gi!OH#rfB*TvVZ;C4rT>5b{XcgblKTHZ zfByjpAQrd=h&?HOAa>`R|6Hv9XB30N3RxDY7$AV4en1PH(j<7uAT&Tc4G=&q@-F{c z8i9e$01Rv(35=ybe;NM%WdxES!M~uG0dj%y@b5pvikg1_0mOLw_HE>d#AF}?ph|!M Z0{|%qc@l5wel7q2002ovPDHLkV1m6PxaI%= literal 0 HcmV?d00001 diff --git a/data/images/flags/in.png b/data/images/flags/in.png new file mode 100644 index 0000000000000000000000000000000000000000..e4d7e81a98d705da8d7054e77e7d311805659678 GIT binary patch literal 503 zcmV<T0SNwyP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUziAh93RCwBA z{J)<84*o$f82$Uh@b4cO{edAc8z2CMa&Q0u1Vb^j#VV$>l^KlW*80IEmzVa(K3*_6 zG7fg0I9Zj&0woGah`r_&Kwu3FK=xChQigwjfh>?7kc!_h@)sEWW@MKI+5iwhEdRtz z89B8WSj7JS|MwrL=l|b3uZ7Osk^B4auaUxSRgtG4v;Y11_x}$gi|9Y8?EnG9`1|i) zCPv2p|ADsrhuF4k`@Nr^zUpfTpS$xp!A}Wj4A3Yb2~_s}<0pUsVqyY2p8>1`g1&zJ zsVvR4Yya)fUw{4wtNss>0tLxGfB<5Gm<mFSV157p>nRH!zxweH8<(Mq0N7e&^ba6_ z7#WHgIs!VLeti1p-=9B!fB*jb=l8$ge}LrQ-#`%%`S%Y9{re-sFSERHIY0ohF#KVF z2*K4Ml>Ykz*ZJq)UtlmW{9*tIAQm77@<q5sAjUvRFaoLof<OQNfT;gq4gVMb0*H|z tnE|K=Xb*^lqd!O@e_#ld5EuXg3;_B(O%_D8pez6Y002ovPDHLkV1i)Y+=2iA literal 0 HcmV?d00001 diff --git a/data/images/flags/io.png b/data/images/flags/io.png new file mode 100644 index 0000000000000000000000000000000000000000..3e74b6a316477b90cce8b5f2111f911b1c640950 GIT binary patch literal 658 zcmV;D0&V??P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!BuPX;RCwBA zyk6V8<A+?~lxGVSj<7NPi@d7WZ2n=-2dRTMe=sooVPN>e!0?Mfa}mS!7%=x2KmY(S z0M7pbTSu9imq`K?75n7n_u%3MB_szB3#hl|A07@E6$<6-<>30S5egmg@cSkZL+Ix9 z0st`p&i@3O7{&wE8wU3C1oia^`T7Cw<N5vn2H_E_%nSVd{`~#@8$RUR{NVig0qXbJ zdsXKGh-J$=5g%1e1}>I=ckcau@Q71iA1L?!*;{7^Nm*&$?GLJd|NjPb3=0E0KfCb4 z#nAu(05Jg0{{-+tQ26`)!^rI-{P_e371QGlF%9qW_x;Sb;QRdk{r*xJJ*zy?_rWgu z&$=$1lYjz<N#?_ycmKIGOqJQxRUZH35awt7BcsU0!|v)Vo0+Vm;Ih<QH2we2U&;)b za<*G$&aDCnAVvm;&dxsPr_VowT>R(%pFe+p|Ni$IOoG{eTR0XemVUia$~;LXXZeDO z0096o0M7peXKyl1N+90g@dyY5ARP|N&gc{s2^<#+#>?j*9u48*^A9U5-*(6(kqBPq zC^|QM0*Gbi#3NFYyicBf1{wyk;Wx-spzFb0kX0fvuWZ`CzyHEKd)}3G%ew&r05Jg0 z{{dZPvvzMc=<D?e2Ll@#4AIl;6B7v-6$s7I>Kqyj=IHYZ2nGGUe(f)J7ZwZK+v5TV s=!a5B6v9X#`iH^&14cjw13-WQ0BQ>oQ(TIK+W-In07*qoM6N<$f`&OQ<NyEw literal 0 HcmV?d00001 diff --git a/data/images/flags/iq.png b/data/images/flags/iq.png new file mode 100644 index 0000000000000000000000000000000000000000..878a351403a9a33fd9ae3af1ffd54739545f364d GIT binary patch literal 515 zcmV+e0{s1nP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl}SWFRCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5P+gNxB&nJfgpGfTOih;e`=>T z5jZ8;?_>v5xi(~iU^udv!6f5$jpNVh2O?$m1OPDr&i?@Y{r&#_{`vm=wBfh>{Qda+ z{Pz3$!R5rm=Ee2+`0e-d?)LGV)1LD5^!4@i=jZ1F2;wA$U5|F$_;B;f&rf1p(qbG! zCte(9VPa-y<oNva^QE_!?tHra|KI;el?b5G00G1T_Q8Ko8INCoe_2Ud0xjfa<1rC4 z`v0Fnfln4FWhA1%?Dq0-#ZaI+pay^d48<`K05Avxz>5lGpbZn4ZFhzcT9$vfdqmuG z@j$ZG>u9())mkwqmYHSd7eFi*FJ3%$?AX0~_kM%HFED^GKqQ#;=g)7T_f%9=fX)F3 zAdr)QMoCIaf{X{6{|BNG$o>N%f#5F;02KoS5XlH2zyJ$0KZ{``H1_}i002ovPDHLk FV1nFR>VE(L literal 0 HcmV?d00001 diff --git a/data/images/flags/ir.png b/data/images/flags/ir.png new file mode 100644 index 0000000000000000000000000000000000000000..c5fd136aee534ecb59914e336cad18d18ead2a4a GIT binary patch literal 512 zcmV+b0{{JqP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl1W5CRCwBA zWSGdn@Ph#g{xLB8fsx4Q7mQ?K00<x!hA#}&uGJvLK)~?t@4vr){{8*?|IeR)5cK=^ z-#>r;gUH{1e{Y(x2_S%2fSMQ?7@vH7`tSc=xS~J*|Ni>>`_JFszyAFKs{8d9NdA)L zm1AIH00;mv0M7pew_3Ln1`-ek5ajjb8VVZW^Whu|9pCfc910uY_2L}~8{YEX9t$4Z z@!Kj9D)d(L0*LYN-@lBEj6f&-|Nox>4F7-s`Ty{t|Ns8~x3>Pz!S){pfXY67`UDU_ zOc38f#US*GW&hv2{?Eqpf6;>f$N=n5fB<4bR}BO)G5?=F{eR-b|HMQT_5c3^H2?$< zb1geNgNn-kGiMln{`!CM;{TsNL8PAke-;*?JV+Z*<irVv@821&T=}wV2S5NZ{$yZa z&dmIG?;gXiU%=1<Avgnx%>eYOvhv$==KunT1sF?AKYlWZiGf7_{AKv_o8k9wMiBcC z1B3*kzkfmK*Ds)AfB<6r3XWMgVnF4hNdW;sfB^vU;z%SnI0)(h0000<MNUMnLSTaI Chw@bb literal 0 HcmV?d00001 diff --git a/data/images/flags/is.png b/data/images/flags/is.png new file mode 100644 index 0000000000000000000000000000000000000000..b8f6d0f06675a9570c2c6e696ee51282097c3876 GIT binary patch literal 532 zcmV+v0_**WP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzrb$FWRCwBA zWYC|<00V!|rQBm?R{HywSz!{xuRkF415Ez?!@%&1fdNDU1Yl5(Z2*9vAb|Rb5o{wd z7ot(xLSajH_i0}B;(x2<ujFQydJDnEEQCW22y}`Uy40@c2_P1*(f^;n{qgtTKOpPR zqenpQ<CkB5|M?G8@#oLqUm&G_fjXsxIf3#30mK3{`QKj#pz6PW|AWAv-#`MW=nqH( z*kG`pzd${I{{x)_5I~GT7SJUiTmJuN_|NbrJVa>4s{hykP}!$Xp8x`g@iqg4NJaUd z$B+Mm%>4cD_iu*Zzrl=O|9^qF|9<`Y547mdFIIVlOMCYL1Q5sui18rvfj0Ph3vJwt z)dnUeruXmP0|XEYv&@95W{1bGfWG{@sKWZ+FOVO6s`df7U=M&0&<j9M%ZqdG?41S> zKrFzJ{`2=AL>j0R<bePG!5)QZ0J`)qM8hADQ~m%15DUXEpwvGxLC!y50TwAKASorn z1vUi~bfEA7g(1V=|G;qn2M|Dv49xZLh<y9L=8B-O*6Y`zqD5elf3OJt2O}8(0t^6J WL1vT{W6G=m0000<MNUMnLSTX~F!E{u literal 0 HcmV?d00001 diff --git a/data/images/flags/jm.png b/data/images/flags/jm.png new file mode 100644 index 0000000000000000000000000000000000000000..7be119e03d203695325568174b72522124bb2f12 GIT binary patch literal 637 zcmV-@0)qXCP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!4@pEpRCwBA z{C}R|*%yYO8w@93F@V57F#3apelaloU|<kqxTwb<!^Hp)05Jg0{{dAy0HUOO2nzto z@BX^*{__0&`~dy^{`~y^{QUm>{QLU}0{l%6`#}@@_VD{9F|q;xF#yj01gpgWJ+A*m zGUYZW{T>4SpXmF{^Zon(`}_X;`}_MY3j1pm`Wy}V&B*(tydCWT00ICp0M7pd0000m zGd#J&@cQ@tG$8vu6a54J`qTCN{{H)06Z&u*`VIU0o2UAMn)~_v4c^|~0*D2u;qTwS zKYspMz5CDEtAAp>e+Mf5)@1$t_wR2Fu3uNK{_0!&`}CDxK-+|V{{|`s2p}dP{`2SW zZ!oxe=XdSY-}fK=Qse!l!T0O_!(X}WAk`4?=MOLh7ytr@32Xz9{pZ*3U(#Z~DiVIl zOa8j^;n%JAKjo!<wdVX(k%nsc4YmOwfIv<HvViJ+tpCJ%{bJ|+z4qy^Y0rQE`u96k z<X5E8@5M^L`WF43u@)G7AR7Pz05Jg0{{sL307gPFVoU8!NBi#k`-R{7qU!qe{rmL& z`oHh{)bjf?5Bf<u`z{~++vXA4-RS~|QIntH=#eBIf#1j8{9gX*?@OS~;4lS70*L(m zN09kXqR3x&vA^$M{grpR0uVrqU-vRR`@~>$j^Xni)Y$z6WB&k?3JlvV7}WR}00ImE Xyv9Bjb9W)}00000NkvXXu0mjf@Xt#6 literal 0 HcmV?d00001 diff --git a/data/images/flags/jo.png b/data/images/flags/jo.png new file mode 100644 index 0000000000000000000000000000000000000000..11bd4972b6d5f134045d4e8ce134601ea9b5654f GIT binary patch literal 473 zcmV;~0Ve*5P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzYe_^wRCwBA zn9V>M00<z){|pRos;hT0FkJly0)PMh{qqM*K)~<czhNYh*tBUAKmf4-HHk3%k9zWS z-`~H>|Ni~>`!`VeZ#eh`f<ORd%F4<D6$1niNW=es{~7-Nb^7~P@%L{95AXj8vH!6F zMn=Ym4<7;q5Ys=Pj=z5yjEw&O`={{x&;Om<{x4ee9|r)s86bd|Kps(1`JbQv-_r8` z@8ADFegtXg>&K#;fdS|pfB*tJ>FU-06DIsWc#z@uumAs9{`&>~&q~MQB#;&cfB*tH z31sk|Jq+K!KjiuK-^&B5YLK~LCjkTy3s3{|pFco7yH$Tr@L>D>cm1y|D}MvS>F@7f ze}Db{_vg<)5c|)+zsmedM_Y~p1Q1BWd$vDo!X?isvq}Pk|KA^w>VH5L!1(y{_x~TD z9$-NK{r~sxzrPHB7ytr@v6F$JJdlAwh=Ji34E;f3{DCq4fk_4ifB*vkxQ1J~H9>i| P00000NkvXXu0mjf0T$ba literal 0 HcmV?d00001 diff --git a/data/images/flags/jp.png b/data/images/flags/jp.png new file mode 100644 index 0000000000000000000000000000000000000000..325fbad3ffd3075a4a84d8d898ad26ef7d3e0d56 GIT binary patch literal 420 zcmV;V0bBlwP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzHc3Q5RCwBA z{QC7P0}}Z6?;nIjX28LpfBygihy^Ih#l;0A|Ns9F(F3Oc|A$jZ;Khp<00G1TQ}iFf z*thTh;UoWl|N3uc_TS43u9}gN5oifO0I{H}Uc2`H!iE3;{Q3Xu_y5C(|37{5KRE@W z0pdb{0Ahl>9whYk?f=!Q|Ns8||JN@lTD;`{<HvAI{`~m^5I{^2&%xdJ|Kmrf(%)e6 z%a{KTAO43~0aOeSKrBEE<Ns$675&e|1Xc>R1ZWk|EGa3dAO8ObDh3E3Cb;oH_5X#1 z|NHy@|M?558b}5Q|Cf`4hZv9q2p|@?lb|{i68>{>{ol0<q}tBze@qPA$3Ot|13&;V zzJ2?akB<*73S~gt$;kNsKSBnG@7}!&5I`Us-o1MVi%1Z}1t69H1Q-A&lWd>lx`{mi O0000<MNUMnLSTa2cE7*? literal 0 HcmV?d00001 diff --git a/data/images/flags/ke.png b/data/images/flags/ke.png new file mode 100644 index 0000000000000000000000000000000000000000..51879adf17c0c29167225a81645cf1123dda84a0 GIT binary patch literal 569 zcmV-90>=G`P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz%Sl8*RCwBA zU?33y1fV$#OaK637>0NEt6k^VGA)9E1hT9ocRoN>wSfaWv)?-raRm?)Slj<6Po6w} z{P+<NJbxY)9WD0pqxie`iAhO!A3wf*`}WOSw{G6N3Df{o3J^eyS5KZ)($)R{@85qI z`1b16za>i;{`~nD6~!ec29@~tkBNo($fnHz0mS%{fq}QS{_m4#|Ns2?|K~SQUHy-* z`HU>AfB)8feAoK(-@hL}|Nr_0bQ_Dj+^xMk00K}f2RQ&hFc1JYLgN3=lsJ)M+p3uR zWC5ydAM*!ly4H1hsiEFvIU^1)HH_GYz!QMsIYt5i1c4ZMLC4DfztdU-q)WGxxg;|L zi3%uAJ2fm~N8&I2%AdL;`{4^9#;e!&D=C-&Lk8;9|NlO`c*HPy9@C${KeOWnB;`P2 zATRu9VP-jSWF<fV05Jg0{{aAb0BlNa*z?)@{rmg=`uhI=2mT2&+xr3f`@JIj?fvZf z{`UO+`~3d>D-S8kh{*zoh2aO#pT8A2<bH!h|NQ<3)b{(nHi#kd_mAj5p8x+De*gRP z_dig}KcHfO0AggwX8=+Re;62k!O$Nh#vd5tADHA}00=MuTVg#c35sB800000NkvXX Hu0mjfH;e~; literal 0 HcmV?d00001 diff --git a/data/images/flags/kg.png b/data/images/flags/kg.png new file mode 100644 index 0000000000000000000000000000000000000000..0a818f67ea37e1bf1398b3e2f92a52e331abf4e7 GIT binary patch literal 510 zcmV<a0RjGrP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzkV!;ARCwBA z{K$X+|6n9C1CIWH1pxwZ7zQZ-z#t6sFxaF0mCxE2+UkmsgdBS_-RTY^4He>W0o(w3 z|5bef!~)j#|KF3RAf-U``!@sYUq;#A42-}3UHtv;%kTfcfBpOQ3n<PcD+{y)Ab?o@ zft3G8sAl}j;`58i=nuo+-;A2SelGa+@9XdXU=2V7R16S6OdtzD&Vbs^r16VU`S+ib zzy2Km#i;a)S@Rc2^)IL;Kn(x^#02u+pT7_T{{Q;TDEj+9^RNFee*Jy^>mTE<|1vNq z0ns0zZx{dq2xP;5h!=kY&G_~A;V*`-zy9<8Vi5ZI|I4p`PkusG11$kMn1KNxfWUtE z{TpHc!>?a|&i!W8{>5bQ`~TnHf3N=fz5n<BpJ2xTUHj(`P%%INfi*xp0Cm-`AG3Zj z>Hq%!<JX_FzyEy%iTwNX8|3W2e}PT{2q1_Je;6et89|=@3zP#f?t=qB@HZpxACTFQ zF#P)$=p=vuVtfvc!hbLlGj<`d4vA)<j{yP<0QTopg7jx!Q~&?~07*qoM6N<$f{8rv AK>z>% literal 0 HcmV?d00001 diff --git a/data/images/flags/kh.png b/data/images/flags/kh.png new file mode 100644 index 0000000000000000000000000000000000000000..30f6bb1b9b6c5bf355f67a17531fa73beafa6639 GIT binary patch literal 549 zcmV+=0^0qFP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzw@E}nRCwBA zWZ>P;@arD~1pHxO_zPr1&>t9y%wPZrAQpyS3=Ms1K-T|%K*j%o>i_=z2W0&D^Y8ax zhQELQ{rLl7|Ns5_-|C4+00M}Gf#D0s|6k8u{RAokD*W^JKSaeZAp18+HBcQ8{rdA) zTAYJ{;SE3lu^j&CtN66?*W<_k{(=kvTJiVSPc{h&pyuy)ZZrJ(`}gOM|G$0#rP$=; zY#H_d1P~L*>3>1SGXDMzbOE=49-9EaM0J&9T`emwH;=g~P!Ocy*DnU30tN<v0Ad39 z=+9rEt$%>VGcpOKXK;IXGH`G*aB%Pig@_cF{AXeP^XnG~{rU5QfdL?ZSim;?VE{Sy z7c(ax6CWR+s|%BWAkauYPfsQR0VXz9sPSMM00M{!7+@fO0fqklmFn#S3Nf;>{s#gU z7DjgV{|pRrOO`MKef9?wUO?vn1P~(w!@{x_lZQ{f0d@UhVEX<0FF08K{sNMJKnNIa zzrX(mdR{@6d*A9+009Ja65pra?7SkZU^!3-{)PrTC`>`Y0b%_6|LHH#J`sQb0@|>a n0T_AEh(trkF%3aX009O7j5IT?Rho+J00000NkvXXu0mjf2r}#E literal 0 HcmV?d00001 diff --git a/data/images/flags/ki.png b/data/images/flags/ki.png new file mode 100644 index 0000000000000000000000000000000000000000..2dcce4b33ffe1f40d490cb1a2e03efe22ea56155 GIT binary patch literal 656 zcmV;B0&o3^P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!B1uF+RCwBA zyvxAwj{yXjV2nQuQ1Amx{sW^wFmZqYVgafX>8t@U|NsC0_uv12fBrK4XN%za&+zB_ z{P)aXfBpaa=ii?{|9}7d|NHmt1seeZhy|?e|DWei8UFwK|L5=jKYtkh{P{kK2}m(L z`||tm?|;94|Ns5#-_IY+QnEm+00M{!r2OxHhJR4iK=k+TZ>A77)*#lue}Db^^$Tb= zko*TE|NI8J3LpSOb8Z9x2m%4nC$a{*Gqe90@enzQH`ta%tA$7d{Sv5iP&~x@8dMF~ z(;dfh&fyCHF#yj00oRR-9sTeJMNR(n^YxvS3U`G57YhIQc>VqR{rPSM@(uBDU=5F+ z{|5&5hj_*A{sI6o0M7pbe|x2IYDAmA_)R$v7XIy^!u#*~18PtP{r&xWnDPVw`hiji zey{rM^8J)$5m8%@0*JA9=57`qRaXnayHCFDJ@r1pR}vUfQ&&Fu_xE3Vu+-%{U$!58 zQy3!)<gVTK_UX-C00G3vpgi&3fuy+`o<(|y-Fy0d-^sVXfB#DglmdprlogNu{QZ|5 zDs$~VNLxjcl3hSQKmaiYXRdUxHPcn&Ieq05P})d?_us#N$1i>avcufO?mzv0@a%gl z1HS+N7!IC&H?5`#AOHX{0M7pb00(zPR4BWp;5AJa{{8^XwcY>#`eSh{`1tyzm&^bF q{8L{tuE6I1;oe7AG|b!z0t^7P6ga05`yJ%~0000<MNUMnLSTY44nUFs literal 0 HcmV?d00001 diff --git a/data/images/flags/km.png b/data/images/flags/km.png new file mode 100644 index 0000000000000000000000000000000000000000..812b2f56c5a2a6af805d9edd67d549952d5278ca GIT binary patch literal 577 zcmV-H0>1r;P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz(@8`@RCwBA z^k2>}L!W`0l>rF;|7So3KrTcC!ho;=0*Gbf0S0@;YBwbYEs=i=3_$ev|Np-X41fOr z{{tp}0~v7g%iT=?0mQ<<!SMFW{~2dK8cKc)(~{+4;b&m@4wgnnAPtO+qCiUk0*Hm- z7099*yV@^5zce109<Ar8E2{e+3jkWq_*bL|AOHX{0M7pa09gR*^y}{T?p&~4&gafg zr%{{Dp6vMY`T6<y`1tqt_xASo_4W1i^z`-h_2=j30*D3Tzthi7fB*SShDYZ6pRY?V zFM4|8NQqg||9@bw{`<%94+NN4*}t4U0uVq@4MyTde;NKh`1s)Wzh6!de+6E>{9!@# z-(SD~{`vL)_wRqdfBpaan?+XX#@15+0mRIp%kY{1qrJR?xs-YLhQhSVzk_f6;`sgh z57Tc3#$ODKzZsZ*F#y?2e^`Y0-&}tV5I|5HD)yDjz7n<jSaw@p<dz)B0Sq920^J01 z6v)T_7??RZA4xv|2p|@QUkty0ar6BM|HEN(N1EZs-~Ygf`16<H&tD+<|L;FA`JdtM zUyxrmPZk3RAV!8zafT;r7{2K-eEP@m>mS1pU~~aVF#A6wD&YtO00ImETIDprOD_2B P00000NkvXXu0mjfKOhx^ literal 0 HcmV?d00001 diff --git a/data/images/flags/kn.png b/data/images/flags/kn.png new file mode 100644 index 0000000000000000000000000000000000000000..febd5b486f3f90056637b23caa26d838fbadd7d0 GIT binary patch literal 604 zcmV-i0;BzjP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz?ny*JRCwBA zWSGdn@Ph#g{xLB8fssJ;3q&&NFwEm&NQ{Ik0SF*QhE|4Z*J_aB|NnsK@BhDl{{8*? z?+*}gaQq3E{Jn4CpC?a$zkK`W_wV1EHf;h3AQlD|pyK~ezCHyi{rm6l?|;Al{{8#= z?{6)M-y!V3r&s-}Yh93-n8EV@iK3G1@mvOg0Ac|;0BAB$=^v2UK$U-)*nZ`y{Mj|- zclZ5YMwWrx+&n%W#?EH<)R}*Ov}XVaAdvZg8Gx$){P_!VhO+4IFy7y@TYg`^19ZZ# zFMAfVGQNqjUc=Y-`~SP&K*az7!~*md(9yqt{bpeOlcn*8=lSo6wZ9%d2de%p!vCuv z=eHHd8iurA|3Cg_Q2YZ74uAk+0R{!oXepsTF+#tt?|0m`W!ux&zkdDtEg|x2TFq~v zW551S`ThUbum8V)GyeV!)Bq4bEDTHxAqu})p8dLVz}3eu>h(K@ANy8uaQvQ^_nWcs z*Z-GL)eL_?t_B7*Kmf7KbznHW;LqhtK<$TZ9ej=*TGLweOZn#S|EVB#AOzI#2dDw) z)4xDJ00a;t5ND<*{rU5ogY7p9)8EGU->gS|Gwk>cG!LX2Y%nmu8NlfEl`8-N#0ZOI q248)K1w0H4M?n#d6+r+%fB^s&Q!OA|2rzyC0000<MNUMnLSTaUha&I* literal 0 HcmV?d00001 diff --git a/data/images/flags/kp.png b/data/images/flags/kp.png new file mode 100644 index 0000000000000000000000000000000000000000..d3d509aa874809a323ea99f3b37ece8a02201f77 GIT binary patch literal 561 zcmV-10?z%3P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz!%0LzRCwBA zG_3vq<p%=<{Q39q*B>Cs@aq@DuRlQczuzDZ5@KTj2q4CTH~*`MftCON|DS>3-+w6c z9|(gO{~7-O`v>9vKX&5_Kmaj*WME|P@B8=S6~kYUnG7sU|G#_z>G}QV|KC3#^7rq5 ze}4b}^_xjb^7)+E00G1Tlwy4KiiwGVIVgltUY_yi&tI!o|Jl8p;n#15-@icU*KbCk z6Mz3^`1ON{fdL?ZnEnBs@%JyYzyH5mxBfr4|7*t%=AfYeY;6C2{Q{!DKY#uG1wwy- z+}}Xm3;+QH(!lWNFN3HElfM4XRjZk-tp4-xFo=l!{|(XrbkBdVP9XXJ&!0aG3;+Sd z1adCWtuJ2w-n@w=AmGpT?F`?)|9k%Y-_M^QML&N+RfBB+Dh3E3pdT0*fp#rf!j_o$ zUs8faP3>oG?f*A#7{O)(oeuE;(0C>wVqgFWAQqr4|9E)*J$m%5y#o|bz~KAxm4S=v zKP&4$usU!k0b}RSKS4oYgaHH)%c-;9wWL`<fej6FNT`FO0xSd({|Ay|*m3GLKmai^ z$AA6%0Tz+Kcm*2r3yBMl>3={r0|OreK!5=N4TM<aXCNYy00000NkvXXu0mjfVxt9d literal 0 HcmV?d00001 diff --git a/data/images/flags/kr.png b/data/images/flags/kr.png new file mode 100644 index 0000000000000000000000000000000000000000..9c0a78eb942da568f9cdac7190c17e23cceda7ed GIT binary patch literal 592 zcmV-W0<ZmvP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz;z>k7RCwBA z{P_JV0}}Z6?;n_iu%H+Q{s1vR05Jij`8c?M=-GouSI=K${m;n9!7aeW#m~(x$j!^i z%zXLG*~fS9it_V|2?zl-00a;V#NgMjUvJ&I^~3uQB4T37ii-d5-u?gQ&wmw_XD?o^ zUAsm=P*7M%NJdr`Xazt3fo%By|Nn;%AAbM-ZD?YwEG7BBxA%WR!T-FR|8><0Vxn$d zUT@yKdH&)BP#Mq$fB<3y`hl076R7Rgt5;mSyo`Gea7>-}|M%}Nf0%y${3VbO@hKwm z<Lft4(o&3!j1QhY0SF)#h%bS<MMOk^jGb@zjQ=qIX5wrz%zyTiz54MveRg(kX(^xr z21Z7plK=t;r~#xJ2;RPZ%frp}Rb4k%y^@)Y^%1++haW%h8oQ}~|NQ3dM@dOZMxdU5 z{{RAr35bEJZ{EB)ZQ3-TzdZ~%RV`GWFbgq!|KY5|=ISK9Z{Pm<`udY6PeS|v5I`*d zz@fy&#RcRaJakx7OS3ji?UD)mpTGZg71;0HySaMJdSPK<0RaJ^0-%!s0tn;>AWK?W z+TGp#<;$01Vv<1pdP-ancZ!Qkd3bmLHK?nrgX5I}Ab=R3zkQ1wk#LIP517FKVgLC9 eRt>}e0R{ktF&Q^6#MUGL0000<MNUMnLSTY7u@YSX literal 0 HcmV?d00001 diff --git a/data/images/flags/kw.png b/data/images/flags/kw.png new file mode 100644 index 0000000000000000000000000000000000000000..96546da328ab142ab0c7370511cbdbeb9a20efaf GIT binary patch literal 486 zcmV<C0U7>@P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzcu7P-RCwBA zWB`Ir1_llWh96+`2ZsK^7{6c$%moO*p&Szd00J=(q@Y3$_Fe}@u$Sb3q1kHt`$e_c z7$9<D66|9?k@Cfg_S#nffiy6D`Tu0u)4vXX{|f&FivIog_curdP$>xg`upqGzh6Lx zERQVE2><~E((v!!|G%IA{@M07EI*V_ln<!k-#?I5|NevMzyJS1M7UYG*)rGw0tlqx z?_Z#;fByXVV<KcCB`NhE2Vi7mG~qD;2q2J#KYxIB{|0LL$MEkzZot6E2($+vfSABG zfGqe0bnSn9fZ;#Li2wlvvH@uPuV26Z18q8b^q-Ir(1l?41IfP-Ux0l1?;jHn&zI@b z0Rjl(q(3YSzbn4|mY+858&D?@{r(Lkf#C#Y{Q1rB=P$F0%7=>=0RjjdN{oM|h)7EO z{{7?6U#34`ML_iD4-=RKMg|km5|FijfgS(|AV#2u+YAh13=HqUkqe1m1{eb(!T=Kl c0)PMm0G()MDW>>^I{*Lx07*qoM6N<$g4p`a`Tzg` literal 0 HcmV?d00001 diff --git a/data/images/flags/ky.png b/data/images/flags/ky.png new file mode 100644 index 0000000000000000000000000000000000000000..15c5f8e4775b2b68e0360c1f4ff1f37e61611276 GIT binary patch literal 643 zcmV-}0(||6P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!6-h)vRCwBA z+*(+FgHNe=-j#BJTO3RbjkiP!P5$*CKEl8t#lY~3f#C-O!#^<j1HuIe05Jg0{{bg6 zbGVOJ0~Z+h+}!^3^#T?b3KSBFsKovO4Ep>0{QUt82Kj15^L?rd`uqE`oNxjFF#yj0 z1e^fI2i-9Q(8&Vs@%;Dq2on+Z{QuwM00sm0@b~}!1OzrH#hk+X+Tt}E6bb+U_yPbi z0M7pcvI;2tA|wYPA^Z*x0300&EGqR875n@A_WS?(`uvdQq*oFTSt1&p=b-!h{Qv;{ z0st`p&i?}PGCBeX39IA)-~tZg`v#`;6$Tz2`uqI%`ThF(|NnA-^wR?L!uSAshx_~g z0000205Jg0{{tif814-hz}E2g`1#%M`@GEN)${-9=jr|Z1Nim(?*qH%B39iMC&AR2 z>ggNf+PVUWW##cF5^jI~{P}h24a1+`jKBZ?zJB$|uU|aB82|la`WF`1`0wBUe?VZ- zRnf8uXahh1u`n<IUHkvnpTEEm0fx%2-+zDoXZX$d@Ar>?Z4sYX?@pO{;x<q&!9b?k zU&?P213&-)HT(f9{R{Lc&|4r4Kn76fpT7;O*sb*chv+MQ{mn4<7~{Pc>_8g;0tl$# z2T+})81L`j|Nel$KOpz_zrTNegN!(Tli}h6pb7teeq#K~3=Aa(fB<4-V9<rdC^89> d!wLWb3;+YKCC*ol*cJc)002ovPDHLkV1loTFLeL_ literal 0 HcmV?d00001 diff --git a/data/images/flags/kz.png b/data/images/flags/kz.png new file mode 100644 index 0000000000000000000000000000000000000000..45a8c887424cff6eb0471f5a1535139b965e241e GIT binary patch literal 616 zcmV-u0+;=XP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz`bk7VRCwBA zWZwV(4g;eW%YQBghCdAdKK=nw4F7=SKZbu`1Y|IQNg(>g01!ZoKn?Fz`<^k#?O_Q1 z`=8<8e}=z6_5c2{{QvRuAM+0emcRf01JR%V|9}7gKjGLHfB<4)U|@L0AiL;yFw=jq zasNSbGXHKG{NMlfpWxpAK41Q^gEaj6FV4=$@arEy05LIu!2gi{Gync$`1$|;-~a#q z|NCw7f0xa__4fbPn*ZDO{onuJ|3JnA^#F|o2q31v4F4ql-&_7K9cVD)|KCjiesKSP ztM&iLe}<oY3{Q;z@8JIT0i@wC$Y!7)00M{!=s%#jq5miU`v0H*-%GB4Z`J>uy71TZ z|DRv){|H?Bqoe!x=x?wkfBu3(0w91`fMN3Vzue;gQUCt^|MZVr;{RRU{|DCmOS=6} z@%2BU5C8a_|1a6~&;1YB8$b;J0mKBvKpP_e^#OhS_CN2(f86)~DtrE)Yx94t!v9OT z|MTDe69TJd_zQH>zdry01PrBrQvYt3|7!q-C@@&>{!_XBPnqZ6E6@K61pYml`M2uu ze+NdW22lI}1Q5_kK)3wPV4bt}e=;a?{xkdrMUmL?|7pP3c>kYY0b~&4U$AjN34j1% zWLW#P{SRLy(>r)v0s|Y${(}_LK*N79FfcIy1Q-CnX<Bn1arW2%0000<MNUMnLSTXe Cm_mX8 literal 0 HcmV?d00001 diff --git a/data/images/flags/la.png b/data/images/flags/la.png new file mode 100644 index 0000000000000000000000000000000000000000..e28acd018a21b62d2cc4b76eec7bbe1f714dfc6c GIT binary patch literal 563 zcmV-30?hr1P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz#Ysd#RCwBA z{L6p>{(%t#68R664G@6GIJN-*24NrwB3rW+v!ydOp^Ef6{n_)(btIFVjHa=pdp6)} zz^!@$h^2tpKUmc4)64h&|Ni?2LVth#{QKwk-@kwU{{Q=j;qSk{fBpc`pWlD@C4}l3 zHUR_xF#yj01d}3g9TFqw{rUX<|NZ>{`TG464+L38761SLpR(uO=J){s0Q&s>`~3bJ z6bIb^kphT`;m^O{e;I!LWBmP>@yDNkj7*H>@iN-VTtHNrD96Ue^ySz8pMMxZ=pRTg zKmaiT)&Bj@@b3@E4ZnZ>72sjw<zoI127-Ld;sVUSzW)Zg2ZVqQVE6+NKp-3b{{8ps zAH%P||9}5uc=7i4r>}n?8s2>T{qV)lKMV{%|NQ?241(YPff@h;hz00Cp!a_N2HF7% zr}y9gR8D)C;wJ_Sx5=xYz5MWpi4mv-=yjk&KmiL7K#aZ&_9^w5@1DH=3l15e)xUl~ z-0=7B?|)4HL4sh3f5LL21siGr0*GY=!$H<Z$_zmJK!N=iXfeaT-~ayo{{J87FmUh# z5zx^<58V0d$-n>*K#ZR;BJ~dv8zS`wDeeIR3;><DLVA{FxIX{@002ovPDHLkV1h3( B6PN%1 literal 0 HcmV?d00001 diff --git a/data/images/flags/lb.png b/data/images/flags/lb.png new file mode 100644 index 0000000000000000000000000000000000000000..d0d452bf868e0cd6417f518f1dbe695f191ef392 GIT binary patch literal 517 zcmV+g0{Z=lP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzmq|oHRCwBA zyw89D|6nA9@dw8K2V(<CfB<3vN=S561IhpY{xSUf_y6x-5c>1y|KC3hK=RLTAp0Mf zWcc&@%EC<m0R+;(z{2wP`7?%pKxv@rKmTFsK;*CA|9%76zkm!TDH)(800G3p_V_Wo zloUwYe-L0`V0`uU)x1;le*FBNXr3&^Ck3<_Ec2g{k%2AcJU{?3F)%SP0GW)8AOS|E z|BMVf@9w$u;_{gn=Xc!M`TPGrpcyd4_~+k0fB<6p_wOIfbfEeFfl7B>-|_DIyI;S5 zZoa(f#*3@};Q;9GfBygi2&4h78pxV*WHQhn|Nk-k{`>pa-{0-~I{v`b{|EZ(4?qC1 zz%@X;G0|`0)6dV-R;K{9rJ5v|%9;KB_nVP{8R7~c2@pVx*BKb3t8)H6dH@UxP=NgY z{qNULVEFv`^^@@rIK+N~gX`}f7I~!;+fM-m5DPFa{(t+%C?E(7W+q^;{`t)a3di3} zzd^yz010JK%>4cT^&8LzfB<5=h#HaqkRlk)Wq^@D01#jR5K~0vg#SK#00000NkvXX Hu0mjf%Ubyh literal 0 HcmV?d00001 diff --git a/data/images/flags/lc.png b/data/images/flags/lc.png new file mode 100644 index 0000000000000000000000000000000000000000..a47d065541b0d998da832e1981b479097a9b36aa GIT binary patch literal 520 zcmV+j0{8uiP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzs!2paRCwAnQ9Wx^K@`0+Z#P*tMlc}+ z3r#=>#-NSZgwz{Qh@GSmETWyIzrrS!pkkqDDw`CEYY|A1Dxfxke?UP9EWUlaZ{K@! zXYTRdfWyFioqIm+xW_M=V(hYvbO?~5ZHkEt37xh?rY1@PqM%`P+i2w@_wXJJO#nzA zheNt~+w1B3H|fqGMx=x!lms6SMy;uqQ4R``NH&49%B_cditsDHI6DHXLNubec}E0~ zb8dy|txA`rTkZMN{rCM3FV`MqlfuwZrz$X1)+?ntF|<I0#+9se=<{K}e{{UsMp3&I zRRn?6l{zNR3N?$+%DP79n)~~chaprP@*mCg64f<v0taXV%#yNNC5)z-VEK;!^6t~_ zlknzQv$y;9`i9fys2LGUZw0ET^rpyEd7ORa8y8;P+6pW8d42Kq;3STtp_$FIhV?A} z2@>UvTkD3M?FxSaem74ag}Vzy5sA+`s~GjvDl6>(E?=UGu{=w?r5#MJIwhn?GrT#s zeRSo}v&#TUmcjL&mxEF>2%EIxN+SI=O=izlM$T5JH;yv-C%^zTfK|9CLa`qJ0000< KMNUMnLSTZ|5$YcR literal 0 HcmV?d00001 diff --git a/data/images/flags/li.png b/data/images/flags/li.png new file mode 100644 index 0000000000000000000000000000000000000000..6469909c013eb9b752ca001694620a229f5792c7 GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzt4TybRCwBA zWRP9X@Q;Cki2;QFFfjc2!|)4&{xSRoO8fz%UogfWfB<3vl8vp#azd|^g`XM7-Z}G7 z@!E662XBP_{Q3Xq-~T^<{{8v;4@mw7Ggh{21PCA&u-X5;)HbrS{9yX?%Ubrf5+85L z<=Ef9|NZ_8RQLBcSoQDUf2GAZfi?gH5J>sI{|q~>>T<JvV`u*H=kK5O=Z!(CK}!Gr z`t$GC@4q1Q2V@V>Z2$qp!tjsj_dmuxH+6SkQv+K1^Dpafp!@&+{r#8W&u@@le*a|z zk$)I|{ss9HAb?mjKkZQc#wYOgBhb-*|NQ;?=l9<~e?U(E{r5MB3DgSYf*4|4g1Z=I z0R#}sfv@v<-|;cLeDMG8um8XQ{Qv#?|L<RzB+vk6CZ#BbuK)qW@{NIk6YN@$>Oa4L zz5|hefaLFgf557NB#8Tm`R|`M3=9AP!~zPVKOloaN+E`UP5lQo8*B+s^WVQre?jpA z5I`*dz#16EB$<GI1p4~-Z^pkM%}ii_Fo2@t&%b{l)eK+_00G2!8ytcEU?c-77YzXc bK!5=Nefwbn9iuR^00000NkvXXu0mjfX7&S@ literal 0 HcmV?d00001 diff --git a/data/images/flags/lk.png b/data/images/flags/lk.png new file mode 100644 index 0000000000000000000000000000000000000000..088aad6db9515dc659152b18ffdf60c269768777 GIT binary patch literal 627 zcmV-(0*w8MP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!1xZ9fRCwA% z#<2|mAP~dAFeS4y3Q`iIuW)hlx@YT`!l2TK5EnBO>cmd~05Jg0{{#R40Cvt10RRAw z`u+d`{`)-+^5ON`yb$y0|Ni^@{rvm=`~3g>{0I^G8ZGb(2;2e)q~Yi9|KETAVEFqN zi2na)xwe&yo%hS1uZ%yx{Q;WF%<}W+mp@!0-yWP|m)B$h2q2(_|NnsK|6hiGKudoE zUGn9{-`6*oEmQ72y~rUb^YQPmzn@?Idwh=T>wA{p3V#3shzY3p@87?_|NZ&@_cy~| zpmRZP`1kJ@JI^<r;3q<gAFgiW<re!UDEr~>@8AFa{s9OeCZO+ts{j1?4b<=-Nd5zA z`2GL?FD{9%H@9*#{QvEce@o8hC9n7wpg7QIfB<3vS^!iHvf<A!kPToBf4={}zmHSZ z;ROT3FOc-7KUcQ!gMI%OAb?naVfXtVPzhM|pI`s~{rdZh6-Yn3!v5)n0KfF-rx)3k ztY7{4`4<>yK-U8V5ZH#le}4S|yXQB^Nt}W|`Q?5-JjKDn&hY;a$Df~n9-WqyG5z}b zHWMS`AAkU20!9tv|KD6JO#c`e|FbePFfi-*z4`Hxjg_16|9{rM|5*PsvHt$a@%k1I zzvO#v;hz8j#Q5o)BT__yf)$AVfxHBa6HpujodJ?%_y>%AV59*A7yy`5b5c`Z!JhyC N002ovPDHLkV1l?nIh6na literal 0 HcmV?d00001 diff --git a/data/images/flags/lr.png b/data/images/flags/lr.png new file mode 100644 index 0000000000000000000000000000000000000000..89a5bc7e70711575c1ee3b83cc2be7f0e1fb29c5 GIT binary patch literal 466 zcmV;@0WJQCP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzWJyFpRCwBA zWMGJ9K!AVe8J2@628KUi@*ff#LIMO3P&Rt=*2~|&|JuCea%=mJfB*jd{R>2Y|A4{o z-@kwT`t|eY&mTX2eE<Ia_08)50mK5-;o_vu&dy?Et+Zq3)#ug4|Nr~}qJKd0_wWCI zf$ZP^fQ;Y2StKMcOq>M}Kp+i!_T03wQQEla((gZiC0cs^;{c3|jAkj>009Kl@aN){ zC);;k0UG-E&);Xo*&wq)roznr3pD=Ezu&)DC1p;}S_BY4jKAN$W)>8Nm;toyKW@Ot z#Ps&Y4S)b*xg;zq)7SR<*)x!NAa?^@4{|ZkY%l|8FPQu15397y$%U%{0*LYZ>zAxx z8}J(slm+_X#f@tK0mO1iR9wET{^!#tU}GSb{Q3uSG}s1+e?a74b~(9Y%Qpf95aZY9 zPuWDo(ENa58%O|%pI^NU5I`*FB&GkLM&}>Ys6}P~0YHEO0B+J}4VS0Fk^lez07*qo IM6N<$g3a05u>b%7 literal 0 HcmV?d00001 diff --git a/data/images/flags/ls.png b/data/images/flags/ls.png new file mode 100644 index 0000000000000000000000000000000000000000..33fdef101f74e38e2422bb85dc8a31bbf1da326b GIT binary patch literal 628 zcmV-)0*n2LP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!21!IgRCwBA zeEaq-0}TB8_YawbfIol!Kp2nSeR8fi01!YdK)El^PKn7I|NHy<)uS`Ax?YTo%nS_w zfq(%*fx*<H4^KS!&2ZobKmdU>NT^udI6UX~&u_xA`izWBNXkIq%g^6Y%Pu{8|3yZC zlYxl^Ab?nacHcU-^!@XD|Nj5|`r(zVwkHE4BV6^xC+~{3+<O1z$M1g(zkmH=P?-)8 zKrG)rz54(EpQyaSx6iMBfB*95_YY=P4xrNSzkZi(zIN)#*Iz(|KmYvZU;10(J%bDb zKmY(S0M7pc1`HG?I$iAJ((>xxCp=yQ1PA~B|JUd6PoU1p;PCnS`uh0!`Wt}y4#)fi z@&Nb%0tn=Xmv@c{N@*}KFh0M&kIP1>YRmQG4?lbdn);9N*SEXB6{h}Te)Ie9Zy;d! z#Q+chF#yj01pfa18Ye&*C_n-K{_XVnU82h1@caA!0{i;;`V*Y{7}WX#^!xhz{Qms? z{`>&^00Ic4fsvW@|G$6Ruf6C#{P_EiKfi$f`+Dz}{FL7;Z+`vz4fMjFe?Sd?fe5Gp zAb>y`UVr&E@!<UjAODE+a{l4?`<rL=Z;qRP7zKX+<^J>c&!7K){sO`Ozkh)melY+9 z5KGUl3lpbYV0iHf6xF}JF{n*t;A3C_dhHtn&^!?O1t$N2Nj?UE00RIWZBXJNY9>Gc O0000<MNUMnLSTYti#(A4 literal 0 HcmV?d00001 diff --git a/data/images/flags/lt.png b/data/images/flags/lt.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ef0da0919b1e77ca91232de0cdf0d99dc8d68f GIT binary patch literal 508 zcmV<Y0R#StP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzj!8s8RCwBA z{C$;y;SU2G{QJZ34@7|wgbgI2Y#?G{00<x!plT+mW}uq?|Nk=l|HsVmm-+uchW~&5 z!x2#7|KC6c!@u93F0TX#AQl!T1_nl;f@h2%4ImW^|Nlayf$G5M*Z+UNfryb+8fY#+ z0I^*9!XWkQ&+lJAGyX!1{P*uS*kF*NfB%2|gE0R6VrBnz{uKj20I}%rVJLI`{^-k> zKY#!H`S<7dzuzGA_xCRl`Rmu;Um!M^l;`6=xPSp5fLIuQF#P%V7sv;y25A5(1xW+7 z{Q_w~Xakza@Pz>&fLMUe`uqRSpZ|Y=>VQIE8-4+ehiC(l5cdDyKm=3_5I`(zTN!l! z|Nj0Es0O49Xx6_!5M^MM5b`fj@gGk4KbtNx00a<=83P0Vn?HYFf{Xx4|Nr&tKga?w z11|FC_y0eCSvdcCFfafF5XlYnMRN&=-B{`>{W0U03nA0WvHB!TYz`<L<W-#>r< y{$OAL2q4DW;E4VQBmbdt8IZ(*2pDGo0R{jiB6maa(%qQ=0000<MNUMnLSTZO&*O>! literal 0 HcmV?d00001 diff --git a/data/images/flags/lu.png b/data/images/flags/lu.png new file mode 100644 index 0000000000000000000000000000000000000000..4cabba98ae70837922beadc41453b5f848f03854 GIT binary patch literal 481 zcmV<70UrK|P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzb4f%&RCwBA z{BN$y@aY2s82tP9kKxxJhCe@m<iB6P{{8y>?-#?r-wgj45C|ZESQtLMVW?~Zs{a4) zALIXj41fOq2a<pPGXDL?{P!=@pWpxg{skd0<4aQ~Kmf5Y{P@N2``7>1uNeOQW%&E= z|DQh$fB%40fEE4z10q3;-;ClCKpOx8h=su~<iC<CgNPVN^?$G)kdY7)tn(jGCy2qo z&;L(e93X%g-@bh-C@2VW(*OSqV2BGaGBUn?{Td*ESRmeo2*DXx0Rtl=FjN2nhy|et zzxhBLfT8j4A3y-Hl$_uT)A;}4JJ8t-zyJa{9_Vu*31dUN0}OCM&i@rVnE?WbW&W9e z$^y(!KL7vymjS5m_g@eKhS2YS;BbO4K!`z(m*L|s27mwpI_V!11Cum2!=L{^X$Fuz z{}_J%V}vmN{DX)vf?@|0sQ&;0h>@}N>;ErcnO|V^hXG82+5i4Q+5aFU0|N&GK!5=N X;lz1sunOP500000NkvXXu0mjf*7env literal 0 HcmV?d00001 diff --git a/data/images/flags/lv.png b/data/images/flags/lv.png new file mode 100644 index 0000000000000000000000000000000000000000..49b69981085ff54568907cd51a56a1e5d8b01ada GIT binary patch literal 465 zcmV;?0WSWDP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzV@X6oRCwBA zoXEiNg8>TuF);jrk;v#5jAUY900<zKFANN>)zv`N|Ns31p}&9s{rUUv@1OsF{`>=? z-@pHYNg(_0@82^wZ2|}&78a1v|Gz$Y3Q`J0Kshi8lm?N%fQ(=Ne*FS+xn*U6mIDM3 z3(!4({{8=rtQsf{G!?8Agn$gN2Dab7KQS->1Q6rPlP7uP<bXo||NmzIItl0ph}-`E zhcf;_IV@~!4~`!P2q4D4fB!NvGX4hxkmGOzMkc0@A3gvC5X+l$=eT8LfHp8d&HVo# zZUc-Bv5JMA{n5#j00G2U&cNW6mG$xdeNZ_3`UUnV$i;u*46q<jmS0J!{oFZ#0Ac}p z9LV_(4igai1qu@&<3G?>KoY|E^ZWmwzrbJwx)~sVSb)*-|Mwp*NlCCVfB*i0>4ZfB zFhYR-gakS;`Tzomv6O+~6D%TsAw}vh)M$o8KMw-~K!5=Nd?<lQMT<~c00000NkvXX Hu0mjf!bZfZ literal 0 HcmV?d00001 diff --git a/data/images/flags/ly.png b/data/images/flags/ly.png new file mode 100644 index 0000000000000000000000000000000000000000..b163a9f8a0660fc223c2648b22ed6a074fe28b21 GIT binary patch literal 419 zcmV;U0bKrxP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzHAzH4RCwBA zWSGf-0{$>C`~#DJkl4QvBtQTT<=hAW5C&pkLRXaMS_oX@ePCublh%7`S0>4^BHJ{X z&jSWU1bzAnAdrUt4F8|~c=q?-U!ZD;3Wy>I`UNEa{sJ<jIi-O%00a<71IXZie<9`p z86fli{rLq{^dGF}F9`km_Y0^1=qi8!VgWgY;V;Au5Y<pcAjYp>Fg8g4-`_wR00IcC z0c`wlknw+DN<l_~4F+k02m+M>Z2$-$7NDlT|NlUo0ap!i1F~k2r~my1Y5)izkcK~h z{{rRU9);Krw*=%9uq;py&^Z7B1lI8X4~o-~jQ<6)85pKOHYf~%iU9%$qyZ=)&LIKv z04P8aRsoF!DgoLL3cdee4gVMb0*H~J5hWs_B!uw~i3^Ex1_pot0|0+0kn{N-xWWJc N002ovPDHLkV1lkWn<4-J literal 0 HcmV?d00001 diff --git a/data/images/flags/ma.png b/data/images/flags/ma.png new file mode 100644 index 0000000000000000000000000000000000000000..f386770280b92a96a02b13032e056c3adfebfa18 GIT binary patch literal 432 zcmV;h0Z;ykP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzLP<nHRCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`(ov;U*0{`Ko0*gOW1x?dnY zU=0kve*-lD1P}`lGXhluRs8wG@Eb_}{{H{>S-s!?{`@vR^^5K2FR(pO4M5WY0*DFZ zqCZG(`2G8?)UU4`zrJw%x*-cw4MBhY08Ix7Ah47E{sH^x7s!U+ztwL3`tbkv-#@=J zum1jzWCJ7ENdN%^b`n?!<SMWP)9+vAXFyi{`;Futu$%udFaQJ)NCPv(i_l<!h6%_B zxX2%%22iK~odXa+APr2v|1d~Of{g+C9;A~IY$OAO1R`)40ufL#Kmaj*MU6;2aSQ<f a0R{k!aaYa7CfscR0000<MNUMnLSTZdHLwK$ literal 0 HcmV?d00001 diff --git a/data/images/flags/mc.png b/data/images/flags/mc.png new file mode 100644 index 0000000000000000000000000000000000000000..1aa830f121ab8ee0107d03251a03fee7cbcf790b GIT binary patch literal 380 zcmV-?0fYXDP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz4oO5oRCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`(ov;U*021*0XgD3^5{teN< z@cTDV13&<=05Ky_HBiN$KMcQtBo?#b8i1w)1P}`YD=UMn?0*)P|9^oV_=9jUlG7n1 zgOt?2g9iZui1GF7*Fr)<|ABx33~>V{CZ_AxuLA@S%fEmBAbhajaRP`eP%%INfiyrk z1T_G`pFe*90tjjYTn_{=GBPrt03a7C3lKmc4KH52_yY$2zyM+rgbiXafFO_o^aD@| aAix0StzUbk+v2SN0000<MNUMnLSTY1w2QC+ literal 0 HcmV?d00001 diff --git a/data/images/flags/md.png b/data/images/flags/md.png new file mode 100644 index 0000000000000000000000000000000000000000..4e92c189044b7ec02a5b7a3a9460e1d01b354801 GIT binary patch literal 566 zcmV-60?GY}P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz$Vo&&RCwBA zWZ>M*00(~<{@!wBU}0eR!0_)M!#^$%c|o1wA4mpD0t66HlA)zv9Z3HD2a;wKuKxf3 zKLhV?#{Ykr|9@us&njQ==l`GI|Ns8^_xsQFIm-b82&94GKf}}4zyJRI4@Cd}JZ511 zyk#BtzrVk|eZKww|NG~I-~WGu5Q~)bF9rsH0Ad2#1T-6>`p@704FCVWdiqyE_S>%? zzn?t&`v3p0|G$6zgQ7n`R{;bN6VP4%{xkdmsRoGvjXbc2Q*ib#i+$e?>}LD_@7KRy zzyJOE_4n5=pu>QE00<x!p!t9PfK-D>u!g^X8F}1){Re7)$pq2>GzRP>hz5WF0{Q`{ z7%1`&WXbP;Kox)fFb41M1d2p!76A1_RfAN3oCFX+Kn)<q??3;62K@zU`2Q;+^7pTw z#ee_&ii`gOwCDdXP$+@4{rL;D0U&@tHvIm}0JavS6QqLWT~zSzfB$~{XZ-#D9|y=V zP_X{}_ZMUXKmdU>0M$tdvjY9_8yH^9QVjn@nfZP*{rmI(|3?O9b~d@cVAX$tK?&3V z5I~F!3@gDg2t{v?r~d;6^$&1NgV7;ZP#i-L5C8-i0C2iwRaxXp%>V!Z07*qoM6N<$ Eg62#MXaE2J literal 0 HcmV?d00001 diff --git a/data/images/flags/me.png b/data/images/flags/me.png new file mode 100644 index 0000000000000000000000000000000000000000..ac7253558ab939481a85cc06dcc4d73503afb9f0 GIT binary patch literal 448 zcmV;x0YCnUP)<h;3K|Lk000e1NJLTq000mG000aK0ssI2<b|r%0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzVo5|nRCwA{QoS!jQ4~L~_x2-gW9Tj+ zBqDU_V(VZa{s5cB-(WO#VCo`BY!<tf*u)@}FjOKI#bO|Wrc`^+InKHFz2<p2dGCDu ze&_Q={Imq=hX9y?0gzNDW@1G3IU<K_q5O-heL=FvW5P-ad!p*a&4Jag*ynBZ9P*sh zn~`HZSFfU0AjP@==)u7(6s%jjVPi0H8i6^^NU>l$FJ^m&#tWvBA4<Q|WwSfyl8|DE zEO3kt=_7(@e}3wCgCn@#X%0KzE*VCm4#9%c3JHPfrJt#dRTi!$i*Jr$aN8ZVq|=kM zgn__AU_t!sJ$|jUE+;OIDt90Ke51YS!ssf36@nx5P!(UQbXx13y`qh>C(n)b76qu^ z3TjKJi*u=MzAs;^(tHqT@6cZ|8AHxz+0T%zR}I9mkc`8<M%NEC0{hv`=C)Ycl9fpU qIZecBQusl{DZ8dPJwoYEfB^tOY6=xe&wjc90000<MNUMnLSTaA=*d9< literal 0 HcmV?d00001 diff --git a/data/images/flags/mg.png b/data/images/flags/mg.png new file mode 100644 index 0000000000000000000000000000000000000000..d2715b3d0e11b3a92c4f33cfad6b4f3488d0310d GIT binary patch literal 453 zcmV;$0XqJPP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzS4l)cRCwBA zeE<GE0}TB8_fJ4r7|O<h00M{wC?_Bw03`qa{|{2w+WP<X8-~As82|iX{_}_F_wWCI z{``lLUp8$52p|@Sc~I3r#?z+^&!2--K+rEB`Ro6$UqCjatSrz5fB<4aQ4M4OO#(`T zNU&0n>faCz48MN^H2?$<3((yN)j;^`52o304M1f80R+|X9}Iwu*X+N)@cjDs2c+lU z?_dAGBv9wyKfl@e{~Tjr00<x!xN5NZ40$2HpM3@y_>1}1@4rADVDj%Tpgtwu-=}^u z00a=o2DtG+^6#HtKYxRyQB?o^{pa7Gzs!FaUNHa!5W-0i5^f%h1nT_t=O0kRUm#-m z!vGLKP#fTS#5u+P{rv~@0nkXGhChFS;q~V)5d8o97pUPE13&;VK7Rc89~k`k^9M+( vx2Q8b0Y@nl1JFDW`UNKcfk_?)fB*vkB(P&2-J7g<00000NkvXXu0mjfGX%sy literal 0 HcmV?d00001 diff --git a/data/images/flags/mh.png b/data/images/flags/mh.png new file mode 100644 index 0000000000000000000000000000000000000000..fb523a8c39d40401b9abcfb144a73cbb2d76b286 GIT binary patch literal 628 zcmV-)0*n2LP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!21!IgRCwBA zWZ<l3fP;U37=Ha@`2UB20giqlk^ih2HUb0yF#yj01ONa4gpy4%Clw6|1;Wtg^Y{Dx z{r~*_{{8*_{QUm={Qdd;0Q&m?CIsYE48<9C00Ic4K|+Yj!$$G{e}*e}-dw)-_V1s6 zfBpiMg3!0$%$$Ee#qb;m;5fx9BL69t0U&^Y8ve1fF$1+RGBU8SF+)rRTJr5TBhX+o z)+>qpJCu1p{#SASZ=3QjbPhlO05Jg0{{#T~{M_a8tGL(@4F<^6===Qr`uzX+`vVjI z>RSuFND|!tCprH%UjG&-`Tqfgkh}r_F#yj01OWa1_W1j+!`}P+|NQ*@_xS<*{r*D) z#a<K26)P(LI+y<vDdOw-v#Qe4-0Y-=bOH$IB%p$SzkmPx{rBJZ?@W^aAIA!wur=WQ zZ&vqR(B$~FciR^qIdJOHi`QS26oi1m1rR`B4M0nNGqL>o8o+xY#_%t@QS?0~y<I0? z?>fBW+Wj{_zyJQv%*f2f%)|)v=^uaq0&4j6{l|Ybrbh`TZ`2ITPyN!~wBya;Q+wXO z|N8qsP{n^H7N*~zApZOB??0dhfB*t%@KJoPW~Y4ZyXB%oU++J<@%!g5F#%3te)iws z==lAQ0jwGr+CT&T00a=DyL0x5XB;1||6pKv0FF^K^bd&vjBEyg00RK!=O6aq+V@KU O0000<MNUMnLSTYmBRR$Z literal 0 HcmV?d00001 diff --git a/data/images/flags/mk.png b/data/images/flags/mk.png new file mode 100644 index 0000000000000000000000000000000000000000..db173aaff21955d9aed640beb344986335a1d164 GIT binary patch literal 664 zcmXYvUr1AN6vxm07-lXb!Zt%46(si1GGs#1wwUDlqoz{&q*vVRuaXE(tnQ{{D$yUK zW)^8r2}O$@d<g4eBg)EV^dJfIrlwF)E0(tX{@l}zdN}9Hc{t~MKZny(R<g&Cl92)c z23N7OT-7ydtV-6ZIpG^<R7Kz5IN$&XzO*LwepYK!OHoxzMU$tcRcLmDqrR!mZFZd$ z8r<b>p}y_ZxVsQQo9l8qD!tQ%&&F2zEbEdU-v3mY$p-gb*;wwp?LFG<9EeNpZLj7Q z>zeacpNZ>XJG@0bcmXcALo;Ad(L@#C92p0~G#aM!FfF0T7^YIJVVFaIl|0gRpSyF_ z@0dgJ{oT}qqUk#4;-a-U9Fej5EJ`tIE9)E!qDfL@GEq>vEDI}q@P8EmenIHxu!*Cc zLK?@%1j7u0A`mPlm`kyT;5daWs;EH!+LV0IWO0Zyj4+sHxRqia#e9ki#cu?^6YQn9 zOfZ8&4uw%r9nR_}FG?tJ1sBpnAsdGMh7pA$Mlm3ABvgz9aj&GrxaT8{1^YB+UzPEM zQQ5*0;(QnblJRNh>E*%16wccXl466KPu9Lk=M%$}%9~Z3x<u<O|GOW*kxIsTWNCZ} zXQH@49c>s9<RI>na6KbZ+^U;AoQ+JVg=BO3kgY$Vuu?iP6r(sQV=EH;Iwf|hcN2Nd zPl_EfW;$kS3zh=H4ojC&<yaWprmJ|K-Swgm?=p{<1RRUjP*lEfOfvN=mK^vX8XHkK z!d{oLz!*kt)qlM)a&5LoACe|g?e~1$n*G{HBAOkt9UnGV_TKQ%rt9oJ4MZ<F&mZdB RX;EJbTty|$2fJ%K{s61TW*Gng literal 0 HcmV?d00001 diff --git a/data/images/flags/ml.png b/data/images/flags/ml.png new file mode 100644 index 0000000000000000000000000000000000000000..2cec8ba440b76ab6ebef1bba4bcb924f6ba40eaf GIT binary patch literal 474 zcmV<00VV#4P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzY)M2xRCwBA zWSGdn@Ph#a{xLB8VR&4?AjJjd{R5*j4F9Y_GH?VCKr9Sj7^+>X!7Bd!`~OdX{VxLp z5dC3f{KL%rhe`hT|Cm4jfB*jf2Sk3^v<V=9SQuD<ivK_P`t<L=zaa4MHv{wE|NnnO z(XZeCZ~yxL>laWpqpU2@27mx!VE|eEAE^2d5dHlPWd8sE>;L~hU=6?i|N8~f1J=Os z`!`SnKmdWv|I6_A575egAcQa*n+Bj_fB<3vdK75(@4vtQ{Do`yh0yRHtQw*LD9^wE z5I`Uef5B$|`u7W{2T22%M6!Vq>?D8y0%`dD|M#!IAl1MB{`!Yz!@u8f<AJXJ!@vL# zKwu|<l>UYwuxex*z#9GlH2{4I3~mO10Ac~g+V6jVB)KI)-uUyEi4o|t-;5v<XxwiG z);|nNe?UsX2<Rk$0Age)WnlOOmi+-jr+XL_cwo^CMEe;2#X{p40ssOG0An<AGWZPX QS^xk507*qoM6N<$f;i#URR910 literal 0 HcmV?d00001 diff --git a/data/images/flags/mm.png b/data/images/flags/mm.png new file mode 100644 index 0000000000000000000000000000000000000000..f464f67ffb4c7108d217a9f526acb17786641284 GIT binary patch literal 483 zcmV<90UZ8`P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzbxA})RCwBA zWMDYX!0?9w1lSlDelaloV_;xmVEFW#!Sf%OgN6VC2q^cbwpLMGf=yS4<HwKx2M&Dt z{+*HW?{CIGnaqFwF#Z1h|IeTQF!IZ$O#lG|)WE>X%A}^sE-TCS?;peW@7xO)zGwLT z>;Lb2Aa%cf{|B;v0lB{!Wo3aj00dxJ4sHN|VGstA{YR$4C2D_2_!cBcz(8l;%*ju; z_5-pDt^fjRVEX=(;rPk#zkV}({q}dw+K<0~Gco-B2UY|%8?FIpIzRw{{P+Lo&pY>j zzJB!i)2Bb*-v4F%#qjU<Zxq!K4S)UsZ2$-$mbVO^oPSIHet-1u6W9!p(%($KzW@LA z6K*!t4`3$&1P}`!0|VplZ~uRN1{#1YjiwqT`{xfZ_yGcl1*nM`9Ape|MVQ+D05$w& z`1=>=2Y>(qX<+*Ohe1*jY|LMVKOmiqU?UkIBoO`m3qe4|00G4K6*VFmu*EK13J3rK Z3;;9iRuWt9^;rM_002ovPDHLkV1h$@)rkNA literal 0 HcmV?d00001 diff --git a/data/images/flags/mn.png b/data/images/flags/mn.png new file mode 100644 index 0000000000000000000000000000000000000000..9396355db45a8ee040c790782209868acaad4b85 GIT binary patch literal 492 zcmV<I0Tcd-P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzen~_@RCwBA z{Lg>@{}>pU8D26lh`@M2^zAEy;6E4#hyVhJ1te8n&0xjw|I|OmAOC9q@qGTx`1kMs zKYtki{9$JJ&B6L7=Kt^CKt7QCvS|}Q0D(0y{Qoccl;O=^hIfA-|M~k7Bn3tPfBpOS z`}Y4|zknP@Sy`YB00G1VQttboLEtY##9yH3?>`J++5f+PL6rRiQNLgu1_potVgX{t z{eKx$|NOuHhk@zOpC3@OA=>```VZI207MK7009KDfg$woe}>=xgMa@Q`TZMV99$1f z=+7UZ=>P!)^22||KmS$!{Ac;a@cR$YT8L7JY6c`57{N{g2q3VN{s9Bw7X!l|paf9r z=TC&I7$7zP9Rm!dKY#u(FaQJ)NCWfVKmVWoW?%<u`;F!vsD?j44SyN_{slS*Ab>y` zm}dTA_#*kA1?Z>0QqoLZAnySq0pv->KTM2<4<s2&fn!fEd3b#Uc<1GhAk1kU$dn i@{mCt!h!&R00RJ?8BP+2sash90000<MNUMnLSTZvb=2+v literal 0 HcmV?d00001 diff --git a/data/images/flags/mo.png b/data/images/flags/mo.png new file mode 100644 index 0000000000000000000000000000000000000000..deb801dda2457f619d53bc176cc889d362cfa032 GIT binary patch literal 588 zcmV-S0<-;zP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz-bqA3RCwBA zWSGdn@Ph#g{xLB8fswyp3<w)SvM>My5DUW>hHBSpkmCRUfavf4zkmMxWn=;={POe9 zpZ~vq|NHajABg<@_xGlmn*aic1*nOE;s2AbPk~B-;NSn>+$?|p{{I~>&N%tu*Drs5 zfBO0B*Z*HY)v`RYKsx~fhzW>+Cjb5S_s>6&>Le+qufKnBv&bF%YWVBlABO)w3uGC7 z{rLq%Ks`WL0R#{e(EPs)fB*akI^_5N|9=)g|N8#t9~WuY61}otf4Tqs`!(tD7m$X( zzkdG%X#fZyCZKPCW&?Et`7z=QS^~DI8Yx-=nhgK{7whGvYgWDg!B8&G1k~{7FHk81 zKmY(S0M7pd06qXVA~x>%?)v`v$?y7WENVL!I|c#<=Jn<;5-<%03=swpg4MkH|N9RH z59P$=0*D2u3CIB%01Ag+CC5rQSvY_G`T70NcNRvLcR${}{{9+hIZ*cRKadRo0R++j zv<u=SAo>0Gk5I)>DQ+oULEUYSww-@{VeP#&z`*?j4i%tcfB<3v#u_k;CAlSmszIWE zes6rZ@!!9HKqG(r{Q)GE1e8GG4GeUkhF=T-0mR5q%E0gm7LmVTk@^EErhg$tKMw;y afB^u%K|axUkwLit0000<MNUMnLSTaY@e5Y~ literal 0 HcmV?d00001 diff --git a/data/images/flags/mp.png b/data/images/flags/mp.png new file mode 100644 index 0000000000000000000000000000000000000000..298d588b14b9b19e04c26ab36266ace317b81d59 GIT binary patch literal 597 zcmV-b0;>IqP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz=Sf6CRCwBA zWYBG7fP;U37=HZ&lVHY=UkpF~F#P$&@b?b`!!HN|2p|@qvI$k5K=S|pfB*h5i17WE zm-=li^NW+=&%-b5+fV&_^qTR{-~WI9{QdLy@3hKE00G1THu?X{cVGYh{Vyr-OGoMR zpQqP8f0krq5R_GZ=&bT}!3&uO&zOGy`70s91GE7kfS7>p`2X)eQ1zd`|Kz0p8A$%R z`bTZnZKVTj3KfeK`2Ky*5B&e@&)?sFfZ#7s13&;VG5q=W=RZg_5SU54z5Mv&imk%` z|FW?#NF2HHZpKX|PKHOnfBpIO`|qDW|9~0*0*DFdzkh#$Hv9ux`j_+Xe->2}ZC)XM zHV0!dKPS0Ur}$Y}|Nr_6wBa96o`K;HKmdVl_zUt7(16dMgtTSeTz>rQ?Z?ko?mjsC zklD%d+V79-zd^=BZ2$-$7GT%`Z2%eY=kMmT9MVkp=Y%mFes6g9+4CZ|=Y~?>`*tz_ z9Sja7kOqJN0yzojravHW{k?SeQ|&okK5=fKkARLe6n%c?uEf?;0ubZ>f;9XA2p|@Q zU%!CHiwJW41sn7GB}e}J|9<wIz>v$?qW1mmH(Bw2pdk4RbO%twKY##YWMEE2jz}>2 jgG53Y5Cqh~01#jRFwa04;J&RL00000NkvXXu0mjf4K^ZQ literal 0 HcmV?d00001 diff --git a/data/images/flags/mq.png b/data/images/flags/mq.png new file mode 100644 index 0000000000000000000000000000000000000000..010143b3867f21e7791b8254e806b325c13b2895 GIT binary patch literal 655 zcmV;A0&x9_P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!AxT6*RCw9| zF#yj00RSb30QmfYctR#15|E(G$-LSC{{H>$g8u&g1qF5h0Q6aJN-{1Mbz`Ie0O|k$ z{sM@ZfzzR@(T$Ir&B098+eukMUeMl2+1^x^fq~&CAE%j<mXIK4sJnV_lqsW_)P*BQ z00M}Gfr)|PKf}{kU+z49ud5>Z{^O4ipMU-bgNM&P{`mQajg9%ivrm#DT>t+4XJGgP z5I`&pzyAFG^Do#{Ra%^vg_(hwneqG2-w+MozW@IH=Wj)-86P+6=Wo9l8G*Kb0|+3- z*{jc+=}JF+`H7W<>HmKQUT#*P1{)(8poaY?9|3*x^4(W97N$Rc|4EDS&R=&5Ab=SE z{{73y$Ox4F`Rgyx2o@H`|EK_{=f}^#%*+g|tSlctd;ka_mU(L~nd(ak@UpY9GCq0n z<?FX!Ko!<T(hwW|{`;pS&CkNhc<cUqHddxZ>(2rN05Jg0{{a96b$W_S@ACL(Rxa}M z`G=gy|Nj54lTiQv|3YG^`}+MH7z}h_G^4TA+~V=RqI&{}iQ&&bpkF?J{dw&CQ*JI+ z5dlsHn4|vw{rBhJzZb8+p1k<X$3YnwM!--42p|@Q|3Cix{hJeQBrn19>+fF;B_XE! z42<9q0s8Xa-+!garV_&3Uw`~!Vgv;%KmY+HvKbgYfiyF)F);jMVE6!x-Ip&{0DU4U p=>-f&1_my$6wo1$fFwYG0RX)13*@;vt7rfK002ovPDHLkV1lt{Hh%yB literal 0 HcmV?d00001 diff --git a/data/images/flags/mr.png b/data/images/flags/mr.png new file mode 100644 index 0000000000000000000000000000000000000000..319546b100864f32c26f29b54b87fe1aee73af21 GIT binary patch literal 569 zcmV-90>=G`P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz%Sl8*RCwBA zWSGjp@P&bai2(%u!O$Nt`UPV^*gqH;I2Zr|P$=&<0Dv$MLsMimreYBGU@}^CzVMM; zJvn1VZ~f1wUOonULqS4F4@xRz%`vY4VqstdD*pfE>r<f8zd*77Km~t;RDb{f|9ipJ z-$3rKKfiu4{sPL#^2jo<FaQJ)$SFXR|Na9R%*Fm2sO{I6U*Sr>Bb0vs`uywH-=91j zzd+y*NDt6q00G1Vbl+cwzkmM!`SbO+m+YTk|9<^u`R%^_m*bXSzgT|#{QE0P`4>nh zQ2ZZA13&;V0loO=-(MgHX#3gMzn3`u{`KzHucyC$Kl}CT<*&8wzt6n|ss8o%*Pp+C zfTjZk5DUl#p#T2<`NjV0^vyqym4EMy|26&E??3;3WoiC?{O#A4)4zWSfZPMI0U&@t z8bCe+8UPFj-d`E(e|zZv%GLeN@c(z$rC;+8f>r<j4KxvGE>HtN0D&|BIS>OtI{AOC zx$tZGDUcCBkzc|<aiC*>7XAeyp!)#=hy@sHz%Z8Nmi!G71?uGb{rk^vkcEsO8W<jb zfdCljKn-6Q00M}Sp^|~&9XNJ>!D1Ad0T=%Vj%gtVfB*vk>3V2g53(}_00000NkvXX Hu0mjfpCtxQ literal 0 HcmV?d00001 diff --git a/data/images/flags/ms.png b/data/images/flags/ms.png new file mode 100644 index 0000000000000000000000000000000000000000..d4cbb433d8f9fe49f06585dc46ee15593e3e621c GIT binary patch literal 614 zcmV-s0-61ZP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz_(?=TRCwBA zJe*y7fJv=()u(!<O?)hWik~^8E8Oz3lJH;riGkq{1H&%{hJRoZiU0xtF#yj00WT$k zu#`Xm8XNi7*Z}nN{}>w!6%f1G{My^>{rmp(`~Up={s8&+{Qm#<{R63-jRF8M0M7pe zp74|h+7SfEy9M#{{`U3;5)b(M0y+2j#`QMv`vWF8IQ;$o{QUp?{QmLR&;S7V0*Gk~ zkHv3!H5OU9Kb*Y(q-ELEH9vC;U*%N({+r?VuV26a{F4-VqagC^H&EN}zd$De1Q633 zj~FHafsLPJA96}RV-#A)X~v?X^MO<N^RNHEfBgZP{o^+qGusBB4Xpn_egFs{mVc*? zZB*nt{^*|p3kSoWKZkGJyeh}7$@-u1*N<O7m;C+r^UnvM>Aye@1K9u&05Jg0{{zeC zH7h(V@9+2F_Xpti2mAc}=j!&)<^S{h0sZ;_Q7ob~2($C-{v8Cq(%>lF*}4LV2^e-j zV}AbzqQAd@=ogUu2Vs2v^{02G<;{1(|2aQ(EV23Z2N=G800M{wXv^O}|A7VrH9+)$ z7=NG)mcRcxm)QV402KN07wDuv009Kl@CPU%A;t|Pff9c}I{*Fo2PXf3L>PYm|M&kd z6T{yxzy1Ri0|XEw14AS%MzNxQNDQDd27mwq0OfZ^Ej7^!+W-In07*qoM6N<$g6Gg9 AMF0Q* literal 0 HcmV?d00001 diff --git a/data/images/flags/mt.png b/data/images/flags/mt.png new file mode 100644 index 0000000000000000000000000000000000000000..00af94871de66cd0fbf0ca8e46dc436d66e2f713 GIT binary patch literal 420 zcmV;V0bBlwP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzHc3Q5RCwBA z{P^)B!@qwFOiT<&;NP1!Jj%-dfD8--5I`(Iz{<jM^X5%$Z7l{ykhcH-85met{udQ7 zfBww$`}hApfBwVBFPk<21Q5%=KY#ArzxV3dvp>Io>l+&W2PtRx|M&0z2M_*#`0)SN z@BhDl{r~j~sG3n$7H9)N0I@K!uxMy#e*W?mp&AJO{R1fisrv=e1J=Os`!`SnKmf7) z1AD;K%nYO&458rnZ<rp02B2{O0R+~-096HNfB>>;h=xCZfJy-Z2&4h30>ylo1|%C8 z!A=4QAQtS#|N9SD2R9xVP=6R000Ic%Boqz*|DhQF2dLpM!{5I^H2?v`f~*=Iag5B& z3^Fndyu3hzLFn&a2m+c75I`Us-n@D94+enNf~Z%o__l08i(?1?2rvLmwOi|Xk;8TX O0000<MNUMnLSTZ8imiMA literal 0 HcmV?d00001 diff --git a/data/images/flags/mu.png b/data/images/flags/mu.png new file mode 100644 index 0000000000000000000000000000000000000000..b7fdce1bdd7d174a894a4a075743695301d32450 GIT binary patch literal 496 zcmV<M0T2F(P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzf=NU{RCwBA zJkG%IhXDcp!MJ~5?0+x@L>wT1SlAdCl&Y)$gVg>5qW^#Y{{Qp$|KC6V{`~n5M8AIn z$$wxH$UeVm6F>m5>|$VWV&MJy<ONXaKcKqbAk}|=gB1M*qF+Gv?|;8QY#v#uc?=8y z0mM?t&|Jyy`1sq`KY#!I0cioM0U8HZ{QKW;AQ#B^1vKHWoG|Ymh9>|4IE<4N06-W9 zfN4+=|Nq*iiwn$Q2C&jbT&$=TD3oCSlHOGFpRx(;BY;>KBo9alsWSh04>T2o{{H<9 zq`@}){__Va_~#GM1}QP2gACgN0*LYNU!Z^U|NnpRpWzq7|3Cl#|Nj5~7gmyqN$LIj zQvd<P0t_2QMvxFN+)x#vs0PadZDIfjAeOX!3=!&o-v9jl?;j{={{H&~LjOUGe<1E} z827Ib$KTR@3;+SdGJh2VLj*9WCBZ@R7aE)(_8$ld3^Sl=5cvQ1V-EvB05LMeGXNbA mMSrj{{=gW(2xnpd2rvM|-({c=R~H%p0000<MNUMnLSTY4!q-Uv literal 0 HcmV?d00001 diff --git a/data/images/flags/mv.png b/data/images/flags/mv.png new file mode 100644 index 0000000000000000000000000000000000000000..5073d9ec47c3b98e18bd3cd8499433d463ab8e67 GIT binary patch literal 542 zcmV+(0^$9MP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzut`KgRCwBA ze9ge{4~!rH&i;pi{(!{+0&pnCHUPjN49n2D5Azi#^^GJ@gk(z|e8wBGOAT$e46CJ* zKw^UQ_RIVNhy|?e|KBH1f3p4m2h{NU&;P$b6~BNQK<wYY{{z`Sf3bh~&Lk@fv;-i4 zSb%o@|M&k7!@p@QYCl+ktUo{g|4?L6&|%YA`+42Ze?NaQ{bl_AtJ&`a$M4@j#Q*`s z1adLR8Gru%`~C0l@1K8vNHa;fbGiyL3Yf8(07d@(1v=>WzhA$B4g=Z%5I`U&fgJtk z_g|1x{{H;?U6oxK=&at?J**6@V8K5Oe}Dh~1#%?NbbtT?*#Hy-y7l+}Kff9N{$pgg z_WK6Ie}+7v0!uEdpa1{-Wc>4s0pu$NunhnK1op#kuysJ&8U8Z;`FH#0jm^LQ>Tv2E z__*)izhD3U{sq|pR0FmFAb=oF`osMFS8?ijP~d>v`s?4X6Tl#928Ghke_*>=zWoA* zI8ZS_0I~c5+3<%^Qj!Z4<bVGG-Sr!4-hW6W02zP({u28O1VF_A0mOJ07K?w7V)P#p g7a1`zFaQJ?0BOBTFz0)c;s5{u07*qoM6N<$g2=!9(f|Me literal 0 HcmV?d00001 diff --git a/data/images/flags/mw.png b/data/images/flags/mw.png new file mode 100644 index 0000000000000000000000000000000000000000..13886e9f8bf65186eb96071d4399fbe077ec92a3 GIT binary patch literal 529 zcmV+s0`C2ZP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzqe(<TRCwBA zWWWII3=A0z4F4DyCNp3G009_?V<LcI7zlzBK5Bzbtb`zqA(%}5^tOuKXJqDV``FR_ z(qD5RU`q>rNibE_6+kRtZU3J<dHVP7U)R5Xb$<Vz{`YU)@81l+f4Bbr9sKL}iQm6& z{sL){m6Zi51qdJ}Am;z~U+C{&yT5;pfB#<i_ixm%Uk`u(zW)oT`q!b~Khu8w5&<J2 zpeYOt00Af*gByTh5QrU82kM%UGEC<?LkNBPWBVy7s+9jjlPek9fi{6(ds3g>46>yw zfWS`r^B2emYGC;Nhw;yEHn21ZK^Q<1%m5k+3<-b$0%`dF7h*inus>j>Kov-;VI<H7 zpay^dVquPEsQ38&=)<o+fB*dc`wK__$=@LI_a7kRFNpgG=)}K&<OKh0zQ_O&Kr9SW z49~g$JO)XC(64{Leu342$bUdCRKp*jKl%SMs4xHo5DUW(phNyha!7)V{P*|IU$B>f zD*pWek$*v<0SxiK|NjEzfQkVEh>@X`f#DO--wX`DVCWAL;}4AS4@`0~00bBSj-y@M TF2~k{00000NkvXXu0mjf^ET$> literal 0 HcmV?d00001 diff --git a/data/images/flags/mx.png b/data/images/flags/mx.png new file mode 100644 index 0000000000000000000000000000000000000000..5bc58ab3e3552b74d990d28a0f500e9eb6209dfe GIT binary patch literal 574 zcmV-E0>S->P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz&`Cr=RCwBA zWQgZxU}9l_fIoj;_T6J*VEXs(ACLzm|NQvzN>LFc1LT4cKmY-iGBrkbGB7ay`}g<P z-`_%l!c2@z3=IE)0I2T&@87IB1q}cG|NHv~$i6ji7C-=j&Hm5u?9;2i|Nege{R^ay z@jn9m`~U0S0}%E5H?x!^&<212VgcC<)DN^BXvn`m|G{S8yK(^N9$5`*W}w>Nzrc?B z!}RAj&|v@p#KiFL&)@$*g@6D2{reke@BjagZ=QH_|HA5tt#{7v1Ul>guV4Ru|Ni&u z_rKqNfHnXG5DUYvUm)ZE{sF26>H%tC6A=5t#K$G4Eg-G_A87rbKVS#^0WyF#00a;d z1Ca3#WHt~1jRI=eclPL04k1AU{avRH{sTG*Y{UOQAO!R;KmY+XFaVtnvEdic(+vNe zOr8IIc?-17*~$Z`|L?Eg|G`fB4^+g!01!YxCouq>54Pd=FQ6a({TC7tjr5F#2GXxz z|3N{=01TJ^fBykB00a<7!|#88#Ce2)8ovGh$;ikEv=>5x7)(s8a<ZV12L|Pz-#||T z1P~*GEjSu~fTQsP!^LeE*_haX-unjzzrK8VW^2m?Rs!|_13-WQ0Q#d;@Jwk0DF6Tf M07*qoM6N<$f=7G^pa1{> literal 0 HcmV?d00001 diff --git a/data/images/flags/my.png b/data/images/flags/my.png new file mode 100644 index 0000000000000000000000000000000000000000..9034cbab2c02704b65fba6ecc4a7a1c1d053b6c5 GIT binary patch literal 571 zcmV-B0>u4^P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz%}GQ-RCwBA zWMDWc$a!JYk{$*I2Ct~1&p-7T82$m#Nd|iX2#bN?4-yFwK#YQ%Gaue(2CCkFR9#2? z5gXgj^;->Z-xKO+5@Bcl`=5c~|G$6#Vc^TNX8-{JF#yj01a@w+002<N$qzLy#taJf z0RH_-LFaJ2R_tP92mty2`~3g=`u_X-|N8m^92?xIsRD?F0R;ZNc*&!t#Kgn#=i7JY z|NsB~{>>mTdm0Dxpa1{C#)An)W@eTA0)PNw<Yu1!>iNI_f1h_u_DTrb&+z}xv;{Wj zFG<a1E9YeR`R~sku<^hD|M|@<BXfPj27mx!WMDWtWg@qy!*-y-`BjlXC;S3CVe=|c zPUc@=<H6=LFaT}%c<&xS05P6qV35l%|M}n%!|&h!{(wvZn)mP5Z;s!e7&+Mg0|kM) z!6d7^{M9{s00M~d_w%QWynO%v{$=>j0Cg<H(LjSCW`jwf93u<Mo4a=b0*K|Fq_k*$ z{+|aAKqme912PQaCUD4rxDdg=f7uijFC9G!5I~II@84$;6oeWB3v~t{36B9Vg8?l2 z`sPi50Al&|;@OWUPyPV`FhGC^#`ptfGeG!%AdG)Z3=9AP1_0b=TNV`IW>f$G002ov JPDHLkV1gu%1+M@A literal 0 HcmV?d00001 diff --git a/data/images/flags/mz.png b/data/images/flags/mz.png new file mode 100644 index 0000000000000000000000000000000000000000..76405e063d43f2f3b5b9cae4f76d9f1c73cea25b GIT binary patch literal 584 zcmV-O0=NB%P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz+DSw~RCwBA z?A2!|4r5^8VPN>d!0?ZO;SY>NM!#Sr3j;s^4&s;y01$=&U~zyG1R{bN8&$-J2AZUB z{O~2s*67}BSLujJh!l{(TM_&V!SLx<0I@JIFetqFAHVYHe9OP56#xDPD){#oDEIpx z82$bI>))?me}4fPvfQ#j8vp_bq~ZU+|9p&pvv&RstuJJil>ZL~|9}umff)b({bONa zxh{1bAb?oDF)-ZV`m5;k=ljw>l6BpeezVN}{rlPPKR~N~|N8X{NCKH4>bI<%?516t z00M{!=o3jd#=5m)yzyNB8Nca#eH--U&Fe3(f#?+keR}os(@P+F`}Hl*IRF8~0t}ng zTmN>%etWlrgX7)b6MtD}{$cp_8-#v?7>pqD50GU3{f7;x0U&@Fe=;yIS7rUbaG!zu z&%cJ+j{>}Z{{3SByBg&1|Nj_3<X<40nU(YArR@L#1oAY)n?L`Ve=~UgW?-w}`~2(w zKOp)IBf*USzZw4jWn@=Td3F&XfLMUB#3cHMAx`qY>~Dr2e;Gh>zZrofD5`!lF#Z7{ z2B7#~hM#}`y<q?dAjYqv4F7r<{)sXC`@rxI9IrqZ0U7^(ff*3?A5iRrOkiUG2rvMj WW=WDjy5ute0000<MNUMnLSTY#5fNPg literal 0 HcmV?d00001 diff --git a/data/images/flags/na.png b/data/images/flags/na.png new file mode 100644 index 0000000000000000000000000000000000000000..63358c67df905515b49cf50cd766834dea8c18ce GIT binary patch literal 647 zcmV;20(kw2P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!8A(JzRCwBA zWMG)c!0>~Cfd!0!j9&~4{}>qlfDw=_GI6Spo7yiaSL1(R34j0qF#yj00RRApetkm@ z3-<^I`04Bn_xS?+{Qmv@{`~y?GBy}bOB@O2-v*J4|MK+z`}_2=vH}QbDjOT??*y+^ z9PHma=D0k1DE9l$Z%$6;=mbqObq>D1hX4By|NHgp|L;GHwRPtz;{gH)sDXiz;U6Qz zZ)V2dKoY20MNK$ASA+NcQ;z)f|Igq2|MmO7w9@DL!aL%O?v>U60R+_W>(dw3rWqbw zY+vs`7YYhj_p}$}TRP+a`c40T|N0L!J1pWwjPJac>kgmX!|;RwAb@}x{`~&)@5xJE z5fO>bZXLnj?>WlS|KEEGbn$;~p|4%Vx0QJ+&gI^Ic|%b^5f~5v0R*z)@87>RPSSB9 z(mdPNGA>^D|K~4|Gn`ysXGCuKcCvq8=a*k!egj?m?>8{m00Ic8fvd1cUx@iTXM5HE z^H)FyGjo2eEq-FG+;YA1)Puvn7=8hj{`vO@7;-=x00M~R?)3!82NxK-n*V?K1yZeJ z{HiGaB<th$gGq0Gy!iz*`0uYjAPs+k2&e%dfLNr=6qrJy{|ia{7hw7o?*Ga{Z}qc{ z`>yR2;1J+u<Nova&!7K)fNuW#9~d`(fldMlAjYrY_yeL_D<^ML_*Ors92gWJ{x78H h{sW7B9tMB_0{}qp6`A;n{k{MI002ovPDHLkV1i9xHqZb7 literal 0 HcmV?d00001 diff --git a/data/images/flags/nc.png b/data/images/flags/nc.png new file mode 100644 index 0000000000000000000000000000000000000000..2cad28378232e91848d9a2c8bd9d72a9e6a635f8 GIT binary patch literal 591 zcmV-V0<iswP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz;Ymb6RCwBA zWbi)C00)2mG5i6Ozy5$2K=K!u{R2XR7ytpp!q4!dAwvzM`uk5VmgoQfGk*Lo^^1+= z&%ghF|ANpT5c&s3|EFi21qdLPj|?0P4F8Q~*2)Oq7UqA&&Hd}cC-%+fd=K9+`|}5+ z==Yz0zyJONl9GaKK-&QVh^6q-reIk`VbwF7oIjQ=`+e!s-?p|7QML=z-`u-*QuXhj ze}Dh{g&@AKyayR300aOr0M7pejN^&`?tK0P_y7O=($f4WDEt5a`vC#_F&@4$j?@48 z`v3d<|N8s?`}+YO8$kfe0#F>sHUNMy48Q{FH!M8N)Pwp9Y)wTFo81A^+}-HTA|tp~ zO4-IyLWKVGUAVv#KrFzpU^vb8&*4vW%&*_Sfp-5+Oa#jR{(FIy5oqwQKVTa`BqPut z3=9AP#KLU;d%N^MnR|>Uj{MoN{TEQv&!4|iQhqnS=35v13ux)DKYxDz{qyJVA7#<M zXHPKz1P}|*g7ufUofMf59{&CH8_)y44j=jbLR@g|74F}EA*zA){(*QN82A7I#KQ1{ z0cgP1n_7R2fAf6)_UHGX@7zphU;dRB0xAUu643O2e<28{0U&@F8I&1-dVuzTNFe&n dfFA$^7yyhLLC25C)g%A_002ovPDHLkV1mg58>|2T literal 0 HcmV?d00001 diff --git a/data/images/flags/ne.png b/data/images/flags/ne.png new file mode 100644 index 0000000000000000000000000000000000000000..d85f424f38da0678471ef4b3dc697675118bc7e0 GIT binary patch literal 537 zcmV+!0_OdRP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzt4TybRCwBA z{2$4{@QHzei2(@y{bOMG1*AaazdvB~gW=yVFak2ZG5q6U00=;#92x)s!Y}~afLHN& zzT&ny)|O$SGAzvIt}W6<BSh47WJfk!Ayx7l>pTI(!tjlO;rIXl@1HV&)cpmDGW-GR z`3+L^2aNvy`v2$G|KGnEMPz|W00M~d>&=s#l8OvKbwEpiHvESG2>tK>|GyyO9|$ut zvpzk42q1u17-V!9I61+pfvQ08|BZwH@16Yr=jVTUlmA*S|6zcEk@1g`6+i&7`~&$E zYA%%V^7j8zTmS$3{hyirzos*?2B2bq00L=%yMh7Y5+V8jy0-s+|NJkm_8$o_f}Hjb zAb?n|yt|^zto-ZmuYW+t0)q+&<<<Vlsez37_vhc|2Vk!NRsLgVVL$if96$guGUPL) z`K8_ac<=Y$-#`S^2}HmDff&F40LkCK|NZ*&@Asd-e^mrjE;d~R2p|?<l>KJ>BgrQT zRQ>1Q-#;J~AoS<o9}xNX&!7K5#ead}{TCRh91H*f#K=&}!0-VYyHNB8iSY-<fGFT& b00=Mu(FA22L6!-D00000NkvXXu0mjfMcDvR literal 0 HcmV?d00001 diff --git a/data/images/flags/nf.png b/data/images/flags/nf.png new file mode 100644 index 0000000000000000000000000000000000000000..f9bcdda12ca7b07b3d16bd88c759db2c82c88884 GIT binary patch literal 602 zcmV-g0;T<lP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz>`6pHRCwBA zU~p!DquG0BzkdJv^XJbWKYo1u_VxSs@1MSW+P$@#L70Jomw|zm0SEvBhy{qNbE|>m zzkmO1v}^=;1(+C_zx@3C{m*wPUMbGM98Q`}5v}3B|NZ&>_xGmUO#lG|(!jv*|H=EO zfB*gc{`33)|Nj{n|Mza|{r>BFYe?(AfB!%K_<Zx_t>3?Y%L>Z^l>!7{V2qIffI$#e zz5f4~?wt{_jNwA=k=tvV8m;04hxH1ClNsp|jaD0d_yUND;Sa;#|9^ps{`~n3bQJ?5 z!?D}Pp1*tk?A@~oJ0>vvX8@W7)C|%C)&LMdARGSt{R`y$`S<5P0|@TEy!-p_?>~S2 z1lj|1A<z_%ZGV0PjRa}{2p}egUqCbd096A;frc$RwhZX#KVUDu|N4H`-dRu^{y}X3 z2q2IKAPXo2bQQ?3-@hKed;ICkr?20?-g$NB+wX5cuK_&(H2&{jkPQF<#KQ1{0b(A| zQlS6*jQot1jr+Fr0ohqDS)#n6H(%WR4KfZCDnJbY0mK5b;m;pQeo3GPHdb~X4jxTT zO%-t!pb>`hhCuT1`^U<nDu00htN|c^7#SoPfO<ga2SeqO$~c1<HWrR|U*7%r^+SY5 o<japQlMYQ<oWGa>1^@yK06yzfAV)~#?EnA(07*qoM6N<$f^cITh5!Hn literal 0 HcmV?d00001 diff --git a/data/images/flags/ng.png b/data/images/flags/ng.png new file mode 100644 index 0000000000000000000000000000000000000000..3eea2e020756c41abf81f765659a864c174f89db GIT binary patch literal 482 zcmV<80UiE{P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzbV)=(RCwBA zWSGdn@Ph#a{xLB8VYr`mUw}>E-@kt#fq(zr{dlL|rVbPb$^K$sU||3VAQpx%4ArjH z5b1ya|A`BUvomopF#HDspdkBi_LzW}KMcQr|NHa%@9#}BHvt3?3s4hK`pMU)fB*dj z0+88E|KR{Ac>Ck+U;lpn{`Xs!M;2%gKmf4-G0<e7#2+B~`};oz`1SV}NW<UXKm>FZ zKmdWv|I6_A&;LJv{(%shYM_N64Szv401;3FKmf4-Jp?oxsQb@fum%PU8~*%)I0>i# zs2CuCKpOsn%?64Bg>X9QKhy?*00L<Ma)6>h)xZD#`v33$e<V)>!wuw~|G$AI0?h>~ z1_&UqlR!#;Ll9OQ{s1-n2ZahyF+c#Z0Amdp#**BUAaDHn%gD$G3<d}Z1k6m#O8iQH z|NjLcpoU)z00G3vP|CpY2^7bm`2N9gyy3VIn-C<X|NQy$>dPzV3};X@Gl13bFaQJ? Y0MmSSAW;3b&Hw-a07*qoM6N<$g7#L<oB#j- literal 0 HcmV?d00001 diff --git a/data/images/flags/ni.png b/data/images/flags/ni.png new file mode 100644 index 0000000000000000000000000000000000000000..3969aaaaee470644115aa805cc344d032d2faa29 GIT binary patch literal 508 zcmV<Y0R#StP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzj!8s8RCwBA zWGMX4@QVQo{{3V4!vI77K<F1LVqgFWAQqtN`Vg?<|NsB}XZ-t*;qQN-%Kv}=GXD9; z{QEBxkp1WH|KC9J-~TmDUjPD#1!UNNhNqwYgOq{{1xhmjrGNj0s{8%t|F6Fwt_(jT z!?%9`0mNcz!k{YhUyu`M15g7*8<70|_urr2|G0p9AT|I=Q7(p!4*viGi19DbZbn9+ zlm7qz&j5!1zs&vp<<q}^zgf6A|8r;jhXJ6nPoF*k1P~L%w@@(%{rA`Q`)aq=fBgUV z_kUyn3Y32U0mOo=ngObV(NX@wUs;a-3@GXufuZv6A3y*x>QDb&Xu<gU>wlP!fzJKM z`1`}(|Np-K;|B=>MS!;cWf13KY+LvSAb?md?)rCHn)&`aU^p;<LIV^gz)<1`GC*ud zn1Vu#K}DE>VI>1V00D#b-){yc34R8!1Sqb6<ZoakfV46E1CqdK`O655r9Yr}`v(v} yj12x?8NR?G5-9eA0X=phQT`jGoPm`AAiw}GF=D&pm~5>80000<MNUMnLSTYFr0e7W literal 0 HcmV?d00001 diff --git a/data/images/flags/np.png b/data/images/flags/np.png new file mode 100644 index 0000000000000000000000000000000000000000..aeb058b7ea8b5d88519dadc69cfe7cdba77a587f GIT binary patch literal 443 zcmV;s0Yv_ZP)<h;3K|Lk000e1NJLTq000R9000XJ1^@s6ty!lV00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzO-V#SRCwBA zG}1iz?=3qo0}%Xwa9)#{0Sf>KAO>U2WB)$@6+CBT{3ok>^7H@y{~6FRKmalQ{r8#S z4^Xx6<Hw9QmvM2+YVY`lZV*5KG5!1Zi9z7Q2ZsMYe=;z0a4_E6qt7X=I`0p%B7gv5 z`t$c4!?%6=7?}9@7?}9^86HbXF>Je&%q%J2@E@iKAb=Rv<(vO+e^|@#56B5{*~0Mg z<s*h~A6^38@{{5Jzh4aRp7t^V1Q5&bzb_en0TqaJbTI7ub&f%O=4Xa?Pr4Y9tpf-k z204k)|F3Fm|NngO;Q!D2_y6Db_WsW&7W*IBcK`tdbkj?Qzd(mG{P_dMtU^KzOJ6N! z;1TqIy9pqG7{r9k{vQX1)06P<|Br)${vQIe4+8OOAm-q;0-FdBK#bgio<RCHQ0N!X l=zn0$0H#540I?1rzyR=(sPE1RX_Ei|002ovPDHLkV1g<O#?k-) literal 0 HcmV?d00001 diff --git a/data/images/flags/nr.png b/data/images/flags/nr.png new file mode 100644 index 0000000000000000000000000000000000000000..705fc337ccd50d4d49709597d5bd4b946c0d8a32 GIT binary patch literal 527 zcmV+q0`UEbP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzp-DtRRCwBA zWRQ$!_`?7N|Nb%j`or+&4~YExivc9@3km%IiU0%<3sA7GBL<}S@4tWl8U6ynzkh%J zf_Q)a02zOO1Ia&se}fo**R@vy1P}|z;Q#-hzW((0-+!QDpi;2(KcF-m{rdd}NXm%u z1ML9_AQpyyKvNn1{QVCy6{Py#FEIK0&p()IFbQP;{tMIq5I`&pTz{CE{xCEA{?GXL zKhUoKAlHB$2D0xD#DO5*pMU>>Uit<QKrHuX91_y7|NZmTpTB>A{$OPI`~Tk`F#7!u z%=q{3H<<jx!Xa@}=r2G3u_y+Z6!%L$e!*d>BYX7n!%ts-g52=?&#%8AHvnDo=g*(t zU{@(f3Lcro01!Y-3_uV4{%5PNloM>zp5gQNALI8Qf4=|z|KsPsAHV+n`1$wy&wt;4 zfzU5-=mP{0FqD1)U3&iJ)0b~Q-+l7x$B&=CVSxxV`w!S3U{3%6(9;Y60R+_W0~jFx z85t(6Ir{$dcL`zMKcF!E2U7}WK$QOlX#fZyMg|5;)aZo7D4hMD0TloQ7ytw<a=r{0 RIhFtb002ovPDHLkV1hsJ^DY1Y literal 0 HcmV?d00001 diff --git a/data/images/flags/nu.png b/data/images/flags/nu.png new file mode 100644 index 0000000000000000000000000000000000000000..c3ce4aedda9bea0553b43c8d3d849eba6b3d2cd1 GIT binary patch literal 572 zcmV-C0>k}@P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz&PhZ;RCwBA zEH1v@#WJIfr{E6<SKW!2B9&P`e*KtqC&%_l-p&<E*g4t${rw9;|NjBOUw{B&dBY`j zC$+cc+wEtZVvPCb={JSdKVLq;vc-DWKW4FI%<N1||9~nP{{Lh6`<LPOv!4I~05Jg0 z{{y}u?-(;B0Trzc#_a$B*Av3x|1!rE&h#?LnhHkw{r~*^{QCR*`uzO+0s#T~@%jQV zIL0UdfG`kK@&C_su!x|7h1#pPCnTlzmg&NiNa#&thH30mZ8<FPoHK0jiVW}t5L24Y zyqo+Q3^(i^ScfoZd^%;Ez;MfJok&o<eAU;V-~Rsn^Z!541AqSi1Cc<N0R#}s|A+TA zk6xSf-0~AAb8-Ez9i|L>zKX@%&%DST%gMqDF&nJ<9}^SM9e)7=i1G97Xij+!pc8>s z0}TKw06GH{9{>J;c@WiqflO8wrXNp!0t65X1H*raF>uuoM}q<p+4Ue*48MQ>1_&UQ zzyE+PV}l7nR6|1@Xe}f}K_mmn5Fh~%KtLN9K+(a>4Du5*FzA2|{m;zE0Q4slD+}03 zKxJTefLQ+k0*D1@+|Oq}Kt_TL2e}yHXa<;L{(_<zstHH}1Q-D2rg)(rd?B0w0000< KMNUMnLSTYVXZvXY literal 0 HcmV?d00001 diff --git a/data/images/flags/nz.png b/data/images/flags/nz.png new file mode 100644 index 0000000000000000000000000000000000000000..10d6306d17429012904035e4097bf93a8d205971 GIT binary patch literal 639 zcmV-_0)YLAP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!5lKWrRCwBA zJY3kg<C941+)MSMSJ;@Cs$WW^%K!6nPzzl6jDg`B1H&IM`oX~P3yfF*0st`p&i?@| z7i+hvUHlXm_}0|`_x1o88x9o`w9V|$-tznV{`~y@Lkj7<^Aq#;{QLa>vY&wh05Jg0 z{{*4}t^?F62l4F%@9_Wk_yrLV`1}4n_xiEzG57obO$p>A_3H5V5SZl@9UBk;0Q>>~ zF#yj00<a1&`y?p_BP9C{68;<?2rw`75*g729{K$L`uqK;?G?cA518f_T@mE}{rvy{ z`T~gQja>jE58tx;oDUfVAO7ZA^g)(IQ}Y8G-;ZB^s|2r!a55}@Ef&J{K=uDyPxdE3 z4FCZEF#yj011R$m=m{0X((d>7`QGgNy~*X%==~K4{m|+BO$p;F|LeE!5zX@o6aMwK z|0Cnry8?)1@m-dDW`RF^3@85Z{`vj)H~*iTuYX^D_V?HC|LG63e*OOQ`}ZH9+h%{4 zR^kLX2_S%&fc^l6#;?EsK&pTL{q^hL&)-1T|AVLlN&WsC!*d@f`}fa3pay^dVgYLS z^XKpHUw?lCNw6Z2x?f-t$o}(Zg7`(je_wy{{yg~C5U2qlfPfml0Rvc4oChck)CmlD zkWL^2sNoMV9{$c|wex0wa`2z{-@pGE82$nT5F-OaFayIEq`3SAj@n<S2om2wCvgD; Z7y$T?I;_p&;syW!002ovPDHLkV1iQ=H1Yrd literal 0 HcmV?d00001 diff --git a/data/images/flags/om.png b/data/images/flags/om.png new file mode 100644 index 0000000000000000000000000000000000000000..2ffba7e8c43f160bb0d9634fb9e6cb4093741340 GIT binary patch literal 478 zcmV<40U`d0P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUza7jc#RCwBA z+|Pgl4BmfaVE6|F3=j$f00a;VP{Q`Zhi~W3aameEZ)o7>=l%a5DE9w9oI(Q6o;?Ey zAdrT?A3yTg*!;YE_t&FGSk*xQBO~LVKYsuMhzTgoEGG70+cq{OrGLMF<5CR)KqmnN z5F^kJDrIFqfoA{t^Z)mszkh!J|NR@N^WQHZ<JZ4mzkuxjV3J*4{=mV500G1T^xgkI ze}RgC8vgzM!|>-f!>?ZqK&!y&{zFIz<If+U{Qv>P0wn(b{R37FH0BS}Z;-(ZzkW0R z`o;JgO8$qifBy!003d+C8bA*C^$+NzU%!${e*gOW>-S%fvmgwhvw&=%Tb23#oNZ?S z2p~`>{R0^fayL*XP!U2MkOb-c1#;`ZzkmMzW%$Pc5I_(c{;*0)g6#S8SB49$^)FDx zACSQydx0wd{{073{+Hno13&;Vu4Mp{AoLGRCWbOF`~tJSfrb9TxDaUu27mwq0KDvZ UcsT?Vy#N3J07*qoM6N<$f`X&bC;$Ke literal 0 HcmV?d00001 diff --git a/data/images/flags/pa.png b/data/images/flags/pa.png new file mode 100644 index 0000000000000000000000000000000000000000..9b2ee9a780955566cc7dc2f59ce175f32d3731a0 GIT binary patch literal 519 zcmV+i0{H!jP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUznMp)JRCwBA z{P^)B0}}Z64@>|lVPPN>g8sl5Km-s#EI@faK0YA%?%RKmGG2!N|AD;!zX}Teef|3X z_wWCI{=muCn>GOi5DUcMXP^GBIsf;|&;M~|Oj3M||NsC0d;h_|&!7JP`u*?sum8V( z0aY`}$^xwd2q2IKptK|}gRT_Am!FIf)j$AI2PS_*H2ec11_potVu8EfUYUvEKf`}G z`1lE^`X5*+Sm$r%-@k!200a<=Rv$}~=bu-f|Ns5_|IfewfBrH6Nrpdvs+^pde*a<w zIvphOhw1lkMlms#g9iZuh~?>>f6qSrfAZn~Z?Fa+2||AuS*t4lqXH%-ra!^K00G3r z@ax~7e+)2nAoSxWko^y??$?YNza~t8coi7j009Kl08{<<|1U847wF+XP}P60U;n*# z@9*{N{$9Nb<o@~d2Oxl0fKCP)C&t4FbUiQ>{(u|?WHSN*&{SqkP0pSkP;|=6L&63i zfEXEE-vV95@Z%rDuRkCLl>P7efnPxL{(%8d!yj-G00Mvj0{}mHT?%@XEt>!U002ov JPDHLkV1lK4=}rIu literal 0 HcmV?d00001 diff --git a/data/images/flags/pb.png b/data/images/flags/pb.png new file mode 100644 index 0000000000000000000000000000000000000000..9b1a5538b264a295021f4f717d4299bb8ed98d98 GIT binary patch literal 593 zcmV-X0<QguP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<4Ht8RCwBA zWJqQ}fPV}Oe_-S<FoH86=m!Hp0I>j-HAXl7XJGjM{~r+i{r~sxzrQwG-&h#_Y&p;L z=ieV7_s^f-zyJOD{rC5(+EoAn1k&)I;s3Kw&;LT?{{2}P{Pq8T&j0^^J4?LvUd;UK z&+k8ffB*XXONL(tXahh1fi?X94^$0Q>Z$uRRO)Y4)uapmB!2(-ul(c1=C+V!kAF`) z$PCo;`_FHns{jIs3Fu;wy-Z(c2YwAz{&V4aXk?bejFO*Op&u`@i<P#NM5z2anDp@% z$W_0AHUI<=6VO{y{6FUU{$~3B`#;0){+-SR)BjJ|$ol6OM|0|bK^^`7|9=6|$@CBU z(q9<9G5`b+6VR3i@0ilI{`t=Uk}wuL1$4~Cd;i;4vwV2{U!V5`M1#wcUk5J#X5eQ4 z2q2IRfByV=_lYTJDa)3Jzp`T5RX=}y{qq0s&;Jju|Ke_2x8cDr?MbYUUbC`tu`+yQ z00<x!pbdZigMti*ChuqN;{3XAg2Vsc(*J+|{?C3(Z!*j8e=I;B|M?3F6<`Pf1Q1BW z?>~PecqBpL@DIoojb8mb+WHGK%fG2>)a3=iVf_~v+`s?*1;!`?Kmai^I3i*ZIYtpN f{g(k500bBS@<T5{{p$N}00000NkvXXu0mjf4z?hV literal 0 HcmV?d00001 diff --git a/data/images/flags/pe.png b/data/images/flags/pe.png new file mode 100644 index 0000000000000000000000000000000000000000..62a04977fb2b29b96d01ffef3b88b6bf2ff05862 GIT binary patch literal 397 zcmV;80doF{P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzA4x<(RCwBA z{Lg>@|A6@UbB2E)`X3x9D*gjYK@dOyv49j-R|ARv|Nb!om5Pi12buAo@&A8j1_q}8 z|NqCt{Qv#?|DQiV^2?@8009Kn!0`Y7lcx-S{{qo}h;n3b`}Y4|zksS4Wo3aj00a;V z*zEsM)xUqEs|Es~YOn@|-@kzx00M{wh#7&Zfj0j6!|?mhe@uX+0cadR0D+zK=P$&S z1T_5l12i2VfWS`r`v>f&U-&gJf}I2qKwu{UjsNuv<SL*9Rt;d|frkEJU;qdpkOpRO zkO7?tau`+(e}Ec5p#pRcKmdU>F#Z0+ASnqp<}V}$85tQ7(W#^a2}2+PDh3E3#;-`R r2#VqJ=b;e{^dbb<+Crtk03g5s0zF}bJ8sS=00000NkvXXu0mjfKVzU% literal 0 HcmV?d00001 diff --git a/data/images/flags/pf.png b/data/images/flags/pf.png new file mode 100644 index 0000000000000000000000000000000000000000..771a0f652254b4e891fc73910aab38967864da54 GIT binary patch literal 498 zcmV<O0S*3%P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzgh@m}RCwBA zyw1SzhXDcpp(2RXA0#$F01n021^^g@VGKQ@zw#Bg^cxH*LW*n;Iiu^o-`1>3lobsI zohi_A`bB&J!~)dH$ngL7lczwX3_#UDxxarw>LBRX?|;94{rmL`$Yzn1{l&ll5I~Ht zU%nI;6$RPw9|VBf|1(_vbYnFmA3K-0+yDOt{~_StKSoBzkDopP1Q63dnCt(82%zd$ zpFb}6I_2!oo##J&nDhVtbEpQW0tSEpVuGrMivIupee<8UpWZS`Uj1hH_v_am-&g;K z1CTfW0R#{WvT7jw$rQ2Wy6P|4+kZd)xq2}(*g=aGrk)Yxu73al#Db(Bq?4D0-N;b? z5G()RfB&pB<@s4TkY)e;`2!F@EcYcO{->q=ymt>64xqsK^^5b@FIO-F$h{9?`~e2* zUv?FhqZcm%1P~}#|Nj2NBq_=8`#0mCzd$+0-@loGnqiRuWPl>)F9-k?0|XG`aR!E8 ou!#JF#Q1|6-w+1S#{dBa0Kx%7Vg$%BF8}}l07*qoM6N<$g2}Akn*aa+ literal 0 HcmV?d00001 diff --git a/data/images/flags/pg.png b/data/images/flags/pg.png new file mode 100644 index 0000000000000000000000000000000000000000..10d6233496c10e52ead975c5a504459fad68ffb8 GIT binary patch literal 593 zcmV-X0<QguP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<4Ht8RCwA< z!a)iE5D);s`Jn$l!AX)fNur!gBau1p{w^kipwp-nt^f?jF%bYD3<JQhA_NgwKoS}- z(*b4Onl^Cv%QpaL?*)u1e3@b*DH5HUp-XxED#Fd40Ac}Zc*pR+<H=LIzkfsj{^k1p z8>Hv=@BjaQ|F{1AU+wpQ{$Kys{`!CTH>0fVKL!SX0Ad1a`1cQJ=-<7ptZmH9SAnX3 z{Q_yQ`~BbL*ME+m|K)!Dcl`DLKS&U$0U&@t8vg$M`{&P}#F!W*N5{%vzvln`{r&f^ z{||ospYrSf)?fc0{rZ3A*Z(w-2B2bq00L=XVPVP4%$zZO`i8aZ($dmav$E#>{B`Z@ zum6{R{h$5&|A}A!9e(}a_zUO_pbY>4IGm#s06-9cg3a4XeYH!D;D>_*`V5R3;NTtr z%*@^Mq=>M$LXVh`5jChqDeaf800L?F_U+sH_3M8E<^KOyQ&ao$<qOb=kH3HS{Q8yr z`}a+tmuCHD_z4QPKMV{20R##qAmjJ%-#IxsckbMoF=GZ$HING-kNo}}^6OXGpWl&x z{xbaqMh^o(0D(08{{2T%Qu4ur2iLD(mywbA4F(WX{(!;kzkjd%{hRyuFDnBBKmak2 f1o9ae00ImE6ca$b<!3b600000NkvXXu0mjf(4!>p literal 0 HcmV?d00001 diff --git a/data/images/flags/ph.png b/data/images/flags/ph.png new file mode 100644 index 0000000000000000000000000000000000000000..b89e15935d9daf25173f89a36d8111824fda5db5 GIT binary patch literal 538 zcmV+#0_FXQP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUztVu*cRCwBA zT)TF9aPT(<hA#{t@DGgsfDw@K3(WY1%m4_$VZ74-0K!lJ3=@(N%8H80sR$6z_aU%c zuGwSn711+~WYU000WT;O_*E4n<`qDU-@m`);1C0<+qUh=nl-n-ef#zIFT?NO|9}4m zsrdct@2}r~fBgcnWo5ayZ=V1VKuin_eE<J{V`94J<HK4~q9rTK`|H<#u+l%jpsIlg z$o>N~0_Y@w0Al+04@f;`V3_{@|8#9F=K6XQ9UY*azdwKd1Cc)=8bAo50U&@_7#Kb? zFg*PK|H1$NTnr5Nxw#FRn^&xvtbK0f`#-;cuKxpc)t_H~fDExeq6ZmH0|XFI1NZ;` zGXMXp{AUnj_^<r``_KQ2m4a5x4+OjHKN89GTPcr$0U&@_fGz@Sh+$w5_<!yG|0%Qo zpFj5>>}zDz4B$`$1}#7U0X2MOU=aELU!7sY=Kr&1{QvMCSsj$|2dLpM12Fg)7ytqY zsKMy}yX6c^FZ@4woIyZ<frlFysElAE86YGO{rw9;Kwkp{5DVKH{U53J|6q{}k8Ml@ c0RRC805>63`(G)I*8l(j07*qoM6N<$f~Z~XumAu6 literal 0 HcmV?d00001 diff --git a/data/images/flags/pk.png b/data/images/flags/pk.png new file mode 100644 index 0000000000000000000000000000000000000000..e9df70ca4d63a979e6bcea2399263c081ce5eaeb GIT binary patch literal 569 zcmV-90>=G`P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz%Sl8*RCwBA z{P^=D0~mby^+kG`G{YALhJOqUf4~UH_yuPCf-zVa00M{wsFaV54@j~xv(?+zfBpUS z55wQT|Ni{>`{xe>ko^7oADI06=l8$gn`UeR2p|@S@&Et-|M&0T<FAiD{rvRz-{1cX z|E0NQ9=^Z({qOf*zkmJu_Y0_6mPZz74?qAhK~zJ?KmYy!RsZ_)D_c9;M%?=Q&+jaZ zETsmelAMx2L7*O>s{jIsi2(+HrUEqpC9EW^)CAOaJl^^9-%lBC8BHP081)#ChQGgo zHUI<=3(zGH<ADGu1O&XSyqwIO&%QkU_4n7=SLfQ#v_Ja%=-2;WfBydgY5)izCZPKN zFaXp5lz8>+B`^Tu)Z%{q{PpYS&%;j+KluCr=pK*_e;5D)h=t+bKO_wx|Lu6N(?HnJ zN6`lu;-7zgR^V0GaA(7wCwqWF2h;!%KrH_l5Kj8_?>EpHKuhXR);;<B#6ik|hmB|A z`H45)-T3kM$DhA{fldMlAdnv*p~T3@D9J6!&CLD#Kgjk?cQ>uSzaAK9KY#yZW@T0p zQ2G1+FVF^n0AhUp{`o&J`0?k5YKbbt8;l4>j(9ExfB*vkhsr>Vq>*li00000NkvXX Hu0mjfu=^7c literal 0 HcmV?d00001 diff --git a/data/images/flags/pm.png b/data/images/flags/pm.png new file mode 100644 index 0000000000000000000000000000000000000000..ba91d2c7a0de26e554979f6351d42a1a4e22de3b GIT binary patch literal 689 zcmV;i0#5yjP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!LrFwIRCwBA zR83|$BbWQ@&p)k8i(YSRU|{&c!0?ZO;R^!;3j@O&Fq?~k;S&P`69dB^AQvD205Jg0 z{{!DpZTDt-{xv>#D@XkmAv!xDMMW8-mEZ&mK%|%B2@pa*J{pUD?(_ahBqlG}*TVt; zF#yj00{;H~{{a8``T70x^5Ww5-{JLFN2dS#-{Io#@$TjE?&8zZ_V@JO>E`A8`2YX_ z`~rx{;oS`3$G61pUY_{!-0$E2{{CbX;QPSJ^3BEStf<hN82^L90&gTmUuY`d6W{~6 z5Fh{mF#yj00oT*h5E2sy1p)8*0ds86`~d+W9ON7q<z7&}008>g-V`n;-q6+;t+Xyw zOy)sS^8$#8OF-b~@87@v{bTs||L?z_Zq~=&zGIaZd&b4_{m<{;|Ns5^`sL5xKflEU zUjXHSP67x3F#yj01nBVe84eEr00I90|NZ{}FelkBB-0BC^O2xB85HWz)f)HsDi#s! zMLEolpK<^Iq5=Rh0M7pb=>Px;h=2d#;^FNBZtPGJ5bKP1n*{>+?(qio_5k|){tF25 z`1Sjqtv%A&PR-7P0st`p&i?}b008M5=kFQg|M&R&{r=?U4x6Pn<KqSD=>hWa{`B$w z?CApk{rU?E^8f<&008;|hy@rKe}6M^a>=kt2+9et{QJXr>zQ5ua`W#$8JQS={rbi5 z^Ctr{)6XBj7@3%V17i>%fEb@MFnj<<;{gVSbOv{z8Q{qN1r~!u@ISB;HgNQF0R$KT X>6#2<48ze<00000NkvXXu0mjf$fQr4 literal 0 HcmV?d00001 diff --git a/data/images/flags/pn.png b/data/images/flags/pn.png new file mode 100644 index 0000000000000000000000000000000000000000..aa9344f575bc92f4c1a5043e6e7d0a8b239daa64 GIT binary patch literal 657 zcmV;C0&e|@P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!BS}O-RCwBA zOinyK#o}DX4L6w|4~qV*@cU8X!f?Tb?M}osho{FkFfe>$VEDzr@CQtS7ytnPF#yj0 z0a0(~eP|Fd7x&oU`XvhO+V>M05dgm54eRCe)!y3s`vCp^_5Az)`uqj_{QRhrlmY-T z0M7pd#W4OILofm6<rLGy0nN=5{q6tn@E_^c{lU1l^AY*>?eh2PFCO>E*7Xbx0@lgN z0st`p&i?}F01o{F1pxvB0QdR->g@pe`}O_>?Enew{R9F2_ajBO3l7@{2K78Gz9u`z z008^~05Jg0{{!Vds4FN77!dx<`5YAi^1B#F4D8&$5I)oT72fOU?&Jm;1k~m5*82ws z?b$fe$;$!&F#yj01PbEP9OVUy$0!>9?*iHF&;9=9{2wXIjNS}D{P+g={1WdM4-6DF z>gCe?_5uI^`T_tk0M7per>)2uHb&;=(#87${QNQl(a`kw{Py}Y1nctu{`UwC>ihis z2n-DS{QU(1_5c9-0*DC|aR2`P{`;Tl?{DV6zkmJy$H?%Xjq&fFKfk|zXRC<W_xsQ9 zdsh$s`TINC|0K{4009Kl01U2wzkmM)qF)g57nJe)&%XnwRG!?BOLV#L>bma58?r#f z00G1Tv;`<3F2eQa&)+|P|NZ&>@AqGj(m#Ly0)zk56?XydpHmj-@bfY4J_0oTA3y*x rGBBhgM<k2^LI02#f50RIK!5=NlhZF+ItPJS00000NkvXXu0mjf*<?Z4 literal 0 HcmV?d00001 diff --git a/data/images/flags/pr.png b/data/images/flags/pr.png new file mode 100644 index 0000000000000000000000000000000000000000..82d9130da452fc294baa03a349ec2e71259a80af GIT binary patch literal 556 zcmV+{0@MA8P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzzDYzuRCwBA zWMH_tn884Y;rkB;hF=T}|B%ohFk*l)fQX5K0U&@_7#P$ZRlgBoIIP2Tf|c>-@1KAF z|M~a-&+mVKfs8*8^zZj?AObQ@tXl^VK#U9w|7)um9zFT5!1JoGnu}M2{r|uJ|AC@F z;y(;9F@d=M{=L6-3m|})KsNsS&&$bh^PO0375Ag3EPt6;7?_!X=sz3*^#j#0Gyh{@ z0SF+Vlm3Z`F-Ar(?BDbM+y#c5od5B%>y$nn=KcNZ|L>nr$AC#T8JS}%RsaMLPy>UR z34^BAe{Qb-hmSD)`~82~jewZHH;sOtVgB{~2T<v+Um*1RH^=|~&lng00tlpG=l1_V zA8y<7|JzrF@4x=1$gecv`M~@~ALw;<pazJ)|Ndo_kdQli6d-_rp8j2s#dz~RP>_Lx z^M7~ue_6TzObGu%0nl7VM#g8?t^ouP$PYjMFazDG!uzbThlgL98zlelKgeJZ;~z*Z zSPxJG!?$k$0R+;(`ulr=<aSer-QOC%zW)Xeb)agvV?gBJfB*h6{QbM*!Ucc;0&2J? uHeXwmf#DJZ!#AV|{)H{R*%%lA0t^72Eh{lq%f<Ns0000<MNUMnLSTYe(FRHY literal 0 HcmV?d00001 diff --git a/data/images/flags/ps.png b/data/images/flags/ps.png new file mode 100644 index 0000000000000000000000000000000000000000..f5f547762ed3a7f556b1cb8b12fb80ed17fe1c4e GIT binary patch literal 472 zcmV;}0Vn>6P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzYDq*vRCwBA zn9V>M00<z){|pRos;hT0FkJly0)PMh{qqM*K)~<czhNYh*tBUAKmf4-HHk3%k9zWS z-`~H>|Ni~>`!`VeZ#eh`f<ORd%F4<D6$1niNW=es{~7-Nb^7~P@%L{95AXj8vH!6F zMn=Ym4<7;q5X(QHj&I)>LE8Q){QmQQ=eGY$|NqCtU}^v=1iBd@fS3^8`SJ7r@8ADF ze*C{^(f_{w|JZZ@y#o+HED**2KY#i6{~yEeU;qEJ{Pzp|pOucqNnlF=0*DFdB%s0n ze*I+l{{11(um4^iSXF}z{SS2YKY#!N`GNV*AE2Jys=p_Au>JnK{@0e3zk%WO_xG>A zzkdJw^XDIk{pa6bWqzfjEk^+Y2&CaX+n+VzlIN6JC4s^J?+-}zKcEU=eEj+Q{|`_P zFrfeb|NHmfUxq&n00G3<$-qz^$iN`P!0-!({va{_z!?9)Bm)CLfB^vHpj0t%_B3$- O0000<MNUMnLSTXbd*(L) literal 0 HcmV?d00001 diff --git a/data/images/flags/pw.png b/data/images/flags/pw.png new file mode 100644 index 0000000000000000000000000000000000000000..6178b254a5dd2d91eeaa2a2adf124b6dba0af27f GIT binary patch literal 550 zcmV+>0@?kEP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzxJg7oRCwBA zWSIP);Tr=C{A2h9A{hPv(LV(G1tVD)00M9*Cq)2&Fbn`G;0#$v!3Em-YtnGc>~nkZ zGRMDC%7~XK0S0Tx8Wd7-QG59jKr9SQAk|O4{{Q=*0jSzY^rW5C9>)KFc0aM(`^fh9 z|Np=Kfe=u&EDs~YzkdJ$!~!z=-~YcrJO42FE6xsAYGq`2_Mbt_P-yG_e|4Mhx&31J z|LZSE!|%TgKvw|-5ED@OU$BE2n0|Z9O=M(v1mpqrFfl%gR9?uy{Oi{rkOq*RfB%6# z1PCA|pp${Bfd()#{N!P|4fj41<69Aq$E=K>;Tryc4F(7xpbZRv|AD;q^*8I6-->Vz zfB*5n`o{nM2Peo6f5F}W+3*h_fS7<7;<N9+Sk~Oj`1fCofr0t&KcPQ=`RCq<`SP6& z;$xukKy!f}1qdJ(pdbGHWd`}_AH$rh*>^vxc*?8?`g7w0kCk@<fj))=D<nRE?gt1U z7KR`He*a^V<YoX%FkF4>cHxaHDByoH$n$~K0fYN5BQR!wAqjL2Kmai^lz(OTgcO%Q oK%!9YKcr{|u^AZn7ytqc0H_5zuk@Q*SpWb407*qoM6N<$f;OS^T>t<8 literal 0 HcmV?d00001 diff --git a/data/images/flags/py.png b/data/images/flags/py.png new file mode 100644 index 0000000000000000000000000000000000000000..cb8723c06408828ce68a932ff472daabecc64139 GIT binary patch literal 473 zcmV;~0Ve*5P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzYe_^wRCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`&pU%&omV`KRD53KJ$2txq_ zg7fbmh|k3I@68*40Al&@kBy(1nSq%Zss!k?M~{APS@-YvZ#JJG201x|0gQ}qez5@r z5EI0wa6>`h%cteq@9Mue|Ns2{KQdro`19u<KmalQ16%hW$u$g|907;_SN&MYz`=>4 z9*F+^0|+3-w;#WAv9bOIX#?r_2Xj8qX@CF21pWab0}C_Lljko10*H}8@M3d^)Z>@G zf!6*0!vH4#gGo5!|DS*V{{HzdC&9L6!fAj26p=v+03Zy*=I{f8|9_}7o838uP_pBd z=z9{Sra^iHOE|+IC{<Xep1WTFF)}cp1x6D%5|K#=;}234|AC|cfB*vk0+mz#U98Q} P00000NkvXXu0mjfg|5uB literal 0 HcmV?d00001 diff --git a/data/images/flags/qa.png b/data/images/flags/qa.png new file mode 100644 index 0000000000000000000000000000000000000000..ed4c621fa7181fb14c46325a76a16422653aafc7 GIT binary patch literal 450 zcmV;z0X_bSP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzR7pfZRCwBA z{Q33kKL#lH$M7#nLg?J*-+vewelalofRVo$5C9;67=aqtxVV6<|Ns9(gcetq|NZ;> z-`~G~{{ZRVfB*dX11A6d`CYbrEkFRVK#Yg0UeQ$d`q2ZhA|Uz&)bIy{egR1#X&Imb zfB<4bR{ihazwp{dkXn!mkfPsU#_wMs1hfID1|Wb~;Esc6TU=EJGWFLlunkbN!Q9_} zL0)432p}e)sSwo=4bd$f5a~a^fBpd(3|0LbOac`E1P}{Q!+)sq|NsB}pI=e{a>g%& z)4?|U{s;0mNCQ9sG5v!Z57O~Jp}QY!<S!8U3#=1r;csB50W|;w5DNp$Nf4jR%*_BB z4{<u!_&*@y{{X}I4@f0I05N`i|CWn~5AFvb*%uq}7wlzl*#8BQpcwfJjF|~1&I1Gx s$PXXhz553SKzS2c`Iij%06>5N0Be0)<y2nMSpWb407*qoM6N<$f~Xa|0ssI2 literal 0 HcmV?d00001 diff --git a/data/images/flags/re.png b/data/images/flags/re.png new file mode 100644 index 0000000000000000000000000000000000000000..8332c4ec23c853944c29b02d7b32a88033f48a71 GIT binary patch literal 545 zcmV++0^a?JP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzvq?ljRCwBA zES`Fq0S5m4`IqO%_xJDLpFe*B$v=PoFfuYKxfuTY!tm!O!yljm1_potVgafPw37mo z|Ns5}_wT=?B=7(K|3TdUK<+;v$=cHQ|IhD#zkdDy{rhc26F>lgG%);U`26kn-@hOg zU%!6+4+cOs(0HIde9xZz`}Onxub&LUB0x(30+2WcIRJn#2ut|?gWYu1Cf+!-K%B8# zdf?1WA}#uZ8oj7u>$I1i0Al&`=O0k%-@icgAIJnM0xA6maSq6BK-ECw|NZ*S`0Lj% z1_pot6puj;05Ax`F!=umqj7^frO?t|3^&I1kxUq9yECc+jQpY84SWH_0#pxl$?v~F z@*hy-KN0|X07U)z`4{NpU%#2aHUI<=%a31wK(7Du52Oc(|3O^?R1IN+RRjI-n*kVB z3=9AP#PZ|EACPLGJ%9cJNh|>9B%spYzZw7h1%?tp0I_@ndg9MNE>313@6R75NcceF zkr51-#U+7;F#`Sf7i0rK0I_`g_NQ&Z<sUzOef#m}%a32uoRUDN{s!{@0^RcK$B!R+ jljj4~L82K500ImE?yY-fn+$bC00000NkvXXu0mjf0<ihi literal 0 HcmV?d00001 diff --git a/data/images/flags/ro.png b/data/images/flags/ro.png new file mode 100644 index 0000000000000000000000000000000000000000..57e74a6510dd6a4b29668db181cb94727d1eb4b7 GIT binary patch literal 495 zcmV<L0TBL)P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzfk{L`RCwBA zWY9j&@aYc&2rw})u)IHD&cVjO@Q>ji7_t0d_@@ozArL?Su`s;*Q{AKpRQ~TD<G=q* zoYEDntUnnT{`~*{2Sk4P{Xgc<|KGp={{fL-Hf;h3AQlD|28RC(PhS82`|tnXe@y@W zJz`+^{{R1PDEjmJ|LtG@fBgcgW|Wl$+5iwhEFiQ0GXPcp`3pioga7~k1tx!k82^B( ze?v4d{QeEp01!Yd41fNCRsZ|*hXJhN7s7ZD8$|=qbbtV2VfghAsP6Zl|1b@|k@SEW zzo1TH`11#713&<QH2eh_|Nj?I&tC>44PX+<NsM480R#|8!|%Tgzy5&q{QkoLbq1OZ zVB>*q00taD0I@Lq1k(S2nt`tR{g)9*1H=ZfhCe_Je;NM%1^NLXfPkU&2dGq1gcanC zzsyWb(hLmW7#V^70~+_6f#nZ_(jSmgFakOWAb=Pd7`8Gn`~pjZT=;x%FbfOAzdzu3 l1(F{a{)I#17y<wS3;-vsZ=FTY?nM9q002ovPDHLkV1l#N)|LPO literal 0 HcmV?d00001 diff --git a/data/images/flags/rs.png b/data/images/flags/rs.png new file mode 100644 index 0000000000000000000000000000000000000000..9439a5b605d82713decf23aba9c63c8d719cc200 GIT binary patch literal 423 zcmV;Y0a*TtP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzNl8RORCwBSP)$q2KoFg6HUY(4MEs~) zso0!*^5|7je}RZc|A8KYH^IMAM4?v^6pz)TAP9wmR0L6!qM?;EBAT6XHk*Wk_}Cq0 zX6L<`9q|l+$x)dz7{Z^tcmx>R-T)##NTdjqb^wzQ(`1@?t)Ix4MUXz556teM9A7Ic zq_@itH|pv>q+zrjZ<ejp9SP$kYmE%MJM#e4Y227hT0P)PujDVzEnR<ifmE;z?bRBq zmi^t)+KblZQGut`->J^Hx5bj=fD{5McI3ol<@^-l_@~tZGV7p>1CU&qG~{YccyC-q z$8~P)6sG{nMmQy85K$E6L33rja<I<=cm7`W;*2ury&yV{)9dv_zuy-^7{XWk{^{@t z6;5w$4tWcv)Ks&zEGrB{!6e(ZVE}p?h<RxILZQG`6M`VX1m-QD;XnKeFaTJ1{yf~x RG?)MY002ovPDHLkV1k>$x-b9$ literal 0 HcmV?d00001 diff --git a/data/images/flags/rw.png b/data/images/flags/rw.png new file mode 100644 index 0000000000000000000000000000000000000000..535649178a885355c836b5c838d096ec3ce8d365 GIT binary patch literal 533 zcmV+w0_y#VP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzr%6OXRCwAX ziNOs3AP|Fa0LOM|Q_)lVAdt2`_LRTq3RLC>N_~0!B1ZtR02zJmAl3i>|Nr}+@!x-t zvcLa8?7#n*fB$3p|L;$z$Rx)9zm`4EX`B2HAb?mPX21FhQ~^@@2dMNf!=L~E|NQ+A zWdHd6KUK1c=hsc9f8Rnyezh^=0R#{e(Ek5GMIci_s{j0D`2GL?@4p}ozrlKzzsUIh zhw0@n(cNzYfer%*AeP_1{{8vS`0Fp&84w#lmi+$#)ARI~X!Y&+KyeWsW`<9n00M~d z^G^m==F0#79{mS71ZX;t`~_q%fY`tPf!Mzw;*3o4FJA8j2p|?NkT1UdXZrG=;TOYy zh@M|qNhT&CJ~jq`0Ac}pl#vl6#K7<eRS}A6u<V~de;5D)h=sL|p~UOYgHOMI|NH&> zFA)9u_3syi0VMzY2QmHtx&Qtu@&7&0&HxaAAvs6^7=(cU7?W7&6Z-$piRe`dyDK`^ zN$WO$zWL=wEu!PO?Vu9@iVSM&8cWvf2p~p=WCn&G3_$lI&>tl77dYY}Tp)vm0U*Et X__=7oxWDB`00000NkvXXu0mjfV`BRN literal 0 HcmV?d00001 diff --git a/data/images/flags/sa.png b/data/images/flags/sa.png new file mode 100644 index 0000000000000000000000000000000000000000..b4641c7e8b0dd79aafaa73babdb525d3d2dc6a8e GIT binary patch literal 551 zcmV+?0@(eDP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzxk*GpRCwBA zWbkG{fPV}Of57M$j0B<|NQ@s0009^jlN$g)7>4!1u&@QJ10&`rk^HbMk8Ee|uIe&H zS+;4$DbWCt7$DsBz5oJg0IGiW@flDZ69W^F_wDDmUw?kFvatRB^XvPsAO9KtfBpI8 z%a6}8Lb5=`00G1TbjJVx|9}4ZVWnuJEUfzZ$EVZx<$*voWzTGcp~$d(=+V{_?X+ z3y#kPS_RYq5I`UeU<dpK@;`n5z`@EXBOuMm%z5wiy`O)6e)#_3?Uy&q49vg&0$m2u z01!Y-K;Hn}_vi0#5pEF$5rx-Z-hBD><=TsD9Bk~@o?qi&=DPd(?v86a{(`&&3<m~) z03^-P4FC`f1wifoi-=(?;#WfZ+J_k6a&L$PSz50`xtoQV8-RtaqGJxgnf%}gAQqqv zf56uL`u#J>I*E&g8|Wo?Sn;v*0j086Wc~d66UYT>00<zM1|a<RXY#(ufBu0K11$lH z0LcQUg2T5C1NA@x8mIvvfLMTW@%!H&2|fvMkOM`3gQS5*`~#6I&MpV~Pfkc4r~zmv pPy;{!F)}ElL?pvc2K)dZzyL6MHy4IEPOSg{002ovPDHLkV1ie1@{a%j literal 0 HcmV?d00001 diff --git a/data/images/flags/sb.png b/data/images/flags/sb.png new file mode 100644 index 0000000000000000000000000000000000000000..a9937ccf091a3faecacbd5101c6630ea0d0b16d8 GIT binary patch literal 624 zcmV-$0+0QPP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!0!c(cRCwBA zWM)3w(jrw^d84{ot)t_`-@l(282&MUz#lLJLUJ)MJee4tXyn2G5CAa%&i@3So*q_K z5(NbSZf+JXE(pxb_51q({r&#@{Qvy?{`~s^`}_n-JH|dT(EkZzgqfWJ05Jg0{{a91 z!=9ceG&BpMqVDME`uO<%{r&&@`~Lg;|NHv|0|57TU!^Q7$Kdn@fy@QBbhiSCg@J+P z<Hvu3g3N#Z{^RFo`ug?HuU|m38Grv`^Rqh?;<}fC>&^E24DBZ$$%)DV9SRUYEY{Wn z85tspN%!Z^Q&_X+!-^Gee*b1;`1hkUW~HJ2(d(c7<ZgTM^Utr}U|WG200ICp0M7pe z#l`Htz8U-a0II6*v9a>|`w2)m##KhU^#<R%<O7Jw===Wq`u_U+{QLa;`~Uy|0to1& zU%PgF0D5u7%Fp_$_k3)Qt16zo|Lte*(QjAZeEIeN*YCf-e*tX(dg?F827mwpY5+1B ze*I!g^xvtiaQUCWxq~lxTaN<`{tYo0q8e!Y-@hOm00Ic)X%~yDf_9fx<&VE+__Oc| z+r9_SfiC{_7h?AB-+zAn0crp$|MwTD0U&@_B26141%joNuHO16IqlrrKmS=}MYw@V z|NQ*}a`!(_Wc-Byum*qtVmv;X;RPGRlyeMw7cnq=XCMdw0t^5k-z(s#_c^Bk0000< KMNUMnLSTYY6E7(M literal 0 HcmV?d00001 diff --git a/data/images/flags/sc.png b/data/images/flags/sc.png new file mode 100644 index 0000000000000000000000000000000000000000..39ee37184e09c39dd05425db127288def220abb7 GIT binary patch literal 608 zcmV-m0-ybfP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz@<~KNRCwBA zWMG)c!0>|s1eh2YzMVW-Z(;)C{QJl7?-Rp6DTaSP8U8UK5I_L2Ffe?nu66~hU^Xzg zV`^IR|NmbGhChsd{xGln!xZ`Z|DQkqe?!SHn>GOi5KsdU{eSZ0I}_6%bMu4$|G#2j z_zP0e_xu0e-~WI8{{Q>e|6jj=B8;-KKpOx8hy`TyKZd`5nT(9?%F2RO|M~TQ@}K{E zz)C@?e?v4d{QeEp01!YxcLP=bXJGj0<#nFn|DXSVfvSK1-v%}nh<?HtIy(RJ@)*va z2M8db4S)XpVKUIaqp0}&KTyNe-~YFO%mX2yI-p^emj8o;|65uyu(JI7@BtuzAWr(_ z;d9|X!!L$efBtX$4Rr=9>wkOu|GvKejg0<-0ptJwK&1cy1oE`H<`Z?5M+~$7{9gq& zn~(3mv&(;P@Bi|05T(EW{(k!9>4UEid{_Ab1OPDr&i@1e0Q5gX)c>UW0I2)_6d3<V zNdHSt{}2iG|NZ0f+S2UO(Cg64>&w~i*%}NQ0G<E>05Jg0{{#gAwk6j%|K36Wcrx-Z zDBuV5-R-~I;@8;l*yr}=_xShx{`~y_{Td7!{r>#`1^@zxvDKIVAJ?B1c2ZXvZZlkB uU;qZ}2Vg*eW0eWa_yywvNgf7(00RJ0?>)A@UfIF`0000<MNUMnLSTXzgDhtN literal 0 HcmV?d00001 diff --git a/data/images/flags/sd.png b/data/images/flags/sd.png new file mode 100644 index 0000000000000000000000000000000000000000..eaab69eb78776f8593b41c8fdc3fd65a86119a0a GIT binary patch literal 492 zcmV<I0Tcd-P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzen~_@RCwA< z!a)iE5D);s`G(i||3Q*AN#f#Q8Yw&8{as83L8nnETmi)L+<;+XLRZedk4%65G5-6< z@b~ZkKYtni{$c#{hxyMRrr*E+|M~MDMt<3}2_S%282&K47WhzO`n_e%Yo_0SfubNi zKot=53rPO@|LYf!%_uATkAVRofIu4l{rdkz;B`qLf44(3(|^YQ|Ns3*fPZj;ftmT= z{rdm`#KZt}@&CVne*JmE`!e@n)nm4=|M~ck5I+d<gAp(93l0u|0Ad2#@aNy(-+zAt z!DE)kHCrqGqS$~07#RNj`v(v}APs;2{rU6n&#!;K#Q%ync{VZrXTYu==(K<T00Ic) z2Z+D8-*C5kwKM%>`tbb&P~tyW8d(D~GxMiUp8x`gh2bB=pZ|ZjUUI!&{yK1G;4d%$ zIuA;MS%3cg{`2Rrii*m`ix&X`h=t(`1IH^)u0vdsLXv;@{sQG7;18Jf2Mqqg05F6A i0*HaZmVqPy5MTgGElxU<64PS<0000<MNUMnLSTa6K;@bM literal 0 HcmV?d00001 diff --git a/data/images/flags/se.png b/data/images/flags/se.png new file mode 100644 index 0000000000000000000000000000000000000000..1994653dac1fc1c6ee3c9fcb35c8af97f16eefc7 GIT binary patch literal 542 zcmV+(0^$9MP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzut`KgRCwBA zWN>a|fPuet^$h<QfQY%Ql;PJuhF`xJe*Izi@e9HD{R<#~Sb*xPb3K9NzkmN3mA^6k z`}?2aZ*Ga-pTGbA{QU<+zyJOPk^ex9O}Uc+0*D1{^#3RCzy1C9pW)}D|Nnn8{QrCZ z_2=I}MSuPR75)12_ZN`;`>(7pHv_{jfB<3vn*8q{15ov!pR5qo|Ns4BVf^v!57RHO zo?l?}`yWs<(7C?=0*LYNK?X*pGKOD3v;Q#s|MUOfZxH$qCjb2cBQWFtZ$@_cPkRpn z1P}|u9|n-AAQdnT|9?TXAyk7H4FCQBl>h_~&`AvcLF)bhMS(&{8jwh^2qVzve;5D) zh>=0RIIAq+{+o}$pxEl(3%2T)`P!1-fBt~{@Pp~sA7J?W`}60ovKZgl)=2;X#KQ37 z&mWM7{{H^+4`$e}UqIEMAo>Rk|KALM|1kdh!vsX^3=B^I0*D0|DL|!?{M^6*VPKPH z00z{*-?BnHps)wJ`QKllIe-8D1EGJQNCgNWMh3}bctpOPvlbWzK!=K^+cJPc;};D5 g19O2S13-WQ0NlBGh$rR(5C8xG07*qoM6N<$f_QiWUjP6A literal 0 HcmV?d00001 diff --git a/data/images/flags/sg.png b/data/images/flags/sg.png new file mode 100644 index 0000000000000000000000000000000000000000..dd34d6121073fffcb2fcb5b9402b3e6361cded35 GIT binary patch literal 468 zcmV;_0W1EAP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzW=TXrRCwBA ze96G@kAZ=Olat}suRnkP{sVIvz$6;_0~QAeAQm7?<kTr<9i3kX4*a`(nMqsw-^Y)C zPMrAv=MNAu{Qmt9$oT#HKbU;7X%j#Iu>dtOYis{Le*Dj#JuDs`KNc)tl9OYUmifJZ zA4uJ=-~WIA`uFP>kj*SB3$z3vfIu4l|NYA>FaP8D^M5;ba1<8<4f|GC2UPm+H&ER# zkRGsxe?Y{*01!YRC;dKnkj2`X)zkCW_8s5b+Wvn23^5y|0jv~A0#!5q{{4%A0U&@_ zfL8pya^=_l{r?#l8F+XY{;)G~as30ko(ZTFY%s&0Ka9WsFiJ`S6$1ni<L{3jnRs~q z0|5gV;s#7iOmFYs2M8dLlNiB#kY{iMh%C_400G4E@83UWFo&Q95cu;4Ab>y`;Hnv5 zfRT}r0R;fL{~@sn5I`&p|Ni}W_wFAQ`~gBVzyJ~jk&qMs5MThRyiZo6SsHx+0000< KMNUMnLSTY6dB!#X literal 0 HcmV?d00001 diff --git a/data/images/flags/sh.png b/data/images/flags/sh.png new file mode 100644 index 0000000000000000000000000000000000000000..4b1d2a29107be96413eb86e64a75ac7a3ba5793d GIT binary patch literal 645 zcmYL{Z%9*77>Cd9?woAXO+#E-F%m^1b0Xy*Qky9{B^@hJqB6}jOKKPc70u1CS|Ug( z7>SA^1`5-}4+VvY=3G*k2%(8O!OWTIn!4?td(P>GANugV4?p-l@2B^fCO<ns6ea=y z1gLW|wH#&Ki2W&U`^>NIbD;IAODX_{rV|BCn_NC>%qlWoHrzH=l|0Y^Rhgkwr%>N3 z(d)FjlCqjgyY4&yRH!;rb)|Z-v~HjxIkvar`<uk@gT7R+U9*-taq;PdPSuHKfZr2? zL}HT5>*JLyzxBc-B?Ix`3*qGz4q3JAd`#LY+Xw^k(ph!n`d2H7`aI`Eh(LrOLs%9g zj93;8ws%s88WHkIqXqnSf?YSjh=@dF-}4L7dS0HFB@iNj8OY*&4>%Dn8t&*i)aXz6 zSX_wQ?~e=9UcwhrAtAf8XLVoTbE5+<^|-KK=D&>)yX6u!zrPCrbEr|4Yi(XyIGTQI zFEDsraAY{)DhUd*DN;Q?!uSxvkoT|31dF#>2L0DGeRcNZNehm>xm~}-9q?gtV@Qz` zv-lB19|m}3LHcg92}TUOb+%v(0bnUhB(5rQI9?ZY)h~Hw=%2Au&~WB@t;^kVE@F0Y z%=8f1ZN}R1MniiNxkJ!a;3!XFerfimE2A;1XJChGXJ=)MAVRubE8WFo1T(1Cmhdfa ztzC{Qms6asjkstFkFp5L#maeek84Y+NtW^Wf=SRytjpC1=BCX4NH^VxnQ`+YXocAv zR?lKskkKZN7D>{S3>4;4+gPYYq0_5iq@jsB^}M0yMT0|p`lM;R_dwbVrBg^4RRbsq Y$WB%-43-yHbAJTXS^1gPjGK@C0`m$%7XSbN literal 0 HcmV?d00001 diff --git a/data/images/flags/si.png b/data/images/flags/si.png new file mode 100644 index 0000000000000000000000000000000000000000..bb1476ff5fe8e0d3af4fc6bd11e513d95fd9cccd GIT binary patch literal 510 zcmV<a0RjGrP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzkV!;ARCwBA z{QC7P0}}Z6?;nIjX28LpfBygihy^Ih#l`jiKf{k7e;FA6b8#?(HU0+zD1`*ReE9+p zKr9f0`=>m9@#;PEzrU@Gx(rNA|FHo^Mn<3|00G3vEO>ZAhtlWIf1*N!=PZ8p<;&lH z{~7-L`S<7dzu$lU0~x=8==VP$LqeQ==EOq)0mSn6{g=m2e$<)X`O9Zpu5&$Np~P<n zW}u>9Kn=gZ<nMpKegVn9A3ptNV0Z%%KtLz``}3b+(ehU!o0k9i{rC5O>0kf<1I-4a zUtk1c`~{K>fBu3(0U&^Y8d(1RW7^4V^6LZG89>!QBmXe{`pfY9FT=0Dj8O6)!|#7g zK*az7#L~(T<NUS$)2A0eXa4;Kax#<zI^x%#zd*MGxj?DkfB6I@Co@b32q2achS$vB zFa7&=kKy-kpyB_2{Q@Bf11|FCH_$<hKU9nv7ytr@1sFX{KnF@n0_8x?1j_yX%?M)u zVStc8^!G0W0TlxT5F;?2{~;j;R4$qn5C8-i0A7e(F(6QIEC2ui07*qoM6N<$g8Aa* AcK`qY literal 0 HcmV?d00001 diff --git a/data/images/flags/sj.png b/data/images/flags/sj.png new file mode 100644 index 0000000000000000000000000000000000000000..160b6b5b79db15e623fa55e5774e5d160b933180 GIT binary patch literal 512 zcmV+b0{{JqP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl1W5CRCwBA ze8>O=a{vExMP2%`MCSoB^FIcLe_%lf;|~%E5I`(IQNh}3Ao>6Q|DFUXMn*>AqQd`w z|1kXd^B;tM|Njjl{{hM0zwd6?1Q0+hV1xeud-4=Wy?p-%sO`^#2S61Jzk!N?s)6X& zzhA%p|N6}=D+{y%Ab`Lc{sL9~1=0UN4*CD*7s%9KAf+JHKs~=eB-8KTKvw|-5R1&; zzd&a|ob(5%^Z$Q=wHy9p13+aOpFRNu5F>N&`Tk_-7w>=n{RejQzkfh&Kn{rf10?_b z{tFTZibx5v&dxav5I~H7|Ney-|DWN1$%1FyagzUW0464;_wU~W1Q5$TW@eGxtUvee z3vAf*8|<XFcGmxXfqcvW^6qb_H-7(NQC7No`W!$2u>igK9~@*rr66bh|NrkNM8z+V zAV?>O@ek;bKfu6d00<zkhChFpBtb3_<pTv8vy3d$Ur@j^fP<g;&mWNLzkmOM9S;yd zj8|b%sPO*1px%kM7tF2+3;%(|F(iT+U?dO#1Q-DHo?4QnY^9|D0000<MNUMnLSTZ4 C8Rb6! literal 0 HcmV?d00001 diff --git a/data/images/flags/sk.png b/data/images/flags/sk.png new file mode 100644 index 0000000000000000000000000000000000000000..7ccbc8274ad8f76f28960b83f2bba2a619029d87 GIT binary patch literal 562 zcmV-20?qx2P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz#7RU!RCwBA z{QLDg1H(TCIQaJu#Q6uJ|3Nta;NTCC3lKmoKv^a(E+F~;|9^-UCJ_Cf@&A8juplz{ z^6Cvh0I@)f|2}^)!>2F*|Nr~{=Pyu%qcHbB24G}l{PX7zKmY(S0M7peLS*D^UKK~y z+6(XH|9gD^r>6bY*OZ>+`2GX^{Qmv@{`~#_{QUm>{r(dW1b1xK0st`p&i@3&%J4-( z6Giy=|7vRh4-WrbUHx`??d0*<@CX3>{QLa=`~Cm?`~Ld;{u~zu0R89!i0SVi2B1U! z{r&fU#+3gbKmA|6l!@v8=U+^J{{8>`mjUR&KfnL~{sUtC1qTg400I5L26X!4i0my} zo;{uZo#CfPX{7DT1DwDAf(-fnkMZ|^ra%9I0mS$d6bftr0mQhUfkCOfi<O`2yS+>E zM%HqlKU{maemr>kAILSo!3gZ8-+$N@WcKe}3J^dnRtyZx@9+FOdynDWy$P0%j62V; zZC)?>`}hA}zy1SB2;<Lhpqm(ZJ_s@}00a;V&|yqKH%dzWfAWlT`68g)e_2_^-#`_A z7$77N{rw9;K*az7#P}5)5pcwS%0-g`0)PMm09-0P6VOeYy#N3J07*qoM6N<$f?%`} A#{d8T literal 0 HcmV?d00001 diff --git a/data/images/flags/sm.png b/data/images/flags/sm.png new file mode 100644 index 0000000000000000000000000000000000000000..3df2fdcf8c0b0cc9b581f704c466db1f15c0d422 GIT binary patch literal 502 zcmV<S0SW$zP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzh)G02RCwBA z{P^=f!#@TX`1kK0gn}^tA%j0)Nq_)i0jlQX04e(a|33o*BbWi<0oly||1<qZ0?*!j z0SF)#h{<5p2*$sEzrh+9kySG?G6F3D2p|?r)qnr~et)Hc;r~AYoo-|e5Elak5EBE; z2Oy#U3=?;^ytuaP+l$v9pMHFDeopPGTx31}{xARp5F<nN-#%}~7oY$C`3FP{rsCJ6 z?oYcS=JWOUf6a#*AM$wa|LO7j@Bcr4|4Z>PEol7!5I{@}e;EG$`~Mq){{FvoV+so& z6Zek;JYV*``O13q$!wq?2!V|I&+z9TKmf4-@!$W9Km&lr1Ie!(IeUNcyZo#F`{&0p z?&{CqnEw9%50U)~((nf$fIu4l{AK<FG6SUH%U?Au;s32`?SKCI?-i{0;O_t5zoD-E z3-s(S27mx!VfY1%5hf9C2ACxbH{LKP+C~7)yZ+|C6c59n|G;o#1lsc#9Gd_E#K@5R s6{rVj4~PUI1}GOU@&|^%!3hvx0JcO=q21kKpa1{>07*qoM6N<$g2hARp#T5? literal 0 HcmV?d00001 diff --git a/data/images/flags/sn.png b/data/images/flags/sn.png new file mode 100644 index 0000000000000000000000000000000000000000..eabb71db4e8275a5bfb7b1b8f3a8374d50da95db GIT binary patch literal 532 zcmV+v0_**WP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzrb$FWRCwBA zWN2hSfPW0X6BwA7K#YG7^oQY}CXk0f00G1TR95X-4O0C79}qFh{bgVPqCbq^|1kgi z!^H9Xf6Sl%zkmP#10ug{+5`|lEMSxWKl%Ff@BhDl|NUk7|C@pF@Bjb5|Fi!Fk^g@G zzy0g~uU|mbjIy#o8vp{3GzK{UfG`MiiT{7FuvDfu5?a{rouH<jI`bTGqnZ#}UszIv zD}X>6{xSen|M~Ox&!2yPfTn)={d@hr-#`EU4p90fz>J~+=oo+i0%`d3@9*!wzk#ZO z2<W6M?|yB6^!xMg-%`B4T*bkvAsT@43=9AP#KQ20;V)R}uYbRQmH=%q6a8f_@%!7a zUv?5eKOk&i1Um^JfIu34|Ns5#?=PU9-+zDoXZQ{DL$)T!$A5ty`1c!bJkZsD7#IKo z2&4gMFwjZA|NRD%KwpAA4Pr3<2L}{T!yljqpihCp%>WQUEDXOGe*gO;$t?-?1~^z5 zels!xNuY7SApXOk^arFAjDSu82p~p=WCoxfknArIdOZOg<M5dN#qdub8pjX-5MTiQ WEL)MUi<?*g0000<MNUMnLSTY$a`Kb_ literal 0 HcmV?d00001 diff --git a/data/images/flags/so.png b/data/images/flags/so.png new file mode 100644 index 0000000000000000000000000000000000000000..4a1ea4b29b3f541f047dead7c202fd3b566575a9 GIT binary patch literal 527 zcmV+q0`UEbP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzp-DtRRCwBA zRGj$#(+>s+`19}IpFa%0{xSUi1L7diFBr+f01$veIX40Tgn<}np4MoMn3N`o$god9 zrkzn;+j{#q5F}xeh49xZuI$05IKi0v3Lq917Le*!UxBJYN`Vr8|1$jj50VD5fvSOw z-(ZF~4<k?oKmaiT75xV)0-4GHHiqHD&;NFE|D6>6zx($8#~-kUKOh9u19TNY0I~dG z`1hBA5y<}g_y4cI3}({*g*gAq^Z#dL_#dnH|JmpNJMaAe2etv|3x;0|00G4G>kr86 zzYquf`+xk=e@^EAQat}9xc>97{@;He<|?3qzo3u+2q2a}APrzwf*tnl_y6<H|0@ar zgW&(UXaC=S`_IAz^Upt!lK=vU<?o+=z`%jn2qb~F%klr8fA0U!zyB2lLE6A(gJgl` zGW_`i5I`(ITmJlI2H6YKu<Y{xUw=S$o_PxL$uFq!AeS@zVE_mq7KR`HfPpQ-%>c6m z5?7266~G|=0}2mdsDK;{bP_-SF*1~XW%vY(NDyK`u#uw~6h{mUJPZH<1^~Bhc<s1= R$gThY002ovPDHLkV1f}T@C^U} literal 0 HcmV?d00001 diff --git a/data/images/flags/sr.png b/data/images/flags/sr.png new file mode 100644 index 0000000000000000000000000000000000000000..5eff9271d28cf8bf1cb85378600c4fa4997faa33 GIT binary patch literal 513 zcmV+c0{;DpP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzlSxEDRCwBA zWawvL_`v`L|6u431H&&c`3H#&Bv}{$0#GQ%MgV{?5VCMI$I?k$BKtl07z+*xW^1qW z=SnvN+<_`FB$SlM!ipPD05QJ(`H_#6`#%scfFJ|Ie=wT?EC2=nz$7CR<HL8400M{+ zB-ho$@ccQ$-@iaZffoD+BCiks|Nj2Jn&Usvrr*E+|N0GNFiJ^%T(TG-fLMSIW_<IC z;rUabI)>kWKq{Jk|L6Yw|Nrm*vcLXk{sL<NkqkiN7#IKohzaD^KYu}@5Cqh)_1Ax? zU;kx(ff#?msv!vII|c@T00L<Mx&-8m-@icU=db^6zy2Tn_5aXM5aat#xCVybzkwP6 z0toCRUcUeQ0$|qz&HMAe^EXWY@BhLO_HPEDl&~n!O#lJJ^5N@iUN*jekdR=22FmY0 zzd$rBnEnBy=05{7BlD|Q4*>#*k)f0!)jRF?KX6FHLLC_7zyJOQ#RJf}AnqSv4E_E0 z_d>^cfB<4-C}v>z1dGUDNRj#r9OZBkNc8hC00bBS^Nnc?6(4BA00000NkvXXu0mjf DM<U^J literal 0 HcmV?d00001 diff --git a/data/images/flags/st.png b/data/images/flags/st.png new file mode 100644 index 0000000000000000000000000000000000000000..2978557b19d7d4283aa9a00ca78dcdd2580edc7a GIT binary patch literal 584 zcmV-O0=NB%P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz+DSw~RCwBA z+|9t?I+=mt8v_XZV_^6LBazWB7|Fr_5I`(e3=Ho+Rrh)@F#i1q1b-R+{{8p&&%Zx^ z|NjA!fBydd{U1#J`}6zX?{(8R0R#{W7X!opzyEU=J}nRW3j}}u{r&^e095h&_g^si z@7J%tzkdDsEyE+rz{CI$KrBFqaDD&J$o_ZIwZH!pe*XjN{tt4&e}>=x{`~^7feaw| z@9%GBrr%pd7ytr@3Fu&;D>$G0;d%9&ckUlP_FsJLznOpkI<);49~%fU|NOOg<1c>p zU%YI;<@gu?0tjRS1JJ?0e*b^_hvDg;|Nnpg`SWY#%HJzjff!!CzZWj}wP?w&U%!5b zMg95nhXEjfKpOu4{R2W=zZm2}8km@V1qJ<PXZz*j4{}UY^lwhiUm@YY8UFtUItL(t zSb&)E_wOIVzy1jR`IqtQ-%pStMvmX!!M{KL0C|9c^H*Th?+-uzurU95@Qwi>fIu3a z^ZvQT`ny}__wU`%Fad@Sg!~P3A(#OSI%WR9r+OFw0*K`e1H&7RKZ`vj#ee<&!}=E_ z2M*0Ye}N7Llfc;c2aF@2)L(`#3;+SdxR!w-xR`<A12`goAw}vh)M$o8KMw;yfB^tr WZAq0m(VtuZ0000<MNUMnLSTYU%oe}^ literal 0 HcmV?d00001 diff --git a/data/images/flags/sy.png b/data/images/flags/sy.png new file mode 100644 index 0000000000000000000000000000000000000000..f5ce30dcb79b443ebc1615fe4889cc26e2d762b1 GIT binary patch literal 422 zcmV;X0a^ZuP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzI7vi7RCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkfqWDEb8?fBpaU3&>`al?B=W5I`)SK7Qoo<z;~C`wt<p0tQCL zcMl%|1P~M4(LkZ^|9-Bzx90o*-w+kQ{{NkSd(o#qU*LNF{rd+HKukakH+B8}_4}Xf zTXlN{guCj_%H5B4O}{!FW(C8aKYsuMh~*#L4GjN-6azl}{1~Df2oZ=>3V-qKd5l^# zgv$taFhBq?zJ2?apPwJU>mjm?jEr~g+yMw6Mj$IKE$!aDd%wZp7Z^YoAQH^_^XK=U zKYvwJR4!h;2oOM^VEz63kEEm|Pzoptl!JgjVAdZn_zMF-#Q*_BG6D!N0MDLEDh$KY Qwg3PC07*qoM6N<$f<UXUnE(I) literal 0 HcmV?d00001 diff --git a/data/images/flags/sz.png b/data/images/flags/sz.png new file mode 100644 index 0000000000000000000000000000000000000000..914ee861d419bc6c1e8a7ac432e96deea7504d3a GIT binary patch literal 643 zcmV-}0(||6P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!6-h)vRCwAP z#=!{yAPfY-C_Ze^x-86oBpl13WFNz}wa-nWvW-Juj4Tk&uWG~`{RtqJe=MIeJXAr- z|1<pi{~w5e;P2mmfB*db2Q=b8!{2}Z{{H(9CjS*LIsy<tEQ{FO*jOk2|NiR#zkdw> z|Nj36M1Mf!|33`>e*gaqWCO|HKwFtO#a&sZ0t5gt0M7pel4t!zdFAut<Ny8r1poWs z`Te&40Q>y<qoJPk)zi|Hkn!K;{rmd(`TG$b9-O=Y0*DD{@9)2V85sYlI&ld(aBl$` z#mMyY=dWwG?{KJVD%aGjM@gA3oO|Qfuiw9ae_&t$2p}e)e;FD6%6UkB7Z7dQ{KnVU z%g@sVDEIa2*OH3b4SV)+n`YnMROOy&#KQgW7Xt%80I`4ri0Q}==U>c%F2ByLSib7d z-@iY8{9s{WsjaVha_-{UlG^LGvEO)A^**!$6$1ni3ljqaFhGRYPy6$qf&JG{kKey{ z|N8ae_ixs}{}``bdDhkT?#VOp#VvoBm>H#|fo1~)5aY*A1~#Xrf1jTKgW~`H-wePI z{Ri~aZw6pM{r$!84-_k)u=@Ltg<tl`<h1|+#NzgCb>1)Cf2{xiF#iKZ95W~`fDE7~ z|1pDN1{?_>vH!pR*Zxoe2p~qo;+=0k{eVOy5dHe~@Bg1a48OqWACLsaJOfY!7)!uZ dz{3I%U;rfUVNTmRI(Yy9002ovPDHLkV1m_xKPvzL literal 0 HcmV?d00001 diff --git a/data/images/flags/tc.png b/data/images/flags/tc.png new file mode 100644 index 0000000000000000000000000000000000000000..8fc1156bec3389e54d3c5bb8339901773a881e68 GIT binary patch literal 624 zcmV-$0+0QPP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!0!c(cRCwBA z+@D{&gF~%z$EV^?n*><?)c$aZ6}T2)Bo@5z0|Ub!F#3gr{s06JqpW%Jfo2B=De0d# zZZLfQ{7*`nPndtpgTI&Wz4`O^-=9DKe*gIkBLDsU{pa7N?oNOJ05Jg0{{*n&mI>Mt z2Fbqp@bUfi_XiXZ_x%As_WHU2H1hfc9TM^w3-JC0{^0Bg`~MgK0Qv%mWs{6kqN+Cc z+b3Uc-enXLWmi^s_2~6APR{Qy8GilxQ)j<PSdj1ghiey~OZS|J2WkKaAf^X)eoS0k z%RY+UVH11wpJy?L9+QgdYZjjGzy7f@v$0DAvdQKD`TXbq-ycA?0W|;w05Jg0{{;NZ z$fzL@$>#p({t5y7`^LQ4&lw9Z0Ra5^`SSP!?%<&3?fU!$2L%N9{QCa@0Qv%mW$D2m zA^~iFet$Uihac>@UpF6ozx?O#ufL4HfBpRUndtzp5U;>@#TUPR{|1KG4}bt-0)`aO z`@jDF|MM3p^6S^{zdwKf{q+mXVfa4fgq7?27ZxvH%|34Sm*EEk!#{ukVgYLS^A{)$ z3^fq>3urc&4I=;kz4MwgW4X`2zkh%J`Om=vauPrQ0X6*k{pY`=2p7<vKOh7OZ=g~L z<Ii7SpmBfy{$u?6|L;Gbr~d&25F-OaC~91y#4A!v|AQca00RIrB|gH_)g}A@0000< KMNUMnLSTX+Su76# literal 0 HcmV?d00001 diff --git a/data/images/flags/td.png b/data/images/flags/td.png new file mode 100644 index 0000000000000000000000000000000000000000..667f21fd9d552df546386174e506a6b5b606a258 GIT binary patch literal 570 zcmV-A0>%A_P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz%t=H+RCwBA z6m{PD;ma=u28MtCnEw7?x;-aLOyJd@KYtkh{rboB|IagqAFd34fU*oA03-ndh~>?P zUwuVdAkKe=-@h3}<ZId4zOnuP&+z}xf5!j+cz$u_Jp1?e&;LKa8UFlvHh(@q0E*_= z1^^HQ0$?O;!TxI*TP>_C6p$AOxEwQo+UIeHT5%mg3lYGL;@HP(LjqG0$?6F}(Ht8A z0K*^*BsuTDFa|;zm9Mc;PRcq|KMMBO%8|{G<t^S8cmhy31}Ol*FbLaW|Nrk=O;^D~ zKmxfI`wQ$NZ<!|Z#O&}ZND{3Tz5pCfNfH161RBQwKP*x%9okEEgFk69tg*?Zx<fni z^&=7voB#r8080M(3l#bP>krU*a2x&r-3HS3`xnqW1|R~39RolBu`vAj19S<{HjoWK z4L}tj8-Qm20eTwfuYbROLyY(bwgDi3SQvf*HG+%>>H*pSauQI_zkmNAP67ugNCPlf z{{97O00<x!hA)4B3{gQgpq`&U8JJmx8JXFEerIC-{h#3<Bik<)If#W&4FCbe$RN3x z;ng>g6G8FFbZ~XHln?_bqJeH^V)*lb;YTJ&0)m(r7ytqc06HRil3NF`RsaA107*qo IM6N<$g5!7R+W-In literal 0 HcmV?d00001 diff --git a/data/images/flags/tf.png b/data/images/flags/tf.png new file mode 100644 index 0000000000000000000000000000000000000000..80529a4361941e01d1def5d581bf2847cf99fef6 GIT binary patch literal 527 zcmV+q0`UEbP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzp-DtRRCwBA zWZ*1k0E7SEI{y9p2VyZZ0|_9Dl_8OV;SU4DFBtm4z`zC&KrBGH-hTi8Kn?%@Gcq!Q zbQTmc{QblD=TE+v-@iXV@c+->KfiwinSYl|od6I(APxT+{=azr6>87_{|pZw{QvzM zh#vfW|M&NwU%!FG-`~IfN=xzq?EwhD!Wi2C0Dv&)|Bp?Zs+hPi0R-LQn75%cY-O8s zAPsw;Q!9X27=Hcx_y6DDzyCn0!4P6RP{Xg^fBpb%_yyDhbQs7<KpOx8hy`T$-+zDp z{)ZY51V9_WdVc=`O96HL1{ny}@CP7(fKGyl{!K021T+R{?;n*kkfVS9W&88j!dx~x z!3=2ow{O4Zuez{};Riqf0X6*k`}aRc9Z)sIe}AA(2QrSHdCb7TXm2UkHt`V96+jOI z1P~Jt11<awQvC;FFxb%`cLPa5e)iO8!~g&P&F%9uH<SjN4iG>f8-Qku33L4cS@IvK z8OZ(n2kOgTzyGb@ek0IRW9ouaN6$P0x*s5bfH4)06qlIj4>;1{Yyp4(0|4dQTo>gF RMrQy3002ovPDHLkV1fdR=hFZH literal 0 HcmV?d00001 diff --git a/data/images/flags/tg.png b/data/images/flags/tg.png new file mode 100644 index 0000000000000000000000000000000000000000..3aa00ad4dface0a9c23744ab451cec0443f187bf GIT binary patch literal 562 zcmV-20?qx2P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz#7RU!RCwBA z{Lg>@|6mN}b_RwI3=Dr@=ogG+VE_mq7La^(HIVqv%*^=nCj$%1|9}7Uoj(8m`RC6+ zAo~61@1NiQe*Xrt|Nh=Ea}z)Sfi-Y&{FjhmFg0aZxaj}$=L~=U)(L)O`TP6-zu*7= z1Ib@N^zYwqCU)6YF9v`BVgi}{>lcH)J%gU!|J)p)>i@rg^Kt&>WCWsLoQyxg<S#A| z`CEvM0U&@_fbIsl<nG=7TwDxSul@h<;XfN&<?Ua;fBgRQ@7JHdKm=j@7U%ihw}k;9 zfWS@?6#Tz@`Ts?W82<bL8V?MH<DY;1{`&jZ-(SD}{{9UjfBpXZTk-E7hI<SE0R++j zG8L%q&u@m`fBv(w0@+gkfAMgEO#KZu8>k29dnvBpN5vQb0*LYVQ3gi&tp7jnGyMMp z@*mKbe}4Uk_!#8f-w;8De}5R+mEP<>2M|CkH{LKvi2wQh{WmZi82<hK_a7Q2|Nes1 z{R3+NlYiM5|DONA01!Ydx(66id4VA<2@LE%|NerV@f%EnJOqpcaBTej_YW9+e=n?I z00<yPhGGVWPq2vmg%qj3z%dUO`3EL>7ytqc0C8?ZF&p#S!~g&Q07*qoM6N<$g49|K A6951J literal 0 HcmV?d00001 diff --git a/data/images/flags/th.png b/data/images/flags/th.png new file mode 100644 index 0000000000000000000000000000000000000000..dd8ba91719ba641502bc7ffda16c25dc71b2066c GIT binary patch literal 452 zcmV;#0XzPQP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzR!KxbRCwBA z{Lg>@|4`Xj5kLTn#2^O%5QJf({+!S7KyMr1NSdb0?wsyYS6cNVdko7w<kkcR1^FVr z0Al?AA7lrFgfsqQ1B{G}pFVv82p~pB*Nr)uI`7{7{QD1R$N#_o{{8*)?+=ji2M9sz zzkmPz`Tg(Dzkk9)oMok300M|*|L!xUh9Zw1eg5+YB=P&tzu&+A0@1Hu|G?y5I4LjB z$H1@!Ab@~=_y=(($dN$T{`vpw7sT2BfO>ub89?$EL;)x)00Ic8;m6;<fByXW{R?O> z*ldXL$Yz5{poPB}7`^}m5DPc2nu@9r4=+1782$pC1a=$HjDKJ@i17#P6G=(#Q>V-T z0*LYF&!5c9%>VxV!xcCX0F(uW(%ZLh0Ro8SA3Hl!NeKhQjeq|#{05SL7=Ql;MhF4{ ujRQ&nZP~jQAb=RZqDCa1IEDa#00RK>mSUL#9?hx%0000<MNUMnLSTZHHpKt{ literal 0 HcmV?d00001 diff --git a/data/images/flags/tj.png b/data/images/flags/tj.png new file mode 100644 index 0000000000000000000000000000000000000000..617bf6455f69849b7f66f43ff36093bbcb07fc3d GIT binary patch literal 496 zcmV<M0T2F(P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzf=NU{RCwBA z+|Pgj|G)?c{(upL3ui+Z00G1Tlvb^-29p2({R1L~zkmP#`TOthpZ|Zr==bk`U=qmw z_xtzZO`8A$hy`ro|6fm@{{Q<Is2V8x52y!70u=$tUqHsM|G$0#xoonsKpOx8h{f#d zS58h&h?D;R{|_X8-DzOr(f-fC{P*JtW`*kijGX_$fRT}r{pnMH0AhlB0Hpdq!|#U^ zm;@~U|NZv=&j)6itiO*Y{6_+R{`>(5Af|u+{y|g&!S9Ft%(8imf-XR*|Ns8{XO&`< ztN44b9;zJZ^?&~W0*LYB$B#TbJpUo){|D*(`>^x>w|gKB?9z<#wQ%E^n3(S0zYh>V zEO6uD3`W^f29Wmu3`mZF`w$?27#UO<(rVJ~y}I}N&+p%VfB*XP3y6U1Uw^?QNbuL6 zzrX+d{i`Cba<TOyKmf5Y{9yR~_m3pM<ZrMjP!7ldD*f~C4}|>l7Zfib@b53s27mx! mU=U|u;Adc9XCM*)1Q-C-v^a{iiydVE0000<MNUMnLSTY*0rJ`a literal 0 HcmV?d00001 diff --git a/data/images/flags/tk.png b/data/images/flags/tk.png new file mode 100644 index 0000000000000000000000000000000000000000..67b8c8cb5191080a1cf33125cfd05efe0b9a76e0 GIT binary patch literal 638 zcmV-^0)hRBP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!5J^NqRCwBA zWZ*u^@QVQo{`_P3|APU<{{$v^7#Ntqj6X2M01yB%0M7pe0QdlVks$y80RH~}2LJUm zC)6+`*bxlw|Ni{!^9;+@CBx7w>+lNu{Qmp=|FW6D0*D1<1Ovm9m%kYQ|7p*Yo3ikU z7~kiESLFZv{{Q#iA0zqwf4<*Xu-*CD8$MYncA&8U0mQ`ckKy0n|9}7f|MB&2MbDkQ z1j{KKBL!IA2(Z7F;(zkuBe#mo9TT;SzkoLU{s(jwKmai@{QdX$=kGt?etVi7(p9*e z(|o73K&5wiaQBkXEB9so{`tH8jIplb^<Te$P6BH91rR_$2YvhV=Rc5Rs&<*1=S!;J z^8D8GzkdJ!{rm6EQwD$j{9$DHB`5aq*YAHoWk4GM0tl$#&)<Ll|NQy)=%pag7-g}0 z*1Bha8h-v}c=q-8>d$ZPi5<ImNA}n6zd$2^iU9(M1?Uf;_kjjnzALM*diCQsR$tRS zrb;ItzY^@+>h|uLD4*o5RnOS|{Qd{@Cr~j!0D)}y0}8T#$FHit{lE#dc*g~k_utsL zxj&Wpto`+k?f2h5mv;mM4gK>M6!-uE1hnA~P_?8O+wWh(`!4wX{Cn2j=z=is`~Uxb zzx=@K_DcEUJ7IZAUZAu8{sn~+Kmai^Fsw$4O9qB73=GeJhA}V*Ffg!yW&gnt6F`6g Y0QOrYft$MNbN~PV07*qoM6N<$f;FE<L;wH) literal 0 HcmV?d00001 diff --git a/data/images/flags/tl.png b/data/images/flags/tl.png new file mode 100644 index 0000000000000000000000000000000000000000..77da181e9c57a490c90a99ec08a8718ea8fc0835 GIT binary patch literal 514 zcmV+d0{#7oP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzlu1NERCwA< z!a)iE5C8zMT|L1wJzSC`N#tfticWC6`@5J7I*m#pa|IA10|Ue6)iqxH42;MBF?{>W z@b~ZkKS1)&|37~i{`~p>`}cn&^2?@800G1T)Nta$zvt>t<757@uK4@^{4bCmpb7~3 z1tfp{|Md&VW|Wov$G`v(Kp+kO{{26+>+jbuf7A2+upj>Wf7$Q<uVG3-s((W?F#P@v z)Bq4bOh66V+S=9C)kpXJ@9g{i+Vc1R*5Ci_fB$C!tNRHy{uk5|pkjak0%`E_^pum6 zYi@2hbMp7HdB6Wj{`_D3>%RxoV1$N0e}Fas1Q1BW)@@rKJa|xATBWA`D>(WW1JFHv zzy7cP0k+{MTm!^O009K@!>LoJmn~avX#S_9;5YZ-KmTX``u`YaHjD&?*dJiPF#rS* zNW;&cKP?=87yABYZ~OCq-%n(9P{tpi22h9t6$1niNQ1e-pD1xDrvBdy@BT2zfI@>2 zY$OAO1fsuxAqeOsfB<52Wnfst00RHO2+qJn5C9Nh04p0wT74^6IRF3v07*qoM6N<$ Ef~oTCkN^Mx literal 0 HcmV?d00001 diff --git a/data/images/flags/tm.png b/data/images/flags/tm.png new file mode 100644 index 0000000000000000000000000000000000000000..828020ecd0f6fc73348373c9e7a235fdced09de7 GIT binary patch literal 593 zcmV-X0<QguP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<4Ht8RCwBA zWUyoag5B>VKiqu&g@GZ_kb&V31Gky(*8`UselaloV1SZ886W^4001!n&i@1e004Y* zk0R&!|NaLC5DIk$@CyeC@AU9ebDYiJ)zswL{Qdm<{`>p<{rmm<v2&RMhzX?YKf|X7 zPnmu`e0AoW04L9-hd21Sc-yzk3^8;6`RCW|mk(||e|YQJz2ATT0M!Bn5EH{chJXM6 zv9kUD`20H?>%TMiuO{1voVt6d+$)WVk%^O){nwx0zkdJv^%rQ#Z=eQ%0Ac}Z`19}Y zkI%oqe*ekA`2WvehFzDBOo*xZ{rB(cQ`_&qdGhP`Z=jyvAOx}jAb?nae){+CFDvWc zZ(siLa{c@D=g;LQw^HX+sY@u`d-M3!$G1SGAWQ!Ifj9{ufLMTG0<_`#=U?|Ye)D|< z8pH7G?=PVA*@stt1I-4x<PX?}KR|B-H2?$<$Oa&jjrA|buiqS8|9}1Y1#!SHVBq|L zY5=+C@1MW_{sJ`s1P~K2jDP?8!)Iu2!p6X%p#2N#fZt%9zkdDx191;9aR2-TX#fZy zhz)-}-@U~mrugf_b16xQKOpA<jr<K(^zRSYY#{ja599}c0Agg|2Swz>pA0e#40jk9 f${7d%fB*vk3(!F(w2U8u00000NkvXXu0mjf+94t7 literal 0 HcmV?d00001 diff --git a/data/images/flags/tn.png b/data/images/flags/tn.png new file mode 100644 index 0000000000000000000000000000000000000000..183cdd3dc98c6957bde83f375a431e543a3ce9e4 GIT binary patch literal 495 zcmV<L0TBL)P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzfk{L`RCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qn85s<6a{mPc{sY0`!~c&T|G$6#|6jj=su^Wvfi?gH5DVDs|4`L& zaSWe7|DQ4A|LxoVWo7^S`TduX{%>gT|L_r@28Q3iff@h;hzaCwplYy<4FCT9KXmB7 zhQ|Nt)Bg(#gN*m^{Qvt8$Of<_K(zn?!~}BDpT8hi0uj&>pxItt|NHv>A2{&e(C|Mm zFGw7s;m;qS=>P!)((wQ9AF!W(0sZ}7M&|#9_5TYB{zpfHZ20g2t^wjCfB*tH2_nSs z8?4R7=Kq<q|3gDTel;`uf9Vn=d_Zpg^XCr(13&<=05vc}gn-(BE~%*aUs(8GObirC z+qV7Rv*-W5egFReHGo0|=p29m0%>6S{f9wP5^T(0hI#XVIvK%6GC)WrB}f<o5l}He l05N_=jYvFk3;_TE1_1ghIE%Sjb({bI002ovPDHLkV1h|M+V21W literal 0 HcmV?d00001 diff --git a/data/images/flags/to.png b/data/images/flags/to.png new file mode 100644 index 0000000000000000000000000000000000000000..f89b8ba755f5609dc761384fe0656f73c854031e GIT binary patch literal 426 zcmV;b0agBqP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzJV``BRCwBA z{P^)B0}}Z64@B?@3Ib`Y2q1u1fbx8Nd<=K){9m<-AtZ!BRrUXW28P^R#y@|U|NLS4 z{rmr)KmTFmmra`h0*D1-@c)(;hQEIqTHF55o6Eq+`2YTWkRl-Z1tfp{|Md&VW|Wl$ z+5iwhOd!=jGmDEESXllS7C^XAr66@bLF6x}9-sz*0D{`UpriAD?p%idAP52=6$rB- zBm)pJFaQJ)3lBd(Ls9YnhYx_RWcdB({~xF{TpiqahChFRHUI<=$cF!a|A77U3#8#U z#Ngk^W<&h|b`n4Uu>dh6SO{nUiXv3iAlW}aH!uJM5J&?vILH{_iZHeP0c!XQ3PuJ7 zfB*t%VEX-sK~fTI%wL8-Af1e0BN-qh5dHlNK|sX-0mS$fH6j^sMDRaYQUC}r0RA0L U-~OCsp8x;=07*qoM6N<$f)6&Rz5oCK literal 0 HcmV?d00001 diff --git a/data/images/flags/tt.png b/data/images/flags/tt.png new file mode 100644 index 0000000000000000000000000000000000000000..2a11c1e20ac7f5a4761049adf5e326654b94069b GIT binary patch literal 617 zcmV-v0+#)WP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz`$<GWRCwAP z!9fPVFc1L1`Ni6&<^OVus;Y{JC3HrOk(quGQN34d#bz=2czL+FI0A_AzpCp03l|ow zTzUKU?SKFNDJdzXL`E{~+xLIxPKG~!{{Q~{ABp_3X%j#IF*m$>$7pYF;Nfxh(xqFs zZUMDjzjMbfEbRY>5C0Dx`v3aP|JSeozj_5kj66I)?%V+g05Jg0{{w&k0RJ5w0P^!X zSy|WJ-R9=z<KyGh+uK26V*mdB|Ig0<`uqR-`v3d;01XZJ-rfR;xsHK>(aMV9<VlAA z|7~Mquin1>7z{4myy=pj4$^bx3|zy{hYtY)h`Ao9!QA5iyLbPuTmkB_PfP?Vee~$j z!-o&gUAyj)n+wu&>J&%=KmU)%j{yP*q=CWA45Z=rFNTYkfO?!$Q_o+&{s0WlT)E;? zRQUhj|NkdWF!J+%fBF<4fS7B68jOwqzkLe|6QI@S&;S4b-#s()-1Y1C!Qjl5tA6F> zpisYc>&x@!00G2Y!@$5~Xb92(47=Z8{{bD%z~Ggi4>bPny?b}>+&OjWQc!g@0~6Et z-Maw-h`Ac5K~JB7o9jOp7XvpB0}lu>-nqlT$mpA&|LpDCZ{NOs`t<42?c44-IbZwx z0Rjj;B7x{N1H(%OhUW|nPgz+XA3Vr_1a9x%4G>@ep<^{rq?UTJ00000NkvXXu0mjf Doa;3l literal 0 HcmV?d00001 diff --git a/data/images/flags/tv.png b/data/images/flags/tv.png new file mode 100644 index 0000000000000000000000000000000000000000..28274c5fb40e5d3bacd7c05d9a1b8017eeaffa6c GIT binary patch literal 536 zcmV+z0_XjSP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzs!2paRCwBA z%w#}-e+&$NzzE3r1w(&ejEq=@p2Z9R0mK4S7v5VBQvL5A!@s}({{96r{{Q(6V*UC1 z@6T@~O9nwXhNE*oi>mzpv3Mmw05Sgm{~xFtLc$sUu>nR##!sI<0R#{WMEdpj-yJ7j ze)|0V&%b{_!~X#7`1>EofS_N0|E)GoUH&SsFy}2m05O4F@}ffYb$qvjbfSUipMQUU zgY^9U_4m&&5D8KZ)HC5u^uad<48Q&W1P~L@hOIAb7eDp_T4W)^*Xg48{r4ZBJ-_}k z{QCPJq~Q-pPnz693xQJ%zkUG(5aZwHtqd&u|9`*u&+TyOvq{_Ghd=&6oB?#w{~Y<b z4uXf=j?D(zDJRIWt8^<s05Sgl{hRT}+1H=nmz`Gm{N>v(xYPgr|NRH*haKioowt*& z{dC`3v>70P7=Ql!$;`|Q@(GT>fdHT^FqGcDeG3pkjNS|k>E&sEfzAeo{ckY&=g(gd z`CCrwmxA8!XLtTy-uM6CzrVnM%iVPzAb=R%ks=j>et^j`MFs;Wh8c4i)_^1XKP)u> a1Q-A&>r+tW$-Nl>0000<MNUMnLSTY{KmOMM literal 0 HcmV?d00001 diff --git a/data/images/flags/tw.png b/data/images/flags/tw.png new file mode 100644 index 0000000000000000000000000000000000000000..f31c654c99c023dbed9a7070103c4542326c4464 GIT binary patch literal 465 zcmV;?0WSWDP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzV@X6oRCwBA zWKiGC00)2mfzYpi3_t!nd><~s!0-<P0R#{WP+3c@4v;hwW@lz%I`Z_(-+v5$|Na%Y zP{91>E7R}a|Ns2?4<o;9+5`|lED)nbzW#F#6=!8-d@%dV;Wyv^{Q39i-h+R?KmGsp z`~UA>|9|}gs%Dgx1=;`*KrBFO{{8#+``2Fv2B6@-@8AFY{`(K80jd<F`Zq)a15lcQ z0U&@_82<eE`<LOukKZ+O?)~}q@7c$nfByda4KxGEY`6xXTL1!xh2a-a*}uPk{yu;A z2War`zyE<A05aHre?h2*XaI^bFaQJ)%ipJ=443l%KX?E%kKs4ie@M<iCK<s_0tg@$ zAZ7#$0S!P=gsK`O3v>Yk13&<QG%$mMi~+6)Q`;Y)hQGkT2KoUYfIu3Ue*a;Rlmr{| zm*EdcCnMNM1_%j6fB!-dP%%INF@8mjNCs@N3zq@{fB*vkNmFLv=8dPY00000NkvXX Hu0mjf=E=rh literal 0 HcmV?d00001 diff --git a/data/images/flags/tz.png b/data/images/flags/tz.png new file mode 100644 index 0000000000000000000000000000000000000000..c00ff7961424da8dabee61bfb53158c537e935e1 GIT binary patch literal 642 zcmV-|0)737P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!6iGxuRCwBA zWSGdn@Ph#g{xLB8fssJ;je&uOA>NH4;SYnOGgJvc0I@K9VW@Vk1}Xmk4~YK$|NH0P z-@pI<0D+Rop8&DnA}oIvEc_iD{O9-Y-<vjV0tg@$1{R>=|4+U?1uFgf@9*z_zyJRJ z%fS3QR^zuR^Y0tyerNXndi(YlP_?gzto2+5fB<3vIsj-gQ0X6_*?&Q*<wSm^D*WO7 z_IuXsU%U7I`uX!W8`H1!^xsaQ;)fqH00a=o{J#u9)qno{{_~INPq5Z+XRhBb9{<j) z|NZ98FCfrS|D937#Vsv*;<ZU~T_QjLu>d^>boB3^zoi8Kq^tbl{qlR!{9jwQ|Nij< z=(=A?F~8j-MSe1{Hl8Yc@zaXo%Ljk}VgUvb(2HJbe?0kqy?_3vsPXrUmq2rWE6M!I zE@4*I;Mw}vw&7&iFGdC>zW)rr{s9CK3xfnhrsi*1wqMhx{@J=6sPgxpU%z6)f4fBr zzxpd#a3tyYE8}0lKnA+|-+zWbe*glA<)R0}-=}{Pnt#7~1q_Mb;=;f3i~oshNgsS> zIr~z~(_bRL|NZ|1LO_>;A_pLVSoBpHQd9nLa7ao@{<gFD>lMMx#__lBLe9Cjx?EiU z71{s)`Oon8FXP|;z>s7BItd_v7-5mj(Adb}701wVj$!S*>kNN{fN~5FV<C)x|3Goi c!vGLq0CnUk68Nn9Jpcdz07*qoM6N<$g0Nyk5&!@I literal 0 HcmV?d00001 diff --git a/data/images/flags/ua.png b/data/images/flags/ua.png new file mode 100644 index 0000000000000000000000000000000000000000..09563a21941f2a94c937d43aceda1aa546246302 GIT binary patch literal 446 zcmV;v0YUzWP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzP)S5VRCwBA zWN7`*00;m6G5i6N3?TB)KPdVIBY}v40U!W}a&iLz2m>)t0eYh-DxnQ(<CkR3A^A6M zmq-6I2uq%(2rN`X#gh<zOV->+0I@JIfmA>F`X8wD?>`2h#NWRRzyE`zA&Pzh5tt#% z!^rUCA3y+sZ1@LM1hf`pHc0ia|NsB|1uKPX0CRu;1-SztfLMSo`wOxir1}rsY$VlC z667idh7Sw?0mQ-}a!Q0#;n&}vAb0=!_Zy@WNd5!6=O2ju7s{65Vq=gx0uVrqe;+b1 z$mIY3|KLBvF9x7j{{!v#g_UGtQhI;<6hHv607H@yECdWlR7EJN!LomVp$!l~APvkQ z4p0cL2#YobhQA>6hXEjfKpL3-19eKmL_s<k8GnN#4?_Ndss;x8KL&sRV*Gj&6u<xe oKuHET8^T~fa6udf27mwq07!>!=;22W8vp<R07*qoM6N<$f=xiHIRF3v literal 0 HcmV?d00001 diff --git a/data/images/flags/ug.png b/data/images/flags/ug.png new file mode 100644 index 0000000000000000000000000000000000000000..33f4affadee432c0d4f499fd7e04736a29c48b06 GIT binary patch literal 531 zcmV+u0_^>XP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzrAb6VRCwBA zU?33y1P~(-cXZSP$$$U;|NHm%@85s_{{8v$2gvvX0>6RGKQPvx1q)UK1Q6rb{|u}Q zRsa7#{m<~1f#L7}|Gz<uf4{*DF!}!%82x5slzsDQJ3s)ju$^OIlm<!xm4ehU{QnD< z{`3DISn0oCAjZF64F7*Ku>NK{&HxZVEKJ4>jAefr9{u_M=MTegAo-i2ynN}MJ5kZm z42KRel$J95{>|{~*Z<$Y8RdVo9Ap3pAeIsa2H!t_KmPs=bot-kKnMR0GBy2s>sC=w zQFmwO;@`jj{`v(%zkl=p`BTQg01!Yd{Xp;j{qx|@pI^U!0X_En_iwRd$6{h)tX8f( zaOhC?&!0e%U%!3<Nu}SvUo$WO1Q6ru%?!GlSwFtt2L{f+f4~0y{|zGl0m<Kgp^RVu z{{qQBoLov9ww(hAAjY3R8JL;V{{6ehfEp$+63PGuAS0v7+qV}10*K`wI|EbeABMk@ zKw}vG{AB=o`tNT>phy1#1%V=eK#YHXK~n$!{#(ib5I~Gyfqp<j45(Z*DIfp{FaRDN VMvdo^?<D{L002ovPDHLkV1h7q^lJbB literal 0 HcmV?d00001 diff --git a/data/images/flags/um.png b/data/images/flags/um.png new file mode 100644 index 0000000000000000000000000000000000000000..c1dd9654b0705371876d3e3d06f950be02de2a73 GIT binary patch literal 571 zcmV-B0>u4^P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz%}GQ-RCwBA zjBdWl00aM!z@I;VrtHc3`}OPJTetrI`}dcD0fGPmhykbpD81|S+rR(*?mYPhsBYVd zSHFJ!+H&;y_wV1oef#$1%a>1J@ZrOUii!$=0Ad7ci1(2A^!3kwFaT->5<te^fB(GK zv@m@7{O{}6|KGp;`|*QGSm@E_EdT+;@{a*1%@ANCy!O!RKp=VWWq^&ynu9Mt{QJ++ z*9X)OaVXROe@slww|4CU2p|@Qe+&%&|E)dv3TP@&HP8Z}>i7Ttz54h6XL$JkA3s3o z=g)t?e>01U3h&zo5I`(I-#Hr#t~u}$WXZu-Kyv1Rm&rE5wcB1YFI@@=0EYkn8G$4- z^Xb{M0Ro8S57@^*QJ}#<$Im$M^4<S`RhwUa{rLZvmlx3N|G$6#{qyI?pFa!|5>iKw z00a=@`Sa(sw6y;I{R{T)e~6KA2AmBHF`#D(ii!XNh(#nN<!4UL-wPN1zkU1f^XLEH zzXK&f27}H1_2<veKYxDz{rmIpUp8rJqmw5A0*LX@p+hDnCJ=LwZGf|X{`~pl$4{`{ zKRunD00G2!mx&SRba+JmhcUh|Fnj`&?->}rF)(}plRy9vU;su%e@J`J?dSji002ov JPDHLkV1n4Y8}|SJ literal 0 HcmV?d00001 diff --git a/data/images/flags/us.png b/data/images/flags/us.png new file mode 100644 index 0000000000000000000000000000000000000000..10f451fe85c41c6c9a06d543a57114ae2f87ecc1 GIT binary patch literal 609 zcmV-n0-pVeP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz^GQTORCwBA zOin+-00;m6G5q_(!0?NK;Xja^CBNtYx36zrzX0<8fe}y+AOOR1ZUO)Z1VGUKD=-j5 zV;e9~EK2V8xn<G~KHPiiGJR5}scIU=OtG5?dqju`4q&&IzW`zcYOs&ASbpf=-+v51 z^y|<6zyCpuAAkOzWvFI&_krQlM}{w-8NPjEjPks3<_JIl0d3e1G#F@-eci9GU%y&a z{(S%by?*fzmXaTzK7IP|;lsOk@7}z5^Y?G`moJ}Ao;(N;Kr9UZn1POWZ)W`W@4ssc z%b(v24lSHl8GioxdYUV43eaGNFJJ!u`0?-8FV3W{KUdEI1Q0`V@(!T8ZL5BM{P@wV z<mc<xubB&<K7Ra|!G9~n>{qW|y?pud`Sa)3|NY&vWd%S0u>b>P!2!lUe;6EF*#G_c zFVXVt@6Q{uX@40W{p0iY2Aa+A^Cu7i8KT+YH}2j52q4BskM2rJ$^k9;2Xxc_|Np=M z&VaLlA*IO5FlECMfB<5VUNC{tBZO(|zW*;@GJN;|bTJ71`0*d;`d`2P!x=ymOA`2> z+y@9C##^^8%gd{MW@Y91_2d742B2~OQNf=-zkmD?Vqkdk_wPTUNeuu2#KPTG{_;O4 v7C%8E5*DLB7#Kb?Fnj}}-(W6879hX?8lYRg`Y`<~00000NkvXXu0mjfD6Jtx literal 0 HcmV?d00001 diff --git a/data/images/flags/uy.png b/data/images/flags/uy.png new file mode 100644 index 0000000000000000000000000000000000000000..31d948a067fe02d067a8c2e69f28cca446bc7c57 GIT binary patch literal 532 zcmV+v0_**WP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzrb$FWRCwBA z{P^)B0~q}K$H2q{B0)?zg#iEphy^IY#`fp$-%X5+|JYaq{xAwGJ@xj-&%gf|{sZyf zzyE&!1v38s0g?aO+K&JP5aZjoZ`s&3F)<xrU;v6({AW)4|DW+c!+$vV2V*cXF<rTF zA0U8O{{8#&@85SOroaFH|7Bpf!ubEs&THR*ih!nq(BFR`<3Vou&&$DFy67H20I{&K zaj-B4{QULw|Gz&RY?;6Qvu?lq@$-*AzyJOJ4Rp^RkdwgVKUqPx=QHmB1Q6rvx9<h{ z`G5WT4YZV%4QS!NZ$E)v{)ceVKd8f4n3&FAxd{+JEJdrH#5*v3{PqXz29VW%!M^<a zALy8WfBu4e2IK-I1UOl;X50e^AeO}|9?6RHKY8~H<_5S+{=v9^K>_vmzq~N}&z08z z0*LYY{pZr+B0$d}2MCnI@DC;m3N;oM#uMkR0R#{ugY)L9Y<*xj0QCR^`!^)W!R$Za z5CobHbl5+T3;%B|S`QFFjQ1Zt|MTw;G#Vi+hCg5i(ELAtfD|ak8UBG;ObiSF0R{lf Wla#5zB1?M!0000<MNUMnLSTX}=lNm) literal 0 HcmV?d00001 diff --git a/data/images/flags/uz.png b/data/images/flags/uz.png new file mode 100644 index 0000000000000000000000000000000000000000..fef5dc1709d69d32f6535fa9694069a56097adc9 GIT binary patch literal 515 zcmV+e0{s1nP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzl}SWFRCwBA zWMD{KyS7Y2L!_(g$ijsi7#RNS+*x<+$}1H$5ntaTAeVvR7a0A3FaQFG5vbwQr)fKP z-aU8j#lL_5va?OUfB(zI#`yN__n9-#{Q3Rw_n*HI^7rq*n>JMe1P}`Y1H<dr-|X!b z-n{wd>Z<hQ$tPhU&c~0x3JY`Iz5DL>ufM;3|NZsr4^XwNEI-g5fB<4iWtjhPj`qjL zFGT<TJ-_$Q-@kwE{{oqL@7LcbpsL@$|NZ*?=NE{<|6A$|!)t&5V*L2=CoeB6(4PPQ z|1*FgF2Kmhc<$T-fB<3vx*MVzq=5mqa-b|D<G+7@00M~RAHzSG0ABNfHvIqp=MMuw z05JwLFeGI)yubhW-=9B!!EXKY3mhUq1a$pxkRT+a1eN4+&TRq+AeOcM48rcu?!9{m zR`UDTpI_jB0SC{Y-@pEW^Z^Y3hK7ppe};1m00G1TjF8`d|48ym{sxQw`3Er^7WyE2 zfx>_P{{Q>;-(R370Ro7T!5$Ws$Po%5A+Zb!3j_cNFaSC{Z(fWD@s$7o002ovPDHLk FV1jsy^u+)G literal 0 HcmV?d00001 diff --git a/data/images/flags/va.png b/data/images/flags/va.png new file mode 100644 index 0000000000000000000000000000000000000000..b31eaf225d6fd770e0557c2baf8747c91ce88983 GIT binary patch literal 553 zcmV+^0@nSBP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzyGcYrRCwBA ze0^S&;omz31{MYehJOry82<eI_mANp6C;<(Jq9L$f2iQkzkdJ$#KQ3Jlb~`xP&LE< zzYPEXgGh!y{~6dA`9&ERIR2x87cX7_1Q5tz5cu;FsPsQb&!7MQfBk3VVEX&#KjS~J zvcF)G5eR^m00a;V$RQwgFx3n|29N}*X8Qj9&zDcv?%&_=^B1dzniC(N6i@>|05Jg_ z#sD@3Z1&&(|Ni{{_Zx)%v%Gr!<?zWxtbZB#8SY)Xn*aUV#Xo=k00a;d!@vJvrC`<n ze}mB<5c&7Jj5M#WXXDo=Pwt-KH?`c!#qS2R0U&@_{sDc<@E4{BVm#0f%>NnLzkb;C z@#QWhJ{xI9$<xP<2uaDZF|z^$5DU;nAP)f50p0!Y57ZI{7N7_#m!*ij#k;#V*qPs& zn7jY}_xI(CmjD680t|Jqx<63IKs>+*)bNX$S?E90e{K^EMkYok78VXhR-hjM0*K`g z$j^Tmm_>oEVgxdn{xJOg$-v0L$jHRN$OsH@P9Y&+^ne@=1^@xXcy~X;zaPM$WdOPj ri2i^{AeYDB@IM9-<Y<Oy00=MuQ<g?9H^|D(00000NkvXXu0mjfw9@%7 literal 0 HcmV?d00001 diff --git a/data/images/flags/vc.png b/data/images/flags/vc.png new file mode 100644 index 0000000000000000000000000000000000000000..8fa17b0612bd318a649571fbc4f68e4512c65c5b GIT binary patch literal 577 zcmV-H0>1r;P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz(@8`@RCwBA zWMDYX!0?BG;T;15!{5994F4D)2t)wMKOmC3hk@Z26afSf3s7}+wIT<H^xuE~e@WG{ zvw#2p|1VIG@&6xYhCfXIe}DV)CnEOu@4tV3|NFgZ!X|(K0%`z(r_X-<`}^<j-^c&| ze*xO`|Nn0|`t$eK&G)~4|NSk?BMY<vAb^+{z*hhL^Y71}|6sHKzW?#-*Wcej^WOdV z_5c6R|Nntp5CVz-H2?$<&<3Ev|AFYwpMMPhf1h~uYum%$Mk2pi8GrA1^vhr2mx=K2 zUw=TVftCO@00a=w29Vi6)xUrLXZ-*B%DdkuUj8x?{-rDQ>)4B5XW#s0WPsWLbObPT z00IbN14#96puqp%TKvBf)PA*{`~CO-uM~~n%6z|m{re4Z55!3T0R+<U`!~aHumI4x zW}?4LM1F0!_xr=I-~AT9m>GV5`UO(`=kK4te?c|?1P~}#f$jmC10=!0@{8gBFK@Zu z-~WJALqveC|MTzfA7GIDVE_mqkOrW8BqZ2?K4D^#WMJTBVE7%b47BbKBg1c?EHl#| zW&XdQF#ikE01!Zo3=CVrargu%_x5NZPzVyc@Ms2-@<rgp@DCJY009O7PkUPR&aIIR P00000NkvXXu0mjfxSAW& literal 0 HcmV?d00001 diff --git a/data/images/flags/ve.png b/data/images/flags/ve.png new file mode 100644 index 0000000000000000000000000000000000000000..00c90f9aff09fb1b6d697c6a5680df01f37cad60 GIT binary patch literal 528 zcmV+r0`L8aP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzqDe$SRCwBA z{QH_=*9!&)CI$~7hJXJUK;Rz;{evSQ?+=*q2ZR6uh{a_M!=}&moJD`vUgHhYdB?!; z_y7OD3=Dr5|NmiT_`~%7_y7NY{=>*Gr#1lu5DU{kh6%sopXB}uDxAo`@Bt_a*7F+) z{Q{%kjEu5C8vp``Wse8Lf5q7h438n|z~nEOsZga5l7ZnjPy;{!05Jg0{{a91!FF~d z;^O)I`~dv>{`>s@o}L{E2?GEB|Mm6!N=n`P`~Ld+{`>p?A0G$7!M*~B3FM_eAU;q6 zNCH*w+4I@c^Ny?Q?QPpWZQiU1R1HBuWkA0H1P}`l|NYDG=g<FNzyB|o4^lmC+N)o` z{sUF?^}YQ0^Y5<R%2t*)AtnM90|XGGGlO+@R{q=jZ~p={{`&Lx_uoGd^7o&=KvO{^ z&;ft{{P`=QBvN{=3Lt=3wlN%K{-gcx_dN!%A^*XKLm6<9Kfi(QVEn5R!oUC!KrBFO zn126Zkdy?<0Y!lx2kK-5vHvhYNFV|_8jOI70Ro8eD>P=Ihyj(0CItil0R{kn^jdV2 S*Eqib0000<MNUMnLSTaZCGi9R literal 0 HcmV?d00001 diff --git a/data/images/flags/vg.png b/data/images/flags/vg.png new file mode 100644 index 0000000000000000000000000000000000000000..415690798657a5921fd007b8ae85a5e5d414e7fa GIT binary patch literal 630 zcmX|<ZAepL7>3_-&c2+@O*Bba;fM%rBo$$qwJnuekcf#0k*RG7MM`2+5Y7dK4k?R% z*@ue~6f;u_A~T0evP6qS=VxXnGS_mt>CSmS&gmG`kLSL)e_Vf_=c&!fKB7`4C;<Q} z^U=&)ks5I#6||UKbwim5NvY|$34r$gRH?%&_V~)Ig31%+XDcrkS6G3`QeI+BG+!t# zwdPujEmtpow59_<E6kauyektWQ;mgPbNBNXew6n;@JaSnjl2sx=6hnl>*C(7s2O*P zOs1$U0urzb3<=a`d9ABG4Q$eK<6~5Cg~V%B8`UqrfRSN8f&?QTVICs^VT=&p*?Eut zt4Ur&-BL_ON(Z|Xfgo93lf|gRkT$Mz-7^OQoD^XMF@}g>R?uoU0094KbXv)l?aF0E zh@GXQVr04m@05R(Rjxq*p|CCIRfv;QJmH1kf!<U9HTvhFLMB?%Jg1wcv~wi7iA!hn z`d_lxFs2~n5#f?_@_a)oA}m6}x>hsqX*sha?_l;f%e5i5I~4YG#H4;6sPG2&U~bv( zNr`eMXGIX9?wP^<h-JQt7g*m0-84(jugWnF5BM~F-dG6H_?Z8x8-!R1gjgV`d$<(? zK@cE91OQCeMAX>zPQP(6+&<G&MF<x()MJ9wIT<ZaUMHtaww?4XPFr|^xuG|L4zL$< zL3Ojdj;Aj5<}p%X`pw|utuC}Q(|F#q-$rETSVm(ss*GK{`B(*Ib`BYA#);|%`%c;2 z;e`S!-X`oO1o@UuJ8#+m^}gR#|H&0#;vV+jacDniLbvVqh(s4iYReccb0}KH#{uT7 L?9Atfi?06xI#fRL literal 0 HcmV?d00001 diff --git a/data/images/flags/vi.png b/data/images/flags/vi.png new file mode 100644 index 0000000000000000000000000000000000000000..ed26915a3238534bf8f1249b75dd9ddde10db65a GIT binary patch literal 616 zcmV-u0+;=XP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz`bk7VRCwBA z{P^)B0}}Z64@|%*3;+;7EI@G%#$C+p#*9p?|9{{8|MLP9U%{`B3m6!GvGS+<`Lcq6 zNrsU{^xxm#zrXH%{*nVA001!n&i@1e00R5|`t<LM0RIvP2NnVY6bAtJ`~KAf1J?fj zTK@+?`tXSQ`04ff?fnE8=I#>$05Jg0{{;N}{t6CR|M}we`P%>f+X)5&j)Fc04NUj< zI`HZ2Woe@k6UF22`V|Qe2MTi(6bb?WF#yj00*Hv<{QLmn<v9KVANu$P{`~gt>Fd+P z9@fS%;@9^E|H1b5`Vk8^2@Yw;#}(}C_yUN9iGkt%y>Cxm>T2lO13mifZBc!n`jhHL z!MK>$6Z_d%|GfHMapIVnqod%T-`{|q1_&S~IR%b4Z+;63vi@iI?>jBz=eJjkjQ{>J z{QUQa@gMhBW_GSe5B=V~`@+h~@c%z3lmG&V@$K8U{QUg?|NjRX|KX9x@@rf{MxIY^ z?fG>3gQ&3ruaE-Yw>fW_PfAH~0~Or8dmA8tSRg?T(ZDLQOiOw%6RWM9%$i>kkAMHu z-oH0qLBYb*loP0ck&zJ?LI42-(g0BnBsn<*tTlsx%zxbLe*S!R<%*E3>|Z7(9Y#iG zpdch>0Ro8e`Sa&!arp;m4%k`<1H=Xjf<=G;Aix0Vb{`Xo7A9K&0000<MNUMnLSTZ( Cb0$v! literal 0 HcmV?d00001 diff --git a/data/images/flags/vn.png b/data/images/flags/vn.png new file mode 100644 index 0000000000000000000000000000000000000000..ec7cd48a3468a511e27c49a69194b0ef5564e615 GIT binary patch literal 474 zcmV<00VV#4P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzY)M2xRCwBA z{Lg>@|4`Xj5kLT%<k$cJ5QG63s9*CHx4F?4b#Td&^)lkpl1oN5XoLe37*!4QcV`{} z1lGXt|NoPxK&2qnzkh>B_22*h{r-RE_y1qN|Ns8=|JN^|IHRmA&<212VgZ}|A4N4# z+WpUe-rxU^{Q|504bi~x`!`SnKmf4-F(Xhl(8fQ1fG+vp^85d{-~aW0|NsB{f9tRR zg1`PJA~XO^2M8drlm7gLxRT-bpa1p0|1bLmQVm3Azy5pw{{IQ;B%q-{(*XjA3FM@| zf53hMJK)W)|CK-uK=ku}!>|93eu7kk5yVLV0R(o^Z;+)RSAk6exg_KF|98LtFaPy_ z#c#OrKtum9FaQJ)NCPuC$bbfbRI>n`@$3IHusZAC|2cmD|MKhqAD{+Mr~sV<5I`Ue zOuzpyNJ@f@0s0=KlTj9AB*>SXe;D}wK*A7+fQkVEi190GMB<5K2mlB$03I1qT8uIj Q5C8xG07*qoM6N<$f}XY6qW}N^ literal 0 HcmV?d00001 diff --git a/data/images/flags/vu.png b/data/images/flags/vu.png new file mode 100644 index 0000000000000000000000000000000000000000..b3397bc63d718b344e604266259134e653925c9d GIT binary patch literal 604 zcmV-i0;BzjP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz?ny*JRCwA< z!qEvp5dZ+d`!$0ZjB5(>7(<dIy<YisofOp~kolh<hs|O#=rpbXV!8a3;nJ7tCwEv@ z&ii%w??1i2fB*ma%kcLP<DWmwfBrE2{{8>YpZ_rO%ce~L0mQ<<z;Nx}|F>@*ghnz= zZvXM=_iyVze?clB=ogUu_5ar|Ae&KE_8$WSKmaiTHT?Vg|MaE5OE>+<E@RyM_wTmf zzo1G%>VAUAUr;?j4FCZIvLVX%uQ>mocOU*NTK^}%njy93_oP3+fBpUa=kKpye|`ZO zzyE?r`M<ySGB5xH5J<z1AAdVm{4P!TbM)+=A3uK2Z~OE2H{-v54FCTDNv41Q82|rc z{`dDUD;M+SbqoLj1k&*M`5$kGKkwfEGS>c+5Xcyo_v6>E-#|Nl{r(LkAS94bRQz)6 z1Oq?-fi&zp{`>yDUnV+0)eJF{zWfaN_0t9DjNiZhfxY|}ME?2z=O_CghC>Vh0R+<U z=l36D?cYGNqbGg+<?!o=<*(mAz^eZNNs#yd{QLdqA26i<GJIhG2q2IKEtNk(u5zV| zzexrQ{MH7U{hJMBBv1p8{PPbO%zys^J@Nl9!!HJa0AgWeU@+#KC#BE8V9US&B##4w q8E7d3P!7a^uz`pHOmZ*)1Q-A$#ZjA2N}W*v0000<MNUMnLSTZ=Xe5#V literal 0 HcmV?d00001 diff --git a/data/images/flags/wf.png b/data/images/flags/wf.png new file mode 100644 index 0000000000000000000000000000000000000000..9f9558734f0482439b2292a01f768639a287ac25 GIT binary patch literal 554 zcmV+_0@eMAP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzyh%hsRCwBA zWMG)ez`*eTe-@D3z{vQIfkDaB^TGD*e;61r5fcLgKmf4-)m2y9f%W{?D=+`^_pgAt zIhUI1|6jlV{rU6n_wWCI{`~*_`!ATiW78&p00L=%s0NbXA3gf>`!~0W%8&aG{{H+4 zRPp!M??1nPG5q=k)Wabw3seITKp+kO{xLwc{r>&$?_UrCGJgGL;^k4Qujka#`tk7L zjnovNAkb9+0R(gvNCCu>f4~1QF);xde}DY=^Xr#<ZS9@1vUj_7-AGQBDJuR8G=+fy zAb?mdI<J#37JEA5=>I=|`5hhp|NZyp>({@(egUPqbabS$v+tIbNu;L>xVSv%=>ggR z5I`*V_U`A@(fj`3!N1?XfHr^}17rYw{OcFcC69Z0BvMmfELgy8V88%&5<md405RCb zzd&IEBq8kIzk$*>l9HrLN(5b9xs8l&q^Gm-@&XkD1Q1BW|346ee?wJ+H2_`p`^l3V z+1Wtu|3AMO{`_T8Q2{Cj2q2bUARGR$NlJo(=nv2bFrDDw2U!bb{QV0-KNuJQ0*G-b s1H(6@xcrC2{sj~H2V-zBFaQJ?0G2^Lae{Q+uK)l507*qoM6N<$f;6K8u>b%7 literal 0 HcmV?d00001 diff --git a/data/images/flags/ws.png b/data/images/flags/ws.png new file mode 100644 index 0000000000000000000000000000000000000000..c16950802ea95b40a4e024be6cce870b1991f40e GIT binary patch literal 476 zcmV<20VDp2P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzZb?KzRCwBA zWMHsnK!AS?48LIHkDm;8|A9Hk2q1u1fU2siOB9tvOibkW?7nVfBzyMk<5!Pf{r&Tg z`Ogui-@pI=`STw}e%Z7MAb?oF2LG3r6P`Ka(63*A1q8T`9lOK!`{(c9fB*fy`~TPP z|G$6z|Md%~no(93Xahh1F#+xQ_m5%!{+k~^{_yb7x_b2)Q1;jFKS0f3bw5GmFQ^`% z27mwpY54n>>E69JA<-5ee*C|6>(%ey48MQ<`3*E1sOaY}WDP*a00a=wNxxYB{9yb2 z<J7K;cW=L7{qvXM*B_t_Kqo;IAyohQ1GE7kfLQ)AoM-rZ{QsW^|8M_d_zm_S$kD$M zs$nD}*hv5Z!~(>OU?HFZD2h;3gJl2w`NO~f5I`Ue%-|qnfGfh(_6MlpFT>xzKtBKk z5J&^l?>`KZl3-*0GW-GQWCR<@03m_s?_UT4Dh3E3#;>Rm$$$~H$WlN65MTgr(_Ikn S3@&c~0000<MNUMnLSTa45ZY+~ literal 0 HcmV?d00001 diff --git a/data/images/flags/ye.png b/data/images/flags/ye.png new file mode 100644 index 0000000000000000000000000000000000000000..468dfad03867903f825e82de35934f3191e5f638 GIT binary patch literal 413 zcmV;O0b>4%P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzFG)l}RCwBA ze9wRY|6n9C1CIWHNq_)i0ZMSTRs+fZ|Nk-k{m1a{@Bcr48UFtH4@7@}=r@pL`1AYU zZxH*%l1%^s!~!<!|G(!?8UFtL4@7@{GyMJwR`>h=Z!r1m*T3JtfNUmdS)dI70mLHE z+xy?%p5e<EpcViB0d4pPBtbg={sZzs46vO*K}H@Pp>Hez0mS(A>sL-rPOy{yGk_s3 zz{tq>=+Ps90Al&~?;k`pNCN|I<v>|RMxbJV00L=%3E(v!XamT}{{RArWx|9$7Z<nB zpFjWo`xnTElW+i33K8Vx<?ZY10|+2SpfmFF^6uZi{~HW`{rZJO{RWbMfCvnfl$1`K zH~|nqpkM`xNJvQhhDiJdLm&<1{stNj(E#x+Kmd`900ImERx@FudJaVU00000NkvXX Hu0mjfwqCMH literal 0 HcmV?d00001 diff --git a/data/images/flags/yt.png b/data/images/flags/yt.png new file mode 100644 index 0000000000000000000000000000000000000000..c298f378beee6b170a6909fd4f73ffbeb5997cff GIT binary patch literal 593 zcmV-X0<QguP)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz<4Ht8RCwAX zhCvDdFbKm`{QqxzkrCFp5JFN?e84*IW~d6+bo*HjTmdMOV-o;C7z&~hgur(u_b*%| zc3B{v+FbFjzc1;Py=^TUgVxNQs*mT8$(%?)L|g&H0x=!PVGt2vaB=w$<S;S-_5A<) z?>{pWi2U{IKN}lRCnF;x&=P<EVu7mu|NrBcFMk*qc)7XHoO$-){jY!jf$IO*+sc0e zI_%3A0RaIfu)_cX2x`N<`}aS*dj~XHMO|aw+`BJd{$ODG|L4aaGZXP!moNPO{f~#A zPft((&!0a40mK6F=C`k3RaI5lIM{#u_yy!l=r#vBtT1ywP*zi0=l_4k%a^YJ9RlP6 z1Q19AkOkCw?b=lXBO_)erpljFerL7)|MR<(nVI>&!(V3Rv&T;|GP6K500a;d#1A^U zy8js%fB*jT=jRXRU*A|5K#2L*r(ZvQ{P_O!=da%umKG4l0|XG%NkAKznV4BvSbj4y zawHV~fBoj~uiyXJSQwZYIoR0QIXM3O{tc7`Dgy{0#<y?Z^6~KjEqV9u-T#081q20v z*8By#laYY|=)%{pUjt1P78VAE(%rjv0Ro5-$o~fefByXX^XK0ma0CMl16mDq63}2K f7N|pj03g5sS4||u)e=Io00000NkvXXu0mjf)-MQ> literal 0 HcmV?d00001 diff --git a/data/images/flags/za.png b/data/images/flags/za.png new file mode 100644 index 0000000000000000000000000000000000000000..57c58e2119f402072640ca758657798b621f3fb1 GIT binary patch literal 642 zcmV-|0)737P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU!6iGxuRCwBA zU}zO)Xyy0bUw-rA>Cfuwe;F7c=r;qyPX>nX3=Cfx7(O#Fd}LsF&%p4OfdL?Z7#j~W z{9>KL@b8bfkaUokt?1q(EJu$q{Qdp^&mV?AfBydlG2rBvO`8A$h{fmzgIW0J+B07s zK74uV#pQB`MAdI!SdN|k|KJ`--LK#OfB*Xb>lcvCC@cGqfdL=@05Jg0{{a910Lj6A zAt&mC*9zzO1pWQ@b1`Tn1`iV6=KuBe|Nj2}{{8>`{{Q~|0SOB6tE&PCq@k>=Oh@B| zgDu0v2b@p;u)h2C^Y4EK4rY$O|Ni{`3qc?^$?%A8m^2X}fLMSU{`~p7aqHLD@0rW| zzIWc|c>Ry-$DePXKfn0(_xG>AzksTL{re5n#{Tyw!wUw000L?F`}dEV6O*(3zu6Di z9{gne@#pKG|NpqSh1vf81DXBz&mW+Me}8zmL>PD&00Ic4!QY)xLzl7RCikV!EWdtz zoorLj&BXoYF88-DfB*gk`{Eza7yo#;S!C4G00M|*elvr*8B6X-zBgb0FtGkEHc8mM zoOScs_b*;Q0~!ksq<=swfJjA!^Ww!_00G4E&x@hvA~zQkvxU59n3VsHd7Nj?ec|R~ zkmuqAx#JHo0{#K*`TG}00$uYDAb=PdGJ(Ek5Vx?d6PS=4{E7kSFNS|$^b5xL14ayB ck_{lh0N_F{UmK66LjV8(07*qoM6N<$f>aVd=Kufz literal 0 HcmV?d00001 diff --git a/data/images/flags/zm.png b/data/images/flags/zm.png new file mode 100644 index 0000000000000000000000000000000000000000..c25b07beef894408ae11c3be294d6e0eeb28c0bb GIT binary patch literal 500 zcmV<Q0So?#P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUzhDk(0RCwBA zWNu_;fPugN{xbYyVE6+@K*ldH;};48Ab?na>Lz(k0+Rp#|Nr~{@4x^5fB>iy$Oe)U zA3pkg`SAPSuU~)vaR2(F*|!oPfLI`Azxw(Lr25~#KmY#x{rl(7|GyyW-|wp|{||Bf z=)Cuc_0O*fYWnY`7ytr@1!(pEfB(To0uBE22c#NA{{8#+_rKq)zyAFG`zKdY6euFW z2yzlY0I`6SGJqWbGV%`;{r&ytA5hcp-u`P`AAdOg`t|1D?}h2=^+y>10tjRS&?yMj zfBygt2HEouXg1LCzyJLD1w#LR1MLR}1V8|>0KE@nffPa916Bvu_V4#Epof105yNkw zzknJ50tl=D<VC1Ue#4Z4wEg?@|JN@d`V9m?bwC9S3;+QH(g0G8qz<AKLIQ07Y4`)w z@RtFko&g|$Sb!b}N{Dcaz%2O#_c|~<SmZu43I3G&|5pGKMGOo80mR6Vj2ffxcm<0b qJ<A}(z@Q0^tMd#Dd<+Z#0R{lguXjt`we0=?0000<MNUMnLSTZRN#Q&I literal 0 HcmV?d00001 diff --git a/data/images/flags/zw.png b/data/images/flags/zw.png new file mode 100644 index 0000000000000000000000000000000000000000..53c97259b9b3e31c2f612e78344d035281682fa7 GIT binary patch literal 574 zcmV-E0>S->P)<h;3K|Lk000e1NJLTq000mG000XJ0ssI2`GA^k00004XF*Lt006JZ zHwB960000PbVXQnQ*UN;cVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUz&`Cr=RCwBA zy#DaIW|$^}9|OZ528MrN^asZN1!nw0Vz4m)1Q6rb&tKU&*=KHVlGXeD_y50t|Nnx~ zA0Ybs4~T$*VDjIeKmUIh9^3>FK#X6%e&u3i|I74$@9uP0*YE!se={)r{r~?r68Z&3 zzZn^2KYiK`5I`&p3=IF7JHD>uaSofgtUAf*-w!6nU;qF7`S<(x|35&+uYZ4j|Njjl zS;T)|pU40ZKrEjoRWbQ1@P?oGFzcPCjLiCzcU8ZC{rmejNW<@6e}U*1kPT$~=Kk_~ z83O}A05QHjx|ikdd7h}_|NsB_wl9j+&d<}^`}fbEzkmP!1q4tEL@O#P9zA*#Ab?m{ zv`qe9Isj6Ah@Z*IdsTw}WYHgB8-D!*lmGvK*?)d>i2vThzyJ_HEdLn(@uk-N0|t-& z`$Yv&?#y3UfPVP*`ya#a|BS!>G5-Ds<TC#L&-90l?T-or13&<QJk7ws`1{xQZ99{b zC%*sB0uGaZkidb362otX|9=@7{~EP200a=@_wV0-{Qfz6dy#}TEXaY803v_?`S<7F zUwE7V!}@R4n)3hw#Q5;}LwPBAhByX>FR)1c1&eHCHYDzO7ytqc0P0;>l>h9)WB>pF M07*qoM6N<$g71PE`~Uy| literal 0 HcmV?d00001 diff --git a/data/images/imdb.png b/data/images/imdb.png new file mode 100644 index 0000000000000000000000000000000000000000..961177009aabef915420ef26fde96679d31d7c68 GIT binary patch literal 9158 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 SYQpNZ8qxYYYVUuV8Tb!^lDNqL literal 0 HcmV?d00001 diff --git a/data/images/like.png b/data/images/like.png new file mode 100644 index 0000000000000000000000000000000000000000..92460f753888ffa65f349b86047fd6c31cb7dd63 GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4d3(AzhE&W+PB_5#&B@U5gM8P+ z2F`A!z#OZMnm*1++zWTJr};85GavR>S)Ev+uRKX9#Vj!FvirkeHa51lw>%u%58gQO f!;+b~nVq5WfmpQF>2C@^V;MYM{an^LB{Ts5C=)0` literal 0 HcmV?d00001 diff --git a/data/images/menu/system18.png b/data/images/menu/system18.png index ecb1061db5c0a0dc71792f908d482c5e6b76467d..fc3bbb0f47ba6dce2e9a5309879f6377edcfdc56 100644 GIT binary patch literal 3278 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 M07*qoM6N<$g0cfM0RR91 literal 1259 zcmV<H1Qh#;P)<h;3K|Lk000e1NJLTq000sI000sQ1^@s6R?d!B0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBU$jY&j7RCwA<RC`QQcN9MNQEq8<ML;U@ zwgn27O-3O)9Ex-@R%Rynhd9Ib2T`+WSTr$?%(1v+*^*7;mSrQjM`jil>9VPdab}>w z35eQ=U<Xp#@^JS3qPMrVy^sB_6XQv~++T9e{qDKH?>h$r6necL+S}WKrs<<<b=C=9 z5M1F<=qs9znR|MA<^q9$R0eq)_drhdZV>q-1e~{E=F+FI93(swf*_<<8w`}8x~5=c zWcc%efx#wG6nCLi=-E0^<RL|&N>k)jHIbyuEF<vbV<MJi0sB9W^_zc9Fbs3NcJs^N zc6%C7mjzFWL=n~DQs-GEI(^^qLwo9b|FD*XV~NwaN~KaXHa32Pl4sG^Q#D%ciAZD# za2y9-;Gw0Zm6bSfY6TE-6);ghWT=$plke1S+p&RSE3$=;2#(GnIgZm9joS{?*VlvD z{F^Z&W0hE0xdC`C2_ymT!ovLW_m3T&nsT^dWG(?rEUwqDTU{gLmf+MmtNi+O^oST= zdO&J48ZXZYlQEjsx9@FJquxQ_d5DCAu>F-rHP1o+y?b{}sdDOagr>`lW$FuW8g-PZ z``TTZLS<&dk4`?g_%RTjot;s;-G0O8^KD_1$<>RC55eR0LzY?%BF95nSt-WGnO%dU zldO7e;b$!k71|HKzR<@fnVq58;eom9XCM*vVOV!}H}I%~%*;$}C={%YMnWK`sPvq? zg14KSUpK5uTL~!?1?KaA!ta0Gf@`Cby)im^P>eVdwAUeBA128_;+Tkzk{Xzek}GY) z-dqUyI8e>nF8TdG{IYM~{u&I`wza;7L=!-s^xhze?8NtH2A`5h2ja!r?a~7*o)s|c z&VraAhe$MjkY@S*+tz>c>{EA%hPoQycmZChD1%f=W?GzgTI_bmj8dtDWe`kh2iT(N z5Kd4)5Euk~{?77T>dSZAHc~~|D~OAO7O+m<hpn5d0Y^d<7ncy6Ahu<%S!1iKtDD%g zX%k2wa;tWN^m`=I+V2Y<t}IfVdT(zXEI94Jh!n_39IoDZxR{!lAJ&!YwdgKDr_*6E zCf+=SOhJ<51hNniYYnY{;S!1O@D_c>7f0TzhvCsXF#9M7s?|A?yCEExEo9=Z9n&+j zhKh>ki%{dBE7K7?FTUz<*qtdUDHFt+%AI5+6zW1GpM0?2h+Vll28(_g3N^(rZT$!C z%)|l_uXFeHOJ{9pkcZItyi{c{<XEj%bortzKfj>0tE=lAA*5B7m*%PZPakW+ES7N? zcX*(1od)ht+=r2SZjWpF@7;0VjC;wq@En>+g~#I=a=APPlgXq`OIroVgC4Wl+)Ijr zK%lt?r#lQJrG)imr7$vPfzk0ti}T}G4n*fi?MPN7LaNv66%m4n?9kZQ&An*NgynMT zJlYx#Z2g{ZRXof5SgBq4Ms1ZAoX&Z;Jo?ZVjYc|LH@_Q-hdtC|e>~YV()=RC5&X|= z792Bl{3Llq$u0$d77|P&E~7$pV6Mr1yKg+ngi{1A!9RI<*?`L?6rT`FdGS911^~$4 VIhzFo)W!e+002ovPDHLkV1mdNU3>rl diff --git a/data/images/network/cinemax.png b/data/images/network/cinemax.png new file mode 100644 index 0000000000000000000000000000000000000000..c00865c89088c7e7834b2691a6cf86891fc54a18 GIT binary patch literal 4017 zcmV;i4^HrjP)<h;3K|Lk000e1NJLTq002M$001Be1^@s6qMd$(00009a7bBm000XU z000XU0RWnu7ytkYPiaF#P*7-ZbZ>KLZ*U+<Lqi~Na&Km7Y-Iodc-oy)XH-+^7Crag z^g>IBfRsybQWXdwQbLP>6p<z>Aqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uh<iVD~V z<RPMtgQJLw%KPDaqifc@_vX$1wbwr9tn;0-&j-K=43<bUQ8j=JsX`tR;Dg7+#^K~H zK!FM*Z~zbpvt%K2{UZSY_<lS*D<Z%Lz5oGu(+dayz)hRLFdT>f59&ghTmgWD0l;*T zI7<kC6aYYajzXpYKt=(8otP$50H6c_V9R4-;{Z@C0AMG7=F<Rxo%or10RUT+Ar%3j zkpLhQWr#!oXgdI`&sK^>09Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-<?i z0%4j!F2Z@488U%158(66005wo6%pWr^Zj_v4zAA5HjcIqUoGmt2LB>rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_<lS*MWK+n+1cgf z<k(8YLR(?VSAG6x!e78w{cQPuJpA|d;J)G{fihizM+Erb!p!tcr5w+a34~(Y=8s4G zw+sLL9n&JjNn*KJDiq^U5^;`1nvC-@r6P$!k}1U{(*I=Q-z@tBKHoI}uxdU5dyy@u zU1J0GOD7Ombim^G008p4Z^6_k2m^p<gW=D2|L;HjN1!DDfM!XOaR2~bL?kX$%CkSm z2mk;?pn)o|K^yeJ7%adB9Ki+L!3+FgHiSYX#KJ-lLJDMn9CBbOtb#%)hRv`YDqt_v zKpix|QD}yfa1JiQRk#j4a1Z)n2%f<xynzV>LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_Ifq<Ex{*7`05XF7hP+2Hl!3BQJ=6@fL%FCo z8iYoo3(#bAF`ADSpqtQgv>H8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ<AYmRsNLWl*PS{AOARHt#5!wki2?K;t z!Y3k=s7tgax)J%r7-BLphge7~Bi0g+6E6^Zh(p9TBoc{3GAFr^0!gu?RMHaCM$&Fl zBk3%un>0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 z<uv66WtcKSRim0x-Ke2d5jBrmLam{;Qm;{ms1r1GnmNsb7D-E`t)i9F8fX`2_i3-_ zbh;7Ul^#x)&{xvS=|||7=mYe33=M`AgU5(xC>fg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vF<Q0r40Q)j6=sE4X&sBct1q<&fbi3VB2Ov6t@q*0);U*o*SAPZv|vv@2aYYnT0 zb%8a+Cb7-ge0D0knEf5Qi#@8Tp*ce{N;6lpQuCB%KL_KOarm5cP6_8Ir<e17iry6O zDdH&`rZh~sF=bq9s+O0QSgS~@QL9Jmy*94xr=6y~MY~!1fet~(N+(<=M`w@D1)b+p z*;C!83a1uLJv#NSE~;y#8=<>IcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a<fJbF^|4I#xQ~n$Dc= zKYhjYmgz5NSkDm8*fZm{6U!;YX`NG>(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-k<Mujg;0Lz*3buG=3$G&ehepthlN*$KaOySSQ^nWmo<0M+(UEUMEXRQ zMBbZcF;6+KElM>iKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BK<z=<L*0kfKU@CX*zeqbYQT4(^U>T#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot<a{81DF0~rvGr5Xr~8u`lav1h z1DNytV>2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z000EsNkl<Zc-rilTWDNW6o$Vfr@0tSOqwQ5(=<)$T?CtY0}&Nc1O*WxQw5<S_+Z5+ z6+sa%Zv_>VinJoAsK|JGs4t?31w{p|7le9?*S48V+9Z>Xoiw-c<=b)`CNXA0O`9w@ zOma^4-s}JWwf?>5q%5D$vy`KpB_2zjfU@4+-hbRkCX**U=tLp`a2ik_Ezo5DTl^Qn z5{X0vGyqv32TVpMnX**^Yyw(=cCU4QwgAn*wZNArA_1oga22o?XiFp#?LY_676VuZ z%!KVvAOq9_Q;Ug!6#{Dz#0H?TK*?3WN&(I{jsoWb>w%w+Cjqr4-wdqr>aaZLs@_~< z1V{m=1FH+34Flsq1&~`P0xAU9?A5A*Z6?rZUp4~uC9ahNCSq0a0r0Ks-UPe{Jf_P( zUS#}$b5sFi(HzaC(gj;$3U<cSTdTSaz$ptr=rpho=y#2?yuSnZP!gZg6GnjNRXYOS z01gB9SYd_H@B9<Mu{Z(En#vjh1#kgiql;9Q0Bj;=LTT4b1Mlkrcd5+Fz)qEVL;}76 zlE9=Y{s43XHv(Td#}3PT3$S^H91a3y<}wioSPgut(Ug~{Tp0horP(Fmw}DaM5OBoL z2Z0}f*CqC1tLsJ8tk<lNlqoAQ<2Kgqvu*yKam+2V<d`zYERYWbw9im3#kP<UOYeZp zpXt4%KJchXJ_Ec9JOu0q_Qa|wxc!B|NB(`8z{^y=+IDIOzHt1Nz!|_U0SChO31~`k z-vMqflK&(yD7iUeZre2j;6H%f_RSzLWcl5%+3XhJZzfp*ykIF03$hjX+7jOkT;^O= zK1-QL1oR1N1Ms`!D<x*yww!WopZNehDRDg#^$Ktiu(1HosAJ0A4*w>gnEH9(2~8)` zRqITyMG%d)&tBCpm;DYIuJsyF%{Q%-oXMUYE8A-%?`q&Y;Az$W#QCd$R|Q&W)$IX3 z2fhPt)A;Un%xl2;1)g=-N}-T|bBYj<wRCHMTRlG3S^g=_D*#NZaGebA1deH%72d0K zo9dK|wN};<TVji<Zv)<Pd>(ko*1OrBZ*cxW;A7{#1K8?X4@gdZk@G~#c_Tk(t$<-m zrWUx){tc0qpk`GcQlUG6XEiw7`^N=YtpfXjUw}=LFyb1Gz{AcnV0Glp?FvN*`ED0% zjl{=erB|^Sw3Vjg1XNpUg%Lou3AB*OF_jyas3zbl$86Vd8U;NZYk?}0?y;?I(A@A@ z9=Ka@)idl$4RhR;rpza!D4%&hV}3-jZ%)Owi)njhm;2_gz~$b{%Jd<b?Q*{B?aMxm zsMX2{?jOz*;mF%l_>N*01q)-`Jci6;zzRDUyV5=%jkVQym>@E-+8GEq>VW4|A`e{Z zXE$((&U_zmfyr0673O0lyQzdPF2T9edPYi+;pYJ>EDL0l$>hJj<cz<=i0rYlB7fIo zsnqE7O<wCXo~^NQ8;g85n9HFl8#^2YfN^jsp9Mb}E9Heqv54H(ip6?+alSFB`Hk2j z0Z_l^i*U9`OLQ(6)A5YRlhu4;OjU*j(jV))ey`Af8Mm_O#rB3JvUg`Y_6`*Q3?`Ej zd^p?Z?E4ccA`c&XfuAkS6#_aSxFH~yOeV(`<4e(!UyCpQXK0DXG6Bm3EEBM_<F5e# Xfqik>u@Ss_00000NkvXXu0mjf%}I7~ literal 0 HcmV?d00001 diff --git a/data/images/network/city.png b/data/images/network/city.png new file mode 100644 index 0000000000000000000000000000000000000000..979582dbb49462d5b3cc1703179e1ad3650f493d GIT binary patch literal 4048 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 CM|)-f literal 0 HcmV?d00001 diff --git a/data/images/notifiers/email.png b/data/images/notifiers/email.png new file mode 100644 index 0000000000000000000000000000000000000000..ee3fa70755bc66a9710193c44c35e5816b42d46e GIT binary patch literal 4258 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 Eg2QVXivR!s literal 0 HcmV?d00001 diff --git a/data/images/notifiers/pushalot.png b/data/images/notifiers/pushalot.png new file mode 100644 index 0000000000000000000000000000000000000000..f1d9008c1e6078ebc2275fe1b28961fa1143ee70 GIT binary patch literal 927 zcmV;Q17Q4#P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv0000WV@Og>004R> z004l5008;`004mK004C`008P>0026e000+ooVrmw00004XF*Lt006O%3;baP00009 za7bBm000ib000ib0l1NC?EnA(8gxZibW?9;ba!ELWdKlNX>N2bPDNB8b~7$DE-^4L z^m3s900QqxL_t(YOP!U^OH@%5$7dJ)SY?To2~lk;s$~%^q=iI8qAj9L5fMdE`42?3 zs)a}`A|g<nQKK|e$Sgri&7U#jPe^AROUD^)6m!P;dHbH@y_ff99y3!1J}~#3@A;nh z?z``eEmPEdvF!r4!Fw<bRwZuG4DNwKDbGF7Q~+LrC=gkQfoiZ<wr36q&w?djWtNE* zP$q4x1A;3cE*mth-;WDVpiZoYJ&fCL7cIj_Od3fN+>v(XK%oSrnSY(UEE0*RNG6je z;)#gpnkX}AB{Za5+HM3AJ3&ASaK*>Opu0l2ypLeC&W7QLUyS{H3Z95?G{Cu7d`&pL z4_MneeAGCEwyQ|m^ML16aP({wGl2%vl@gKIvS{qzrwzI$E~aS+PoSPXq^?Gp?*L&+ z&Mr1-F>Q<58n?ezn>P;Z$H1W0g(C}mCu;3oe5bUu-9<Fu8YSACr?iN!q@g!Y_?A0( zJ^rgw513x4<{X)JjFxcD(X&<j@V(OgZO)TQUqJ&Lv~bNn=HhyEN!YtLv*6Rn8PPm+ zNQoJ>|MWbJd3*OJ?xmW``%vXuX@H9slXK6w82mG0YD}elq?mczSD02?UK?ePiMcA3 zo1p<dTJ%rd;$k8ZOS|e^Xc<0?-Z*>oP2E)akRI^nTfT_KR#*^<_{8A!ebG5y3NB=! zl#gi$51V=;@E|Ya0H<8w_?26}RJlUEo#vO%Lj&xFg!te*A?AY}|1^lS^TE&DYxDvw zG~jAVlBvb1sPEmb`RF>W4e7VBMp`QmaF+&b2O&e!68e0o0rcBSlLCFl_o%nI(onGE zKL)&Ja|$f-0H3B*ng$N?X&J&vQ3L2V<(dW#r0FF=uv^-11QNx7Zp$QgbZ@}}Y}e+B zx!-O2jZYx~&P!W!pl}If9g@ZTy0o(n2<T4Iwo47kU`~It=cG;MfRLU^6)>GH8e(C- z4qku)*`9l#i3ZW<ybg>4zhn$FfEyrR$}PfX`wOKhK&vCQiFp73002ovPDHLkV1loc BnK%Fd literal 0 HcmV?d00001 diff --git a/data/images/notifiers/synologynotifier.png b/data/images/notifiers/synologynotifier.png new file mode 100644 index 0000000000000000000000000000000000000000..6ec497da27b874b8ae375c3080ac43002d4ebba0 GIT binary patch literal 2041 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 I*_@pB8}hwAM*si- literal 0 HcmV?d00001 diff --git a/data/images/providers/dailytvtorrents.gif b/data/images/providers/dailytvtorrents.gif new file mode 100644 index 0000000000000000000000000000000000000000..fe5f50730a21c226d51ae321722da929ff8b3474 GIT binary patch literal 586 zcmZ?wbhEHb6krfwC}3bv{K>+|z~I85!vF*zv9|yJ{~H?{mzS5HI&~^LJG-c;sJgnk zrKP2}w|B~vDapynXU?37kB>ik^k_ug65E2sz73mW`VXbeJmci#6crV<XU`tfjM+2K z{6BK!NN8y2rcIm7v*xz%dOPvRcYlBXWy_XX<u1rwa5;U}+45C)o3_5#xpSwNm)Fdh zGvg*6aVcNr;^NZN)8kOQEP2|=$e#Uc)~vC&w{K`@a4KD4Wo1=bS{hI`*S%_OQ0tD+ zj@@QvX1Te!H8nL8Cr(_kV#VIQds9<WEiEl=ZEfA$+<bj~L7p0FfPHy`OOp#@b8~B3 z8&g+jZ?{W-r#%xBJG=T6^=VV=UF^9REL^n6UXq(nK|n)DNYIR*kDHNOQc_e*eCMv6 zVxkMSZkLfgd{|sYOrKFyQcGV<QAk-uMOjGkgs8rjmb!w5<^xTBO+f{9M)lk5{6ZG* z-V156i?YAcT=U}xKl@ZIMkdps8x`%$PF+${SXej=ULN6&=iU_g%W&m|Lkzb=3{Jn` N4_w^e#Kg#84FLRtphy4! literal 0 HcmV?d00001 diff --git a/data/images/providers/iptorrents.png b/data/images/providers/iptorrents.png new file mode 100644 index 0000000000000000000000000000000000000000..d767a681647508057e3c756d7b8c4811a6a551d7 GIT binary patch literal 406 zcmV;H0crk;P)<h;3K|Lk000e1NJLTq000mG000mO1^@s6AM^iV00049Nkl<Zc-ozl zJxjw-6o&85=uNs6w^l8&b#Q5tnpDT=pD2O_L2xQ9f`ZAWn8eNQcD73$)J28*vDL8z zw^Ci(a=hnAO4WiUffvrl`#zim!kBneRbA0_eJ6@AgIQ*pX5KJLW7AxND8fvkP*`Gy zvA!QQ97@xQwg8k4;sSJi^<)BYYzH1Y9a=OmnpC6QQx`02^KU@oyaD6kkQQMWg);== z{4EH`_YF`i8Z!Z*@%H)#Ub|g_;2t3%P<(@9mdPws#T7Fzz}d&(odV_Z7VTS?Evhk- zPR~1x^W<JCH3y#Sks+5`hKIX*2zouZYPX?QtAnQMG{?Q~`H;;jfEmuELjrIvgMDO^ z0c$)Pd#tC0`!nC4nneV>JUzpp--pU}<u|~xHYOo7VE+@47q5YynSgi_fJ8#tjSCRj zDwAb-Q8c3%fDvZtkK~*yl9a5Aq5mI!dAWkwPonkD!i5x`;{X5v07*qoM6N<$f})YM A1^@s6 literal 0 HcmV?d00001 diff --git a/data/images/providers/thepiratebay.png b/data/images/providers/thepiratebay.png new file mode 100644 index 0000000000000000000000000000000000000000..c5f1c3ef06d8c4069787d445d14d35ececb7785d GIT binary patch literal 2967 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 N002ovPDHLkV1nldwD$l2 literal 0 HcmV?d00001 diff --git a/data/images/providers/thepiratebay_.png b/data/images/providers/thepiratebay_.png new file mode 100644 index 0000000000000000000000000000000000000000..681ab05476dbce5c0ff55c1f450b15fbd7ab1f6f GIT binary patch literal 1609 zcmV-P2DbT$P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv00001b5ch_0Itp) z=>Px#1ZP1_K>z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUy2XskIMF-gj6cQUS1oL;d000HFNkl<ZNK37idrVVT9LMo76E(Uy zvl&Z8N1+c&3w`(Aw%oS2FZzU*w!8|ZP-p=urHCl<&;<~DATI|DMZ+@ZHs)rwEV^Vd z*__V(q5ES#rrRWfIH%4;8EVwK-!)<TLl}$OO>WM)=lst1^E<!a`JEdTHOC`jQdW}h zTy`%!Z$s#~nNQ=tu)sep^x&eEUYzrTOA_apw$pM~Ej993YyTHusgb|mt4<uqN?{pE z!H_NK6%WR7$~iGB{xe{M6<t=9CK@l&aHM`+GMbAe#8sz@PX_hes%h~ObyBob6Z`VN zh83FyOTtp>;;=~?UFSlN1K<#bMJ=qdO01im=*b;k715B1o@~sNbL(AVm0!p8<#M@8 zF$`Nc4RIzUEQ0?08KHF<h;zM#@2YUhVrm`IAu5%+xG_h(yg3K^Y;QnM>?}|NKz3xa z3mJy?N{K#c9l48gE%yyl&}U^VvrFkR=q`8;@V+x@L9GL63t2^eax$RjFvxa~5APc+ zHxeC%nro4C#MGLnPVX(z-rky}=pyGU3__7l!=r>Vq>F>8yeccopv>rfn-8T_+a=x& zX+rGH94xCoOJ><sh-VL!8Hj<hRH7|kd9brc<LVC@{^<5=A5>U)Tp}uJUXxql_TbEw z^=|Ya00Oh-qd6aAhCzIz1L5uR;n#K+;C-EbJP6)D-Igs6_m=3s8Z1vGhAPa&o<jUI z8R!0D^OAP2@{^`4#c-orcF`b?TRtnGHe0@;z>xgfjskV$ZSc4+Xt>qo$HTh|@gvY5 zI8<#V4py3p10gd3V?)zI?FGt_F29zjccMGzJWbs`4W+w8H@bhlkqpa&-hl4A!LrnI z2P({DSXyE1*t-!c3FsijTC*_?h~Mfd))Lirsb+3~U(N3=(G#7psJ#F`)L&{izpvbQ zyU8v0Y;(zy`pZ&7Bb)35<fgeds9OWczT6Kvg41;ejx>EvKw}ohgb<x=@ubv&_|#B^ ziRcUH&jk!zUYkdWfu~z~i*+CG@oNwFl<1D`gPlT4Kz-Z@*(B>`1T;1_&W9uRDml5) zEQ@JyAq8DUnxkYM$m0pg{b{>b+1OEtf3P)2vAfNyZUxb)ZU_r0PR1Vq4{OsTf>{9( zn?PBUCJ;x`g^AnqRPTWIL{G7fAd3sy`*2deg&h0>V&8-4by67W;vS3<yL{?r1z2mB z{B?yq2XZ={3nErgN`ng>Y0FbiwtH1~NY6;fTS3T4k>&~MahG31bQIx_VK-ACIt8`y zki0&&=P8NJ8OU#ymL&G9d8kQ~)V1VdM2iO_wr0vFV9}%YeAPXG{R|?bO>jkQ%0L@H z=vZ}{xO0nB_GMG1{Qedfa=j*9av_o?`Y~YOeUXbX6K8}}T0|dKT198VR^d0B9Ma!O zkLsMrwK^AavEGHA2f(E&o4BUT$ZrT)g@<eGk}EZK@oArmWA!N6dJtViif15nwMiHs z`y7P4k~8J3T)0>8kVS9;ynImSM2WCXbQQkOL~P;<VXNrtGLx_`WD%YVnFTjXQ+cO~ zG)eElPifeW{QVOW2%=t^aTl4A#8o;WZB4F{?I<-QPnM<fCp@aeo;-MSQrNqF8qQ$A zkbKpzO}bR1<&1gNoKcvYaLJg%4hcQi$Xm;rvld7LH_Ebs;8_>S7%_?Bf;LgSH#;R^ zF9=>KP;(|h^frX$PL7g2m8D=k%s?48Y@)bN)toho*#Ps%iF9u4>t-?C<3t&EA(o@y z*{|u6_@iz)^K!O=^*CRZNVw&!CoUP|5!gJ1al(Nx2$PUDX%xf~I49;Kj2*2+=u{Cx zr!-6BsnO4X3TDje6gIU5432BLYfpgjO_P8&VG+_MY+~9?t2pi!yyW+oSx6f<31i2N z{MhSy-nuccAA@yc7@Intl0dzyNQl0QGAW-+8LLkrGz$7BacwxK!Lv{i00000NkvXX Hu0mjf4?5>m literal 0 HcmV?d00001 diff --git a/data/images/ratings/0.png b/data/images/ratings/0.png new file mode 100644 index 0000000000000000000000000000000000000000..1b8c2eeabfb68dc841baaeabec7fc23dd5aa9396 GIT binary patch literal 3631 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 B)*b)= literal 0 HcmV?d00001 diff --git a/data/images/ratings/1.png b/data/images/ratings/1.png new file mode 100644 index 0000000000000000000000000000000000000000..e1e14ed2dc6d8c00c46b08941debf92bade4b686 GIT binary patch literal 4443 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 literal 0 HcmV?d00001 diff --git a/data/images/ratings/10.png b/data/images/ratings/10.png new file mode 100644 index 0000000000000000000000000000000000000000..54a95807568ecc5225263b9f9055677dd658b177 GIT binary patch literal 5848 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 literal 0 HcmV?d00001 diff --git a/data/images/ratings/2.png b/data/images/ratings/2.png new file mode 100644 index 0000000000000000000000000000000000000000..1c347ed01c54913661ca0b8703c2d56c97b24fb4 GIT binary patch literal 4541 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 bu>Lauxn7)_-0esB00000NkvXXu0mjf%c+_X literal 0 HcmV?d00001 diff --git a/data/images/ratings/3.png b/data/images/ratings/3.png new file mode 100644 index 0000000000000000000000000000000000000000..388722e064884f85679131ac9b12ddab724619b5 GIT binary patch literal 5179 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 literal 0 HcmV?d00001 diff --git a/data/images/ratings/4.png b/data/images/ratings/4.png new file mode 100644 index 0000000000000000000000000000000000000000..5376d114f0759e4b0ddebf97a617cea55742ad34 GIT binary patch literal 4667 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 literal 0 HcmV?d00001 diff --git a/data/images/ratings/5.png b/data/images/ratings/5.png new file mode 100644 index 0000000000000000000000000000000000000000..e8ed6bcd244bb6bde8b3b98be636ec9843555bf9 GIT binary patch literal 5910 zcmV+x7wPDUP)<h;3K|Lk000e1NJLTq003VA000#T1^@s6AdfQt00009a7bBm000XU z000XU0RWnu7ytkYPiaF#P*7-ZbZ>KLZ*U+<Lqi~Na&Km7Y-Iodc-oy)XH-+^7Crag z^g>IBfRsybQWXdwQbLP>6p<z>Aqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uh<iVD~V z<RPMtgQJLw%KPDaqifc@_vX$1wbwr9tn;0-&j-K=43<bUQ8j=JsX`tR;Dg7+#^K~H zK!FM*Z~zbpvt%K2{UZSY_<lS*D<Z%Lz5oGu(+dayz)hRLFdT>f59&ghTmgWD0l;*T zI7<kC6aYYajzXpYKt=(8otP$50H6c_V9R4-;{Z@C0AMG7=F<Rxo%or10RUT+Ar%3j zkpLhQWr#!oXgdI`&sK^>09Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-<?i z0%4j!F2Z@488U%158(66005wo6%pWr^Zj_v4zAA5HjcIqUoGmt2LB>rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_<lS*MWK+n+1cgf z<k(8YLR(?VSAG6x!e78w{cQPuJpA|d;J)G{fihizM+Erb!p!tcr5w+a34~(Y=8s4G zw+sLL9n&JjNn*KJDiq^U5^;`1nvC-@r6P$!k}1U{(*I=Q-z@tBKHoI}uxdU5dyy@u zU1J0GOD7Ombim^G008p4Z^6_k2m^p<gW=D2|L;HjN1!DDfM!XOaR2~bL?kX$%CkSm z2mk;?pn)o|K^yeJ7%adB9Ki+L!3+FgHiSYX#KJ-lLJDMn9CBbOtb#%)hRv`YDqt_v zKpix|QD}yfa1JiQRk#j4a1Z)n2%f<xynzV>LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_Ifq<Ex{*7`05XF7hP+2Hl!3BQJ=6@fL%FCo z8iYoo3(#bAF`ADSpqtQgv>H8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ<AYmRsNLWl*PS{AOARHt#5!wki2?K;t z!Y3k=s7tgax)J%r7-BLphge7~Bi0g+6E6^Zh(p9TBoc{3GAFr^0!gu?RMHaCM$&Fl zBk3%un>0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 z<uv66WtcKSRim0x-Ke2d5jBrmLam{;Qm;{ms1r1GnmNsb7D-E`t)i9F8fX`2_i3-_ zbh;7Ul^#x)&{xvS=|||7=mYe33=M`AgU5(xC>fg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vF<Q0r40Q)j6=sE4X&sBct1q<&fbi3VB2Ov6t@q*0);U*o*SAPZv|vv@2aYYnT0 zb%8a+Cb7-ge0D0knEf5Qi#@8Tp*ce{N;6lpQuCB%KL_KOarm5cP6_8Ir<e17iry6O zDdH&`rZh~sF=bq9s+O0QSgS~@QL9Jmy*94xr=6y~MY~!1fet~(N+(<=M`w@D1)b+p z*;C!83a1uLJv#NSE~;y#8=<>IcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a<fJbF^|4I#xQ~n$Dc= zKYhjYmgz5NSkDm8*fZm{6U!;YX`NG>(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-k<Mujg;0Lz*3buG=3$G&ehepthlN*$KaOySSQ^nWmo<0M+(UEUMEXRQ zMBbZcF;6+KElM>iKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BK<z=<L*0kfKU@CX*zeqbYQT4(^U>T#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot<a{81DF0~rvGr5Xr~8u`lav1h z1DNytV>2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z000a@Nkl<Zc-rKddyHJweaAoNKIYDIXLo1T3+o4z$6~YC*v2;A@~G1oqfp$4{DG>7 zszFp$iI7Sv74;DsDWOf<sA^C}h?E$lq=u02M~I9YFtpiVjBFghae37Sd+qh^cwe(K zJ2Q9g>-_r1+%;oo*TyV=RPB+D?mX`K+<U&~{C>Z4en*AwFPNtoKo&>=>G+xe8h{(u z8qnmzMa6CHwrT=FTlInPLLf8wJ6vq>eV_lW#aiI^L@am#C<0xvXjPyLOvhsRfWDAs zaoaSIiLYinaE-WXAOeK{N5wdg_+4QAmZ;8~lzaLC^Yn910|USnKyNIh^)a4dwgxhN ziKt5lM~;>L***UJo50d|U~}>Fj0;&%)Er7i&7q}HbKj=0dFYR(Km7jTC!Tmh`@Vmr z@B6gZs5wN`9KzJEVd~e;$ISvaJp1gk&+pi=LoZU#i((nEfXR49W`Rzi2Uw0uu0wTh zr#W`3gmp`Kr*8+YjR&AFwup{47nO{KbmD6=9;8lDnE_Pp4+^QN&t&Y1Q93z#AFwPQ zfR4Dm^HE#nw6)L1_Y{C7f_)7t^|`bfU)j~wrCxsd<ws)cTBw}wzsrF??&#?FY_V8W zJ9qB<)*{5eD3*1OJ4nU#65tw03@VjeMe}$<bO4PLn{j&G_})Z(tpFY<KaK?vh{V3$ zZi%M)z}h5YZcOQ;3DE&rb7C9x9gM}cVqV-{Ny`N(1u_>elC8m3OM$gIY}{!3?^yZ# zW`ZEt3gm%FfR|o+iF&=x7q%~%>t`~KE%Qyjm5oN@v!-b}g+c+_w(kc1;uCt2i(*-^ z*%!{K%fPq>We%udtV8^X*4^%Kp0178#%;1dH*h+R0Tp1j)k!5FPCPgTFt1WdtrLH= zNHC57DdWQ;T)(=_YO*oIjpKm^Wwrj3Ts-r8L0zFx>!kMfQqdl{T#jnBT8u4da%gDi zOy{2bw<Fwp&((9{&(72T4&b`c(a{yTTn@)^&{{A5gtPvlSw=kT3$e+p0@ec?QP%aS zM6Xo#8=^f#dx%$ppxoE83f2M}fYqS-V;^1s)fvZxrNC9d0LtEoGW%8afHlW5{8P$r zly3pn0N2IL6`*=Sb%B}_ym%H1Za^8AtNPoPL>}pMnrt@9!Gj0CHlKTW{QHOJ#GkLM z0IqjkH<8U|NvG4~ayhkU&z?`>dM=u!g66w-$mFj3l{dBl!%m=V2g9;4k`_izN%dEX zVB(_&*;N`VCm@0j10?X#o+pu8H<sW3?eXD*pKHzxZ%sIs<s>X@+rYLhOe=#?J3u=t zO0Pv5IkX5tAozZS>v~93JHC9~m%dW^VBeRzf}elhqOssYXe9eMzVB13)o@)G&+`a^ zfO@@7rBcCl-IMqK$rt|WXV34yeQa#(o_s#9a=9GIWD?u9aU6$CCPO-%#x%|IvZ~eU z@dqAwVEe9JyB_)!SjSZ0oq7-wzo$<<daG3TCq9wE*<IQwY<<z_+WL;BV}mX|xt{W# z>?c_{M%EJ7Ub5|9RL9_lqB^$epg9-~|NSzf`L3Gp{!;f3&{`9QA-?a^Y&NOYYE&u} zdU|?JY}l~j@08Ro7nL1oG#WST-MhEXvMiO)=SihfIF5sD+nA<_Qffg~DwR64Wy_ZP z;>E^2p91S{3#1~Lma*(1m-?^maQG2LRR8!<rfZ8fmfZQ4vg`v8&4N)jFa6UR`I}SW z@voaiwU5nGUE6Aj-aDUDrg2E{XOZY#!_Hi3_Wx5MI{njhH2T<YKHo4fjMgeOiXy_W zwO1P)96Zt4+4(H+9)vT1PN&mXZQHi3c=YH|r&KDDOeV1`3(K<3FwSK~Q6%fvuiw|( z+xxFTDc+A9{uEf#=HGtxK9n*HP?wNc+GQ4h-xl{6+Wi2LaN%k=Sj)d0nLW3EsElwQ z;=U;aN0hQg!L&*A70u%IjJU_tg;`0g6%Ulno~<t`<?lgi?<<;P5FS@B4N=y}uIe`O zH#@p^SZV+CnYM23i--te7&1FMODdHL*R5Ok%VaY7J>W;c+orr-64mzxFb;HdcXxN^ zayh45E-R(fS%S5*Ow)8XZru1-Hk*AE*b7X>jQ2hT){ObJzqwm0aVH_Ff$mmDqTB4g z-4@X*s_dUvEbIbK|JOq!(>IQ;z6H%+fxNE>$CNZiKu!Xp47<nd-j);5#=@*3PX9Mb zM*7nI=$Ti*eGjArGOO@vislp&Rw2w8iTn~&(ut=310)0qaXbDH5g`Zyn$0HHU3c9x zz~jIRz!(r3GOR=`p8`GrjsZ?8m0B7_QKr#o;5g1%f(1c9qtTGHYu7#q>;&FzOQlG> za#}RYYNZUCBTD@JP|Kq8DGWV;;n)(*pSlru7*x|k>rv3dz({P86A++eJb+mP-JQYE zcZ!khT9|d5FsjekI(#4WXxp;2-dg8-N)h~8B<vL}D}wT?9lDLxXf!ZQ6RkC#=W+P( z;mOsjSO2orp1IxaT!Sj`O1%16IXOAmV;BZu7!rmdTI(~cAPCg%-MfQ3?zrRF0$y^_ zEF;d()Im=pjSms;q=@$Y(ofJ)bHN&nzi5D-j!khUewjK*4Rj5umyn<&B7RW%@p)N| z(ohlfRLp3!Hf{JIe(SStv}T(bMw5Gx$U`CzltaB<XL@=X*L4AyoSgjpJZX=pJ=Zq3 z@hX?gXWT-$T>cG~*80ybl($<9%ZQU&VQX__`lytto1=DAJ#?m%R`E_!D<1>pocS%B z+MkGLXd`}!DBeZJNiqjf>AY7vl|P%cT~aH*a~4a_ywMe#vKMpo*$F!vR609*>ctg0 zm}>14(pQsO{X@NG&6;{Pn`LTh3eWTKJZ~;#u~10^D~}#MdUX&4)M_=V)ha7itf;M7 zv&PTo^JiGV$_t3UDAw7@ER{-#O67xz7i`fU7zt9>d{d|TANH-nEm8Kyy;$8_5bY6G z-ZHg2s$-Lm;&j3po~X`FQOV`u<V#MA<&awWtxBr@;Zs)Omf7r$d$ra5yNLEMYp<H6 zk&{c?22ZtBL}gQu=?ka+Gb7r^>bqAmH|#oPW|qAL{ItKn|M<YbKoA50v$M0t(9qC5 zaYC;>tvy$k0qaIaMv}E!4coT8n{U4PiDI$%ZQzwFue@?*_3G8y^E`1~*W9sV#~05d z{-Rh$`&GzyiKM#x(lbgWFC%^Z52vi+<~Jo8e_f*KBN$dSHSlEQT=o?$7&K2jH4wXs z;M|T1xl545l5qS-isThhdhOG%TZK(KB^rN9qVjGG`)F!lN5i@NFPj=$S5CZq=SR7n zb6rLzBPv;t>H#xx?bFrNRS)k&&pd^m_#W_&z;}9jdJYT@4vutocGgEmM(&N%74wxV zfaSx(!^WyrtB!5ny!j#EAAm=JZvsCq6bi?0y6L7d%d*CO-~a3Lh`%V-oNv@%`KWY4 z{7KWyt@lzZzww6BQ-{&zlMvOx&!QO+#rni$4|b@`6;=P_cg<FirQRl*fTK~49k`Q$ z)j2qp?*Hm8r6&)dr$!+1z)z#q8=}-_5-YyEROMEU7e4rIHFiDiX9NP4796ipIa$lB zf8k{(v*ZZ6vJaU!0&We(yYV}GkWQy>z3sN!l7|i*nu-HqWgbg6r>CdO+qZ9jrn|d) zC-7<<<cS05Dd0#toxb6&yY4!^d-v{l&Xby16ss*T*-a5OsuIPa)HRR%96kA8NaZv{ zRlq|U5vcv34nlghUD(>RyEp7Z#!KgjW(Zl0aMG9b3v%h7SJ0COka8KKCJ-WZ4eBVU zw;{d8?)+^%mCK(fwyel&J6I7>*$C9>x}W}cD>4#Jy7In~@o{LnKxpdMqR1FL4ZH~a zH?TxRl0c&^k1uWg9iEt&c&<{ZjBVPq=@;jm#`!Vh2f*t<YI=G)eV(M`qF5?EMzkQA z7FFmI%%Wn}RZ^af1F_fUR!m6cMCE%xWxyR*GBXjoi@CsysNSTIEdUuswV`C95epi& zS%?8CM^v#3%q+OmN>0zT9W6#J)+Grcl?H7qnT-_H0K%{>Fw5L56je+@CIe`sI;$w3 z3E+`2C~?|)&K=d-Rx1k~jAUY~t}Srjv?$h`_;bNh#eu5ry8<K*SW4O^l5XpK`)7%a s!S>**FK|3)w5_`I0%ySgCt3e90FEk!*k~avwg3PC07*qoM6N<$f*aXzMgRZ+ literal 0 HcmV?d00001 diff --git a/data/images/ratings/6.png b/data/images/ratings/6.png new file mode 100644 index 0000000000000000000000000000000000000000..e39d578b96b2ee545a805d751327278953d37ab5 GIT binary patch literal 5887 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 VhoCCfQ9S?v002ovPDHLkV1mgzVEX_7 literal 0 HcmV?d00001 diff --git a/data/images/ratings/7.png b/data/images/ratings/7.png new file mode 100644 index 0000000000000000000000000000000000000000..af0568535e42a4cc8bc61eac790c643a1e847057 GIT binary patch literal 6321 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 literal 0 HcmV?d00001 diff --git a/data/images/ratings/8.png b/data/images/ratings/8.png new file mode 100644 index 0000000000000000000000000000000000000000..82b8b52f9aae1d0db2d125c768a4fd8f8531d634 GIT binary patch literal 6174 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 literal 0 HcmV?d00001 diff --git a/data/images/ratings/9.png b/data/images/ratings/9.png new file mode 100644 index 0000000000000000000000000000000000000000..d91d2bcafdfe7903c418457facbf46ef6b9b505a GIT binary patch literal 6110 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 literal 0 HcmV?d00001 diff --git a/data/images/search32.png b/data/images/search32.png new file mode 100644 index 0000000000000000000000000000000000000000..38046cd6798de09edf78005c05183b05e4b7a7aa GIT binary patch literal 321 zcmV-H0lxl;P)<h;3K|Lk000e1NJLTq000;O000;W1^@s6;CDUv0000PbVXQnQ*UN; zcVTj606}DLVr3vnZDD6+Qe|Oed2z{QJOBUy<4Ht8RCwBAV4yKz#3LsU#0pUM10a4# zEld4@_&gB*N5l7_auk92SPVwT^T{<lAI+fmK)fD`-=oRZlWI6Wnj!O{V#xC7zLO`# zf_Nm2=kci9kEAZ27=xLhv;vaY1U%}I<qSx%02w^Mqy7UDTY+2)@VN}x&<mudedN@` zk0T-6M^YD0YD&6~7SZ-da`tGM?>#yBz#dDcdXFU!{D=B@J~^2#9*2R*7RICH36d=V zWuShvAiEDW2qs6h;sYDd$KlISO9r1M`Lwj;JdV<UDwfd1aO6BhGsCe0fB*vk+_iwo Tq|*0Q00000NkvXXu0mjf_@Id# literal 0 HcmV?d00001 diff --git a/data/images/subtitles/itasa.png b/data/images/subtitles/itasa.png new file mode 100644 index 0000000000000000000000000000000000000000..908a52adae06ff1de52837cdd35b7942a7ec43ce GIT binary patch literal 1150 zcmZ8feQXnD7=MY5v1YmtK{kTS3B-cgL^OfmLZcawm@F93kfG=w&W~v3AB`h1I)*S7 zu}mG9m;q{L7-fNlD235|(76x0t(2zDdDPl<-FfS}?so09Z}0o+`$(EJyUXvM_kEt< z@Atg-+#$4r+=YdRJfB1#JdDue2q7Z@Ns-KhJP8Pq+VU%%PNNJoO?hZBIB4?z`u_Pl zXN|twr%M(FzTGtKzV?PSeE*mInkwaFxXYuMjI8lqf4k#$OMdFs$((f0DTCN|revYN zbv<MMvXJj-UM0J%HMUeNv|+hc7Px}D)_wNgmX+FI+xqa|j>c25IoEc9XG*~rv;K{Y z_vg39J9dvae|W(y#OZa}9)3qd>m5sucCTwse~Jr~Lrs-&UYv36WP`nXlhMg%bX`NL zBIJg~&zPrdM=p{53gBUuGLC-t2>#>Ro<3Dd<O4#^<4t4jrIGOqPteq@H!lT-j(#;e z-tuXj8QrCm8AYhehq+K+HR$Py<4t#eDvhL;e47=4ExK>3u}z#W;OU7I$7F#m2ENFL zHjwieG}0gXLXQ2X`vB;Dw(355?5G9KM?1}5-}^BiFZw$VLrvs^WsK|d=48~p9W<(( zFyIB(^8!a1q4vN2`c?`rICn~XpnU3b5lfHMT>vjR6)u^!SNBVVCva0+6_G9CrmeLy z4zC3-IZ3Zmgy`y--l`#u_;UXCcYR&?SXlB_f_~oh;rHxc7Bg^3tu7wytd$cFdO}}- zz`CDX^wgi#R3Vq0{H%GlXR}{Z5(Z6<KN`AOHY0N0N;wgDit||-V*a*zRg6BC(L%&? zH(yE8)<c?t4Kb?m#Qe~UBal~cux$259##dW6xOW!NC#%R4+s(azKmWcJu!3jpeC`! zz)LZmwb9{<NnMk2R57w<zPBJI$34%&ycfqzgA)7O?u7qjBc5sdSO{NzEjev5Yf^M| z#!nN&Yq{SmCe>7+96Vw!lLH67I0kiz9e8<ZbPFX#x?fdzZ>19VY*P~63VCU`Dx-%e z(&y^M*;X^GW!AozcWw-*m@>j%iH+_`{(0z1!t>Gqrrx<I&3C;Hv(45+Ux~JSq9#1s z;2TJD?RD6*V@!=XOr(|zTwTR^@J`vf?6t0w|0F(KrORwF<TX6CMgI46gFJWcBb9gV zAU-Rz`JxG-nF54{!w7w&Aas%JZ=?}=x(T88NUSHQA#ghoVgW&fbQ+;_6G0lG{{Sg# B>Z1Sv literal 0 HcmV?d00001 diff --git a/data/images/tablesorter/asc.gif b/data/images/tablesorter/asc.gif new file mode 100644 index 0000000000000000000000000000000000000000..fa3fe40113a4482d2d3ff9b39c9056067477999e GIT binary patch literal 54 zcmZ?wbhEHb6lGvxXkcXc|NlP&1B2pE7DfgJMg|=qn*k)lz{J}lzkvJ2`CGHoFJ`Og HGFSruQVtDe literal 0 HcmV?d00001 diff --git a/data/images/tablesorter/bg.gif b/data/images/tablesorter/bg.gif new file mode 100644 index 0000000000000000000000000000000000000000..40c6a65aa2862b0939e2707b4d9c1174225f21c1 GIT binary patch literal 64 zcmZ?wbhEHb6lLIKXkcXc|NlP&1B2pE7DfgJMg|=qn*k)lz$D(&-*B4qR#HWXQP<jg QY~gmP3Ez7<Lm3#X0rl7sf&c&j literal 0 HcmV?d00001 diff --git a/data/images/tablesorter/desc.gif b/data/images/tablesorter/desc.gif new file mode 100644 index 0000000000000000000000000000000000000000..88c6971d61ea3eecd468cd38c296346ef8b21bd1 GIT binary patch literal 54 zcmZ?wbhEHb6lGvxXkcXc|NlP&1B2pE7DfgJMg|=qn*k)lz{K0r&v<g}hB?h4MqQDN G4AuZo0t~tU literal 0 HcmV?d00001 diff --git a/data/images/xbmc-notify.png b/data/images/xbmc-notify.png new file mode 100644 index 0000000000000000000000000000000000000000..a657e4f8fce5ef385e2ffa247737a2cdc2f77c9d GIT binary patch literal 8616 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 literal 0 HcmV?d00001 diff --git a/data/interfaces/default/apiBuilder.tmpl b/data/interfaces/default/apiBuilder.tmpl index 1234074cb6..6b1bc5a6fd 100644 --- a/data/interfaces/default/apiBuilder.tmpl +++ b/data/interfaces/default/apiBuilder.tmpl @@ -1,28 +1,25 @@ -<!DOCTYPE HTML> -<html> - <head> - <meta charset="utf-8"> - <title>Sick Beard - API Builder - - - - - - - - - + + + +API Builder + + + + + - -
    - Key: - Past - Current - Future - Distant - -
    - -#if $layout == 'list': - - - - - #set $show_div = "listing-default" - - - -
    - - - - - #for $cur_result in $sql_results: - - #if int($cur_result["paused"]) and not $sickbeard.COMING_EPS_DISPLAY_PAUSED: - #continue - #end if - - #set $cur_ep_airdate = int($cur_result["airdate"]) - #if $cur_ep_airdate < $today: - #set $show_div = "listing-overdue" - #elif $cur_ep_airdate >= $next_week: - #set $show_div = "listing-toofar" - #elif $cur_ep_airdate >= $today and $cur_ep_airdate < $next_week: - #if $cur_ep_airdate == $today: - #set $show_div = "listing-current" - #else: - #set $show_div = "listing-default" - #end if - #end if - - - - - - - - - - - - - - #end for - -
    AirdateShowNext EpNext Ep NameNetworkQualitytvDBSearch
    $datetime.date.fromordinal(int($cur_result["airdate"]))$cur_result["show_name"] - #if int($cur_result["paused"]): - [paused] - #end if - <%="%02ix%02i" % (int(cur_result["season"]), int(cur_result["episode"])) %> - #if $cur_result["description"] != "" and $cur_result["description"] != None: - " /> - #end if - $cur_result["name"] - $cur_result["network"] -#if int($cur_result["quality"]) in $qualityPresets: - $qualityPresetStrings[int($cur_result["quality"])] -#else: - Custom -#end if - [info] - [search] -
    - -#else: - - - - #set $cur_segment = None - #set $too_late_header = False - #set $missed_header = False - #set $show_div = "epListing listing-default" - - #if $sort == "show": -

    - #end if - - #for $cur_result in $sql_results: - - - #if int($cur_result["paused"]) and not $sickbeard.COMING_EPS_DISPLAY_PAUSED: - #continue - #end if - - #if $sort == "network": - #if $cur_result["network"] and $cur_segment != $cur_result["network"]: -

    $cur_result["network"]

    - #set $cur_ep_airdate = int($cur_result["airdate"]) - #if $cur_ep_airdate < $today: - #set $show_div = "epListing listing-overdue" - #elif $cur_ep_airdate >= $next_week: - #set $show_div = "epListing listing-toofar" - #elif $cur_ep_airdate >= $today and $cur_ep_airdate < $next_week: - #if $cur_ep_airdate == $today: - #set $show_div = "epListing listing-current" - #else: - #set $show_div = "epListing listing-default" - #end if - #end if - #set $cur_segment = $cur_result["network"] - #end if - #elif $sort == "date": - #set $cur_ep_airdate = int($cur_result["airdate"]) - #if $cur_segment != $cur_ep_airdate: - #if $cur_ep_airdate < $today and not $missed_header: -

    Missed

    - #set $missed_header = True - #set $show_div = "epListing listing-overdue" - #elif $cur_ep_airdate >= $next_week and not $too_late_header: -

    Later

    - #set $show_div = "epListing listing-toofar" - #set $too_late_header = True - #elif $cur_ep_airdate >= $today and $cur_ep_airdate < $next_week: - #if $cur_ep_airdate == $today: -

    $datetime.date.fromordinal($cur_ep_airdate).strftime("%A").decode($sickbeard.SYS_ENCODING) [today]

    - #set $show_div = "epListing listing-current" - #else: -

    $datetime.date.fromordinal($cur_ep_airdate).strftime("%A").decode($sickbeard.SYS_ENCODING)

    - #set $show_div = "epListing listing-default" - #end if - #end if - #set $cur_segment = $cur_ep_airdate - #end if - #end if - -
    -
    - - - - - - -#if $layout == 'banner': - - -#end if - - - - - -
    - $cur_result["show_name"] - #if int($cur_result["paused"]): - [paused] - #end if - - - [tvdb] - [search] - -
    - -
    - Next Episode: <%=str(cur_result["season"])+"x"+"%02i" % int(cur_result["episode"]) %> - $cur_result["name"] ($datetime.date.fromordinal(int($cur_result["airdate"]))) -
    - Airs: $cur_result["airs"] on $cur_result["network"] -#if int($cur_result["quality"]) in $qualityPresets: - [$qualityPresetStrings[int($cur_result["quality"])]]
    -#else: - [Custom]
    -#end if - #if $cur_result["description"] == '': -
    [There is no summary added for this episode]
    - #else: -
    $cur_result["description"]
    - #end if -
    -
    -
    -
    - - - #end for - -
    - - -#end if - - - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#import datetime +#from sickbeard.common import * +#set global $title="" +#set global $header="Coming Episodes" + +#set global $sbPath=".." + +#set global $topmenu="comingEpisodes" +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#set $sort = $sickbeard.COMING_EPS_SORT + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + + + +
    + Key: + Missed + Current + Future + Distant + + Subscribe + +
    + +#if $layout == 'list': + + + + + #set $show_div = "listing_default" + + + + + + + + #for $cur_result in $sql_results: + + #if int($cur_result["paused"]) and not $sickbeard.COMING_EPS_DISPLAY_PAUSED: + #continue + #end if + + #set $cur_ep_airdate = $cur_result["localtime"].date() + #set $cur_ep_enddate = $cur_result["localtime"] + datetime.timedelta(minutes=$cur_result["runtime"]) + #if $cur_ep_enddate < $today: + #set $show_div = "listing_overdue" + #elif $cur_ep_airdate >= $next_week.date(): + #set $show_div = "listing_toofar" + #elif $cur_ep_airdate >= $today.date() and $cur_ep_airdate < $next_week.date(): + #if $cur_ep_airdate == $today.date(): + #set $show_div = "listing_current" + #else: + #set $show_div = "listing_default" + #end if + #end if + + + + + + + + + + + + + + + #end for + +
    AirdateShowNext EpNext Ep NameNetworkQualityIMDbtvDBSearch
    $cur_result["localtime"].strftime('%c').decode($sickbeard.SYS_ENCODING)$time.mktime($cur_result["localtime"].timetuple())$cur_result["show_name"] + #if int($cur_result["paused"]): + [paused] + #end if + <%="%02ix%02i" % (int(cur_result["season"]), int(cur_result["episode"])) %> + #if $cur_result["description"] != "" and $cur_result["description"] != None: + " style="padding-top: 6px;"/> + #end if + $cur_result["name"] + $cur_result["network"] +#if int($cur_result["quality"]) in $qualityPresets: + $qualityPresetStrings[int($cur_result["quality"])] +#else: + Custom +#end if + + #if $cur_result["imdb_id"]: + [imdb] + #end if + [tvdb] + [search] +
    + +#else: + + + + #set $cur_segment = None + #set $too_late_header = False + #set $missed_header = False + #set $today_header = False + #set $show_div = "ep_listing listing_default" + + #if $sort == "show": +

    + #end if + + #for $cur_result in $sql_results: + + + #if int($cur_result["paused"]) and not $sickbeard.COMING_EPS_DISPLAY_PAUSED: + #continue + #end if + + #if $sort == "network": + #if $cur_result["network"] and $cur_segment != $cur_result["network"]: +

    $cur_result["network"]

    + #set $cur_segment = $cur_result["network"] + #end if + #set $cur_ep_airdate = $cur_result["localtime"].date() + #set $cur_ep_enddate = $cur_result["localtime"] + datetime.timedelta(minutes=$cur_result["runtime"]) + #if $cur_ep_enddate < $today: + #set $show_div = "ep_listing listing_overdue" + #elif $cur_ep_airdate >= $next_week.date(): + #set $show_div = "ep_listing listing_toofar" + #elif $cur_ep_enddate >= $today and $cur_ep_airdate < $next_week.date(): + #if $cur_ep_airdate == $today.date(): + #set $show_div = "ep_listing listing_current" + #else: + #set $show_div = "ep_listing listing_default" + #end if + #end if + #elif $sort == "date": + #set $cur_ep_airdate = $cur_result["localtime"].date() + #set $cur_ep_enddate = $cur_result["localtime"] + datetime.timedelta(minutes=$cur_result["runtime"]) + #if $cur_segment != $cur_ep_airdate: + #if $cur_ep_enddate < $today and $cur_ep_airdate != $today.date() and not $missed_header: +

    Missed

    + #set $missed_header = True + #elif $cur_ep_airdate >= $next_week.date() and not $too_late_header: +

    Later

    + #set $too_late_header = True + #elif $cur_ep_enddate >= $today and $cur_ep_airdate < $next_week.date(): + #if $cur_ep_airdate == $today.date(): +

    $datetime.date.fromordinal($cur_ep_airdate.toordinal).strftime("%A").decode($sickbeard.SYS_ENCODING).capitalize() [today]

    + #set $today_header = True + #else: +

    $datetime.date.fromordinal($cur_ep_airdate.toordinal).strftime("%A").decode($sickbeard.SYS_ENCODING).capitalize()

    + #end if + #end if + #set $cur_segment = $cur_ep_airdate + #end if + #if $cur_ep_airdate == $today.date() and not $today_header: +

    $datetime.date.fromordinal($cur_ep_airdate.toordinal).strftime("%A").decode($sickbeard.SYS_ENCODING).capitalize() [today]

    + #set $today_header = True + #end if + #if $cur_ep_enddate < $today: + #set $show_div = "ep_listing listing_overdue" + #elif $cur_ep_airdate >= $next_week.date(): + #set $show_div = "ep_listing listing_toofar" + #elif $cur_ep_enddate >= $today and $cur_ep_airdate < $next_week.date(): + #if $cur_ep_airdate == $today.date(): + #set $show_div = "ep_listing listing_current" + #else: + #set $show_div = "ep_listing listing_default" + #end if + #end if + #elif $sort == "show": + #set $cur_ep_airdate = $cur_result["localtime"].date() + #set $cur_ep_enddate = $cur_result["localtime"] + datetime.timedelta(minutes=$cur_result["runtime"]) + #if $cur_ep_enddate < $today: + #set $show_div = "ep_listing listing_overdue" + #elif $cur_ep_airdate >= $next_week.date(): + #set $show_div = "ep_listing listing_toofar" + #elif $cur_ep_enddate >= $today and $cur_ep_airdate < $next_week.date(): + #if $cur_ep_airdate == $today.date(): + #set $show_div = "ep_listing listing_current" + #else: + #set $show_div = "ep_listing listing_default" + #end if + #end if + #end if + +
    +
    + + + +#if $layout == 'banner': + + +#end if + + + + + +
    + +
    + + Next Episode: <%=str(cur_result["season"])+"x"+"%02i" % int(cur_result["episode"]) %> - $cur_result["name"] ($datetime.date.fromordinal(int($cur_result["airdate"]))) +
    + Airs: $cur_result["localtime_string"].decode($sickbeard.SYS_ENCODING) on $cur_result["network"] +
    +
    + Quality: + #if int($cur_result["quality"]) in $qualityPresets: + $qualityPresetStrings[int($cur_result["quality"])] + #else: + Custom + #end if +
    +
    +
    + #if $cur_result["description"] == '': + Plot: +
    [There is no summary added for this episode]
    + #else: + Plot: +
    $cur_result["description"]
    + #end if +
    +
    +
    +
    + + + #end for + + + +#end if + + + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config.tmpl b/data/interfaces/default/config.tmpl index c88f3fe588..c03cebfb7c 100644 --- a/data/interfaces/default/config.tmpl +++ b/data/interfaces/default/config.tmpl @@ -2,47 +2,34 @@ #from sickbeard import db #import os.path #set global $title="Configuration" +#set global $header="Configuration" #set global $sbPath=".." #set global $topmenu="config"# #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if
    - - - - - - - - - - - - - - - - -
    SB Version: - ($sickbeard.version.SICKBEARD_VERSION) -- $sickbeard.versionCheckScheduler.action.install_type : - #if $sickbeard.versionCheckScheduler.action.install_type != 'win' and not $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash: - $sickbeard.versionCheckScheduler.action.updater._find_installed_version() - #end if - #if $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash: - $sickbeard.versionCheckScheduler.action.updater._cur_commit_hash - #end if -
    SB Config file: $sickbeard.CONFIG_FILE
    SB Database file: $db.dbFilename()
    SB Cache Dir: $sickbeard.CACHE_DIR
    SB Arguments: $sickbeard.MY_ARGS
    SB Web Root: $sickbeard.WEB_ROOT
    Python Version: $sys.version[:120]
    Homepage http://www.sickbeard.com/
    Forums http://sickbeard.com/forums/
    Source https://github.com/sarakha63/Sick-Beard/
    Bug Tracker &
    Windows Builds
    http://code.google.com/p/sickbeard/
    Internet Relay Chat #sickbeard on irc.freenode.net
    + + + + + + + + + + + + + +
    SB Version: ($sickbeard.version.SICKBEARD_VERSION) +
    SB Config file: $sickbeard.CONFIG_FILE
    SB Database file: $db.dbFilename()
    SB Cache Dir: $sickbeard.CACHE_DIR
    SB Arguments: $sickbeard.MY_ARGS
    SB Web Root: $sickbeard.WEB_ROOT
    Python Version: $sys.version[:120]
    Homepage http://www.sickbeard.com/
    Forums http://sickbeard.com/forums/
    Source https://github.com/sarakha63/Sick-Beard/
    Bug Tracker &
    Windows Builds
    http://code.google.com/p/sickbeard/
    Internet Relay Chat #sickbeard on irc.freenode.net
    -
    - - - - - -
    [donate]Sick Beard VO/VF is free, but you can contribute by giving a donation.
    -
    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_general.tmpl b/data/interfaces/default/config_general.tmpl index 5ab25dbab5..a6b5e8bf8f 100644 --- a/data/interfaces/default/config_general.tmpl +++ b/data/interfaces/default/config_general.tmpl @@ -11,21 +11,30 @@ #set global $topmenu="config"# #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if
    -
    All non-absolute folder locations are relative to $sickbeard.DATA_DIR
    -
    - + + +
    - +

    Misc

    +

    Startup options.

    Some options may require a manual restart to take effect.

    @@ -37,6 +46,14 @@ Should Sick Beard open its home page when started?
    + +
    + + +
    @@ -51,10 +68,10 @@
    - -
    @@ -186,7 +203,7 @@
    - -
    - -

    - +
    + All non-absolute folder locations are relative to $sickbeard.DATA_DIR + +
    - +
    +
    + -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_notifications.tmpl b/data/interfaces/default/config_notifications.tmpl index 54882b1a38..895c48d48d 100755 --- a/data/interfaces/default/config_notifications.tmpl +++ b/data/interfaces/default/config_notifications.tmpl @@ -9,23 +9,27 @@ - +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if
    -
    - - -
    -

    Home Theater

    -
    - +
    + + +
    +
    - -

    XBMC

    +

    XBMC

    A free and open source cross-platform media center and home entertainment system software with a 10-foot user interface designed for the living-room TV.

    -

    For SickBeard to communicate with XBMC you must turn on the XBMC Webserver.

    @@ -62,23 +66,23 @@
    -
    +
    +
    Click below to test.
    - - + +
    +
    - -

    Plex Media Server

    +

    Plex Media Server

    Experience your media on a visually stunning, easy to use interface on your Mac connected to your TV. Your media library has never looked this good!

    @@ -185,10 +193,6 @@   Host running Plex Client (eg. 192.168.1.100:3000) -
    Click below to test.
    - - + +
    @@ -221,8 +225,7 @@
    - -

    NMJ

    +

    NMJ

    The Networked Media Jukebox, or NMJ, is the official media jukebox interface made available for the Popcorn Hour 200-series.

    @@ -248,7 +251,7 @@
    Click below to test.
    - - + +
    -
    +
    - -

    NMJv2

    -

    The Networked Media Jukebox, or NMJv2, is the official media jukebox interface made available for the Popcorn Hour 300 & 400-series.

    +

    NMJv2

    +

    The Networked Media Jukebox, or NMJv2, is the official media jukebox interface made available for the Popcorn Hour 300 & 400-series.

    @@ -309,34 +311,35 @@ IP of Popcorn 300/400-series (eg. 192.168.1.100)
    -
    +
    -
    - +
    - -

    Synology Indexer

    +

    Synology Indexer

    Synology Indexer is the daemon running on the Synology NAS to build its media database.

    - +
    - -
    + +
    + + +
    +
    +

    Synology Notifier

    +

    Synology Notifier is the notification system of Synology DSM

    +
    - - +
    +
    + + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    - -

    pyTivo

    +

    pyTivo

    pyTivo is both an HMO and GoBack server. This notifier will load the completed downloads to your Tivo.

    - +
    - +
    -
    - -

    - -
    -

    Devices

    -
    +
    - - - -
    + +
    +
    - -

    Growl

    +

    Growl

    A cross-platform unobtrusive global notification system.

    - +
    @@ -540,13 +577,12 @@
    - Prowl -

    Prowl

    +

    Prowl Prowl

    A Growl client for iOS.

    - +
    @@ -612,8 +648,7 @@
    - -

    Notifo

    +

    Notifo

    A platform for push-notifications to either mobile or desktop clients

    @@ -664,12 +699,12 @@
    Click below to test.
    - - + +
    @@ -678,13 +713,12 @@
    - -

    Libnotify

    +

    Libnotify

    The standard desktop notification API for Linux/*nix systems. This notifier will only function if the pynotify module is installed (Ubuntu/Debian package python-notify).

    - +
    @@ -724,8 +758,7 @@
    - -

    Pushover

    +

    Pushover

    Pushover makes it easy to send real-time notifications to your Android and iOS devices.

    @@ -761,7 +794,7 @@
    Click below to test.
    - - + +
    @@ -779,8 +812,7 @@
    - -

    Boxcar

    +

    Boxcar

    Read your messages where and when you want them! A subscription will be send if needed.

    @@ -825,8 +857,8 @@
    Click below to test.
    - - + +
    @@ -835,13 +867,12 @@
    - -

    Notify My Android

    +

    Notify My Android

    Notify My Android is a Prowl-like Android App and API that offers an easy way to send notifications from your application directly to your Android device.

    - +
    -
    - -

    - -
    -

    Online

    -
    - - - - - - -
    - -

    Twitter

    +

    Pushalot

    +

    Pushalot is a platform for receiving custom push notifications to connected devices running Windows Phone or Windows 8.

    +
    +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    Click below to test.
    + + +
    + +
    +
    + +
    + +
    +
    +
    +

    Twitter

    A social networking and microblogging service, enabling its users to send and read other users' messages called tweets.

    @@ -939,7 +1012,7 @@
    - +
    @@ -973,9 +1046,11 @@ Step Two +
    @@ -984,8 +1059,8 @@
    Click below to test.
    - - + +
    @@ -1047,29 +1122,29 @@
    -
    - - -
    -
    - - -
    - +
    + + +
    +
    + + +
    +
    Click below to test.
    @@ -1079,8 +1154,7 @@
    - -
    +

    Mail

    @@ -1096,7 +1170,7 @@
    -
    +
    -
    +
    -
    +
    -
    +
    -
    -
    +
    +
    - - -
    - -

    - -
    + +

    +
    +
    -
    -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +
    + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_postProcessing.tmpl b/data/interfaces/default/config_postProcessing.tmpl index 787c8b4959..603e71faa0 100644 --- a/data/interfaces/default/config_postProcessing.tmpl +++ b/data/interfaces/default/config_postProcessing.tmpl @@ -16,20 +16,28 @@ - +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if
    -
    All non-absolute folder locations are relative to $sickbeard.DATA_DIR
    -
    +
    - -
    + + +
    -

    NZB Post-Processing

    -

    Settings that dictate how Sick Beard should process completed NZB downloads.

    +

    Post-Processing

    +

    Settings that dictate how Sick Beard should process completed downloads.

    @@ -52,6 +60,35 @@
    + +
    + + +
    + +
    + + + +
    + +
    + + +
    +
    @@ -69,10 +105,7 @@
    -
    - -
    - +

    Torrent Post-Processing

    Settings that dictate how Sick Beard should process completed torrent downloads.

    @@ -109,51 +142,80 @@
    +
    + +
    -

    Common Post-Processing options

    -

    Settings that dictate how Sick Beard should process completed downloads.

    +

    Metadata

    +

    The data associated to the data. These are files associated to a TV show in the form of images and text that, when supported, will enhance the viewing experience.

    -
    - -
    -
    - - - +
    +
    Create:
    +
    Results:
    +
    -
    - -
    -
    - -
    +
    + +
    +
    -

    Naming

    +

    Episode Naming

    How Sick Beard will name and sort your episodes.

    @@ -185,8 +247,8 @@   - - [Toggle Key] + + [Toggle Key]
    @@ -281,12 +343,17 @@ %RG RLSGROUP + + Release Type: + %RT + PROPER +
    - +
    -

    Sample:

    +

    Sample:

     
    @@ -309,13 +376,15 @@
    -

    Multi-EP sample:

    +

    Multi-EP sample:

     
    -

    +
    + +
    @@ -472,14 +541,19 @@ %RG RLSGROUP + + Release Type: + %RT + PROPER +
    - +
    -

    Sample:

    +

    Sample:

     
    @@ -494,84 +568,18 @@
    -
    - -
    -

    Metadata

    -

    The data associated to the data. These are files associated to a TV show in the form of images and text that, when supported, will enhance the viewing experience.

    -
    - -
    -
    - - Toggle the metadata options that you wish to be created. Multiple targets may be used. -
    - -#for ($cur_name, $cur_generator) in $m_dict.items(): -#set $cur_metadata_inst = $sickbeard.metadata_provider_dict[$cur_generator.name] -#set $cur_id = $GenericMetadata.makeID($cur_name) -
    - - - - -
    -#end for - -
    - - -
    - -
    - -
    -
    - -

    -
    +
    + All non-absolute folder locations are relative to $sickbeard.DATA_DIR +
    +
    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_providers.tmpl b/data/interfaces/default/config_providers.tmpl index 9c91fef323..ff4af4c99d 100755 --- a/data/interfaces/default/config_providers.tmpl +++ b/data/interfaces/default/config_providers.tmpl @@ -1,296 +1,313 @@ -#import sickbeard -#from sickbeard.providers.generic import GenericProvider -#set global $title="Config - Search Providers" -#set global $header="Search Providers" - -#set global $sbPath="../.." - -#set global $topmenu="config"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - -#if $sickbeard.USE_NZBS - -#end if - -
    -
    - -
    - -
    - -
    - -
    -

    Provider Priorities

    -

    Check off and drag the providers into the order you want them to be used.

    -

    At least one provider is required but two are recommended.

    - - #if not $sickbeard.USE_NZBS or not $sickbeard.USE_TORRENTS: -
    NZB/Torrent providers can be toggled in Search Settings
    - #else: -
    - #end if - -
    -

    *

    Provider does not support backlog searches at this time.

    -

    **

    Provider supports limited backlog searches, all episodes/qualities may not be available.

    -
    -
    - -
    -
      - #for $curProvider in $sickbeard.providers.sortedProviderList(): - #if $curProvider.providerType == $GenericProvider.NZB and not $sickbeard.USE_NZBS: - #continue - #elif $curProvider.providerType == $GenericProvider.TORRENT and not $sickbeard.USE_TORRENTS: - #continue - #end if - #set $curName = $curProvider.getID() -
    • - - - $curProvider.name - #if not $curProvider.supportsBacklog then "*" else ""# - #if $curProvider.name == "EZRSS" then "**" else ""# - -
    • - #end for -
    - "/> -

    -
    -
    - -
    - -
    -

    Configure Built-In Providers

    -

    Check with provider's website on how to obtain an API key if needed.

    -
    - -
    -
    - -
    - - - -#for $curNewznabProvider in [$curProvider for $curProvider in $sickbeard.newznabProviderList if $curProvider.default and $curProvider.needs_auth]: -
    -
    - -
    -
    - -
    -
    -#end for - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -

    - Nothing to set up for this provider -

    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - - - -
    - -
    -
    -#if $sickbeard.USE_NZBS: - -
    - -
    -

    Configure Custom Newznab Providers

    -

    Add and setup custom Newznab providers.

    -

    Some built-in Newznab providers are already available above.

    -
    - -
    -
    - -
    - -
    -
    - -
    -
    - -
    -
    - - -
    -
    - -
    - -
    - -
    -
    -#end if -
    - -

    - -
    - -
    -
    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard.providers.generic import GenericProvider +#set global $title="Config - Providers" +#set global $header="Search Providers" + +#set global $sbPath="../.." + +#set global $topmenu="config"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + + + + +
    +
    + +
    + +
    + + +
    + +
    +

    Provider Priorities

    +

    Check off and drag the providers into the order you want them to be used.

    +

    At least one provider is required but two are recommended.

    + + #if not $sickbeard.USE_NZBS or not $sickbeard.USE_TORRENTS: +
    NZB/Torrent providers can be toggled in Search Settings
    + #else: +
    + #end if + +
    +

    *

    Provider does not support backlog searches at this time.

    +

    **

    Provider supports limited backlog searches, all episodes/qualities may not be available.

    +

    !

    Provider is NOT WORKING.

    +
    +
    + +
    +
      + #for $curProvider in $sickbeard.providers.sortedProviderList(): + #if $curProvider.providerType == $GenericProvider.NZB and not $sickbeard.USE_NZBS: + #continue + #elif $curProvider.providerType == $GenericProvider.TORRENT and not $sickbeard.USE_TORRENTS: + #continue + #end if + #set $curName = $curProvider.getID() +
    • + + $curProvider.name + $curProvider.name + #if not $curProvider.supportsBacklog then "*" else ""# + #if $curProvider.name == "EZRSS" or $curProvider.name == "DailyTvTorrents" then "**" else ""# + #if $curProvider.name == "DailyTvTorrents" then "!" else "" + +
    • + #end for +
    + "/> +

    +
    +
    + +
    + +
    +

    Configure Built-In
    Providers

    +

    Check with provider's website on how to obtain an API key if needed.

    +
    + +
    +
    + +
    + + + +#for $curNewznabProvider in [$curProvider for $curProvider in $sickbeard.newznabProviderList if $curProvider.default and $curProvider.needs_auth]: +
    +
    + +
    +
    + +
    +
    +#end for + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +

    +Nothing to set up for this provider +

    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + + +
    + +
    +
    + +#if $sickbeard.USE_NZBS +
    + +
    +

    Configure Custom
    Newznab Providers

    +

    Add and setup custom Newznab providers.

    +

    Some built-in Newznab providers are already available above.

    +
    + +
    +
    + +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    + +
    + +
    + +
    +
    +#end if + +

    + +
    + +
    +
    +
    + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_search.tmpl b/data/interfaces/default/config_search.tmpl index d95c6a6de2..3bfd7bdd53 100644 --- a/data/interfaces/default/config_search.tmpl +++ b/data/interfaces/default/config_search.tmpl @@ -1,427 +1,454 @@ -#import sickbeard -#set global $title="Config - Search Settings" -#set global $header="Search Settings" - -#set global $sbPath="../.." - -#set global $topmenu="config"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - -
    -
    -
    All non-absolute folder locations are relative to $sickbeard.DATA_DIR
    - -
    - -
    - -
    - -
    -

    Episode Search

    -

    Settings that dictate how and when episode searching works with Providers.

    -
    - -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    -
    - -
    -
    - -
    - -
    -

    NZB Search

    -

    Settings that dictate how Sick Beard handles NZB search results.

    -
    - -
    - -
    - - -
    - -
    -
    - -
    - -
    -
    - - -
    -
    - -
    -
    - - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    - -
    -
    - - - -
    - -
    - - -
    - -
    - - -
    -
    - -
    - -
    Click below to test.
    - -
    - -
    - -
    -
    - - -
    - -
    -

    Torrent Search

    -

    Settings that dictate how Sick Beard handles Torrent search results.

    -
    - -
    - -
    - - -
    - -
    -
    - - -
    -
    - - - -
    - -
    -
    -
    -
    - -
    -
    - - - -
    - -
    - - -
    - -
    - - -
    - -
    - - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    Click below to test.
    - -
    -
    -
    -
    -
    -
    -
    - -
    -
    -

    - -
    - - - -
    - - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import clients +#set global $title="Config - Episode Search" +#set global $header="Search Options" + +#set global $sbPath="../.." + +#set global $topmenu="config"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +
    +
    + +
    + +
    + + + +
    + +
    +

    Episode Search

    +

    Settings that dictate how and when episode searching works with Providers.

    +
    + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + +
    + +
    +
    + +
    +
    + +
    + +
    +

    NZB Search

    +

    Settings that dictate how Sick Beard handles NZB search results.

    +
    + +
    + +
    + + +
    + +
    +
    + +
    + +
    +
    + + +
    +
    + +
    +
    + + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + +
    +
    + + + +
    + +
    + + +
    + +
    + + +
    +
    + +
    + +
    Click below to test.
    + +
    + +
    + +
    +
    + +
    + +
    +

    Torrent Search

    +

    Settings that dictate how Sick Beard handles Torrent search results.

    +
    + +
    + +
    + + +
    + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    + +
    +
    + + + +
    + +
    + + +
    + +
    + + +
    + +
    + + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    Click below to test.
    + +
    +
    +
    +
    +
    +
    + +
    +

    Preferred Method

    +

    Settings that dictate how Sick Beard will choose best result among NZBs and Torrents

    +
    +
    +
    + + + +

    + +
    + All non-absolute folder locations are relative to $sickbeard.DATA_DIR +
    + + +
    + +
    + +
    +
    + + + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/config_subtitles.tmpl b/data/interfaces/default/config_subtitles.tmpl index 2108656ede..736ce42642 100644 --- a/data/interfaces/default/config_subtitles.tmpl +++ b/data/interfaces/default/config_subtitles.tmpl @@ -1,153 +1,161 @@ -#from sickbeard import subtitles -#import sickbeard - -#set global $title="Config - Subtitles" -#set global $header="Subtitles" - -#set global $sbPath="../.." - -#set global $topmenu="config" -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - - - - - - - -
    -
    - -
    - -
    - -
    - -
    -

    Subtitles Search

    -

    Settings that dictate how Sick Beard handles subtitles search results.

    -
    - -
    -
    - - -
    -
    -
    - -
    -
    - - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -

    -
    -
    -
    - -
    - -
    -

    Subtitle Plugins

    -

    Check off and drag the plugins into the order you want them to be used.

    -

    At least one plugin is required.

    -

    * Web-scraping plugin

    -
    - -
    -
      - #for $curService in $sickbeard.subtitles.sortedServiceList(): - #set $curName = $curService.id -
    • - - - $curService.name - - $curService.name.capitalize() - #if not $curService.api_based then "*" else ""# - -
    • - #end for -
    - "/> - -

    -
    - -
    - -

    - -
    - -
    -
    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#from sickbeard import subtitles +#import sickbeard + +#set global $title="Config - Subtitles" +#set global $header="Subtitles" + +#set global $sbPath="../.." + +#set global $topmenu="config" +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + + + + + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + +
    +
    + +
    + +
    + + +
    + +
    +

    Subtitles Search

    +

    Settings that dictate how Sick Beard handles subtitles search results.

    +
    + +
    +
    + + +
    +
    +
    + +
    +
    + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +

    +
    +
    +
    + +
    + +
    +

    Subtitle Plugins

    +

    Check off and drag the plugins into the order you want them to be used.

    +

    At least one plugin is required.

    +

    * Web-scraping plugin

    +
    + +
    +
      + #for $curService in $sickbeard.subtitles.sortedServiceList(): + #set $curName = $curService.id +
    • + + + $curService.name + + $curService.name.capitalize() + #if not $curService.api_based then "*" else ""# + +
    • + #end for +
    + "/> + +

    +
    +
    + +

    + +
    + +
    +
    +
    + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/displayShow.tmpl b/data/interfaces/default/displayShow.tmpl index 1000c6247d..1c87c6dd64 100644 --- a/data/interfaces/default/displayShow.tmpl +++ b/data/interfaces/default/displayShow.tmpl @@ -1,292 +1,359 @@ -#import sickbeard -#from sickbeard import subtitles -#import sickbeard.helpers -#from sickbeard import common -#from sickbeard.common import * -#from sickbeard import db -#import subliminal -#import os.path, os -#import datetime -#set global $title=$show.name -#set $myDB = $db.DBConnection() -#set $today = str($datetime.date.today().toordinal()) -#set $tvid =str($show.tvdbid) -#set $fr = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'fr' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" and showid="+$tvid+" GROUP BY showid") -#set $curfr = [x[1] for x in $fr if int(x[0]) == $show.tvdbid] -#if len($curfr) != 0: - #set $lfr = $curfr[0] -#else - #set $lfr = 0 -#end if -#set $en = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'en' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" and showid="+$tvid+" GROUP BY showid") -#set $curen = [x[1] for x in $en if int(x[0]) == $show.tvdbid] -#if len($curen) != 0: - #set $leng = $curen[0] -#else - #set $leng = 0 -#end if -#set $no = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = '' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" and showid="+$tvid+" GROUP BY showid") -#set $curno = [x[1] for x in $no if int(x[0]) == $show.tvdbid] -#if len($curno) != 0: - #set $lno = $curno[0] -#else - #set $lno = 0 -#end if -#set $manq = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE location = '' AND season != 0 and episode != 0 AND (airdate <= "+$today+" and airdate != 1) and showid="+$tvid+" GROUP BY showid") -#set $curmanq = [x[1] for x in $manq if int(x[0]) == $show.tvdbid] -#if len($curmanq) != 0: - #set $lmanq = $curmanq[0] -#else - #set $lmanq = 0 -#end if -#set $subs= $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE subtitles <> '' and showid="+$tvid+" GROUP BY showid") -#set $cursubs = [x[1] for x in $subs if int(x[0]) == $show.tvdbid] -#if len($cursubs) != 0: - #set $lsubs = $cursubs[0] -#else - #set $lsubs = 0 -#end if -#set global $header = '%s' % ($show.tvdbid, $show.name) - -#set global $topmenu="manageShows"# -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - -
    -#if (len($seasonResults) > 14): - -#else: - Season: - #for $seasonNum in $seasonResults: - #if int($seasonNum["season"]) == 0: - Specials - #else: - ${str($seasonNum["season"])} - #end if - #if $seasonNum != $seasonResults[-1]: - | - #end if - #end for -#end if -

    - -#if $show_message: -
    - $show_message -
    -#end if - - - - - - - - - -
    Change Show: - - - -
    - -
    - -
    - -
    -#if $show.network and $show.airs: - -#else if $show.network: - -#else if $show.airs: - -#end if - -#if $showLoc[1]: - -#else: - -#end if -#set $anyQualities, $bestQualities = $Quality.splitQuality(int($show.quality)) - - - - - - - - #if $sickbeard.USE_SUBTITLES - -#end if - - - -
    - -
    - - - - - -
    Airs: $show.airs on $show.network
    Airs: $show.network
    Airs: $show.airs
    Status: $show.status
    Location: $showLoc[0]
    Location: $showLoc[0] (dir is missing)
    Quality: -#if $show.quality in $qualityPresets: -$qualityPresetStrings[$show.quality] -#else: -#if $anyQualities: -initially download: <%=", ".join([Quality.qualityStrings[x] for x in sorted(anyQualities)])%> #if $bestQualities then " + " else ""# -#end if -#if $bestQualities: -replace with: <%=", ".join([Quality.qualityStrings[x] for x in sorted(bestQualities)])%> -#end if -#end if -
    Metadata language: $show.lang
    Desired Audio language: - $show.audio_lang $show.audio_lang -
    Custom Search Names:$show.custom_search_names
    Subtitles: \"Y"
    Flatten Folders: \"Y"
    Paused: \"Y"
    Air-by-Date: \"Y"
    French Episodes : $lfr
    English Episodes : $leng
    Unknown Episodes : $lno
    Missing Episodes : $lmanq
    Downloaded Subtitles : $lsubs
    -
    - -
    -Change selected episodes to - - -
    -
    -
    -
    -
    -Change Audio of selected episodes to - - -
    -
    -
    -
    -#set $curSeason = -1 - -
    -
    - - - - -
    - -
    - - - -#for $epResult in $sqlResults: - - #if int($epResult["season"]) != $curSeason: - - - - - #if $sickbeard.USE_SUBTITLES and $show.subtitles then "" else ""# - #set $curSeason = int($epResult["season"]) - #end if - - #set $epStr = str($epResult["season"]) + "x" + str($epResult["episode"]) - #set $epLoc = $epResult["location"] - - - - - - - - - - - -#if $sickbeard.USE_SUBTITLES and $show.subtitles: - -#end if - #set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($epResult["status"])) - #if $curQuality != Quality.NONE: - -#else: - -#end if - - - - -#end for -
    -

    #if int($epResult["season"]) == 0 then "Specials" else "Season "+str($epResult["season"])#

    -
    NFOTBNEpisodeNameAirdateFilenameAudioSubsStatusSearchHist
    -#if int($epResult["status"]) != $UNAIRED - " name="<%=str(epResult["season"]) +"x"+str(epResult["episode"]) %>" /> -#end if - \"Y"\"Y"$epResult["episode"] - $epResult["name"] - #if $epResult["description"] != "" and $epResult["description"] != None: - " /> - #end if - #if int($epResult["airdate"]) == 1 then "never" else $datetime.date.fromordinal(int($epResult["airdate"]))# -#if $epLoc and $show._location and $epLoc.lower().startswith($show._location.lower()): -$epLoc[len($show._location)+1:] -#elif $epLoc and (not $epLoc.lower().startswith($show._location.lower()) or not $show._location): -$epLoc -#end if - - - #set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($epResult["status"])) - #if $epResult["audio_langs"] == "" and $curStatus in [$DOWNLOADED, $SNATCHED, $ARCHIVED] - - #else - $epResult[ - #end if - - #if $epResult["subtitles"]: - #for $sub_lang in subliminal.language.language_list($epResult["subtitles"].split(',')): - #if sub_lang.alpha2 != "" - ${sub_lang} - #end if - #end for - #end if - $statusStrings[$curStatus] $Quality.qualityStrings[$curQuality]$statusStrings[$curStatus] - #if int($epResult["season"]) != 0: - search - #end if - #if $sickbeard.USE_SUBTITLES and $show.subtitles and len(set(str($epResult["subtitles"]).split(',')).intersection(set($subtitles.wantedLanguages()))) < len($subtitles.wantedLanguages()) and $epResult["location"] - search subtitles - #end if - - trunc -

    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import subtitles +#import sickbeard.helpers +#from sickbeard.common import * +#import subliminal +#from sickbeard import db +#from sickbeard import common +#import os.path, os +#import datetime +#set global $title=$show.name +#set $myDB = $db.DBConnection() +#set $today = str($datetime.date.today().toordinal()) +#set $tvid =str($show.tvdbid) +#set $fr = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'fr' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" and showid="+$tvid+" GROUP BY showid") +#set $curfr = [x[1] for x in $fr if int(x[0]) == $show.tvdbid] +#if len($curfr) != 0: + #set $lfr = $curfr[0] +#else + #set $lfr = 0 +#end if +#set $en = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'en' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" and showid="+$tvid+" GROUP BY showid") +#set $curen = [x[1] for x in $en if int(x[0]) == $show.tvdbid] +#if len($curen) != 0: + #set $leng = $curen[0] +#else + #set $leng = 0 +#end if +#set $no = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = '' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" and showid="+$tvid+" GROUP BY showid") +#set $curno = [x[1] for x in $no if int(x[0]) == $show.tvdbid] +#if len($curno) != 0: + #set $lno = $curno[0] +#else + #set $lno = 0 +#end if +#set $manq = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE location = '' AND season != 0 and episode != 0 AND (airdate <= "+$today+" and airdate != 1) and showid="+$tvid+" GROUP BY showid") +#set $curmanq = [x[1] for x in $manq if int(x[0]) == $show.tvdbid] +#if len($curmanq) != 0: + #set $lmanq = $curmanq[0] +#else + #set $lmanq = 0 +#end if +#set $subs= $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE subtitles <> '' and showid="+$tvid+" GROUP BY showid") +#set $cursubs = [x[1] for x in $subs if int(x[0]) == $show.tvdbid] +#if len($cursubs) != 0: + #set $lsubs = $cursubs[0] +#else + #set $lsubs = 0 +#end if +##set global $header = '' % +#set global $topmenu="manageShows"# +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + + +#if $show_message: +
    $show_message

    +#end if + + + + + + + + + + + + + +
    +

    $show.name#if $show.imdbid: + $show.imdb_info['rating'] + #else: + No Rating + #end if +

    + +#if not $show.imdbid + ($show.startyear) - $show.runtime min + #if $show.genre: + - $show.genre[1:-1].replace('|',' | ') + #end if + + [tvdb] + +#else + ($show.imdb_info['year']) - $show.imdb_info['runtimes'] min - $show.imdb_info['genres'].replace('|',' | ') + + [imdb] + [tvdb] + + +#end if + + +##There is a special/season_0?## +#if int($seasonResults[-1]["season"]) == 0: + #set $season_special=1 +#else: + #set $season_special=0 +#end if + +#if not $sickbeard.DISPLAY_SHOW_SPECIALS and $season_special: + $seasonResults.pop(-1) +#end if + +
    + +#if (len($seasonResults) > 14): + +#else: + Season: + #for $seasonNum in $seasonResults: + #if int($seasonNum["season"]) == 0: + Specials + #else: + ${str($seasonNum["season"])} + #end if + #if $seasonNum != $seasonResults[-1]: + | + #end if + #end for +#end if + + + +#if $season_special: + Display Specials: + #if sickbeard.DISPLAY_SHOW_SPECIALS: + Hide + #else: + Show + #end if +#end if + +
    +
    + + + + +
    + +#if $show.network and $show.airs: + +#else if $show.network: + +#else if $show.airs: + +#end if + +#if $showLoc[1]: + +#else: + +#end if +#set $anyQualities, $bestQualities = $Quality.splitQuality(int($show.quality)) + + + + #if $show.imdbid: + + #else: + + #end if + +
    Airs: $show.airs on $show.network
    Airs: $show.network
    Airs: $show.airs
    Status: $show.status
    Location: $showLoc[0]
    Location: $showLoc[0] (dir is missing)
    Quality: +#if $show.quality in $qualityPresets: +$qualityPresetStrings[$show.quality] +#else: +#if $anyQualities: +Initial: <%=", ".join([Quality.qualityStrings[x] for x in sorted(anyQualities)])%> #if $bestQualities then "
    " else ""# +#end if +#if $bestQualities: +Replace with: <%=", ".join([Quality.qualityStrings[x] for x in sorted(bestQualities)])%> +#end if +#end if + +
    Info Language:$show.lang
    Audio Language:$show.audio_lang
    Custom Names :$show.custom_search_names
    Rating :$show.imdb_info['rating']
    No Rating
    +
    + + + + + + #if $sickbeard.USE_SUBTITLES + +#end if + + + + + + +
    Flat Folders: \"Y"
    Paused: \"Y"
    Air-by-Date: \"Y"
    Subtitles: \"Y"
    fr episodes:$lfr
    en episodes:$leng
    uno episodes:$lno
    no episodes:$lmanq
    no downloaded:$lsubs
    + +
    + +
    +
    +
    +
    + +#set $curSeason = -1 +#set $odd = 0 + +
    + +
    + Change selected episodes to + + + +
    + +
    +
    + + + + +
    + +
    +
    +
    + Change Audio of selected episodes to + + + +
    + + + +#for $epResult in $sqlResults: + #if not $sickbeard.DISPLAY_SHOW_SPECIALS and int($epResult["season"]) == 0: + #continue + #end if + + #if int($epResult["season"]) != $curSeason: + + + + + #if $sickbeard.USE_SUBTITLES and $show.subtitles then "" else ""# + #set $curSeason = int($epResult["season"]) + #end if + + #set $epStr = str($epResult["season"]) + "x" + str($epResult["episode"]) + #set $epLoc = $epResult["location"] + + + + + + + + + + + +#if $sickbeard.USE_SUBTITLES and $show.subtitles: + +#end if +#set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($epResult["status"])) +#if $curQuality != Quality.NONE: + +#else: + +#end if + + + + +#end for +
    +

    #if int($epResult["season"]) == 0 then "Specials" else "Season "+str($epResult["season"])#

    +
    NFOTBNEpisodeNameAirdateFilenameAudioSubsStatusSearchHist
    +#if int($epResult["status"]) != $UNAIRED + " name="<%=str(epResult["season"]) +"x"+str(epResult["episode"]) %>" /> +#end if + \"Y"\"Y"$epResult["episode"] + #if $epResult["description"] != "" and $epResult["description"] != None: + " /> + #end if + $epResult["name"] +#if int($epResult["airdate"]) == 1 then "never" else $datetime.date.fromordinal(int($epResult["airdate"]))# +#if $epLoc and $show._location and $epLoc.lower().startswith($show._location.lower()): + #set $epLoc = os.path.basename($epLoc[len($show._location)+1:]) +#elif $epLoc and (not $epLoc.lower().startswith($show._location.lower()) or not $show._location): + #set $epLoc = os.path.basename($epLoc) +#end if +$epLoc + + #set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($epResult["status"])) + #if $epResult["audio_langs"] == "" and $curStatus in [$DOWNLOADED, $SNATCHED, $ARCHIVED] + + #else + $epResult[ + #end if + + #if $epResult["subtitles"]: + #for $sub_lang in subliminal.language.language_list($epResult["subtitles"].split(',')): + #if sub_lang.alpha2 != "" + ${sub_lang} + #end if + #end for + #end if + $statusStrings[$curStatus] $Quality.qualityStrings[$curQuality]$statusStrings[$curStatus] + #if int($epResult["season"]) != 0: + search + #end if +#if $sickbeard.USE_SUBTITLES and $show.subtitles and len(set(str($epResult["subtitles"]).split(',')).intersection(set($subtitles.wantedLanguages()))) < len($subtitles.wantedLanguages()) and $epResult["location"] + search subtitles + #end if + + trunc +

    + + + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/editShow.tmpl b/data/interfaces/default/editShow.tmpl index b7f7885354..760cd70642 100644 --- a/data/interfaces/default/editShow.tmpl +++ b/data/interfaces/default/editShow.tmpl @@ -1,98 +1,167 @@ -#import sickbeard -#from sickbeard import common -#from sickbeard import exceptions -#set global $title="Edit " + $show.name -#set global $header=$show.name - -#set global $sbPath=".." - -#set global $topmenu="home" -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - - -
    - -Location:
    -
    -Quality: -#set $qualities = $common.Quality.splitQuality(int($show.quality)) -#set global $anyQualities = $qualities[0] -#set global $bestQualities = $qualities[1] -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_qualityChooser.tmpl") -
    -Metadata Language:
    -Note: This will only affect the language of the retrieved metadata file contents and episode filenames.
    -
    -
    -Desired Audio language: -
    -
    -Custom Search Names:
    -Note: Custom names used to find show. Define some custom names if show can't be found. Custom names should be separated by ",". Keep empty to use default show name (based on metadata language)
    -
    -Flatten files (no folders):

    -Paused:

    -Download subtitles:

    -Air by date: -
    -(check this if the show is released as Show.03.02.2010 rather than Show.S02E03) -

    - -
    - - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import common +#from sickbeard import exceptions +#from sickbeard import scene_exceptions +#set global $title="Edit " + $show.name +#set global $header=$show.name + +#set global $sbPath=".." + +#set global $topmenu="home" +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + + + + + +
    +$show.tvdbid +
    +
    +
    + +Location:
    +
    +Quality: +#set $qualities = $common.Quality.splitQuality(int($show.quality)) +#set global $anyQualities = $qualities[0] +#set global $bestQualities = $qualities[1] +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_qualityChooser.tmpl") +
    + +Info Language:
    +Note: This will only affect the language of the retrieved metadata file contents and episode filenames.
    +This DOES NOT allow Sick Beard to download non-english TV episodes!
    +
    +Desired Audio language: +
    +
    +Custom Search Names:
    +Note: Custom names used to find show. Define some custom names if show can't be found. Custom names should be separated by ",". Keep empty to use default show name (based on metadata language)
    +
    +Flatten files (no folders):

    +Paused:

    +Subtitles:

    + +Air by date: +
    +(check this if the show is released as Show.03.02.2010 rather than Show.S02E03) +

    + +
    + + + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/errorlogs.tmpl b/data/interfaces/default/errorlogs.tmpl index 3b0dc039d2..91dbcf061c 100644 --- a/data/interfaces/default/errorlogs.tmpl +++ b/data/interfaces/default/errorlogs.tmpl @@ -1,25 +1,30 @@ -#import sickbeard -#from sickbeard import classes -#from sickbeard.common import * -#set global $title="Logs & Errors" - -#set global $sbPath = ".." - -#set global $topmenu="errorlogs"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -
    -#for $curError in $classes.ErrorViewer.errors[:30]:
    -$curError.time $curError.message
    -#end for
    -
    -
    - - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import classes +#from sickbeard.common import * +#set global $header="Logs & Errors" +#set global $title="" + +#set global $sbPath = ".." + +#set global $topmenu="errorlogs"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +
    +#for $curError in $classes.ErrorViewer.errors[:30]:
    +$curError.time $curError.message
    +#end for
    +
    +
    + + + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/genericMessage.tmpl b/data/interfaces/default/genericMessage.tmpl index 47553c8853..acbaaaa101 100644 --- a/data/interfaces/default/genericMessage.tmpl +++ b/data/interfaces/default/genericMessage.tmpl @@ -1,14 +1,14 @@ -#import sickbeard -#set global $title="" - -#set global $sbPath="../.." - - -#set global $topmenu="home"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -

    $subject

    -$message - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#set global $title="" + +#set global $sbPath="../.." + + +#set global $topmenu="home"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + +

    $subject

    +$message + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/history.tmpl b/data/interfaces/default/history.tmpl index 06f4a1083d..759101f2c9 100644 --- a/data/interfaces/default/history.tmpl +++ b/data/interfaces/default/history.tmpl @@ -1,93 +1,96 @@ -#import sickbeard -#import os.path -#import datetime -#import re -#from sickbeard import history -#from sickbeard import providers -#from sickbeard.providers import generic -#from sickbeard.common import * -#set global $title="History" - -#set global $sbPath=".." - -#set global $topmenu="history"# -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - -
    Limit: - -

    - - - - -#for $hItem in $historyResults: -#set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($hItem["action"])) - - - - - - - -#end for - -
    TimeEpisodeActionProviderQuality
    $datetime.datetime.strptime(str($hItem["date"]), $history.dateFormat)$hItem["show_name"] - <%=str(hItem["season"]) +"x"+ "%02i" % int(hItem["episode"]) %>$statusStrings[$curStatus] - #if $curStatus == SUBTITLED: - "> - #end if - - #if $curStatus == DOWNLOADED and $str($hItem["provider"]) == '-1': - #set $match = $re.search("\-(\w+)\.\w{3}\Z", $os.path.basename($hItem["resource"])) - #if $match: - #if $match.group(1).upper() in ("X264", "720P"): - #set $match = $re.search("(\w+)\-.*\-"+$match.group(1)+"\.\w{3}\Z", $os.path.basename($hItem["resource"]), re.IGNORECASE) - #if $match: - $match.group(1).upper() - #end if - #else: - $match.group(1).upper() - #end if - #end if - #elif $curStatus == DOWNLOADED: - $hItem["provider"] - #else - #if $len($hItem["provider"]) > 0 - #if $curStatus == SNATCHED: - #set $provider = $providers.getProviderClass($generic.GenericProvider.makeID($hItem["provider"])) - #if $provider != None: - $provider.name - #else: - missing provider - #end if - #else: - " width="16" height="16" alt="$hItem["provider"]" title="<%=hItem["provider"].capitalize()%>"/> - #end if - #end if - #end if - $Quality.qualityStrings[$curQuality]
    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#import os.path +#import datetime +#import re +#from sickbeard import history +#from sickbeard import providers +#from sickbeard.providers import generic +#from sickbeard.common import * +#set global $title="" +#set global $header="History" + +#set global $sbPath=".." + +#set global $topmenu="history"# +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +
    Limit: + +
    + + + + +#for $hItem in $historyResults: +#set $curStatus, $curQuality = $Quality.splitCompositeStatus(int($hItem["action"])) + + + + + + + +#end for + +
    TimeEpisodeActionProviderQuality
    $datetime.datetime.strptime(str($hItem["date"]), $history.dateFormat)$hItem["show_name"] - <%=str(hItem["season"]) +"x"+ "%02i" % int(hItem["episode"]) %>#if "proper" in $hItem["resource"].lower or "repack" in $hItem["resource"].lower then ' Proper' else ""#$statusStrings[$curStatus] + #if $curStatus == SUBTITLED: + "> + #end if + + #if $curStatus == DOWNLOADED: + #set $match = $re.search("\-(\w+)\.\w{3}\Z", $os.path.basename($hItem["resource"])) + #if $match + #if $match.group(1).upper() in ("X264", "720P"): + #set $match = $re.search("(\w+)\-.*\-"+$match.group(1)+"\.\w{3}\Z", $os.path.basename($hItem["resource"]), re.IGNORECASE) + #if $match + $match.group(1).upper() + #end if + #else: + $match.group(1).upper() + #end if + #end if + #else + #if $hItem["provider"] > 0 + #if $curStatus == SNATCHED: + #set $provider = $providers.getProviderClass($generic.GenericProvider.makeID($hItem["provider"])) + #if $provider != None: + $provider.name + #else: + missing provider + #end if + #else: + " width="16" height="16" alt="$hItem["provider"]" title="<%=hItem["provider"].capitalize()%>"/> + #end if + #end if + #end if + $Quality.qualityStrings[$curQuality]
    + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index c6495a31c1..49b1d20c64 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -1,230 +1,292 @@ -#import sickbeard -#import datetime -#from sickbeard.common import * -#from sickbeard import db - -#set global $title="Home" -#set global $header="Show List" - -#set global $sbPath = ".." - -#set global $topmenu="home"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -#set $myDB = $db.DBConnection() -#set $today = str($datetime.date.today().toordinal()) -#set $downloadedEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE (status IN ("+",".join([str(x) for x in $Quality.DOWNLOADED + [$ARCHIVED]])+") OR (status IN ("+",".join([str(x) for x in $Quality.SNATCHED + $Quality.SNATCHED_PROPER])+") AND location != '')) AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") -#set $allEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE season != 0 and episode != 0 AND (airdate != 1 OR status IN ("+",".join([str(x) for x in ($Quality.DOWNLOADED + $Quality.SNATCHED + $Quality.SNATCHED_PROPER) + [$ARCHIVED]])+")) AND airdate <= "+$today+" AND status != "+str($IGNORED)+" GROUP BY showid") -#set $fr = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'fr' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") - - - - - - - - - - - - - - -#for $curLoadingShow in $sickbeard.showQueueScheduler.action.loadingShowList: - - #if $curLoadingShow.show != None and $curLoadingShow.show in $sickbeard.showList: - #continue - #end if - - - - - - - - - - - - -#end for - -#set $myShowList = $list($sickbeard.showList) -$myShowList.sort(lambda x, y: cmp(x.name, y.name)) -#for $curShow in $myShowList: -#set $curEp = $curShow.nextEpisode() - -#set $curShowDownloads = [x[1] for x in $downloadedEps if int(x[0]) == $curShow.tvdbid] -#set $curfr = [x[1] for x in $fr if int(x[0]) == $curShow.tvdbid] -#set $curShowAll = [x[1] for x in $allEps if int(x[0]) == $curShow.tvdbid] -#if len($curShowAll) != 0: - #if len($curShowDownloads) != 0: - #set $dlStat = str($curShowDownloads[0])+" / "+str($curShowAll[0]) - #set $nom = $curShowDownloads[0] - #set $den = $curShowAll[0] - #else - #set $dlStat = "0 / "+str($curShowAll[0]) - #set $nom = 0 - #set $den = $curShowAll[0] - #end if -#else - #set $dlStat = "?" - #set $nom = 0 - #set $den = 1 -#end if -#if len($curShowDownloads) != 0: - #if len($curfr) != 0: - #set $frStat = str($curfr[0])+" / "+str($curShowDownloads[0]) - #set $nomfr = $curfr[0] - #set $denfr = $curShowDownloads[0] - #else - #set $frStat = "0 / "+str($curShowDownloads[0]) - #set $nomfr = 0 - #set $denfr = $curShowDownloads[0] - #end if -#else - #set $frStat = "0 / 0" - #set $nomfr = 0 - #set $denfr = 1 -#end if - - - - - - -#if $curShow.quality in $qualityPresets: - -#else: - -#end if - - - - - - - - -#end for - -
    Next EpShowNetworkQualityDownloadsFrenchActiveAudioStatus
    Add Show
    (loading) - #if $curLoadingShow.show == None: - Loading... ($curLoadingShow.show_name) - #else: - $curLoadingShow.show.name - #end if -
    #if len($curEp) != 0 then $curEp[0].airdate else ""# - #if $sickbeard.DISPLAY_POSTERS: - $curShow.name - #end if - $curShow.name - - #if $curShow.network: - $curShow.network - #else: - No Network - #end if - $qualityPresetStrings[$curShow.quality]Custom$dlStat
    - -
    $frStat
    - -
    \"Y\"" - $curShow.audio_lang - $curShow.status
    - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#import datetime +#from sickbeard.common import * +#from sickbeard import db + +#set global $title="Home" +#set global $header="Show List" + +#set global $sbPath = ".." + +#set global $topmenu="home"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + +#set $myDB = $db.DBConnection() +#set $today = str($datetime.date.today().toordinal()) +#set $downloadedEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE (status IN ("+",".join([str(x) for x in $Quality.DOWNLOADED + [$ARCHIVED]])+") OR (status IN ("+",".join([str(x) for x in $Quality.SNATCHED + $Quality.SNATCHED_PROPER])+") AND location != '')) AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") +#set $allEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE season != 0 and episode != 0 AND (airdate != 1 OR status IN ("+",".join([str(x) for x in ($Quality.DOWNLOADED + $Quality.SNATCHED + $Quality.SNATCHED_PROPER) + [$ARCHIVED]])+")) AND airdate <= "+$today+" AND status != "+str($IGNORED)+" GROUP BY showid") +#set $fr = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'fr' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") +#set $layout = $sickbeard.HOME_LAYOUT +#set $sort = $sickbeard.SORT_ARTICLE + + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + +
    + Layout: + Poster · + Banner · + Simple + +
    + + + + #if $layout=="poster" then "" else ""# + + + + + + + + +#for $curLoadingShow in $sickbeard.showQueueScheduler.action.loadingShowList: + + #if $curLoadingShow.show != None and $curLoadingShow.show in $sickbeard.showList: + #continue + #end if + + + + + + + + + + + + + +#end for + +#set $myShowList = $list($sickbeard.showList) +$myShowList.sort(lambda x, y: cmp(x.name, y.name)) +#for $curShow in $myShowList: +#set $curEp = $curShow.nextEpisode() + +#set $curShowDownloads = [x[1] for x in $downloadedEps if int(x[0]) == $curShow.tvdbid] +#set $curfr = [x[1] for x in $fr if int(x[0]) == $curShow.tvdbid] +#set $curShowAll = [x[1] for x in $allEps if int(x[0]) == $curShow.tvdbid] +#if len($curShowAll) != 0: + #if len($curShowDownloads) != 0: + #set $dlStat = str($curShowDownloads[0])+" / "+str($curShowAll[0]) + #set $nom = $curShowDownloads[0] + #set $den = $curShowAll[0] + #else + #set $dlStat = "0 / "+str($curShowAll[0]) + #set $nom = 0 + #set $den = $curShowAll[0] + #end if +#else + #set $dlStat = "?" + #set $nom = 0 + #set $den = 1 +#end if +#if len($curShowDownloads) != 0: + #if len($curfr) != 0: + #set $frStat = str($curfr[0])+" / "+str($curShowDownloads[0]) + #set $nomfr = $curfr[0] + #set $denfr = $curShowDownloads[0] + #else + #set $frStat = "0 / "+str($curShowDownloads[0]) + #set $nomfr = 0 + #set $denfr = $curShowDownloads[0] + #end if +#else + #set $frStat = "0 / 0" + #set $nomfr = 0 + #set $denfr = 1 +#end if + +#set $which_thumb = $layout+"_thumb" + + + + #if $layout == 'poster': + + + #else if $layout == 'banner': + + #else if $layout == 'simple': + + #end if + + + +#if $curShow.quality in $qualityPresets: + +#else: + +#end if + + + + + + + + +#end for + +
    Next EpPosterShowNetworkQualityDownloadsFrenchActiveAudioStatus
      Add Show
    (loading) + #if $curLoadingShow.show == None: + Loading... ($curLoadingShow.show_name) + #else: + $curLoadingShow.show.name + #end if +
    #if len($curEp) != 0 then $curEp[0].airdate else ""# +
    + + $curShow.name + +
    +
    $curShow.name + + $curShow.name + + + $curShow.name + #if $layout != 'simple': + #if $curShow.network: + $curShow.network + #else: + No Network + #end if + #else: + $curShow.network + #end if + $qualityPresetStrings[$curShow.quality]Custom$dlStat
    + +
    $frStat
    + +
    \"Y\"" + $curShow.audio_lang + $curShow.status
    + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/home_addExistingShow.tmpl b/data/interfaces/default/home_addExistingShow.tmpl index 38c4fc6d0f..8db39002d8 100644 --- a/data/interfaces/default/home_addExistingShow.tmpl +++ b/data/interfaces/default/home_addExistingShow.tmpl @@ -1,62 +1,67 @@ -#import os.path -#import sickbeard -#from sickbeard.common import * -#set global $title="Existing Show" - -#set global $sbPath="../.." - -#set global $statpath="../.."# -#set global $topmenu="home"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -
    - - - - - - - - -
    - -
    - #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_rootDirs.tmpl") -
    -
    - #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_addShowOptions.tmpl") -
    -
    -
    - -

    Sick Beard can add existing shows, using the current options, by using locally stored NFO/XML metadata to eliminate user interaction.
    -If you would rather have Sick Beard prompt you to customize each show, then use the checkbox below.

    -

    - -

    - -
    - -

    Displaying folders within these directories which aren't already added to Sick Beard:


    -
    - -
    -
    -
    - - -
    -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import os.path +#import sickbeard +#from sickbeard.common import * +#set global $title="Existing Show" +#set global $header="Existing Show" + +#set global $sbPath="../.." + +#set global $statpath="../.."# +#set global $topmenu="home"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + +
    + + + + + + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + +
    + +
    + #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_rootDirs.tmpl") +
    +
    + #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_addShowOptions.tmpl") +
    +
    +
    + +

    Sick Beard can add existing shows, using the current options, by using locally stored NFO/XML metadata to eliminate user interaction.
    +If you would rather have Sick Beard prompt you to customize each show, then use the checkbox below.

    +
    +

    +
    +
    +
    +

    Displaying folders within these directories which aren't already added to Sick Beard:

    +
    +
    + +
    +
    +
    + +
    +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/home_addShows.tmpl b/data/interfaces/default/home_addShows.tmpl index 25f952eae1..74fc2b54e1 100644 --- a/data/interfaces/default/home_addShows.tmpl +++ b/data/interfaces/default/home_addShows.tmpl @@ -1,36 +1,54 @@ -#import os.path -#import urllib -#import sickbeard -#set global $title="Add Show" - -#set global $sbPath="../.." - -#set global $statpath="../.."# -#set global $topmenu="home"# -#import os.path - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import os.path +#import urllib +#import sickbeard +#set global $title="" +#set global $header="Add Show" + +#set global $sbPath="../.." + +#set global $statpath="../.."# +#set global $topmenu="home"# +#import os.path + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + + +
    + + + + + +
    + +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/home_massAddTable.tmpl b/data/interfaces/default/home_massAddTable.tmpl index d299fa3010..fc47705f7c 100644 --- a/data/interfaces/default/home_massAddTable.tmpl +++ b/data/interfaces/default/home_massAddTable.tmpl @@ -1,26 +1,26 @@ - - - - - - - - -#for $curDir in $dirList: -#if $curDir['added_already']: -#continue -#end if - -#set $show_id = $curDir['dir'] -#if $curDir['existing_info'][0]: -#set $show_id = $show_id + '|' + $str($curDir['existing_info'][0]) + '|' + $curDir['existing_info'][1] -#end if - - - - - -#end for - - -
    DirectoryShow Name (tvshow.nfo)
    Manage Directories
    #if $curDir['existing_info'][0] and $curDir['existing_info'][1] then ''+$curDir['existing_info'][1]+'' else "?"#
    + + + + + + + + +#for $curDir in $dirList: +#if $curDir['added_already']: +#continue +#end if + +#set $show_id = $curDir['dir'] +#if $curDir['existing_info'][0]: +#set $show_id = $show_id + '|' + $str($curDir['existing_info'][0]) + '|' + $curDir['existing_info'][1] +#end if + + + + + +#end for + + +
    DirectoryShow Name (tvshow.nfo)
    Manage Directories
    #if $curDir['existing_info'][0] and $curDir['existing_info'][1] then ''+$curDir['existing_info'][1]+'' else "?"#
    diff --git a/data/interfaces/default/home_newShow.tmpl b/data/interfaces/default/home_newShow.tmpl index 729a731a92..8929b6b09c 100644 --- a/data/interfaces/default/home_newShow.tmpl +++ b/data/interfaces/default/home_newShow.tmpl @@ -1,6 +1,7 @@ #import os.path #import sickbeard -#set global $title="New Show" +#set global $header="New Show" +#set global $title="" #set global $sbPath="../.." @@ -10,11 +11,16 @@ #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - + + - - + + #if $varExists('header') +

    $header

    + #else +

    $title

    + #end if
    aoeu

    @@ -30,17 +36,18 @@ #else: - - + * -

    - +

    + * This will only affect the language of the retrieved metadata file contents and episode filenames.
    This DOES NOT allow Sick Beard to download non-english TV episodes!

    -

    +

    #end if +
    @@ -59,10 +66,9 @@
    Customize options - -
    - #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_addShowOptions.tmpl") -
    +
    + #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_addShowOptions.tmpl") +
    #for $curNextDir in $other_shows: @@ -82,4 +88,4 @@ -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/home_postprocess.tmpl b/data/interfaces/default/home_postprocess.tmpl index eedeac1577..0f8bdfce19 100644 --- a/data/interfaces/default/home_postprocess.tmpl +++ b/data/interfaces/default/home_postprocess.tmpl @@ -1,21 +1,26 @@ -#import sickbeard -#set global $title="Post Processing" - -#set global $sbPath="../.." - -#set global $topmenu="home"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -
    -Enter the folder containing the episode: -
    -
    - - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#set global $header="Post Processing" +#set global $title="" + +#set global $sbPath="../.." + +#set global $topmenu="home"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +
    +Enter the folder containing the episode: +
    +
    + + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/inc_addShowOptions.tmpl b/data/interfaces/default/inc_addShowOptions.tmpl index 66b720b735..58bb82c584 100644 --- a/data/interfaces/default/inc_addShowOptions.tmpl +++ b/data/interfaces/default/inc_addShowOptions.tmpl @@ -1,63 +1,61 @@ -#import sickbeard -#from sickbeard import common -#from sickbeard.common import * -#from sickbeard import subtitles - - #if $sickbeard.USE_SUBTITLES: -
    - - -
    - #end if - -
    - -
    - -
    - - -
    - - - #set $qualities = $Quality.splitQuality($sickbeard.QUALITY_DEFAULT) - #set global $anyQualities = $qualities[0] - #set global $bestQualities = $qualities[1] - #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_qualityChooser.tmpl") -
    - -
    - -
    - -
    +#import sickbeard +#from sickbeard import common +#from sickbeard.common import * +#from sickbeard import subtitles + + #if $sickbeard.USE_SUBTITLES: +
    + + +
    + #end if + +
    + +
    + +
    + + +
    + + #set $qualities = $Quality.splitQuality($sickbeard.QUALITY_DEFAULT) + #set global $anyQualities = $qualities[0] + #set global $bestQualities = $qualities[1] + #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_qualityChooser.tmpl") +
    + +
    +
    + +
    diff --git a/data/interfaces/default/inc_bottom.tmpl b/data/interfaces/default/inc_bottom.tmpl index 6989c20b15..d4264115b6 100644 --- a/data/interfaces/default/inc_bottom.tmpl +++ b/data/interfaces/default/inc_bottom.tmpl @@ -2,25 +2,31 @@ #import datetime #from sickbeard import db #from sickbeard.common import * -
    -
    - -
    - -
    +
    +
    + diff --git a/data/interfaces/default/inc_qualityChooser.tmpl b/data/interfaces/default/inc_qualityChooser.tmpl index 5956b97b24..6a0e91e6f5 100644 --- a/data/interfaces/default/inc_qualityChooser.tmpl +++ b/data/interfaces/default/inc_qualityChooser.tmpl @@ -1,47 +1,47 @@ -#import sickbeard -#from sickbeard.common import Quality, qualityPresets, qualityPresetStrings - -
    - -
    - -
    -
    -
    -

    One of the Initial quality selections must be obtained before Sick Beard will attempt to process the Archive selections.

    -
    - -
    -

    Initial

    - #set $anyQualityList = filter(lambda x: x > $Quality.NONE, $Quality.qualityStrings) - -
    - -
    -

    Archive

    - #set $bestQualityList = filter(lambda x: x > $Quality.SDTV and x < $Quality.UNKNOWN, $Quality.qualityStrings) - -
    -
    -

    +#import sickbeard +#from sickbeard.common import Quality, qualityPresets, qualityPresetStrings + +
    + +
    + +
    +
    +
    +

    One of the Initial quality selections must be obtained before Sick Beard will attempt to process the Archive selections.

    +
    + +
    +

    Initial

    + #set $anyQualityList = filter(lambda x: x > $Quality.NONE, $Quality.qualityStrings) + +
    + +
    +

    Archive

    + #set $bestQualityList = filter(lambda x: x > $Quality.SDTV and x < $Quality.UNKNOWN, $Quality.qualityStrings) + +
    +
    +
    diff --git a/data/interfaces/default/inc_rootDirs.tmpl b/data/interfaces/default/inc_rootDirs.tmpl index 27f995daab..fcd2ed5cb9 100644 --- a/data/interfaces/default/inc_rootDirs.tmpl +++ b/data/interfaces/default/inc_rootDirs.tmpl @@ -1,28 +1,28 @@ -#import sickbeard - - -#if $sickbeard.ROOT_DIRS: -#set $backend_pieces = $sickbeard.ROOT_DIRS.split('|') -#set $backend_default = 'rd-' + $backend_pieces[0] -#set $backend_dirs = $backend_pieces[1:] -#else: -#set $backend_default = '' -#set $backend_dirs = [] -#end if - - -
    - -
    -
    - - - - -
    - -
    +#import sickbeard + + +#if $sickbeard.ROOT_DIRS: +#set $backend_pieces = $sickbeard.ROOT_DIRS.split('|') +#set $backend_default = 'rd-' + $backend_pieces[0] +#set $backend_dirs = $backend_pieces[1:] +#else: +#set $backend_default = '' +#set $backend_dirs = [] +#end if + + +
    + +
    +
    + + + + +
    + +
    diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index e219b876f6..2842e68db2 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -1,72 +1,51 @@ #import sickbeard.version - - - - - Sick Beard - $sickbeard.version.SICKBEARD_VERSION - $title - - - +#import sickbeard +#import urllib + + + + + + + + Sick Beard - alpha $sickbeard.version.SICKBEARD_VERSION - $title + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - + - + + @@ -74,9 +53,10 @@ - - + + + - - - - + + }); + + +//--> + -
    #if $sickbeard.NEWEST_VERSION_STRING:
    @@ -117,140 +162,74 @@
    #end if
    - + + + + + +
    -

    #if $varExists('header') then $header else $title#

    + #if $varExists('submenu'): + + #end if + \ No newline at end of file diff --git a/data/interfaces/default/manage.tmpl b/data/interfaces/default/manage.tmpl index 10b6ca242e..7459921ed6 100644 --- a/data/interfaces/default/manage.tmpl +++ b/data/interfaces/default/manage.tmpl @@ -1,164 +1,173 @@ -#import sickbeard -#from sickbeard.common import * -#set global $title="Mass Update" - -#set global $sbPath="../.." - -#set global $topmenu="manage"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - -
    - - - - - - - - - - - - - - - - #if $sickbeard.USE_SUBTITLES: - -#end if - - - - - - - - - - - -#set $myShowList = $sickbeard.showList -$myShowList.sort(lambda x, y: cmp(x.name, y.name)) -#for $curShow in $myShowList: -#set $curEp = $curShow.nextEpisode() -#set $curUpdate_disabled = "" -#set $curRefresh_disabled = "" -#set $curRename_disabled = "" -#set $curSubtitle_disabled = "" -#set $curDelete_disabled = "" - -#if $sickbeard.showQueueScheduler.action.isBeingUpdated($curShow) or $sickbeard.showQueueScheduler.action.isInUpdateQueue($curShow): - #set $curUpdate_disabled = "disabled=\"disabled\" " -#end if -#set $curUpdate = "" -#if $sickbeard.showQueueScheduler.action.isBeingRefreshed($curShow) or $sickbeard.showQueueScheduler.action.isInRefreshQueue($curShow): - #set $curRefresh_disabled = "disabled=\"disabled\" " -#end if -#set $curRefresh = "" -#if $sickbeard.showQueueScheduler.action.isBeingRenamed($curShow) or $sickbeard.showQueueScheduler.action.isInRenameQueue($curShow): - #set $curRename = "disabled=\"disabled\" " -#end if -#set $curRename = "" -#if not $curShow.subtitles or $sickbeard.showQueueScheduler.action.isBeingSubtitled($curShow) or $sickbeard.showQueueScheduler.action.isInSubtitleQueue($curShow): - #set $curSubtitle_disabled = "disabled=\"disabled\" " -#end if -#set $curSubtitle = "" -#if $sickbeard.showQueueScheduler.action.isBeingRenamed($curShow) or $sickbeard.showQueueScheduler.action.isInRenameQueue($curShow) or $sickbeard.showQueueScheduler.action.isInRefreshQueue($curShow): - #set $curDelete = "disabled=\"disabled\" " -#end if -#set $curDelete = "" - - - - - - - -#if $curShow.quality in $qualityPresets: - -#else: - -#end if - - - - - - #if $sickbeard.USE_SUBTITLES: - -#end if - - - -#end for - -
    Edit
    - -
    MetaAudioPausedNameQualityFlattenStatusUpdate
    - -
    Rescan
    - -
    Rename
    - -
    Sub
    Delete
    - -
    $curShow.lang$curShow.audio_lang\"Y\""$curShow.name$qualityPresetStrings[$curShow.quality]Custom\"Y\""$curShow.status$curUpdate$curRefresh$curRename$curSubtitle$curDelete
    - -
    - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard.common import * +#set global $title="Mass Update" +#set global $header="Mass Update" + +#set global $sbPath="../.." + +#set global $topmenu="manage" +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +
    + + + + + + + + + + + + + + + + #if $sickbeard.USE_SUBTITLES: + +#end if + + + + + + + + + + + +#set $myShowList = $sickbeard.showList +$myShowList.sort(lambda x, y: cmp(x.name, y.name)) +#for $curShow in $myShowList: +#set $curEp = $curShow.nextEpisode() +#set $curUpdate_disabled = "" +#set $curRefresh_disabled = "" +#set $curRename_disabled = "" +#set $curSubtitle_disabled = "" +#set $curDelete_disabled = "" + +#if $sickbeard.showQueueScheduler.action.isBeingUpdated($curShow) or $sickbeard.showQueueScheduler.action.isInUpdateQueue($curShow): + #set $curUpdate_disabled = "disabled=\"disabled\" " +#end if +#set $curUpdate = "" +#if $sickbeard.showQueueScheduler.action.isBeingRefreshed($curShow) or $sickbeard.showQueueScheduler.action.isInRefreshQueue($curShow): + #set $curRefresh_disabled = "disabled=\"disabled\" " +#end if +#set $curRefresh = "" +#if $sickbeard.showQueueScheduler.action.isBeingRenamed($curShow) or $sickbeard.showQueueScheduler.action.isInRenameQueue($curShow): + #set $curRename = "disabled=\"disabled\" " +#end if +#set $curRename = "" +#if not $curShow.subtitles or $sickbeard.showQueueScheduler.action.isBeingSubtitled($curShow) or $sickbeard.showQueueScheduler.action.isInSubtitleQueue($curShow): + #set $curSubtitle_disabled = "disabled=\"disabled\" " +#end if +#set $curSubtitle = "" +#if $sickbeard.showQueueScheduler.action.isBeingRenamed($curShow) or $sickbeard.showQueueScheduler.action.isInRenameQueue($curShow) or $sickbeard.showQueueScheduler.action.isInRefreshQueue($curShow): + #set $curDelete = "disabled=\"disabled\" " +#end if +#set $curDelete = "" + + + + + + + +#if $curShow.quality in $qualityPresets: + +#else: + +#end if + + + + + + #if $sickbeard.USE_SUBTITLES: + +#end if + + + +#end for + +
    Edit
    + +
    MetaAudioPausedNameQualityFlattenStatusUpdate
    + +
    Rescan
    + +
    Rename
    + +
    Sub
    Delete
    + +
    $curShow.lang$curShow.audio_lang\"Y\""$curShow.name$qualityPresetStrings[$curShow.quality]Custom\"Y\""$curShow.status$curUpdate$curRefresh$curRename$curSubtitle$curDelete
    + +
    + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/manage_backlogOverview.tmpl b/data/interfaces/default/manage_backlogOverview.tmpl index 2fd271629d..81a4964d84 100644 --- a/data/interfaces/default/manage_backlogOverview.tmpl +++ b/data/interfaces/default/manage_backlogOverview.tmpl @@ -1,68 +1,96 @@ -#import sickbeard -#import datetime -#from sickbeard.common import * -#set global $title="Backlog Overview" - -#set global $sbPath=".." - -#set global $topmenu="manage"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -#set $totalWanted = 0 -#set $totalQual = 0 - -#for $curShow in $sickbeard.showList: -#set $totalWanted = $totalWanted + $showCounts[$curShow.tvdbid][$Overview.WANTED] -#set $totalQual = $totalQual + $showCounts[$curShow.tvdbid][$Overview.QUAL] -#end for - -
    - Wanted: $totalWanted - Low Quality: $totalQual -

    - - - -#for $curShow in sorted($sickbeard.showList, key=operator.attrgetter('name')): - -#if $showCounts[$curShow.tvdbid][$Overview.QUAL]+$showCounts[$curShow.tvdbid][$Overview.WANTED] == 0: -#continue -#end if - - - - - - - -#for $curResult in $showSQLResults[$curShow.tvdbid]: -#set $whichStr = $str($curResult["season"]) + "x" + $str($curResult["episode"]) -#set $overview = $showCats[$curShow.tvdbid][$whichStr] -#if $overview not in ($Overview.QUAL, $Overview.WANTED): -#continue -#end if - - - - - - -#end for - -#end for - - -
    -
    -

    - $curShow.name -

    -
    - - - Force Backlog -
    -
    EpisodeNameAirdate
    $whichStr$curResult["name"]#if int($curResult["airdate"]) == 1 then "never" else $datetime.date.fromordinal(int($curResult["airdate"]))#

    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#import datetime +#from sickbeard.common import * +#set global $title="Backlog Overview" +#set global $header="Backlog Overview" + +#set global $sbPath=".." + +#set global $topmenu="manage"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +#set $totalWanted = 0 +#set $totalQual = 0 + +#for $curShow in $sickbeard.showList: +#set $totalWanted = $totalWanted + $showCounts[$curShow.tvdbid][$Overview.WANTED] +#set $totalQual = $totalQual + $showCounts[$curShow.tvdbid][$Overview.QUAL] +#end for + +
    + Wanted: $totalWanted + Low Quality: $totalQual +

    + +
    +Jump to Show + +
    + + + +#for $curShow in sorted($sickbeard.showList, key=operator.attrgetter('name')): + +#if $showCounts[$curShow.tvdbid][$Overview.QUAL]+$showCounts[$curShow.tvdbid][$Overview.WANTED] == 0: +#continue +#end if + + + + + + + +#for $curResult in $showSQLResults[$curShow.tvdbid]: +#set $whichStr = $str($curResult["season"]) + "x" + $str($curResult["episode"]) +#set $overview = $showCats[$curShow.tvdbid][$whichStr] +#if $overview not in ($Overview.QUAL, $Overview.WANTED): +#continue +#end if + + + + + + +#end for + +#end for + + +
    +

    $curShow.name

    +
    + Wanted: $showCounts[$curShow.tvdbid][$Overview.WANTED] + Low Quality: $showCounts[$curShow.tvdbid][$Overview.QUAL] + Force Backlog +
    +
    EpisodeNameAirdate
    $whichStr$curResult["name"]#if int($curResult["airdate"]) == 1 then "never" else $datetime.date.fromordinal(int($curResult["airdate"]))#
    + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/manage_episodeStatuses.tmpl b/data/interfaces/default/manage_episodeStatuses.tmpl index 0fb282ddb5..4d987460ca 100644 --- a/data/interfaces/default/manage_episodeStatuses.tmpl +++ b/data/interfaces/default/manage_episodeStatuses.tmpl @@ -1,72 +1,82 @@ -#import sickbeard -#import datetime -#from sickbeard import common -#set global $title="Episode Overview" - -#set global $sbPath=".." - -#set global $topmenu="manage"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - -#if not $whichStatus or ($whichStatus and not $ep_counts): - -#if $whichStatus: -

    None of your episodes have status $common.statusStrings[$int($whichStatus)]

    -
    -#end if - -
    -Manage episodes with status - -
    - -#else - - - -
    - - -

    Shows containing $common.statusStrings[$int($whichStatus)] episodes

    - -
    - -#if $whichStatus in ($common.ARCHIVED, $common.IGNORED, $common.SNATCHED): -#set $row_class = "good" -#else -#set $row_class = $common.Overview.overviewStrings[$whichStatus] -#end if - - -Set checked shows/episodes to - - -


    - - -#for $cur_tvdb_id in $sorted_show_ids: - - - - -#end for -
    $show_names[$cur_tvdb_id] ($ep_counts[$cur_tvdb_id])
    -
    - -#end if - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#import datetime +#from sickbeard import common +#set global $title="Episode Overview" +#set global $header="Episode Overview" + +#set global $sbPath=".." + +#set global $topmenu="manage"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + +#if not $whichStatus or ($whichStatus and not $ep_counts): + +#if $whichStatus: +

    None of your episodes have status $common.statusStrings[$int($whichStatus)]

    +
    +#end if + +
    +Manage episodes with status + +
    + +#else + + + +
    + + +

    Shows containing $common.statusStrings[$int($whichStatus)] episodes

    + +
    + +#if $whichStatus in ($common.ARCHIVED, $common.IGNORED, $common.SNATCHED): +#set $row_class = "good" +#else +#set $row_class = $common.Overview.overviewStrings[$whichStatus] +#end if + + +Set checked shows/episodes to + + + +
    + + +#for $cur_tvdb_id in $sorted_show_ids: + + + + +#end for +
    $show_names[$cur_tvdb_id] ($ep_counts[$cur_tvdb_id])
    +
    + +#end if + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/manage_manageSearches.tmpl b/data/interfaces/default/manage_manageSearches.tmpl index e8e19a0f4d..164b4af74e 100644 --- a/data/interfaces/default/manage_manageSearches.tmpl +++ b/data/interfaces/default/manage_manageSearches.tmpl @@ -2,7 +2,7 @@ #import datetime #from sickbeard.common import * #set global $title="Manage Searches" - +#set global $header="Manage Searches" #set global $sbPath=".." #set global $topmenu="manage"# @@ -10,7 +10,11 @@ #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - + #if $varExists('header') +

    $header

    + #else +

    $title

    + #end if

    Backlog Search:

    #if $backlogPaused then "Unpause" else "Pause"# @@ -35,6 +39,4 @@ In Progress
    Force Check
    -
    - #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/manage_massEdit.tmpl b/data/interfaces/default/manage_massEdit.tmpl index 96789c19e4..7a9b44b543 100644 --- a/data/interfaces/default/manage_massEdit.tmpl +++ b/data/interfaces/default/manage_massEdit.tmpl @@ -1,173 +1,185 @@ -#import sickbeard -#from sickbeard import common -#from sickbeard import exceptions -#set global $title="Mass Edit" -#set global $header="Mass Edit" - -#set global $sbPath=".." - -#set global $topmenu="manage"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") -#if $quality_value != None: -#set $initial_quality = int($quality_value) -#else: -#set $initial_quality = $common.SD -#end if -#set $anyQualities, $bestQualities = $common.Quality.splitQuality($initial_quality) - - - -
    - - -
    -Root Directories *
    - #for $cur_dir in $root_dir_list: - #set $cur_index = $root_dir_list.index($cur_dir) -
    - - $cur_dir => $cur_dir -
    - - - #end for - -
    - -
    -Quality -
    - -
    - -
    -
    -

    Inital

    - #set $anyQualityList = filter(lambda x: x > $common.Quality.NONE, $common.Quality.qualityStrings) - -
    -
    -

    Archive

    - #set $bestQualityList = filter(lambda x: x > $common.Quality.SDTV and x < $common.Quality.UNKNOWN, $common.Quality.qualityStrings) - -
    -
    -
    - -
    -Flatten Folders * -
    - -
    -
    - -
    - Paused -
    - -

    -
    -
    - Subtitles -
    - -

    -
    - -
    - Metadata Language * -
    -
    -

    -
    - -
    - Desired Audio Language -
    - -

    -
    - -
    -
    * - Changing these settings will cause the selected shows to be refreshed. - Be sure the selected show are not being refreshed -
    -
    -
    -
    - -
    -
    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import common +#from sickbeard import exceptions +#set global $title="" +#set global $header="Mass Edit" + +#set global $sbPath=".." + +#set global $topmenu="manage"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") +#if $quality_value != None: +#set $initial_quality = int($quality_value) +#else: +#set $initial_quality = $common.SD +#end if +#set $anyQualities, $bestQualities = $common.Quality.splitQuality($initial_quality) + + + + +
    + + +
    +Root Directories *
    + #for $cur_dir in $root_dir_list: + #set $cur_index = $root_dir_list.index($cur_dir) +
    + + $cur_dir => $cur_dir +
    + + + #end for + +
    + +
    +Quality +
    + +

    + +
    +
    +

    Inital

    + #set $anyQualityList = filter(lambda x: x > $common.Quality.NONE, $common.Quality.qualityStrings) + +
    +
    +

    Archive

    + #set $bestQualityList = filter(lambda x: x > $common.Quality.SDTV, $common.Quality.qualityStrings) + +
    +
    +
    +
    + +
    +Flatten Folders * +
    + +
    +
    + +
    + Paused +
    + +

    +
    + +
    +Subtitles +
    + +

    +
    + +
    + Metadata Language * +
    + +

    +
    + +
    + Desired Audio Language +
    + +

    +
    + +
    +
    * + Changing these settings will cause the selected shows to be refreshed. + Be sure the selected show are not being refreshed. Blank for metadata will keep. +
    + +
    +
    +
    + +
    +
    + + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/manage_subtitleMissed.tmpl b/data/interfaces/default/manage_subtitleMissed.tmpl index 44883eb597..137e097807 100644 --- a/data/interfaces/default/manage_subtitleMissed.tmpl +++ b/data/interfaces/default/manage_subtitleMissed.tmpl @@ -10,7 +10,11 @@ #set global $topmenu="manage"# #import os.path #include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if #if $whichSubs: #set subsLanguage = $subliminal.language.Language($whichSubs) if not $whichSubs == 'all' else 'All' #end if @@ -41,11 +45,11 @@ subtitles

    Episodes without $subsLanguage subtitles.


    Download missed subtitles for selected episodes -
    + -

    +
    #for $cur_tvdb_id in $sorted_show_ids: diff --git a/data/interfaces/default/restart_bare.tmpl b/data/interfaces/default/restart_bare.tmpl index cb8267f99c..70de6fca54 100644 --- a/data/interfaces/default/restart_bare.tmpl +++ b/data/interfaces/default/restart_bare.tmpl @@ -1,44 +1,44 @@ - - - - - -

    Performing Restart

    -
    -
    -Waiting for Sick Beard to shut down: - - -
    - - - - - - + + + + + +

    Performing Restart

    +
    +
    +Waiting for Sick Beard to shut down: + + +
    + + + + + + diff --git a/data/interfaces/default/testRename.tmpl b/data/interfaces/default/testRename.tmpl index 5dc0bfc7d9..deead6529e 100644 --- a/data/interfaces/default/testRename.tmpl +++ b/data/interfaces/default/testRename.tmpl @@ -1,77 +1,83 @@ -#import sickbeard -#from sickbeard import common -#from sickbeard import exceptions -#set global $title="Test Rename" -#set global $header = '%s' % ($show.tvdbid, $show.name) -#set global $sbPath=".." - -#set global $topmenu="home"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - - - -

    Preview of the proposed name changes

    -
    -#if int($show.air_by_date) == 1 and $sickbeard.NAMING_CUSTOM_ABD: - $sickbeard.NAMING_ABD_PATTERN -#else - $sickbeard.NAMING_PATTERN -#end if -
    - -#set $curSeason = -1 -#set $odd = False -
    - -#for $cur_ep_obj in $ep_obj_list: -#set $curLoc = $cur_ep_obj.location[len($cur_ep_obj.show.location)+1:] -#set $curExt = $curLoc.split('.')[-1] -#set $newLoc = $cur_ep_obj.proper_path() + '.' + $curExt - -#if int($cur_ep_obj.season) != $curSeason: - - - - - - - - - - - -#set $curSeason = int($cur_ep_obj.season) -#end if - -#set $odd = not $odd -#set $epStr = str($cur_ep_obj.season) + "x" + str($cur_ep_obj.episode) -#set $epList = sorted([cur_ep_obj.episode] + [x.episode for x in cur_ep_obj.relatedEps]) -#if len($epList) > 1: - #set $epList = [$min($epList), $max($epList)] -#end if - - - - - - - - -#end for -
    -
    -

    #if int($cur_ep_obj.season) == 0 then "Specials" else "Season "+str($cur_ep_obj.season)#

    -
    EpisodeOld LocationNew Location
    - " /> - <%= "-".join(map(str, epList)) %>$curLoc$newLoc

    - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import common +#from sickbeard import exceptions +#set global $title="Test Rename" +#set global $header = '%s' % ($show.tvdbid, $show.name) +#set global $sbPath=".." + +#set global $topmenu="home"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if + + + + + +

    Preview of the proposed name changes

    +
    +#if int($show.air_by_date) == 1 and $sickbeard.NAMING_CUSTOM_ABD: + $sickbeard.NAMING_ABD_PATTERN +#else + $sickbeard.NAMING_PATTERN +#end if +
    + +#set $curSeason = -1 +#set $odd = False + + +#for $cur_ep_obj in $ep_obj_list: +#set $curLoc = $cur_ep_obj.location[len($cur_ep_obj.show.location)+1:] +#set $curExt = $curLoc.split('.')[-1] +#set $newLoc = $cur_ep_obj.proper_path() + '.' + $curExt + +#if int($cur_ep_obj.season) != $curSeason: + + + + + + + + + + + +#set $curSeason = int($cur_ep_obj.season) +#end if + +#set $odd = not $odd +#set $epStr = str($cur_ep_obj.season) + "x" + str($cur_ep_obj.episode) +#set $epList = sorted([cur_ep_obj.episode] + [x.episode for x in cur_ep_obj.relatedEps]) +#if len($epList) > 1: + #set $epList = [$min($epList), $max($epList)] +#end if + + + + + + + + +#end for +
    +
    +

    #if int($cur_ep_obj.season) == 0 then "Specials" else "Season "+str($cur_ep_obj.season)#

    +
    EpisodeOld LocationNew Location
    + " /> + <%= "-".join(map(str, epList)) %>$curLoc$newLoc

    + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/interfaces/default/viewlogs.tmpl b/data/interfaces/default/viewlogs.tmpl index 261af794ca..95d72df14e 100644 --- a/data/interfaces/default/viewlogs.tmpl +++ b/data/interfaces/default/viewlogs.tmpl @@ -1,44 +1,49 @@ -#import sickbeard -#from sickbeard import classes -#from sickbeard.common import * -#from sickbeard.logger import reverseNames -#set global $title="Log File" - -#set global $sbPath = ".." - -#set global $topmenu="errorlogs"# -#import os.path -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") - - - -
    Minimum logging level to display: -
    -
    -
    -$logLines
    -
    -
    -
    - - -#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") +#import sickbeard +#from sickbeard import classes +#from sickbeard.common import * +#from sickbeard.logger import reverseNames +#set global $header="Log File" +#set global $title="" + +#set global $sbPath = ".." + +#set global $topmenu="errorlogs"# +#import os.path +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_top.tmpl") + + +#if $varExists('header') +

    $header

    +#else +

    $title

    +#end if +
    Minimum logging level to display: +
    + +
    +$logLines
    +
    +
    + + + +#include $os.path.join($sickbeard.PROG_DIR, "data/interfaces/default/inc_bottom.tmpl") diff --git a/data/js/addExistingShow.js b/data/js/addExistingShow.js index 5c78e13266..f41117d8fc 100644 --- a/data/js/addExistingShow.js +++ b/data/js/addExistingShow.js @@ -1,78 +1,78 @@ -$(document).ready(function() { - - $('#checkAll').live('click', function(){ - - var seasCheck = this; - - $('.dirCheck').each(function(){ - this.checked = seasCheck.checked; - }); - }); - - $('#submitShowDirs').click(function(){ - - var dirArr = new Array(); - - $('.dirCheck').each(function() { - - if (this.checked == true) { - dirArr.push(encodeURIComponent($(this).attr('id'))); - } - - }); - - if (dirArr.length == 0) - return false; - - url = sbRoot+'/home/addShows/addExistingShows?promptForSettings='+ ($('#promptForSettings').prop('checked') ? 'on' : 'off'); - - url += '&shows_to_add='+dirArr.join('&shows_to_add='); - - window.location.href = url; - }); - - - function loadContent() { - var url = ''; - $('.dir_check').each(function(i,w){ - if ($(w).is(':checked')) { - if (url.length) - url += '&' - url += 'rootDir=' + encodeURIComponent($(w).attr('id')); - } - }); - - $('#tableDiv').html(' loading folders...'); - $.get(sbRoot+'/home/addShows/massAddTable', url, function(data) { - $('#tableDiv').html(data); - $("#addRootDirTable").tablesorter({ - //sortList: [[1,0]], - widgets: ['zebra'], - headers: { - 0: { sorter: false } - } - }); - }); - - } - - var last_txt = ''; - $('#rootDirText').change(function() { - if (last_txt == $('#rootDirText').val()) - return false; - else - last_txt = $('#rootDirText').val(); - $('#rootDirStaticList').html(''); - $('#rootDirs option').each(function(i, w) { - $('#rootDirStaticList').append('
  • ') - }); - loadContent(); - }); - - $('.dir_check').live('click', loadContent); - - $('.showManage').live('click', function() { - $( "#tabs" ).tabs( 'select', 0 ); - }); - +$(document).ready(function() { + + $('#checkAll').live('click', function(){ + + var seasCheck = this; + + $('.dirCheck').each(function(){ + this.checked = seasCheck.checked; + }); + }); + + $('#submitShowDirs').click(function(){ + + var dirArr = new Array(); + + $('.dirCheck').each(function() { + + if (this.checked == true) { + dirArr.push(encodeURIComponent($(this).attr('id'))); + } + + }); + + if (dirArr.length == 0) + return false; + + url = sbRoot+'/home/addShows/addExistingShows?promptForSettings='+ ($('#promptForSettings').prop('checked') ? 'on' : 'off'); + + url += '&shows_to_add='+dirArr.join('&shows_to_add='); + + window.location.href = url; + }); + + + function loadContent() { + var url = ''; + $('.dir_check').each(function(i,w){ + if ($(w).is(':checked')) { + if (url.length) + url += '&' + url += 'rootDir=' + encodeURIComponent($(w).attr('id')); + } + }); + + $('#tableDiv').html(' loading folders...'); + $.get(sbRoot+'/home/addShows/massAddTable', url, function(data) { + $('#tableDiv').html(data); + $("#addRootDirTable").tablesorter({ + //sortList: [[1,0]], + widgets: ['zebra'], + headers: { + 0: { sorter: false } + } + }); + }); + + } + + var last_txt = ''; + $('#rootDirText').change(function() { + if (last_txt == $('#rootDirText').val()) + return false; + else + last_txt = $('#rootDirText').val(); + $('#rootDirStaticList').html(''); + $('#rootDirs option').each(function(i, w) { + $('#rootDirStaticList').append('
  • ') + }); + loadContent(); + }); + + $('.dir_check').live('click', loadContent); + + $('.showManage').live('click', function() { + $( "#tabs" ).tabs( 'select', 0 ); + }); + }); \ No newline at end of file diff --git a/data/js/addShowOptions.js b/data/js/addShowOptions.js index 21ab36b3f6..8d7ad002d4 100644 --- a/data/js/addShowOptions.js +++ b/data/js/addShowOptions.js @@ -1,27 +1,27 @@ -$(document).ready(function () { - - $('#saveDefaultsButton').click(function () { - var anyQualArray = []; - var bestQualArray = []; - $('#anyQualities option:selected').each(function (i, d) {anyQualArray.push($(d).val()); }); - $('#bestQualities option:selected').each(function (i, d) {bestQualArray.push($(d).val()); }); - - $.get(sbRoot + '/config/general/saveAddShowDefaults', {defaultStatus: $('#statusSelect').val(), - anyQualities: anyQualArray.join(','), - bestQualities: bestQualArray.join(','), - audio_lang: $('#showLangSelect').val(), - subtitles: $('#subtitles').prop('checked'), - defaultFlattenFolders: $('#flatten_folders').prop('checked')}); - $(this).attr('disabled', true); - $.pnotify({ - pnotify_title: 'Saved Defaults', - pnotify_text: 'Your "add show" defaults have been set to your current selections.', - pnotify_shadow: false - }); - }); - - $('#statusSelect, #qualityPreset, #flatten_folders, #anyQualities, #bestQualities ,#showLangSelect, #subtitles').change(function () { - $('#saveDefaultsButton').attr('disabled', false); - }); - +$(document).ready(function () { + + $('#saveDefaultsButton').click(function () { + var anyQualArray = []; + var bestQualArray = []; + $('#anyQualities option:selected').each(function (i, d) {anyQualArray.push($(d).val()); }); + $('#bestQualities option:selected').each(function (i, d) {bestQualArray.push($(d).val()); }); + + $.get(sbRoot + '/config/general/saveAddShowDefaults', {defaultStatus: $('#statusSelect').val(), + anyQualities: anyQualArray.join(','), + bestQualities: bestQualArray.join(','), + audio_lang: $('#showLangSelect').val(), + subtitles: $('#subtitles').prop('checked'), + defaultFlattenFolders: $('#flatten_folders').prop('checked')}); + $(this).attr('disabled', true); + $.pnotify({ + pnotify_title: 'Saved Defaults', + pnotify_text: 'Your "add show" defaults have been set to your current selections.', + pnotify_shadow: false + }); + }); + + $('#statusSelect, #qualityPreset, #flatten_folders, #anyQualities, #bestQualities ,#showLangSelect, #subtitles').change(function () { + $('#saveDefaultsButton').attr('disabled', false); + }); + }); \ No newline at end of file diff --git a/data/js/ajaxNotifications.js b/data/js/ajaxNotifications.js index 66d07b0915..8f1a12a513 100644 --- a/data/js/ajaxNotifications.js +++ b/data/js/ajaxNotifications.js @@ -1,26 +1,24 @@ var message_url = sbRoot + '/ui/get_messages'; $.pnotify.defaults.pnotify_width = "340px"; -$.pnotify.defaults.styling = "jqueryui"; $.pnotify.defaults.pnotify_history = false; $.pnotify.defaults.pnotify_delay = 4000; function check_notifications() { - $.getJSON(message_url, function (data) { - $.each(data, function (name, data) { + $.getJSON(message_url, function(data){ + $.each(data, function(name,data){ $.pnotify({ pnotify_type: data.type, pnotify_hide: data.type == 'notice', pnotify_title: data.title, - pnotify_text: data.message, - pnotify_shadow: false + pnotify_text: data.message }); }); }); - - setTimeout(check_notifications, 3000); + + setTimeout(check_notifications, 3000) } -$(document).ready(function () { +$(document).ready(function(){ check_notifications(); diff --git a/data/js/browser.js b/data/js/browser.js index 33091dba79..2f5141ea7b 100644 --- a/data/js/browser.js +++ b/data/js/browser.js @@ -1,178 +1,171 @@ -;(function($) { -"use strict"; - - $.Browser = { - defaults: { - title: 'Choose Directory', - url: sbRoot + '/browser/', - autocompleteURL: sbRoot + '/browser/complete' - } - }; - - var fileBrowserDialog, currentBrowserPath, currentRequest = null; - - function browse(path, endpoint) { - - if (currentBrowserPath === path) { - return; - } - - currentBrowserPath = path; - - if (currentRequest) { - currentRequest.abort(); - } - - fileBrowserDialog.dialog('option', 'dialogClass', 'browserDialog busy'); - - currentRequest = $.getJSON(endpoint, { path: path }, function (data) { - fileBrowserDialog.empty(); - var first_val = data[0]; - var i = 0; - var list, link = null; - data = $.grep(data, function (value) { - return i++ != 0; - }); - $('

    ').text(first_val.current_path).appendTo(fileBrowserDialog); - list = $('

    \1
    '), + (re.compile('(\n
    \s+)
    ', re.I), r'\1'), + (re.compile('(
    )'), r'
    \1'), + (re.compile('

    ', re.I), r'\n\n') + ] + + +def _build_episode(link, title, minfo, role, roleA, roleAID): + """Build an Movie object for a given episode of a series.""" + episode_id = analyze_imdbid(link) + notes = u'' + minidx = minfo.find(' -') + # Sometimes, for some unknown reason, the role is left in minfo. + if minidx != -1: + slfRole = minfo[minidx+3:].lstrip() + minfo = minfo[:minidx].rstrip() + if slfRole.endswith(')'): + commidx = slfRole.rfind('(') + if commidx != -1: + notes = slfRole[commidx:] + slfRole = slfRole[:commidx] + if slfRole and role is None and roleA is None: + role = slfRole + eps_data = analyze_title(title) + eps_data['kind'] = u'episode' + # FIXME: it's wrong for multiple characters (very rare on tv series?). + if role is None: + role = roleA # At worse, it's None. + if role is None: + roleAID = None + if roleAID is not None: + roleAID = analyze_imdbid(roleAID) + e = Movie(movieID=episode_id, data=eps_data, currentRole=role, + roleID=roleAID, notes=notes) + # XXX: are we missing some notes? + # XXX: does it parse things as "Episode dated 12 May 2005 (12 May 2005)"? + if minfo.startswith('('): + pe = minfo.find(')') + if pe != -1: + date = minfo[1:pe] + if date != '????': + e['original air date'] = date + if eps_data.get('year', '????') == '????': + syear = date.split()[-1] + if syear.isdigit(): + e['year'] = int(syear) + return e + + +class DOMHTMLSeriesParser(DOMParserBase): + """Parser for the "by TV series" page of a given person. + The page should be provided as a string, as taken from + the akas.imdb.com server. The final result will be a + dictionary, with a key for every relevant section. + + Example: + sparser = DOMHTMLSeriesParser() + result = sparser.parse(filmoseries_html_string) + """ + _containsObjects = True + + extractors = [ + Extractor(label='series', + group="//div[@class='filmo']/span[1]", + group_key="./a[1]", + path="./following-sibling::ol[1]/li/a[1]", + attrs=Attribute(key=None, + multi=True, + path={ + 'link': "./@href", + 'title': "./text()", + 'info': "./following-sibling::text()", + 'role': "./following-sibling::i[1]/text()", + 'roleA': "./following-sibling::a[1]/text()", + 'roleAID': "./following-sibling::a[1]/@href" + }, + postprocess=lambda x: _build_episode(x.get('link'), + x.get('title'), + (x.get('info') or u'').strip(), + x.get('role'), + x.get('roleA'), + x.get('roleAID')))) + ] + + def postprocess_data(self, data): + if len(data) == 0: + return {} + nd = {} + for key in data.keys(): + dom = self.get_dom(key) + link = self.xpath(dom, "//a/@href")[0] + title = self.xpath(dom, "//a/text()")[0][1:-1] + series = Movie(movieID=analyze_imdbid(link), + data=analyze_title(title), + accessSystem=self._as, modFunct=self._modFunct) + nd[series] = [] + for episode in data[key]: + # XXX: should we create a copy of 'series', to avoid + # circular references? + episode['episode of'] = series + nd[series].append(episode) + return {'episodes': nd} + + +class DOMHTMLPersonGenresParser(DOMParserBase): + """Parser for the "by genre" and "by keywords" pages of a given person. + The page should be provided as a string, as taken from + the akas.imdb.com server. The final result will be a + dictionary, with a key for every relevant section. + + Example: + gparser = DOMHTMLPersonGenresParser() + result = gparser.parse(bygenre_html_string) + """ + kind = 'genres' + _containsObjects = True + + extractors = [ + Extractor(label='genres', + group="//b/a[@name]/following-sibling::a[1]", + group_key="./text()", + group_key_normalize=lambda x: x.lower(), + path="../../following-sibling::ol[1]/li//a[1]", + attrs=Attribute(key=None, + multi=True, + path={ + 'link': "./@href", + 'title': "./text()", + 'info': "./following-sibling::text()" + }, + postprocess=lambda x: \ + build_movie(x.get('title') + \ + x.get('info').split('[')[0], + analyze_imdbid(x.get('link'))))) + ] + + def postprocess_data(self, data): + if len(data) == 0: + return {} + return {self.kind: data} + + +from movieParser import DOMHTMLTechParser +from movieParser import DOMHTMLOfficialsitesParser +from movieParser import DOMHTMLAwardsParser +from movieParser import DOMHTMLNewsParser + + +_OBJECTS = { + 'maindetails_parser': ((DOMHTMLMaindetailsParser,), None), + 'bio_parser': ((DOMHTMLBioParser,), None), + 'otherworks_parser': ((DOMHTMLOtherWorksParser,), None), + #'agent_parser': ((DOMHTMLOtherWorksParser,), {'kind': 'agent'}), + 'person_officialsites_parser': ((DOMHTMLOfficialsitesParser,), None), + 'person_awards_parser': ((DOMHTMLAwardsParser,), {'subject': 'name'}), + 'publicity_parser': ((DOMHTMLTechParser,), {'kind': 'publicity'}), + 'person_series_parser': ((DOMHTMLSeriesParser,), None), + 'person_contacts_parser': ((DOMHTMLTechParser,), {'kind': 'contacts'}), + 'person_genres_parser': ((DOMHTMLPersonGenresParser,), None), + 'person_keywords_parser': ((DOMHTMLPersonGenresParser,), + {'kind': 'keywords'}), + 'news_parser': ((DOMHTMLNewsParser,), None), +} + diff --git a/lib/imdb/parser/http/searchCharacterParser.py b/lib/imdb/parser/http/searchCharacterParser.py new file mode 100644 index 0000000000..c81ca7e4a8 --- /dev/null +++ b/lib/imdb/parser/http/searchCharacterParser.py @@ -0,0 +1,69 @@ +""" +parser.http.searchCharacterParser module (imdb package). + +This module provides the HTMLSearchCharacterParser class (and the +search_character_parser instance), used to parse the results of a search +for a given character. +E.g., when searching for the name "Jesse James", the parsed page would be: + http://akas.imdb.com/find?s=Characters;mx=20;q=Jesse+James + +Copyright 2007-2009 Davide Alberani + 2008 H. Turgut Uyar + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +from imdb.utils import analyze_name, build_name +from utils import Extractor, Attribute, analyze_imdbid + +from searchMovieParser import DOMHTMLSearchMovieParser, DOMBasicMovieParser + + +class DOMBasicCharacterParser(DOMBasicMovieParser): + """Simply get the name of a character and the imdbID. + + It's used by the DOMHTMLSearchCharacterParser class to return a result + for a direct match (when a search on IMDb results in a single + character, the web server sends directly the movie page.""" + _titleFunct = lambda self, x: analyze_name(x or u'', canonical=False) + + +class DOMHTMLSearchCharacterParser(DOMHTMLSearchMovieParser): + _BaseParser = DOMBasicCharacterParser + _notDirectHitTitle = 'imdb search' + _titleBuilder = lambda self, x: build_name(x, canonical=False) + _linkPrefix = '/character/ch' + + _attrs = [Attribute(key='data', + multi=True, + path={ + 'link': "./a[1]/@href", + 'name': "./a[1]/text()" + }, + postprocess=lambda x: ( + analyze_imdbid(x.get('link') or u''), + {'name': x.get('name')} + ))] + extractors = [Extractor(label='search', + path="//td[3]/a[starts-with(@href, " \ + "'/character/ch')]/..", + attrs=_attrs)] + + +_OBJECTS = { + 'search_character_parser': ((DOMHTMLSearchCharacterParser,), + {'kind': 'character', '_basic_parser': DOMBasicCharacterParser}) +} + diff --git a/lib/imdb/parser/http/searchCompanyParser.py b/lib/imdb/parser/http/searchCompanyParser.py new file mode 100644 index 0000000000..ab666fbc30 --- /dev/null +++ b/lib/imdb/parser/http/searchCompanyParser.py @@ -0,0 +1,71 @@ +""" +parser.http.searchCompanyParser module (imdb package). + +This module provides the HTMLSearchCompanyParser class (and the +search_company_parser instance), used to parse the results of a search +for a given company. +E.g., when searching for the name "Columbia Pictures", the parsed page would be: + http://akas.imdb.com/find?s=co;mx=20;q=Columbia+Pictures + +Copyright 2008-2009 Davide Alberani <da@erlug.linux.it> + 2008 H. Turgut Uyar <uyar@tekir.org> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +from imdb.utils import analyze_company_name, build_company_name +from utils import Extractor, Attribute, analyze_imdbid + +from searchMovieParser import DOMHTMLSearchMovieParser, DOMBasicMovieParser + +class DOMBasicCompanyParser(DOMBasicMovieParser): + """Simply get the name of a company and the imdbID. + + It's used by the DOMHTMLSearchCompanyParser class to return a result + for a direct match (when a search on IMDb results in a single + company, the web server sends directly the company page. + """ + _titleFunct = lambda self, x: analyze_company_name(x or u'') + + +class DOMHTMLSearchCompanyParser(DOMHTMLSearchMovieParser): + _BaseParser = DOMBasicCompanyParser + _notDirectHitTitle = '<title>imdb company' + _titleBuilder = lambda self, x: build_company_name(x) + _linkPrefix = '/company/co' + + _attrs = [Attribute(key='data', + multi=True, + path={ + 'link': "./a[1]/@href", + 'name': "./a[1]/text()", + 'notes': "./text()[1]" + }, + postprocess=lambda x: ( + analyze_imdbid(x.get('link')), + analyze_company_name(x.get('name')+(x.get('notes') + or u''), stripNotes=True) + ))] + extractors = [Extractor(label='search', + path="//td[3]/a[starts-with(@href, " \ + "'/company/co')]/..", + attrs=_attrs)] + + +_OBJECTS = { + 'search_company_parser': ((DOMHTMLSearchCompanyParser,), + {'kind': 'company', '_basic_parser': DOMBasicCompanyParser}) +} + diff --git a/lib/imdb/parser/http/searchKeywordParser.py b/lib/imdb/parser/http/searchKeywordParser.py new file mode 100644 index 0000000000..ed72906cef --- /dev/null +++ b/lib/imdb/parser/http/searchKeywordParser.py @@ -0,0 +1,111 @@ +""" +parser.http.searchKeywordParser module (imdb package). + +This module provides the HTMLSearchKeywordParser class (and the +search_company_parser instance), used to parse the results of a search +for a given keyword. +E.g., when searching for the keyword "alabama", the parsed page would be: + http://akas.imdb.com/find?s=kw;mx=20;q=alabama + +Copyright 2009 Davide Alberani <da@erlug.linux.it> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +from utils import Extractor, Attribute, analyze_imdbid +from imdb.utils import analyze_title, analyze_company_name + +from searchMovieParser import DOMHTMLSearchMovieParser, DOMBasicMovieParser + +class DOMBasicKeywordParser(DOMBasicMovieParser): + """Simply get the name of a keyword. + + It's used by the DOMHTMLSearchKeywordParser class to return a result + for a direct match (when a search on IMDb results in a single + keyword, the web server sends directly the keyword page. + """ + # XXX: it's still to be tested! + # I'm not even sure there can be a direct hit, searching for keywords. + _titleFunct = lambda self, x: analyze_company_name(x or u'') + + +class DOMHTMLSearchKeywordParser(DOMHTMLSearchMovieParser): + """Parse the html page that the IMDb web server shows when the + "new search system" is used, searching for keywords similar to + the one given.""" + + _BaseParser = DOMBasicKeywordParser + _notDirectHitTitle = '<title>imdb keyword' + _titleBuilder = lambda self, x: x + _linkPrefix = '/keyword/' + + _attrs = [Attribute(key='data', + multi=True, + path="./a[1]/text()" + )] + extractors = [Extractor(label='search', + path="//td[3]/a[starts-with(@href, " \ + "'/keyword/')]/..", + attrs=_attrs)] + + +def custom_analyze_title4kwd(title, yearNote, outline): + """Return a dictionary with the needed info.""" + title = title.strip() + if not title: + return {} + if yearNote: + yearNote = '%s)' % yearNote.split(' ')[0] + title = title + ' ' + yearNote + retDict = analyze_title(title) + if outline: + retDict['plot outline'] = outline + return retDict + + +class DOMHTMLSearchMovieKeywordParser(DOMHTMLSearchMovieParser): + """Parse the html page that the IMDb web server shows when the + "new search system" is used, searching for movies with the given + keyword.""" + + _notDirectHitTitle = '<title>best' + + _attrs = [Attribute(key='data', + multi=True, + path={ + 'link': "./a[1]/@href", + 'info': "./a[1]//text()", + 'ynote': "./span[@class='desc']/text()", + 'outline': "./span[@class='outline']//text()" + }, + postprocess=lambda x: ( + analyze_imdbid(x.get('link') or u''), + custom_analyze_title4kwd(x.get('info') or u'', + x.get('ynote') or u'', + x.get('outline') or u'') + ))] + + extractors = [Extractor(label='search', + path="//td[3]/a[starts-with(@href, " \ + "'/title/tt')]/..", + attrs=_attrs)] + + +_OBJECTS = { + 'search_keyword_parser': ((DOMHTMLSearchKeywordParser,), + {'kind': 'keyword', '_basic_parser': DOMBasicKeywordParser}), + 'search_moviekeyword_parser': ((DOMHTMLSearchMovieKeywordParser,), None) +} + diff --git a/lib/imdb/parser/http/searchMovieParser.py b/lib/imdb/parser/http/searchMovieParser.py new file mode 100644 index 0000000000..44c78d0cc5 --- /dev/null +++ b/lib/imdb/parser/http/searchMovieParser.py @@ -0,0 +1,182 @@ +""" +parser.http.searchMovieParser module (imdb package). + +This module provides the HTMLSearchMovieParser class (and the +search_movie_parser instance), used to parse the results of a search +for a given title. +E.g., for when searching for the title "the passion", the parsed +page would be: + http://akas.imdb.com/find?q=the+passion&tt=on&mx=20 + +Copyright 2004-2010 Davide Alberani <da@erlug.linux.it> + 2008 H. Turgut Uyar <uyar@tekir.org> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import re +from imdb.utils import analyze_title, build_title +from utils import DOMParserBase, Attribute, Extractor, analyze_imdbid + + +class DOMBasicMovieParser(DOMParserBase): + """Simply get the title of a movie and the imdbID. + + It's used by the DOMHTMLSearchMovieParser class to return a result + for a direct match (when a search on IMDb results in a single + movie, the web server sends directly the movie page.""" + # Stay generic enough to be used also for other DOMBasic*Parser classes. + _titleAttrPath = ".//text()" + _linkPath = "//link[@rel='canonical']" + _titleFunct = lambda self, x: analyze_title(x or u'') + + def _init(self): + self.preprocessors += [('<span class="tv-extra">TV mini-series</span>', + '<span class="tv-extra">(mini)</span>')] + self.extractors = [Extractor(label='title', + path="//h1", + attrs=Attribute(key='title', + path=self._titleAttrPath, + postprocess=self._titleFunct)), + Extractor(label='link', + path=self._linkPath, + attrs=Attribute(key='link', path="./@href", + postprocess=lambda x: \ + analyze_imdbid((x or u'').replace( + 'http://pro.imdb.com', '')) + ))] + + # Remove 'More at IMDb Pro' links. + preprocessors = [(re.compile(r'<span class="pro-link".*?</span>'), ''), + (re.compile(r'<a href="http://ad.doubleclick.net.*?;id=(co[0-9]{7});'), r'<a href="http://pro.imdb.com/company/\1"></a>< a href="')] + + def postprocess_data(self, data): + if not 'link' in data: + data = [] + else: + link = data.pop('link') + if (link and data): + data = [(link, data)] + else: + data = [] + return data + + +def custom_analyze_title(title): + """Remove garbage notes after the (year), (year/imdbIndex) or (year) (TV)""" + # XXX: very crappy. :-( + nt = title.split(' ')[0] + if nt: + title = nt + if not title: + return {} + return analyze_title(title) + +# Manage AKAs. +_reAKAStitles = re.compile(r'(?:aka) <em>"(.*?)(<br>|<\/td>)', re.I | re.M) + +class DOMHTMLSearchMovieParser(DOMParserBase): + """Parse the html page that the IMDb web server shows when the + "new search system" is used, for movies.""" + + _BaseParser = DOMBasicMovieParser + _notDirectHitTitle = '<title>imdb title' + _titleBuilder = lambda self, x: build_title(x) + _linkPrefix = '/title/tt' + + _attrs = [Attribute(key='data', + multi=True, + path={ + 'link': "./a[1]/@href", + 'info': ".//text()", + #'akas': ".//div[@class='_imdbpyAKA']//text()" + 'akas': ".//p[@class='find-aka']//text()" + }, + postprocess=lambda x: ( + analyze_imdbid(x.get('link') or u''), + custom_analyze_title(x.get('info') or u''), + x.get('akas') + ))] + extractors = [Extractor(label='search', + path="//td[3]/a[starts-with(@href, '/title/tt')]/..", + attrs=_attrs)] + def _init(self): + self.url = u'' + + def _reset(self): + self.url = u'' + + def preprocess_string(self, html_string): + if self._notDirectHitTitle in html_string[:1024].lower(): + if self._linkPrefix == '/title/tt': + # Only for movies. + html_string = html_string.replace('(TV mini-series)', '(mini)') + html_string = html_string.replace('<p class="find-aka">', + '<p class="find-aka">::') + #html_string = _reAKAStitles.sub( + # r'<div class="_imdbpyAKA">\1::</div>\2', html_string) + return html_string + # Direct hit! + dbme = self._BaseParser(useModule=self._useModule) + res = dbme.parse(html_string, url=self.url) + if not res: return u'' + res = res['data'] + if not (res and res[0]): return u'' + link = '%s%s' % (self._linkPrefix, res[0][0]) + # # Tries to cope with companies for which links to pro.imdb.com + # # are missing. + # link = self.url.replace(imdbURL_base[:-1], '') + title = self._titleBuilder(res[0][1]) + if not (link and title): return u'' + link = link.replace('http://pro.imdb.com', '') + new_html = '<td></td><td></td><td><a href="%s">%s</a></td>' % (link, + title) + return new_html + + def postprocess_data(self, data): + if not data.has_key('data'): + data['data'] = [] + results = getattr(self, 'results', None) + if results is not None: + data['data'][:] = data['data'][:results] + # Horrible hack to support AKAs. + if data and data['data'] and len(data['data'][0]) == 3 and \ + isinstance(data['data'][0], tuple): + data['data'] = [x for x in data['data'] if x[0] and x[1]] + for idx, datum in enumerate(data['data']): + if not isinstance(datum, tuple): + continue + if not datum[0] and datum[1]: + continue + if datum[2] is not None: + akas = filter(None, datum[2].split('::')) + if self._linkPrefix == '/title/tt': + akas = [a.replace('" - ', '::').rstrip() for a in akas] + akas = [a.replace('aka "', '', 1).replace('aka "', + '', 1).lstrip() for a in akas] + datum[1]['akas'] = akas + data['data'][idx] = (datum[0], datum[1]) + else: + data['data'][idx] = (datum[0], datum[1]) + return data + + def add_refs(self, data): + return data + + +_OBJECTS = { + 'search_movie_parser': ((DOMHTMLSearchMovieParser,), None) +} + diff --git a/lib/imdb/parser/http/searchPersonParser.py b/lib/imdb/parser/http/searchPersonParser.py new file mode 100644 index 0000000000..1756efc5ea --- /dev/null +++ b/lib/imdb/parser/http/searchPersonParser.py @@ -0,0 +1,92 @@ +""" +parser.http.searchPersonParser module (imdb package). + +This module provides the HTMLSearchPersonParser class (and the +search_person_parser instance), used to parse the results of a search +for a given person. +E.g., when searching for the name "Mel Gibson", the parsed page would be: + http://akas.imdb.com/find?q=Mel+Gibson&nm=on&mx=20 + +Copyright 2004-2010 Davide Alberani <da@erlug.linux.it> + 2008 H. Turgut Uyar <uyar@tekir.org> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import re +from imdb.utils import analyze_name, build_name +from utils import Extractor, Attribute, analyze_imdbid + +from searchMovieParser import DOMHTMLSearchMovieParser, DOMBasicMovieParser + + +def _cleanName(n): + """Clean the name in a title tag.""" + if not n: + return u'' + n = n.replace('Filmography by type for', '') # FIXME: temporary. + return n + +class DOMBasicPersonParser(DOMBasicMovieParser): + """Simply get the name of a person and the imdbID. + + It's used by the DOMHTMLSearchPersonParser class to return a result + for a direct match (when a search on IMDb results in a single + person, the web server sends directly the movie page.""" + _titleFunct = lambda self, x: analyze_name(_cleanName(x), canonical=1) + + +_reAKASp = re.compile(r'(?:aka|birth name) (<em>")(.*?)"(<br>|<\/em>|<\/td>)', + re.I | re.M) + +class DOMHTMLSearchPersonParser(DOMHTMLSearchMovieParser): + """Parse the html page that the IMDb web server shows when the + "new search system" is used, for persons.""" + _BaseParser = DOMBasicPersonParser + _notDirectHitTitle = '<title>imdb name' + _titleBuilder = lambda self, x: build_name(x, canonical=True) + _linkPrefix = '/name/nm' + + _attrs = [Attribute(key='data', + multi=True, + path={ + 'link': "./a[1]/@href", + 'name': "./a[1]/text()", + 'index': "./text()[1]", + 'akas': ".//div[@class='_imdbpyAKA']/text()" + }, + postprocess=lambda x: ( + analyze_imdbid(x.get('link') or u''), + analyze_name((x.get('name') or u'') + \ + (x.get('index') or u''), + canonical=1), x.get('akas') + ))] + extractors = [Extractor(label='search', + path="//td[3]/a[starts-with(@href, '/name/nm')]/..", + attrs=_attrs)] + + def preprocess_string(self, html_string): + if self._notDirectHitTitle in html_string[:1024].lower(): + html_string = _reAKASp.sub( + r'\1<div class="_imdbpyAKA">\2::</div>\3', + html_string) + return DOMHTMLSearchMovieParser.preprocess_string(self, html_string) + + +_OBJECTS = { + 'search_person_parser': ((DOMHTMLSearchPersonParser,), + {'kind': 'person', '_basic_parser': DOMBasicPersonParser}) +} + diff --git a/lib/imdb/parser/http/topBottomParser.py b/lib/imdb/parser/http/topBottomParser.py new file mode 100644 index 0000000000..f0f29509fd --- /dev/null +++ b/lib/imdb/parser/http/topBottomParser.py @@ -0,0 +1,106 @@ +""" +parser.http.topBottomParser module (imdb package). + +This module provides the classes (and the instances), used to parse the +lists of top 250 and bottom 100 movies. +E.g.: + http://akas.imdb.com/chart/top + http://akas.imdb.com/chart/bottom + +Copyright 2009 Davide Alberani <da@erlug.linux.it> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +from imdb.utils import analyze_title +from utils import DOMParserBase, Attribute, Extractor, analyze_imdbid + + +class DOMHTMLTop250Parser(DOMParserBase): + """Parser for the "top 250" page. + The page should be provided as a string, as taken from + the akas.imdb.com server. The final result will be a + dictionary, with a key for every relevant section. + + Example: + tparser = DOMHTMLTop250Parser() + result = tparser.parse(top250_html_string) + """ + label = 'top 250' + ranktext = 'top 250 rank' + + def _init(self): + self.extractors = [Extractor(label=self.label, + path="//div[@id='main']//table//tr", + attrs=Attribute(key=None, + multi=True, + path={self.ranktext: "./td[1]//text()", + 'rating': "./td[2]//text()", + 'title': "./td[3]//text()", + 'movieID': "./td[3]//a/@href", + 'votes': "./td[4]//text()" + }))] + + def postprocess_data(self, data): + if not data or self.label not in data: + return [] + mlist = [] + data = data[self.label] + # Avoid duplicates. A real fix, using XPath, is auspicabile. + # XXX: probably this is no more needed. + seenIDs = [] + for d in data: + if 'movieID' not in d: continue + if self.ranktext not in d: continue + if 'title' not in d: continue + theID = analyze_imdbid(d['movieID']) + if theID is None: + continue + theID = str(theID) + if theID in seenIDs: + continue + seenIDs.append(theID) + minfo = analyze_title(d['title']) + try: minfo[self.ranktext] = int(d[self.ranktext].replace('.', '')) + except: pass + if 'votes' in d: + try: minfo['votes'] = int(d['votes'].replace(',', '')) + except: pass + if 'rating' in d: + try: minfo['rating'] = float(d['rating']) + except: pass + mlist.append((theID, minfo)) + return mlist + + +class DOMHTMLBottom100Parser(DOMHTMLTop250Parser): + """Parser for the "bottom 100" page. + The page should be provided as a string, as taken from + the akas.imdb.com server. The final result will be a + dictionary, with a key for every relevant section. + + Example: + tparser = DOMHTMLBottom100Parser() + result = tparser.parse(bottom100_html_string) + """ + label = 'bottom 100' + ranktext = 'bottom 100 rank' + + +_OBJECTS = { + 'top250_parser': ((DOMHTMLTop250Parser,), None), + 'bottom100_parser': ((DOMHTMLBottom100Parser,), None) +} + diff --git a/lib/imdb/parser/http/utils.py b/lib/imdb/parser/http/utils.py new file mode 100644 index 0000000000..f8dbc050b0 --- /dev/null +++ b/lib/imdb/parser/http/utils.py @@ -0,0 +1,876 @@ +""" +parser.http.utils module (imdb package). + +This module provides miscellaneous utilities used by +the imdb.parser.http classes. + +Copyright 2004-2012 Davide Alberani <da@erlug.linux.it> + 2008 H. Turgut Uyar <uyar@tekir.org> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import re +import logging +import warnings + +from imdb._exceptions import IMDbError + +from imdb.utils import flatten, _Container +from imdb.Movie import Movie +from imdb.Person import Person +from imdb.Character import Character + + +# Year, imdbIndex and kind. +re_yearKind_index = re.compile(r'(\([0-9\?]{4}(?:/[IVXLCDM]+)?\)(?: \(mini\)| \(TV\)| \(V\)| \(VG\))?)') + +# Match imdb ids in href tags +re_imdbid = re.compile(r'(title/tt|name/nm|character/ch|company/co)([0-9]+)') + +def analyze_imdbid(href): + """Return an imdbID from an URL.""" + if not href: + return None + match = re_imdbid.search(href) + if not match: + return None + return str(match.group(2)) + + +_modify_keys = list(Movie.keys_tomodify_list) + list(Person.keys_tomodify_list) +def _putRefs(d, re_titles, re_names, re_characters, lastKey=None): + """Iterate over the strings inside list items or dictionary values, + substitutes movie titles and person names with the (qv) references.""" + if isinstance(d, list): + for i in xrange(len(d)): + if isinstance(d[i], (unicode, str)): + if lastKey in _modify_keys: + if re_names: + d[i] = re_names.sub(ur"'\1' (qv)", d[i]) + if re_titles: + d[i] = re_titles.sub(ur'_\1_ (qv)', d[i]) + if re_characters: + d[i] = re_characters.sub(ur'#\1# (qv)', d[i]) + elif isinstance(d[i], (list, dict)): + _putRefs(d[i], re_titles, re_names, re_characters, + lastKey=lastKey) + elif isinstance(d, dict): + for k, v in d.items(): + lastKey = k + if isinstance(v, (unicode, str)): + if lastKey in _modify_keys: + if re_names: + d[k] = re_names.sub(ur"'\1' (qv)", v) + if re_titles: + d[k] = re_titles.sub(ur'_\1_ (qv)', v) + if re_characters: + d[k] = re_characters.sub(ur'#\1# (qv)', v) + elif isinstance(v, (list, dict)): + _putRefs(d[k], re_titles, re_names, re_characters, + lastKey=lastKey) + + +# Handle HTML/XML/SGML entities. +from htmlentitydefs import entitydefs +entitydefs = entitydefs.copy() +entitydefsget = entitydefs.get +entitydefs['nbsp'] = ' ' + +sgmlentity = {'lt': '<', 'gt': '>', 'amp': '&', 'quot': '"', 'apos': '\'', 'ndash': '-'} +sgmlentityget = sgmlentity.get +_sgmlentkeys = sgmlentity.keys() + +entcharrefs = {} +entcharrefsget = entcharrefs.get +for _k, _v in entitydefs.items(): + if _k in _sgmlentkeys: continue + if _v[0:2] == '&#': + dec_code = _v[1:-1] + _v = unichr(int(_v[2:-1])) + entcharrefs[dec_code] = _v + else: + dec_code = '#' + str(ord(_v)) + _v = unicode(_v, 'latin_1', 'replace') + entcharrefs[dec_code] = _v + entcharrefs[_k] = _v +del _sgmlentkeys, _k, _v +entcharrefs['#160'] = u' ' +entcharrefs['#xA0'] = u' ' +entcharrefs['#xa0'] = u' ' +entcharrefs['#XA0'] = u' ' +entcharrefs['#x22'] = u'"' +entcharrefs['#X22'] = u'"' +# convert &x26; to &, to make BeautifulSoup happy; beware that this +# leaves lone '&' in the html broken, but I assume this is better than +# the contrary... +entcharrefs['#38'] = u'&' +entcharrefs['#x26'] = u'&' +entcharrefs['#x26'] = u'&' + +re_entcharrefs = re.compile('&(%s|\#160|\#\d{1,5}|\#x[0-9a-f]{1,4});' % + '|'.join(map(re.escape, entcharrefs)), re.I) +re_entcharrefssub = re_entcharrefs.sub + +sgmlentity.update(dict([('#34', u'"'), ('#38', u'&'), + ('#60', u'<'), ('#62', u'>'), ('#39', u"'")])) +re_sgmlref = re.compile('&(%s);' % '|'.join(map(re.escape, sgmlentity))) +re_sgmlrefsub = re_sgmlref.sub + +# Matches XML-only single tags, like <br/> ; they are invalid in HTML, +# but widely used by IMDb web site. :-/ +re_xmltags = re.compile('<([a-zA-Z]+)/>') + + +def _replXMLRef(match): + """Replace the matched XML/HTML entities and references; + replace everything except sgml entities like <, >, ...""" + ref = match.group(1) + value = entcharrefsget(ref) + if value is None: + if ref[0] == '#': + ref_code = ref[1:] + if ref_code in ('34', '38', '60', '62', '39'): + return match.group(0) + elif ref_code[0].lower() == 'x': + #if ref[2:] == '26': + # # Don't convert &x26; to &, to make BeautifulSoup happy. + # return '&' + return unichr(int(ref[2:], 16)) + else: + return unichr(int(ref[1:])) + else: + return ref + return value + +def subXMLRefs(s): + """Return the given html string with entity and char references + replaced.""" + return re_entcharrefssub(_replXMLRef, s) + +# XXX: no more used here; move it to mobile (they are imported by helpers, too)? +def _replSGMLRefs(match): + """Replace the matched SGML entity.""" + ref = match.group(1) + return sgmlentityget(ref, ref) + +def subSGMLRefs(s): + """Return the given html string with sgml entity and char references + replaced.""" + return re_sgmlrefsub(_replSGMLRefs, s) + + +_b_p_logger = logging.getLogger('imdbpy.parser.http.build_person') +def build_person(txt, personID=None, billingPos=None, + roleID=None, accessSystem='http', modFunct=None): + """Return a Person instance from the tipical <tr>...</tr> strings + found in the IMDb's web site.""" + #if personID is None + # _b_p_logger.debug('empty name or personID for "%s"', txt) + notes = u'' + role = u'' + # Search the (optional) separator between name and role/notes. + if txt.find('....') != -1: + sep = '....' + elif txt.find('...') != -1: + sep = '...' + else: + sep = '...' + # Replace the first parenthesis, assuming there are only + # notes, after. + # Rationale: no imdbIndex is (ever?) showed on the web site. + txt = txt.replace('(', '...(', 1) + txt_split = txt.split(sep, 1) + name = txt_split[0].strip() + if len(txt_split) == 2: + role_comment = txt_split[1].strip() + # Strip common endings. + if role_comment[-4:] == ' and': + role_comment = role_comment[:-4].rstrip() + elif role_comment[-2:] == ' &': + role_comment = role_comment[:-2].rstrip() + elif role_comment[-6:] == '& ....': + role_comment = role_comment[:-6].rstrip() + # Get the notes. + if roleID is not None: + if not isinstance(roleID, list): + cmt_idx = role_comment.find('(') + if cmt_idx != -1: + role = role_comment[:cmt_idx].rstrip() + notes = role_comment[cmt_idx:] + else: + # Just a role, without notes. + role = role_comment + else: + role = role_comment + else: + # We're managing something that doesn't have a 'role', so + # everything are notes. + notes = role_comment + if role == '....': role = u'' + roleNotes = [] + # Manages multiple roleIDs. + if isinstance(roleID, list): + rolesplit = role.split('/') + role = [] + for r in rolesplit: + nidx = r.find('(') + if nidx != -1: + role.append(r[:nidx].rstrip()) + roleNotes.append(r[nidx:]) + else: + role.append(r) + roleNotes.append(None) + lr = len(role) + lrid = len(roleID) + if lr > lrid: + roleID += [None] * (lrid - lr) + elif lr < lrid: + roleID = roleID[:lr] + for i, rid in enumerate(roleID): + if rid is not None: + roleID[i] = str(rid) + if lr == 1: + role = role[0] + roleID = roleID[0] + notes = roleNotes[0] or u'' + elif roleID is not None: + roleID = str(roleID) + if personID is not None: + personID = str(personID) + if (not name) or (personID is None): + # Set to 'debug', since build_person is expected to receive some crap. + _b_p_logger.debug('empty name or personID for "%s"', txt) + # XXX: return None if something strange is detected? + person = Person(name=name, personID=personID, currentRole=role, + roleID=roleID, notes=notes, billingPos=billingPos, + modFunct=modFunct, accessSystem=accessSystem) + if roleNotes and len(roleNotes) == len(roleID): + for idx, role in enumerate(person.currentRole): + if roleNotes[idx]: + role.notes = roleNotes[idx] + return person + + +_re_chrIDs = re.compile('[0-9]{7}') + +_b_m_logger = logging.getLogger('imdbpy.parser.http.build_movie') +# To shrink spaces. +re_spaces = re.compile(r'\s+') +def build_movie(txt, movieID=None, roleID=None, status=None, + accessSystem='http', modFunct=None, _parsingCharacter=False, + _parsingCompany=False, year=None, chrRoles=None, + rolesNoChar=None, additionalNotes=None): + """Given a string as normally seen on the "categorized" page of + a person on the IMDb's web site, returns a Movie instance.""" + # FIXME: Oook, lets face it: build_movie and build_person are now + # two horrible sets of patches to support the new IMDb design. They + # must be rewritten from scratch. + if _parsingCharacter: + _defSep = ' Played by ' + elif _parsingCompany: + _defSep = ' ... ' + else: + _defSep = ' .... ' + title = re_spaces.sub(' ', txt).strip() + # Split the role/notes from the movie title. + tsplit = title.split(_defSep, 1) + role = u'' + notes = u'' + roleNotes = [] + if len(tsplit) == 2: + title = tsplit[0].rstrip() + role = tsplit[1].lstrip() + if title[-9:] == 'TV Series': + title = title[:-9].rstrip() + #elif title[-7:] == '(short)': + # title = title[:-7].rstrip() + #elif title[-11:] == '(TV series)': + # title = title[:-11].rstrip() + #elif title[-10:] == '(TV movie)': + # title = title[:-10].rstrip() + elif title[-14:] == 'TV mini-series': + title = title[:-14] + ' (mini)' + if title and title.endswith(_defSep.rstrip()): + title = title[:-len(_defSep)+1] + # Try to understand where the movie title ends. + while True: + if year: + break + if title[-1:] != ')': + # Ignore the silly "TV Series" notice. + if title[-9:] == 'TV Series': + title = title[:-9].rstrip() + continue + else: + # Just a title: stop here. + break + # Try to match paired parentheses; yes: sometimes there are + # parentheses inside comments... + nidx = title.rfind('(') + while (nidx != -1 and \ + title[nidx:].count('(') != title[nidx:].count(')')): + nidx = title[:nidx].rfind('(') + # Unbalanced parentheses: stop here. + if nidx == -1: break + # The last item in parentheses seems to be a year: stop here. + first4 = title[nidx+1:nidx+5] + if (first4.isdigit() or first4 == '????') and \ + title[nidx+5:nidx+6] in (')', '/'): break + # The last item in parentheses is a known kind: stop here. + if title[nidx+1:-1] in ('TV', 'V', 'mini', 'VG', 'TV movie', + 'TV series', 'short'): break + # Else, in parentheses there are some notes. + # XXX: should the notes in the role half be kept separated + # from the notes in the movie title half? + if notes: notes = '%s %s' % (title[nidx:], notes) + else: notes = title[nidx:] + title = title[:nidx].rstrip() + if year: + year = year.strip() + if title[-1] == ')': + fpIdx = title.rfind('(') + if fpIdx != -1: + if notes: notes = '%s %s' % (title[fpIdx:], notes) + else: notes = title[fpIdx:] + title = title[:fpIdx].rstrip() + title = u'%s (%s)' % (title, year) + if _parsingCharacter and roleID and not role: + roleID = None + if not roleID: + roleID = None + elif len(roleID) == 1: + roleID = roleID[0] + if not role and chrRoles and isinstance(roleID, (str, unicode)): + roleID = _re_chrIDs.findall(roleID) + role = ' / '.join(filter(None, chrRoles.split('@@'))) + # Manages multiple roleIDs. + if isinstance(roleID, list): + tmprole = role.split('/') + role = [] + for r in tmprole: + nidx = r.find('(') + if nidx != -1: + role.append(r[:nidx].rstrip()) + roleNotes.append(r[nidx:]) + else: + role.append(r) + roleNotes.append(None) + lr = len(role) + lrid = len(roleID) + if lr > lrid: + roleID += [None] * (lrid - lr) + elif lr < lrid: + roleID = roleID[:lr] + for i, rid in enumerate(roleID): + if rid is not None: + roleID[i] = str(rid) + if lr == 1: + role = role[0] + roleID = roleID[0] + elif roleID is not None: + roleID = str(roleID) + if movieID is not None: + movieID = str(movieID) + if (not title) or (movieID is None): + _b_m_logger.error('empty title or movieID for "%s"', txt) + if rolesNoChar: + rolesNoChar = filter(None, [x.strip() for x in rolesNoChar.split('/')]) + if not role: + role = [] + elif not isinstance(role, list): + role = [role] + role += rolesNoChar + notes = notes.strip() + if additionalNotes: + additionalNotes = re_spaces.sub(' ', additionalNotes).strip() + if notes: + notes += u' ' + notes += additionalNotes + if role and isinstance(role, list) and notes.endswith(role[-1].replace('\n', ' ')): + role = role[:-1] + m = Movie(title=title, movieID=movieID, notes=notes, currentRole=role, + roleID=roleID, roleIsPerson=_parsingCharacter, + modFunct=modFunct, accessSystem=accessSystem) + if roleNotes and len(roleNotes) == len(roleID): + for idx, role in enumerate(m.currentRole): + try: + if roleNotes[idx]: + role.notes = roleNotes[idx] + except IndexError: + break + # Status can't be checked here, and must be detected by the parser. + if status: + m['status'] = status + return m + + +class DOMParserBase(object): + """Base parser to handle HTML data from the IMDb's web server.""" + _defGetRefs = False + _containsObjects = False + + preprocessors = [] + extractors = [] + usingModule = None + + _logger = logging.getLogger('imdbpy.parser.http.domparser') + + def __init__(self, useModule=None): + """Initialize the parser. useModule can be used to force it + to use 'BeautifulSoup' or 'lxml'; by default, it's auto-detected, + using 'lxml' if available and falling back to 'BeautifulSoup' + otherwise.""" + # Module to use. + if useModule is None: + useModule = ('lxml', 'BeautifulSoup') + if not isinstance(useModule, (tuple, list)): + useModule = [useModule] + self._useModule = useModule + nrMods = len(useModule) + _gotError = False + for idx, mod in enumerate(useModule): + mod = mod.strip().lower() + try: + if mod == 'lxml': + from lxml.html import fromstring + from lxml.etree import tostring + self._is_xml_unicode = False + self.usingModule = 'lxml' + elif mod == 'beautifulsoup': + from bsouplxml.html import fromstring + from bsouplxml.etree import tostring + self._is_xml_unicode = True + self.usingModule = 'beautifulsoup' + else: + self._logger.warn('unknown module "%s"' % mod) + continue + self.fromstring = fromstring + self._tostring = tostring + if _gotError: + warnings.warn('falling back to "%s"' % mod) + break + except ImportError, e: + if idx+1 >= nrMods: + # Raise the exception, if we don't have any more + # options to try. + raise IMDbError('unable to use any parser in %s: %s' % \ + (str(useModule), str(e))) + else: + warnings.warn('unable to use "%s": %s' % (mod, str(e))) + _gotError = True + continue + else: + raise IMDbError('unable to use parsers in %s' % str(useModule)) + # Fall-back defaults. + self._modFunct = None + self._as = 'http' + self._cname = self.__class__.__name__ + self._init() + self.reset() + + def reset(self): + """Reset the parser.""" + # Names and titles references. + self._namesRefs = {} + self._titlesRefs = {} + self._charactersRefs = {} + self._reset() + + def _init(self): + """Subclasses can override this method, if needed.""" + pass + + def _reset(self): + """Subclasses can override this method, if needed.""" + pass + + def parse(self, html_string, getRefs=None, **kwds): + """Return the dictionary generated from the given html string; + getRefs can be used to force the gathering of movies/persons/characters + references.""" + self.reset() + if getRefs is not None: + self.getRefs = getRefs + else: + self.getRefs = self._defGetRefs + # Useful only for the testsuite. + if not isinstance(html_string, unicode): + html_string = unicode(html_string, 'latin_1', 'replace') + html_string = subXMLRefs(html_string) + # Temporary fix: self.parse_dom must work even for empty strings. + html_string = self.preprocess_string(html_string) + html_string = html_string.strip() + if self.usingModule == 'beautifulsoup': + # tag attributes like title=""Family Guy"" will be + # converted to title=""Family Guy"" and this confuses BeautifulSoup. + html_string = html_string.replace('""', '"') + # Browser-specific escapes create problems to BeautifulSoup. + html_string = html_string.replace('<!--[if IE]>', '"') + html_string = html_string.replace('<![endif]-->', '"') + #print html_string.encode('utf8') + if html_string: + dom = self.get_dom(html_string) + #print self.tostring(dom).encode('utf8') + try: + dom = self.preprocess_dom(dom) + except Exception, e: + self._logger.error('%s: caught exception preprocessing DOM', + self._cname, exc_info=True) + if self.getRefs: + try: + self.gather_refs(dom) + except Exception, e: + self._logger.warn('%s: unable to gather refs: %s', + self._cname, exc_info=True) + data = self.parse_dom(dom) + else: + data = {} + try: + data = self.postprocess_data(data) + except Exception, e: + self._logger.error('%s: caught exception postprocessing data', + self._cname, exc_info=True) + if self._containsObjects: + self.set_objects_params(data) + data = self.add_refs(data) + return data + + def _build_empty_dom(self): + from bsouplxml import _bsoup + return _bsoup.BeautifulSoup('') + + def get_dom(self, html_string): + """Return a dom object, from the given string.""" + try: + dom = self.fromstring(html_string) + if dom is None: + dom = self._build_empty_dom() + self._logger.error('%s: using a fake empty DOM', self._cname) + return dom + except Exception, e: + self._logger.error('%s: caught exception parsing DOM', + self._cname, exc_info=True) + return self._build_empty_dom() + + def xpath(self, element, path): + """Return elements matching the given XPath.""" + try: + xpath_result = element.xpath(path) + if self._is_xml_unicode: + return xpath_result + result = [] + for item in xpath_result: + if isinstance(item, str): + item = unicode(item) + result.append(item) + return result + except Exception, e: + self._logger.error('%s: caught exception extracting XPath "%s"', + self._cname, path, exc_info=True) + return [] + + def tostring(self, element): + """Convert the element to a string.""" + if isinstance(element, (unicode, str)): + return unicode(element) + else: + try: + return self._tostring(element, encoding=unicode) + except Exception, e: + self._logger.error('%s: unable to convert to string', + self._cname, exc_info=True) + return u'' + + def clone(self, element): + """Clone an element.""" + return self.fromstring(self.tostring(element)) + + def preprocess_string(self, html_string): + """Here we can modify the text, before it's parsed.""" + if not html_string: + return html_string + # Remove silly  » and – chars. + html_string = html_string.replace(u' \xbb', u'') + html_string = html_string.replace(u'–', u'-') + try: + preprocessors = self.preprocessors + except AttributeError: + return html_string + for src, sub in preprocessors: + # re._pattern_type is present only since Python 2.5. + if callable(getattr(src, 'sub', None)): + html_string = src.sub(sub, html_string) + elif isinstance(src, str): + html_string = html_string.replace(src, sub) + elif callable(src): + try: + html_string = src(html_string) + except Exception, e: + _msg = '%s: caught exception preprocessing html' + self._logger.error(_msg, self._cname, exc_info=True) + continue + ##print html_string.encode('utf8') + return html_string + + def gather_refs(self, dom): + """Collect references.""" + grParser = GatherRefs(useModule=self._useModule) + grParser._as = self._as + grParser._modFunct = self._modFunct + refs = grParser.parse_dom(dom) + refs = grParser.postprocess_data(refs) + self._namesRefs = refs['names refs'] + self._titlesRefs = refs['titles refs'] + self._charactersRefs = refs['characters refs'] + + def preprocess_dom(self, dom): + """Last chance to modify the dom, before the rules in self.extractors + are applied by the parse_dom method.""" + return dom + + def parse_dom(self, dom): + """Parse the given dom according to the rules specified + in self.extractors.""" + result = {} + for extractor in self.extractors: + ##print extractor.label + if extractor.group is None: + elements = [(extractor.label, element) + for element in self.xpath(dom, extractor.path)] + else: + groups = self.xpath(dom, extractor.group) + elements = [] + for group in groups: + group_key = self.xpath(group, extractor.group_key) + if not group_key: continue + group_key = group_key[0] + # XXX: always tries the conversion to unicode: + # BeautifulSoup.NavigableString is a subclass + # of unicode, and so it's never converted. + group_key = self.tostring(group_key) + normalizer = extractor.group_key_normalize + if normalizer is not None: + if callable(normalizer): + try: + group_key = normalizer(group_key) + except Exception, e: + _m = '%s: unable to apply group_key normalizer' + self._logger.error(_m, self._cname, + exc_info=True) + group_elements = self.xpath(group, extractor.path) + elements.extend([(group_key, element) + for element in group_elements]) + for group_key, element in elements: + for attr in extractor.attrs: + if isinstance(attr.path, dict): + data = {} + for field in attr.path.keys(): + path = attr.path[field] + value = self.xpath(element, path) + if not value: + data[field] = None + else: + # XXX: use u'' , to join? + data[field] = ''.join(value) + else: + data = self.xpath(element, attr.path) + if not data: + data = None + else: + data = attr.joiner.join(data) + if not data: + continue + attr_postprocess = attr.postprocess + if callable(attr_postprocess): + try: + data = attr_postprocess(data) + except Exception, e: + _m = '%s: unable to apply attr postprocess' + self._logger.error(_m, self._cname, exc_info=True) + key = attr.key + if key is None: + key = group_key + elif key.startswith('.'): + # assuming this is an xpath + try: + key = self.xpath(element, key)[0] + except IndexError: + self._logger.error('%s: XPath returned no items', + self._cname, exc_info=True) + elif key.startswith('self.'): + key = getattr(self, key[5:]) + if attr.multi: + if key not in result: + result[key] = [] + result[key].append(data) + else: + if isinstance(data, dict): + result.update(data) + else: + result[key] = data + return result + + def postprocess_data(self, data): + """Here we can modify the data.""" + return data + + def set_objects_params(self, data): + """Set parameters of Movie/Person/... instances, since they are + not always set in the parser's code.""" + for obj in flatten(data, yieldDictKeys=True, scalar=_Container): + obj.accessSystem = self._as + obj.modFunct = self._modFunct + + def add_refs(self, data): + """Modify data according to the expected output.""" + if self.getRefs: + titl_re = ur'(%s)' % '|'.join([re.escape(x) for x + in self._titlesRefs.keys()]) + if titl_re != ur'()': re_titles = re.compile(titl_re, re.U) + else: re_titles = None + nam_re = ur'(%s)' % '|'.join([re.escape(x) for x + in self._namesRefs.keys()]) + if nam_re != ur'()': re_names = re.compile(nam_re, re.U) + else: re_names = None + chr_re = ur'(%s)' % '|'.join([re.escape(x) for x + in self._charactersRefs.keys()]) + if chr_re != ur'()': re_characters = re.compile(chr_re, re.U) + else: re_characters = None + _putRefs(data, re_titles, re_names, re_characters) + return {'data': data, 'titlesRefs': self._titlesRefs, + 'namesRefs': self._namesRefs, + 'charactersRefs': self._charactersRefs} + + +class Extractor(object): + """Instruct the DOM parser about how to parse a document.""" + def __init__(self, label, path, attrs, group=None, group_key=None, + group_key_normalize=None): + """Initialize an Extractor object, used to instruct the DOM parser + about how to parse a document.""" + # rarely (never?) used, mostly for debugging purposes. + self.label = label + self.group = group + if group_key is None: + self.group_key = ".//text()" + else: + self.group_key = group_key + self.group_key_normalize = group_key_normalize + self.path = path + # A list of attributes to fetch. + if isinstance(attrs, Attribute): + attrs = [attrs] + self.attrs = attrs + + def __repr__(self): + """String representation of an Extractor object.""" + r = '<Extractor id:%s (label=%s, path=%s, attrs=%s, group=%s, ' \ + 'group_key=%s group_key_normalize=%s)>' % (id(self), + self.label, self.path, repr(self.attrs), self.group, + self.group_key, self.group_key_normalize) + return r + + +class Attribute(object): + """The attribute to consider, for a given node.""" + def __init__(self, key, multi=False, path=None, joiner=None, + postprocess=None): + """Initialize an Attribute object, used to specify the + attribute to consider, for a given node.""" + # The key under which information will be saved; can be a string or an + # XPath. If None, the label of the containing extractor will be used. + self.key = key + self.multi = multi + self.path = path + if joiner is None: + joiner = '' + self.joiner = joiner + # Post-process this set of information. + self.postprocess = postprocess + + def __repr__(self): + """String representation of an Attribute object.""" + r = '<Attribute id:%s (key=%s, multi=%s, path=%s, joiner=%s, ' \ + 'postprocess=%s)>' % (id(self), self.key, + self.multi, repr(self.path), + self.joiner, repr(self.postprocess)) + return r + + +def _parse_ref(text, link, info): + """Manage links to references.""" + if link.find('/title/tt') != -1: + yearK = re_yearKind_index.match(info) + if yearK and yearK.start() == 0: + text += ' %s' % info[:yearK.end()] + return (text.replace('\n', ' '), link) + + +class GatherRefs(DOMParserBase): + """Parser used to gather references to movies, persons and characters.""" + _attrs = [Attribute(key=None, multi=True, + path={ + 'text': './text()', + 'link': './@href', + 'info': './following::text()[1]' + }, + postprocess=lambda x: _parse_ref(x.get('text') or u'', x.get('link') or '', + (x.get('info') or u'').strip()))] + extractors = [ + Extractor(label='names refs', + path="//a[starts-with(@href, '/name/nm')][string-length(@href)=16]", + attrs=_attrs), + + Extractor(label='titles refs', + path="//a[starts-with(@href, '/title/tt')]" \ + "[string-length(@href)=17]", + attrs=_attrs), + + Extractor(label='characters refs', + path="//a[starts-with(@href, '/character/ch')]" \ + "[string-length(@href)=21]", + attrs=_attrs), + ] + + def postprocess_data(self, data): + result = {} + for item in ('names refs', 'titles refs', 'characters refs'): + result[item] = {} + for k, v in data.get(item, []): + k = k.strip() + v = v.strip() + if not (k and v): + continue + if not v.endswith('/'): continue + imdbID = analyze_imdbid(v) + if item == 'names refs': + obj = Person(personID=imdbID, name=k, + accessSystem=self._as, modFunct=self._modFunct) + elif item == 'titles refs': + obj = Movie(movieID=imdbID, title=k, + accessSystem=self._as, modFunct=self._modFunct) + else: + obj = Character(characterID=imdbID, name=k, + accessSystem=self._as, modFunct=self._modFunct) + # XXX: companies aren't handled: are they ever found in text, + # as links to their page? + result[item][k] = obj + return result + + def add_refs(self, data): + return data + + diff --git a/lib/imdb/parser/mobile/__init__.py b/lib/imdb/parser/mobile/__init__.py new file mode 100644 index 0000000000..c391386b32 --- /dev/null +++ b/lib/imdb/parser/mobile/__init__.py @@ -0,0 +1,844 @@ +""" +parser.mobile package (imdb package). + +This package provides the IMDbMobileAccessSystem class used to access +IMDb's data for mobile systems. +the imdb.IMDb function will return an instance of this class when +called with the 'accessSystem' argument set to "mobile". + +Copyright 2005-2011 Davide Alberani <da@erlug.linux.it> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import re +import logging +from urllib import unquote + +from imdb.Movie import Movie +from imdb.utils import analyze_title, analyze_name, canonicalName, \ + date_and_notes +from imdb._exceptions import IMDbDataAccessError +from imdb.parser.http import IMDbHTTPAccessSystem +from imdb.parser.http.utils import subXMLRefs, subSGMLRefs, build_person, \ + build_movie, re_spaces + +# XXX NOTE: the first version of this module was heavily based on +# regular expressions. This new version replace regexps with +# find() strings' method calls; despite being less flexible, it +# seems to be at least as fast and, hopefully, much more +# lightweight. Yes: the regexp-based version was too heavyweight +# for systems with very limited CPU power and memory footprint. +re_spacessub = re_spaces.sub +# Strip html. +re_unhtml = re.compile(r'<.+?>') +re_unhtmlsub = re_unhtml.sub +# imdb person or movie ids. +re_imdbID = re.compile(r'(?<=nm|tt|ch)([0-9]{7})\b') + +# movie AKAs. +re_makas = re.compile('(<p class="find-aka">.*?</p>)') + +# Remove episode numbers. +re_filmo_episodes = re.compile('<div class="filmo-episodes">.*?</div>', + re.M | re.I) + + +def _unHtml(s): + """Return a string without tags and no multiple spaces.""" + return subSGMLRefs(re_spacessub(' ', re_unhtmlsub('', s)).strip()) + + +_inttype = type(0) + +def _getTagsWith(s, cont, toClosure=False, maxRes=None): + """Return the html tags in the 's' string containing the 'cont' + string; if toClosure is True, everything between the opening + tag and the closing tag is returned.""" + lres = [] + bi = s.find(cont) + if bi != -1: + btag = s[:bi].rfind('<') + if btag != -1: + if not toClosure: + etag = s[bi+1:].find('>') + if etag != -1: + endidx = bi+2+etag + lres.append(s[btag:endidx]) + if maxRes is not None and len(lres) >= maxRes: return lres + lres += _getTagsWith(s[endidx:], cont, + toClosure=toClosure) + else: + spaceidx = s[btag:].find(' ') + if spaceidx != -1: + ctag = '</%s>' % s[btag+1:btag+spaceidx] + closeidx = s[bi:].find(ctag) + if closeidx != -1: + endidx = bi+closeidx+len(ctag) + lres.append(s[btag:endidx]) + if maxRes is not None and len(lres) >= maxRes: + return lres + lres += _getTagsWith(s[endidx:], cont, + toClosure=toClosure) + return lres + + +def _findBetween(s, begins, ends, beginindx=0, maxRes=None, lres=None): + """Return the list of strings from the 's' string which are included + between the 'begins' and 'ends' strings.""" + if lres is None: + lres = [] + bi = s.find(begins, beginindx) + if bi != -1: + lbegins = len(begins) + if isinstance(ends, (list, tuple)): + eset = [s.find(end, bi+lbegins) for end in ends] + eset[:] = [x for x in eset if x != -1] + if not eset: ei = -1 + else: ei = min(eset) + else: + ei = s.find(ends, bi+lbegins) + if ei != -1: + match = s[bi+lbegins:ei] + lres.append(match) + if maxRes is not None and len(lres) >= maxRes: return lres + _findBetween(s, begins, ends, beginindx=ei, maxRes=maxRes, + lres=lres) + return lres + + +class IMDbMobileAccessSystem(IMDbHTTPAccessSystem): + """The class used to access IMDb's data through the web for + mobile terminals.""" + + accessSystem = 'mobile' + _mobile_logger = logging.getLogger('imdbpy.parser.mobile') + + def __init__(self, isThin=0, *arguments, **keywords): + self.accessSystem = 'mobile' + IMDbHTTPAccessSystem.__init__(self, isThin, *arguments, **keywords) + + def _clean_html(self, html): + """Normalize the retrieve html.""" + html = re_spaces.sub(' ', html) + # Remove silly  » chars. + html = html.replace(' »', '') + return subXMLRefs(html) + + def _mretrieve(self, url, size=-1): + """Retrieve an html page and normalize it.""" + cont = self._retrieve(url, size=size) + return self._clean_html(cont) + + def _getPersons(self, s, sep='<br/>'): + """Return a list of Person objects, from the string s; items + are assumed to be separated by the sep string.""" + names = s.split(sep) + pl = [] + plappend = pl.append + counter = 1 + for name in names: + pid = re_imdbID.findall(name) + if not pid: continue + characters = _getTagsWith(name, 'class="char"', + toClosure=True, maxRes=1) + chpids = [] + if characters: + for ch in characters[0].split(' / '): + chid = re_imdbID.findall(ch) + if not chid: + chpids.append(None) + else: + chpids.append(chid[-1]) + if not chpids: + chpids = None + elif len(chpids) == 1: + chpids = chpids[0] + name = _unHtml(name) + # Catch unclosed tags. + gt_indx = name.find('>') + if gt_indx != -1: + name = name[gt_indx+1:].lstrip() + if not name: continue + if name.endswith('...'): + name = name[:-3] + p = build_person(name, personID=str(pid[0]), billingPos=counter, + modFunct=self._defModFunct, roleID=chpids, + accessSystem=self.accessSystem) + plappend(p) + counter += 1 + return pl + + def _search_movie(self, title, results): + ##params = urllib.urlencode({'tt': 'on','mx': str(results),'q': title}) + ##params = 'q=%s&tt=on&mx=%s' % (urllib.quote_plus(title), str(results)) + ##cont = self._mretrieve(imdbURL_search % params) + cont = subXMLRefs(self._get_search_content('tt', title, results)) + title = _findBetween(cont, '<title>', '', maxRes=1) + res = [] + if not title: + self._mobile_logger.error('no title tag searching for movie %s', + title) + return res + tl = title[0].lower() + if not tl.startswith('imdb title'): + # a direct hit! + title = _unHtml(title[0]) + mid = None + midtag = _getTagsWith(cont, 'rel="canonical"', maxRes=1) + if midtag: + mid = _findBetween(midtag[0], '/title/tt', '/', maxRes=1) + if not (mid and title): + self._mobile_logger.error('no direct hit title/movieID for' \ + ' title %s', title) + return res + if cont.find('TV mini-series') != -1: + title += ' (mini)' + res[:] = [(str(mid[0]), analyze_title(title))] + else: + # XXX: this results*3 prevents some recursion errors, but... + # it's not exactly understandable (i.e.: why 'results' is + # not enough to get all the results?) + lis = _findBetween(cont, 'td valign="top">', '', + maxRes=results*3) + for li in lis: + akas = re_makas.findall(li) + for idx, aka in enumerate(akas): + aka = aka.replace('" - ', '::', 1) + aka = _unHtml(aka) + if aka.startswith('aka "'): + aka = aka[5:].strip() + if aka[-1] == '"': + aka = aka[:-1] + akas[idx] = aka + imdbid = re_imdbID.findall(li) + li = re_makas.sub('', li) + mtitle = _unHtml(li) + if not (imdbid and mtitle): + self._mobile_logger.debug('no title/movieID parsing' \ + ' %s searching for title %s', li, + title) + continue + mtitle = mtitle.replace('(TV mini-series)', '(mini)') + resd = analyze_title(mtitle) + if akas: + resd['akas'] = akas + res.append((str(imdbid[0]), resd)) + return res + + def get_movie_main(self, movieID): + cont = self._mretrieve(self.urls['movie_main'] % movieID + 'maindetails') + title = _findBetween(cont, '', '', maxRes=1) + if not title: + raise IMDbDataAccessError('unable to get movieID "%s"' % movieID) + title = _unHtml(title[0]) + if title.endswith(' - IMDb'): + title = title[:-7] + if cont.find('TV mini-series') != -1: + title += ' (mini)' + d = analyze_title(title) + kind = d.get('kind') + tv_series = _findBetween(cont, 'TV Series:', '', maxRes=1) + if tv_series: mid = re_imdbID.findall(tv_series[0]) + else: mid = None + if tv_series and mid: + s_title = _unHtml(tv_series[0]) + s_data = analyze_title(s_title) + m = Movie(movieID=str(mid[0]), data=s_data, + accessSystem=self.accessSystem, + modFunct=self._defModFunct) + d['kind'] = kind = u'episode' + d['episode of'] = m + if kind in ('tv series', 'tv mini series'): + years = _findBetween(cont, '

    ', '

    ', maxRes=1) + if years: + years[:] = _findBetween(years[0], 'TV series', '', + maxRes=1) + if years: + d['series years'] = years[0].strip() + air_date = _findBetween(cont, 'Original Air Date:', '
    ', + maxRes=1) + if air_date: + air_date = air_date[0] + vi = air_date.find('(') + if vi != -1: + date = _unHtml(air_date[:vi]).strip() + if date != '????': + d['original air date'] = date + air_date = air_date[vi:] + season = _findBetween(air_date, 'Season', ',', maxRes=1) + if season: + season = season[0].strip() + try: season = int(season) + except: pass + if season or type(season) is _inttype: + d['season'] = season + episode = _findBetween(air_date, 'Episode', ')', maxRes=1) + if episode: + episode = episode[0].strip() + try: episode = int(episode) + except: pass + if episode or type(season) is _inttype: + d['episode'] = episode + direct = _findBetween(cont, '
    Director', ('', '

    '), + maxRes=1) + if direct: + direct = direct[0] + h5idx = direct.find('/h5>') + if h5idx != -1: + direct = direct[h5idx+4:] + direct = self._getPersons(direct) + if direct: d['director'] = direct + if kind in ('tv series', 'tv mini series', 'episode'): + if kind != 'episode': + seasons = _findBetween(cont, 'Seasons:
    ', '', + maxRes=1) + if seasons: + d['number of seasons'] = seasons[0].count('|') + 1 + creator = _findBetween(cont, 'Created by', ('class="tn15more"', + '', + '

    '), + maxRes=1) + if not creator: + # They change 'Created by' to 'Creator' and viceversa + # from time to time... + # XXX: is 'Creators' also used? + creator = _findBetween(cont, 'Creator:', + ('class="tn15more"', '', + '

    '), maxRes=1) + if creator: + creator = creator[0] + if creator.find('tn15more'): creator = '%s>' % creator + creator = self._getPersons(creator) + if creator: d['creator'] = creator + writers = _findBetween(cont, '
    Writer', ('', '

    '), + maxRes=1) + if writers: + writers = writers[0] + h5idx = writers.find('/h5>') + if h5idx != -1: + writers = writers[h5idx+4:] + writers = self._getPersons(writers) + if writers: d['writer'] = writers + cvurl = _getTagsWith(cont, 'name="poster"', toClosure=True, maxRes=1) + if cvurl: + cvurl = _findBetween(cvurl[0], 'src="', '"', maxRes=1) + if cvurl: d['cover url'] = cvurl[0] + genres = _findBetween(cont, 'href="/genre/', '"') + if genres: + d['genres'] = list(set(genres)) + ur = _findBetween(cont, 'id="star-bar-user-rate">', '', + maxRes=1) + if ur: + rat = _findBetween(ur[0], '', '', maxRes=1) + if rat: + if rat: + d['rating'] = rat[0].strip() + else: + self._mobile_logger.warn('wrong rating: %s', rat) + vi = ur[0].rfind('href="ratings"') + if vi != -1 and ur[0][vi+10:].find('await') == -1: + try: + votes = _findBetween(ur[0][vi:], "title='", + " IMDb", maxRes=1) + votes = int(votes[0].replace(',', '')) + d['votes'] = votes + except (ValueError, IndexError): + self._mobile_logger.warn('wrong votes: %s', ur) + top250 = _findBetween(cont, 'href="/chart/top?', '', maxRes=1) + if top250: + fn = top250[0].rfind('#') + if fn != -1: + try: + td = int(top250[0][fn+1:]) + d['top 250 rank'] = td + except ValueError: + self._mobile_logger.warn('wrong top250: %s', top250) + castdata = _findBetween(cont, 'Cast overview', '', maxRes=1) + if not castdata: + castdata = _findBetween(cont, 'Credited cast', '', maxRes=1) + if not castdata: + castdata = _findBetween(cont, 'Complete credited cast', '', + maxRes=1) + if not castdata: + castdata = _findBetween(cont, 'Series Cast Summary', '', + maxRes=1) + if not castdata: + castdata = _findBetween(cont, 'Episode Credited cast', '', + maxRes=1) + if castdata: + castdata = castdata[0] + # Reintegrate the fist tag. + fl = castdata.find('href=') + if fl != -1: castdata = '') + if smib != -1: + smie = castdata.rfind('') + if smie != -1: + castdata = castdata[:smib].strip() + \ + castdata[smie+18:].strip() + castdata = castdata.replace('/tr> ', '', maxRes=1) + if akas: + # For some reason, here
    is still used in place of
    . + akas[:] = [x for x in akas[0].split('
    ') if x.strip()] + akas = [_unHtml(x).replace('" - ','::', 1).lstrip('"').strip() + for x in akas] + if 'See more' in akas: akas.remove('See more') + akas[:] = [x for x in akas if x] + if akas: + d['akas'] = akas + mpaa = _findBetween(cont, 'MPAA
    :', '', maxRes=1) + if mpaa: d['mpaa'] = _unHtml(mpaa[0]) + runtimes = _findBetween(cont, 'Runtime:
    ', '', maxRes=1) + if runtimes: + runtimes = runtimes[0] + runtimes = [x.strip().replace(' min', '').replace(' (', '::(', 1) + for x in runtimes.split('|')] + d['runtimes'] = [_unHtml(x).strip() for x in runtimes] + if kind == 'episode': + # number of episodes. + epsn = _findBetween(cont, 'title="Full Episode List">', '', + maxRes=1) + if epsn: + epsn = epsn[0].replace(' Episodes', '').strip() + if epsn: + try: + epsn = int(epsn) + except: + self._mobile_logger.warn('wrong episodes #: %s', epsn) + d['number of episodes'] = epsn + country = _findBetween(cont, 'Country:', '', maxRes=1) + if country: + country[:] = country[0].split(' | ') + country[:] = ['', '::')) for x in country] + if country: d['countries'] = country + lang = _findBetween(cont, 'Language:', '', maxRes=1) + if lang: + lang[:] = lang[0].split(' | ') + lang[:] = ['', '::')) for x in lang] + if lang: d['languages'] = lang + col = _findBetween(cont, '"/search/title?colors=', '') + if col: + col[:] = col[0].split(' | ') + col[:] = ['', '::')) for x in col] + if col: d['color info'] = col + sm = _findBetween(cont, '/search/title?sound_mixes=', '', + maxRes=1) + if sm: + sm[:] = sm[0].split(' | ') + sm[:] = ['', '::')) for x in sm] + if sm: d['sound mix'] = sm + cert = _findBetween(cont, 'Certification:', '', maxRes=1) + if cert: + cert[:] = cert[0].split(' | ') + cert[:] = [_unHtml(x.replace(' ', '::')) for x in cert] + if cert: d['certificates'] = cert + plotoutline = _findBetween(cont, 'Plot:', [''], + maxRes=1) + if plotoutline: + plotoutline = plotoutline[0].strip() + plotoutline = plotoutline.rstrip('|').rstrip() + if plotoutline: d['plot outline'] = _unHtml(plotoutline) + aratio = _findBetween(cont, 'Aspect Ratio:', [''], + maxRes=1) + if aratio: + aratio = aratio[0].strip().replace(' (', '::(', 1) + if aratio: + d['aspect ratio'] = _unHtml(aratio) + return {'data': d} + + def get_movie_plot(self, movieID): + cont = self._mretrieve(self.urls['movie_main'] % movieID + 'plotsummary') + plot = _findBetween(cont, '

    ', '

    ') + plot[:] = [_unHtml(x) for x in plot] + for i in xrange(len(plot)): + p = plot[i] + wbyidx = p.rfind(' Written by ') + if wbyidx != -1: + plot[i] = '%s::%s' % \ + (p[:wbyidx].rstrip(), + p[wbyidx+12:].rstrip().replace('{','<').replace('}','>')) + if plot: return {'data': {'plot': plot}} + return {'data': {}} + + def _search_person(self, name, results): + ##params = urllib.urlencode({'nm': 'on', 'mx': str(results), 'q': name}) + ##params = 'q=%s&nm=on&mx=%s' % (urllib.quote_plus(name), str(results)) + ##cont = self._mretrieve(imdbURL_search % params) + cont = subXMLRefs(self._get_search_content('nm', name, results)) + name = _findBetween(cont, '', '', maxRes=1) + res = [] + if not name: + self._mobile_logger.warn('no title tag searching for name %s', name) + return res + nl = name[0].lower() + if not nl.startswith('imdb name'): + # a direct hit! + name = _unHtml(name[0]) + name = name.replace('- Filmography by type' , '').strip() + pid = None + pidtag = _getTagsWith(cont, 'rel="canonical"', maxRes=1) + if pidtag: + pid = _findBetween(pidtag[0], '/name/nm', '/', maxRes=1) + if not (pid and name): + self._mobile_logger.error('no direct hit name/personID for' \ + ' name %s', name) + return res + res[:] = [(str(pid[0]), analyze_name(name, canonical=1))] + else: + lis = _findBetween(cont, 'td valign="top">', '', + maxRes=results*3) + for li in lis: + akas = _findBetween(li, '"', '"') + for sep in [' aka', '
    birth name']: + sepIdx = li.find(sep) + if sepIdx != -1: + li = li[:sepIdx] + pid = re_imdbID.findall(li) + pname = _unHtml(li) + if not (pid and pname): + self._mobile_logger.debug('no name/personID parsing' \ + ' %s searching for name %s', li, + name) + continue + resd = analyze_name(pname, canonical=1) + if akas: + resd['akas'] = akas + res.append((str(pid[0]), resd)) + return res + + def get_person_main(self, personID, _parseChr=False): + if not _parseChr: + url = self.urls['person_main'] % personID + 'maindetails' + else: + url = self.urls['character_main'] % personID + s = self._mretrieve(url) + r = {} + name = _findBetween(s, '', '', maxRes=1) + if not name: + if _parseChr: w = 'characterID' + else: w = 'personID' + raise IMDbDataAccessError('unable to get %s "%s"' % (w, personID)) + name = _unHtml(name[0].replace(' - IMDb', '')) + if _parseChr: + name = name.replace('(Character)', '').strip() + name = name.replace('- Filmography by type', '').strip() + else: + name = name.replace('- Filmography by', '').strip() + r = analyze_name(name, canonical=not _parseChr) + for dKind in ('Born', 'Died'): + date = _findBetween(s, '%s:' % dKind.capitalize(), + ('
    ', '

    '), maxRes=1) + if date: + date = _unHtml(date[0]) + if date: + #date, notes = date_and_notes(date) + # TODO: fix to handle real names. + date_notes = date.split(' in ', 1) + notes = u'' + date = date_notes[0] + if len(date_notes) == 2: + notes = date_notes[1] + dtitle = 'birth' + if dKind == 'Died': + dtitle = 'death' + if date: + r['%s date' % dtitle] = date + if notes: + r['%s notes' % dtitle] = notes + akas = _findBetween(s, 'Alternate Names:', ('
    ', + '

    '), maxRes=1) + if akas: + akas = akas[0] + if akas: + akas = _unHtml(akas) + if akas.find(' | ') != -1: + akas = akas.split(' | ') + else: + akas = akas.split(' / ') + if akas: r['akas'] = filter(None, [x.strip() for x in akas]) + hs = _findBetween(s, "rel='image_src'", '>', maxRes=1) + if not hs: + hs = _findBetween(s, 'rel="image_src"', '>', maxRes=1) + if not hs: + hs = _findBetween(s, '
    ', maxRes=1) + if hs: + hsl = _findBetween(hs[0], "href='", "'", maxRes=1) + if not hsl: + hsl = _findBetween(hs[0], 'href="', '"', maxRes=1) + if hsl and 'imdb-share-logo' not in hsl[0]: + r['headshot'] = hsl[0] + # Build a list of tuples such [('hrefLink', 'section name')] + workkind = _findBetween(s, 'id="jumpto_', '') + ws = [] + for work in workkind: + sep = '" >' + if '">' in work: + sep = '">' + wsplit = work.split(sep, 1) + if len(wsplit) == 2: + sect = wsplit[0] + if '"' in sect: + sect = sect[:sect.find('"')] + ws.append((sect, wsplit[1].lower())) + # XXX: I think "guest appearances" are gone. + if s.find(' tag. + if _parseChr and sect == 'filmography': + inisect = s.find('
    ') + else: + inisect = s.find('',)) + for m in mlist: + fCB = m.find('>') + if fCB != -1: + m = m[fCB+1:].lstrip() + m = re_filmo_episodes.sub('', m) + # For every movie in the current section. + movieID = re_imdbID.findall(m) + if not movieID: + self._mobile_logger.debug('no movieID in %s', m) + continue + m = m.replace('
    ', ' .... ', 1) + if not _parseChr: + chrIndx = m.find(' .... ') + else: + chrIndx = m.find(' Played by ') + chids = [] + if chrIndx != -1: + chrtxt = m[chrIndx+6:] + if _parseChr: + chrtxt = chrtxt[5:] + for ch in chrtxt.split(' / '): + chid = re_imdbID.findall(ch) + if not chid: + chids.append(None) + else: + chids.append(chid[-1]) + if not chids: + chids = None + elif len(chids) == 1: + chids = chids[0] + movieID = str(movieID[0]) + # Search the status. + stidx = m.find('') + status = u'' + if stidx != -1: + stendidx = m.rfind('') + if stendidx != -1: + status = _unHtml(m[stidx+3:stendidx]) + m = m.replace(m[stidx+3:stendidx], '') + year = _findBetween(m, 'year_column">', '', maxRes=1) + if year: + year = year[0] + m = m.replace('%s' % year, + '') + else: + year = None + m = _unHtml(m) + if not m: + self._mobile_logger.warn('no title for movieID %s', movieID) + continue + movie = build_movie(m, movieID=movieID, status=status, + roleID=chids, modFunct=self._defModFunct, + accessSystem=self.accessSystem, + _parsingCharacter=_parseChr, year=year) + sectName = sectName.split(':')[0] + r.setdefault(sectName, []).append(movie) + # If available, take the always correct name from a form. + itag = _getTagsWith(s, 'NAME="primary"', maxRes=1) + if not itag: + itag = _getTagsWith(s, 'name="primary"', maxRes=1) + if itag: + vtag = _findBetween(itag[0], 'VALUE="', ('"', '>'), maxRes=1) + if not vtag: + vtag = _findBetween(itag[0], 'value="', ('"', '>'), maxRes=1) + if vtag: + try: + vtag = unquote(str(vtag[0])) + vtag = unicode(vtag, 'latin_1') + r.update(analyze_name(vtag)) + except UnicodeEncodeError: + pass + return {'data': r, 'info sets': ('main', 'filmography')} + + def get_person_biography(self, personID): + cont = self._mretrieve(self.urls['person_main'] % personID + 'bio') + d = {} + spouses = _findBetween(cont, 'Spouse', ('', ''), + maxRes=1) + if spouses: + sl = [] + for spouse in spouses[0].split(''): + if spouse.count('') > 1: + spouse = spouse.replace('', '::', 1) + spouse = _unHtml(spouse) + spouse = spouse.replace(':: ', '::').strip() + if spouse: sl.append(spouse) + if sl: d['spouse'] = sl + nnames = _findBetween(cont, '
    Nickname
    ', ('

    ','
    '), + maxRes=1) + if nnames: + nnames = nnames[0] + if nnames: + nnames = [x.strip().replace(' (', '::(', 1) + for x in nnames.split('
    ')] + if nnames: + d['nick names'] = nnames + misc_sects = _findBetween(cont, '
    ', '
    ') + misc_sects[:] = [x.split('
    ') for x in misc_sects] + misc_sects[:] = [x for x in misc_sects if len(x) == 2] + for sect, data in misc_sects: + sect = sect.lower().replace(':', '').strip() + if d.has_key(sect) and sect != 'mini biography': continue + elif sect in ('spouse', 'nickname'): continue + if sect == 'salary': sect = 'salary history' + elif sect == 'where are they now': sect = 'where now' + elif sect == 'personal quotes': sect = 'quotes' + data = data.replace('

    ', '::') + data = data.replace('

    ', ' ') # for multi-paragraphs 'bio' + data = data.replace(' ', '@@@@') + data = data.replace(' ', '::') + data = _unHtml(data) + data = [x.strip() for x in data.split('::')] + data[:] = [x.replace('@@@@', '::') for x in data if x] + if sect == 'height' and data: data = data[0] + elif sect == 'birth name': data = canonicalName(data[0]) + elif sect == 'date of birth': + date, notes = date_and_notes(data[0]) + if date: + d['birth date'] = date + if notes: + d['birth notes'] = notes + continue + elif sect == 'date of death': + date, notes = date_and_notes(data[0]) + if date: + d['death date'] = date + if notes: + d['death notes'] = notes + continue + elif sect == 'mini biography': + ndata = [] + for bio in data: + byidx = bio.rfind('IMDb Mini Biography By') + if byidx != -1: + bioAuth = bio[:byidx].rstrip() + else: + bioAuth = 'Anonymous' + bio = u'%s::%s' % (bioAuth, bio[byidx+23:].lstrip()) + ndata.append(bio) + data[:] = ndata + if 'mini biography' in d: + d['mini biography'].append(ndata[0]) + continue + d[sect] = data + return {'data': d} + + def _search_character(self, name, results): + cont = subXMLRefs(self._get_search_content('char', name, results)) + name = _findBetween(cont, '', '', maxRes=1) + res = [] + if not name: + self._mobile_logger.error('no title tag searching character %s', + name) + return res + nl = name[0].lower() + if not (nl.startswith('imdb search') or nl.startswith('imdb search') \ + or nl.startswith('imdb character')): + # a direct hit! + name = _unHtml(name[0]).replace('(Character)', '').strip() + pid = None + pidtag = _getTagsWith(cont, 'rel="canonical"', maxRes=1) + if pidtag: + pid = _findBetween(pidtag[0], '/character/ch', '/', maxRes=1) + if not (pid and name): + self._mobile_logger.error('no direct hit name/characterID for' \ + ' character %s', name) + return res + res[:] = [(str(pid[0]), analyze_name(name))] + else: + sects = _findBetween(cont, 'Popular Characters', '', + maxRes=results*3) + sects += _findBetween(cont, 'Characters', '', + maxRes=results*3) + for sect in sects: + lis = _findBetween(sect, '
    ', + ('', '

    '), maxRes=1) + if intro: + intro = _unHtml(intro[0]).strip() + if intro: + d['introduction'] = intro + tocidx = cont.find(' + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +# FIXME: this whole module was written in a veeery short amount of time. +# The code should be commented, rewritten and cleaned. :-) + +import re +import logging +from difflib import SequenceMatcher +from codecs import lookup + +from imdb import IMDbBase +from imdb.utils import normalizeName, normalizeTitle, build_title, \ + build_name, analyze_name, analyze_title, \ + canonicalTitle, canonicalName, re_titleRef, \ + build_company_name, re_episodes, _unicodeArticles, \ + analyze_company_name, re_year_index, re_nameRef +from imdb.Person import Person +from imdb.Movie import Movie +from imdb.Company import Company +from imdb._exceptions import IMDbDataAccessError, IMDbError + + +# Logger for miscellaneous functions. +_aux_logger = logging.getLogger('imdbpy.parser.sql.aux') + +# ============================= +# Things that once upon a time were in imdb.parser.common.locsql. + +def titleVariations(title, fromPtdf=0): + """Build title variations useful for searches; if fromPtdf is true, + the input is assumed to be in the plain text data files format.""" + if fromPtdf: title1 = u'' + else: title1 = title + title2 = title3 = u'' + if fromPtdf or re_year_index.search(title): + # If it appears to have a (year[/imdbIndex]) indication, + # assume that a long imdb canonical name was provided. + titldict = analyze_title(title, canonical=1) + # title1: the canonical name. + title1 = titldict['title'] + if titldict['kind'] != 'episode': + # title3: the long imdb canonical name. + if fromPtdf: title3 = title + else: title3 = build_title(titldict, canonical=1, ptdf=1) + else: + title1 = normalizeTitle(title1) + title3 = build_title(titldict, canonical=1, ptdf=1) + else: + # Just a title. + # title1: the canonical title. + title1 = canonicalTitle(title) + title3 = u'' + # title2 is title1 without the article, or title1 unchanged. + if title1: + title2 = title1 + t2s = title2.split(u', ') + if t2s[-1].lower() in _unicodeArticles: + title2 = u', '.join(t2s[:-1]) + _aux_logger.debug('title variations: 1:[%s] 2:[%s] 3:[%s]', + title1, title2, title3) + return title1, title2, title3 + + +re_nameIndex = re.compile(r'\(([IVXLCDM]+)\)') + +def nameVariations(name, fromPtdf=0): + """Build name variations useful for searches; if fromPtdf is true, + the input is assumed to be in the plain text data files format.""" + name1 = name2 = name3 = u'' + if fromPtdf or re_nameIndex.search(name): + # We've a name with an (imdbIndex) + namedict = analyze_name(name, canonical=1) + # name1 is the name in the canonical format. + name1 = namedict['name'] + # name3 is the canonical name with the imdbIndex. + if fromPtdf: + if namedict.has_key('imdbIndex'): + name3 = name + else: + name3 = build_name(namedict, canonical=1) + else: + # name1 is the name in the canonical format. + name1 = canonicalName(name) + name3 = u'' + # name2 is the name in the normal format, if it differs from name1. + name2 = normalizeName(name1) + if name1 == name2: name2 = u'' + _aux_logger.debug('name variations: 1:[%s] 2:[%s] 3:[%s]', + name1, name2, name3) + return name1, name2, name3 + + +try: + from cutils import ratcliff as _ratcliff + def ratcliff(s1, s2, sm): + """Return the Ratcliff-Obershelp value between the two strings, + using the C implementation.""" + return _ratcliff(s1.encode('latin_1', 'replace'), + s2.encode('latin_1', 'replace')) +except ImportError: + _aux_logger.warn('Unable to import the cutils.ratcliff function.' + ' Searching names and titles using the "sql"' + ' data access system will be slower.') + + def ratcliff(s1, s2, sm): + """Ratcliff-Obershelp similarity.""" + STRING_MAXLENDIFFER = 0.7 + s1len = len(s1) + s2len = len(s2) + if s1len < s2len: + threshold = float(s1len) / s2len + else: + threshold = float(s2len) / s1len + if threshold < STRING_MAXLENDIFFER: + return 0.0 + sm.set_seq2(s2.lower()) + return sm.ratio() + + +def merge_roles(mop): + """Merge multiple roles.""" + new_list = [] + for m in mop: + if m in new_list: + keep_this = new_list[new_list.index(m)] + if not isinstance(keep_this.currentRole, list): + keep_this.currentRole = [keep_this.currentRole] + keep_this.currentRole.append(m.currentRole) + else: + new_list.append(m) + return new_list + + +def scan_names(name_list, name1, name2, name3, results=0, ro_thresold=None, + _scan_character=False): + """Scan a list of names, searching for best matches against + the given variations.""" + if ro_thresold is not None: RO_THRESHOLD = ro_thresold + else: RO_THRESHOLD = 0.6 + sm1 = SequenceMatcher() + sm2 = SequenceMatcher() + sm3 = SequenceMatcher() + sm1.set_seq1(name1.lower()) + if name2: sm2.set_seq1(name2.lower()) + if name3: sm3.set_seq1(name3.lower()) + resd = {} + for i, n_data in name_list: + nil = n_data['name'] + # XXX: on Symbian, here we get a str; not sure this is the + # right place to fix it. + if isinstance(nil, str): + nil = unicode(nil, 'latin1', 'ignore') + # Distance with the canonical name. + ratios = [ratcliff(name1, nil, sm1) + 0.05] + namesurname = u'' + if not _scan_character: + nils = nil.split(', ', 1) + surname = nils[0] + if len(nils) == 2: namesurname = '%s %s' % (nils[1], surname) + else: + nils = nil.split(' ', 1) + surname = nils[-1] + namesurname = nil + if surname != nil: + # Distance with the "Surname" in the database. + ratios.append(ratcliff(name1, surname, sm1)) + if not _scan_character: + ratios.append(ratcliff(name1, namesurname, sm1)) + if name2: + ratios.append(ratcliff(name2, surname, sm2)) + # Distance with the "Name Surname" in the database. + if namesurname: + ratios.append(ratcliff(name2, namesurname, sm2)) + if name3: + # Distance with the long imdb canonical name. + ratios.append(ratcliff(name3, + build_name(n_data, canonical=1), sm3) + 0.1) + ratio = max(ratios) + if ratio >= RO_THRESHOLD: + if resd.has_key(i): + if ratio > resd[i][0]: resd[i] = (ratio, (i, n_data)) + else: resd[i] = (ratio, (i, n_data)) + res = resd.values() + res.sort() + res.reverse() + if results > 0: res[:] = res[:results] + return res + + +def scan_titles(titles_list, title1, title2, title3, results=0, + searchingEpisode=0, onlyEpisodes=0, ro_thresold=None): + """Scan a list of titles, searching for best matches against + the given variations.""" + if ro_thresold is not None: RO_THRESHOLD = ro_thresold + else: RO_THRESHOLD = 0.6 + sm1 = SequenceMatcher() + sm2 = SequenceMatcher() + sm3 = SequenceMatcher() + sm1.set_seq1(title1.lower()) + sm2.set_seq2(title2.lower()) + if title3: + sm3.set_seq1(title3.lower()) + if title3[-1] == '}': searchingEpisode = 1 + hasArt = 0 + if title2 != title1: hasArt = 1 + resd = {} + for i, t_data in titles_list: + if onlyEpisodes: + if t_data.get('kind') != 'episode': + continue + til = t_data['title'] + if til[-1] == ')': + dateIdx = til.rfind('(') + if dateIdx != -1: + til = til[:dateIdx].rstrip() + if not til: + continue + ratio = ratcliff(title1, til, sm1) + if ratio >= RO_THRESHOLD: + resd[i] = (ratio, (i, t_data)) + continue + if searchingEpisode: + if t_data.get('kind') != 'episode': continue + elif t_data.get('kind') == 'episode': continue + til = t_data['title'] + # XXX: on Symbian, here we get a str; not sure this is the + # right place to fix it. + if isinstance(til, str): + til = unicode(til, 'latin1', 'ignore') + # Distance with the canonical title (with or without article). + # titleS -> titleR + # titleS, the -> titleR, the + if not searchingEpisode: + til = canonicalTitle(til) + ratios = [ratcliff(title1, til, sm1) + 0.05] + # til2 is til without the article, if present. + til2 = til + tils = til2.split(', ') + matchHasArt = 0 + if tils[-1].lower() in _unicodeArticles: + til2 = ', '.join(tils[:-1]) + matchHasArt = 1 + if hasArt and not matchHasArt: + # titleS[, the] -> titleR + ratios.append(ratcliff(title2, til, sm2)) + elif matchHasArt and not hasArt: + # titleS -> titleR[, the] + ratios.append(ratcliff(title1, til2, sm1)) + else: + ratios = [0.0] + if title3: + # Distance with the long imdb canonical title. + ratios.append(ratcliff(title3, + build_title(t_data, canonical=1, ptdf=1), sm3) + 0.1) + ratio = max(ratios) + if ratio >= RO_THRESHOLD: + if resd.has_key(i): + if ratio > resd[i][0]: + resd[i] = (ratio, (i, t_data)) + else: resd[i] = (ratio, (i, t_data)) + res = resd.values() + res.sort() + res.reverse() + if results > 0: res[:] = res[:results] + return res + + +def scan_company_names(name_list, name1, results=0, ro_thresold=None): + """Scan a list of company names, searching for best matches against + the given name. Notice that this function takes a list of + strings, and not a list of dictionaries.""" + if ro_thresold is not None: RO_THRESHOLD = ro_thresold + else: RO_THRESHOLD = 0.6 + sm1 = SequenceMatcher() + sm1.set_seq1(name1.lower()) + resd = {} + withoutCountry = not name1.endswith(']') + for i, n in name_list: + # XXX: on Symbian, here we get a str; not sure this is the + # right place to fix it. + if isinstance(n, str): + n = unicode(n, 'latin1', 'ignore') + o_name = n + var = 0.0 + if withoutCountry and n.endswith(']'): + cidx = n.rfind('[') + if cidx != -1: + n = n[:cidx].rstrip() + var = -0.05 + # Distance with the company name. + ratio = ratcliff(name1, n, sm1) + var + if ratio >= RO_THRESHOLD: + if resd.has_key(i): + if ratio > resd[i][0]: resd[i] = (ratio, + (i, analyze_company_name(o_name))) + else: + resd[i] = (ratio, (i, analyze_company_name(o_name))) + res = resd.values() + res.sort() + res.reverse() + if results > 0: res[:] = res[:results] + return res + + +try: + from cutils import soundex +except ImportError: + _aux_logger.warn('Unable to import the cutils.soundex function.' + ' Searches of movie titles and person names will be' + ' a bit slower.') + + _translate = dict(B='1', C='2', D='3', F='1', G='2', J='2', K='2', L='4', + M='5', N='5', P='1', Q='2', R='6', S='2', T='3', V='1', + X='2', Z='2') + _translateget = _translate.get + _re_non_ascii = re.compile(r'^[^a-z]*', re.I) + SOUNDEX_LEN = 5 + + def soundex(s): + """Return the soundex code for the given string.""" + # Maximum length of the soundex code. + s = _re_non_ascii.sub('', s) + if not s: return None + s = s.upper() + soundCode = s[0] + for c in s[1:]: + cw = _translateget(c, '0') + if cw != '0' and soundCode[-1] != cw: + soundCode += cw + return soundCode[:SOUNDEX_LEN] or None + + +def _sortKeywords(keyword, kwds): + """Sort a list of keywords, based on the searched one.""" + sm = SequenceMatcher() + sm.set_seq1(keyword.lower()) + ratios = [(ratcliff(keyword, k, sm), k) for k in kwds] + checkContained = False + if len(keyword) > 4: + checkContained = True + for idx, data in enumerate(ratios): + ratio, key = data + if key.startswith(keyword): + ratios[idx] = (ratio+0.5, key) + elif checkContained and keyword in key: + ratios[idx] = (ratio+0.3, key) + ratios.sort() + ratios.reverse() + return [r[1] for r in ratios] + + +def filterSimilarKeywords(keyword, kwdsIterator): + """Return a sorted list of keywords similar to the one given.""" + seenDict = {} + kwdSndx = soundex(keyword.encode('ascii', 'ignore')) + matches = [] + matchesappend = matches.append + checkContained = False + if len(keyword) > 4: + checkContained = True + for movieID, key in kwdsIterator: + if key in seenDict: + continue + seenDict[key] = None + if checkContained and keyword in key: + matchesappend(key) + continue + if kwdSndx == soundex(key.encode('ascii', 'ignore')): + matchesappend(key) + return _sortKeywords(keyword, matches) + + + +# ============================= + +_litlist = ['screenplay/teleplay', 'novel', 'adaption', 'book', + 'production process protocol', 'interviews', + 'printed media reviews', 'essays', 'other literature'] +_litd = dict([(x, ('literature', x)) for x in _litlist]) + +_buslist = ['budget', 'weekend gross', 'gross', 'opening weekend', 'rentals', + 'admissions', 'filming dates', 'production dates', 'studios', + 'copyright holder'] +_busd = dict([(x, ('business', x)) for x in _buslist]) + + +def _reGroupDict(d, newgr): + """Regroup keys in the d dictionary in subdictionaries, based on + the scheme in the newgr dictionary. + E.g.: in the newgr, an entry 'LD label': ('laserdisc', 'label') + tells the _reGroupDict() function to take the entry with + label 'LD label' (as received from the sql database) + and put it in the subsection (another dictionary) named + 'laserdisc', using the key 'label'.""" + r = {} + newgrks = newgr.keys() + for k, v in d.items(): + if k in newgrks: + r.setdefault(newgr[k][0], {})[newgr[k][1]] = v + # A not-so-clearer version: + ##r.setdefault(newgr[k][0], {}) + ##r[newgr[k][0]][newgr[k][1]] = v + else: r[k] = v + return r + + +def _groupListBy(l, index): + """Regroup items in a list in a list of lists, grouped by + the value at the given index.""" + tmpd = {} + for item in l: + tmpd.setdefault(item[index], []).append(item) + res = tmpd.values() + return res + + +def sub_dict(d, keys): + """Return the subdictionary of 'd', with just the keys listed in 'keys'.""" + return dict([(k, d[k]) for k in keys if k in d]) + + +def get_movie_data(movieID, kindDict, fromAka=0, _table=None): + """Return a dictionary containing data about the given movieID; + if fromAka is true, the AkaTitle table is searched; _table is + reserved for the imdbpy2sql.py script.""" + if _table is not None: + Table = _table + else: + if not fromAka: Table = Title + else: Table = AkaTitle + m = Table.get(movieID) + mdict = {'title': m.title, 'kind': kindDict[m.kindID], + 'year': m.productionYear, 'imdbIndex': m.imdbIndex, + 'season': m.seasonNr, 'episode': m.episodeNr} + if not fromAka: + if m.seriesYears is not None: + mdict['series years'] = unicode(m.seriesYears) + if mdict['imdbIndex'] is None: del mdict['imdbIndex'] + if mdict['year'] is None: del mdict['year'] + else: + try: + mdict['year'] = int(mdict['year']) + except (TypeError, ValueError): + del mdict['year'] + if mdict['season'] is None: del mdict['season'] + else: + try: mdict['season'] = int(mdict['season']) + except: pass + if mdict['episode'] is None: del mdict['episode'] + else: + try: mdict['episode'] = int(mdict['episode']) + except: pass + episodeOfID = m.episodeOfID + if episodeOfID is not None: + ser_dict = get_movie_data(episodeOfID, kindDict, fromAka) + mdict['episode of'] = Movie(data=ser_dict, movieID=episodeOfID, + accessSystem='sql') + if fromAka: + ser_note = AkaTitle.get(episodeOfID).note + if ser_note: + mdict['episode of'].notes = ser_note + return mdict + + +def _iterKeywords(results): + """Iterate over (key.id, key.keyword) columns of a selection of + the Keyword table.""" + for key in results: + yield key.id, key.keyword + + +def getSingleInfo(table, movieID, infoType, notAList=False): + """Return a dictionary in the form {infoType: infoListOrString}, + retrieving a single set of information about a given movie, from + the specified table.""" + infoTypeID = InfoType.select(InfoType.q.info == infoType) + if infoTypeID.count() == 0: + return {} + res = table.select(AND(table.q.movieID == movieID, + table.q.infoTypeID == infoTypeID[0].id)) + retList = [] + for r in res: + info = r.info + note = r.note + if note: + info += u'::%s' % note + retList.append(info) + if not retList: + return {} + if not notAList: return {infoType: retList} + else: return {infoType: retList[0]} + + +def _cmpTop(a, b, what='top 250 rank'): + """Compare function used to sort top 250/bottom 10 rank.""" + av = int(a[1].get(what)) + bv = int(b[1].get(what)) + if av == bv: + return 0 + return (-1, 1)[av > bv] + +def _cmpBottom(a, b): + """Compare function used to sort top 250/bottom 10 rank.""" + return _cmpTop(a, b, what='bottom 10 rank') + + +class IMDbSqlAccessSystem(IMDbBase): + """The class used to access IMDb's data through a SQL database.""" + + accessSystem = 'sql' + _sql_logger = logging.getLogger('imdbpy.parser.sql') + + def __init__(self, uri, adultSearch=1, useORM=None, *arguments, **keywords): + """Initialize the access system.""" + IMDbBase.__init__(self, *arguments, **keywords) + if useORM is None: + useORM = ('sqlobject', 'sqlalchemy') + if not isinstance(useORM, (tuple, list)): + if ',' in useORM: + useORM = useORM.split(',') + else: + useORM = [useORM] + self.useORM = useORM + nrMods = len(useORM) + _gotError = False + DB_TABLES = [] + for idx, mod in enumerate(useORM): + mod = mod.strip().lower() + try: + if mod == 'sqlalchemy': + from alchemyadapter import getDBTables, NotFoundError, \ + setConnection, AND, OR, IN, \ + ISNULL, CONTAINSSTRING, toUTF8 + elif mod == 'sqlobject': + from objectadapter import getDBTables, NotFoundError, \ + setConnection, AND, OR, IN, \ + ISNULL, CONTAINSSTRING, toUTF8 + else: + self._sql_logger.warn('unknown module "%s"' % mod) + continue + self._sql_logger.info('using %s ORM', mod) + # XXX: look ma'... black magic! It's used to make + # TableClasses and some functions accessible + # through the whole module. + for k, v in [('NotFoundError', NotFoundError), + ('AND', AND), ('OR', OR), ('IN', IN), + ('ISNULL', ISNULL), + ('CONTAINSSTRING', CONTAINSSTRING)]: + globals()[k] = v + self.toUTF8 = toUTF8 + DB_TABLES = getDBTables(uri) + for t in DB_TABLES: + globals()[t._imdbpyName] = t + if _gotError: + self._sql_logger.warn('falling back to "%s"' % mod) + break + except ImportError, e: + if idx+1 >= nrMods: + raise IMDbError('unable to use any ORM in %s: %s' % ( + str(useORM), str(e))) + else: + self._sql_logger.warn('unable to use "%s": %s' % (mod, + str(e))) + _gotError = True + continue + else: + raise IMDbError('unable to use any ORM in %s' % str(useORM)) + # Set the connection to the database. + self._sql_logger.debug('connecting to %s', uri) + try: + self._connection = setConnection(uri, DB_TABLES) + except AssertionError, e: + raise IMDbDataAccessError( \ + 'unable to connect to the database server; ' + \ + 'complete message: "%s"' % str(e)) + self.Error = self._connection.module.Error + # Maps some IDs to the corresponding strings. + self._kind = {} + self._kindRev = {} + self._sql_logger.debug('reading constants from the database') + try: + for kt in KindType.select(): + self._kind[kt.id] = kt.kind + self._kindRev[str(kt.kind)] = kt.id + except self.Error: + # NOTE: you can also get the error, but - at least with + # MySQL - it also contains the password, and I don't + # like the idea to print it out. + raise IMDbDataAccessError( \ + 'unable to connect to the database server') + self._role = {} + for rl in RoleType.select(): + self._role[rl.id] = str(rl.role) + self._info = {} + self._infoRev = {} + for inf in InfoType.select(): + self._info[inf.id] = str(inf.info) + self._infoRev[str(inf.info)] = inf.id + self._compType = {} + for cType in CompanyType.select(): + self._compType[cType.id] = cType.kind + info = [(it.id, it.info) for it in InfoType.select()] + self._compcast = {} + for cc in CompCastType.select(): + self._compcast[cc.id] = str(cc.kind) + self._link = {} + for lt in LinkType.select(): + self._link[lt.id] = str(lt.link) + self._moviesubs = {} + # Build self._moviesubs, a dictionary used to rearrange + # the data structure for a movie object. + for vid, vinfo in info: + if not vinfo.startswith('LD '): continue + self._moviesubs[vinfo] = ('laserdisc', vinfo[3:]) + self._moviesubs.update(_litd) + self._moviesubs.update(_busd) + self.do_adult_search(adultSearch) + + def _findRefs(self, o, trefs, nrefs): + """Find titles or names references in strings.""" + if isinstance(o, (unicode, str)): + for title in re_titleRef.findall(o): + a_title = analyze_title(title, canonical=0) + rtitle = build_title(a_title, ptdf=1) + if trefs.has_key(rtitle): continue + movieID = self._getTitleID(rtitle) + if movieID is None: + movieID = self._getTitleID(title) + if movieID is None: + continue + m = Movie(title=rtitle, movieID=movieID, + accessSystem=self.accessSystem) + trefs[rtitle] = m + rtitle2 = canonicalTitle(a_title.get('title', u'')) + if rtitle2 and rtitle2 != rtitle and rtitle2 != title: + trefs[rtitle2] = m + if title != rtitle: + trefs[title] = m + for name in re_nameRef.findall(o): + a_name = analyze_name(name, canonical=1) + rname = build_name(a_name, canonical=1) + if nrefs.has_key(rname): continue + personID = self._getNameID(rname) + if personID is None: + personID = self._getNameID(name) + if personID is None: continue + p = Person(name=rname, personID=personID, + accessSystem=self.accessSystem) + nrefs[rname] = p + rname2 = normalizeName(a_name.get('name', u'')) + if rname2 and rname2 != rname: + nrefs[rname2] = p + if name != rname and name != rname2: + nrefs[name] = p + elif isinstance(o, (list, tuple)): + for item in o: + self._findRefs(item, trefs, nrefs) + elif isinstance(o, dict): + for value in o.values(): + self._findRefs(value, trefs, nrefs) + return (trefs, nrefs) + + def _extractRefs(self, o): + """Scan for titles or names references in strings.""" + trefs = {} + nrefs = {} + try: + return self._findRefs(o, trefs, nrefs) + except RuntimeError, e: + # Symbian/python 2.2 has a poor regexp implementation. + import warnings + warnings.warn('RuntimeError in ' + "imdb.parser.sql.IMDbSqlAccessSystem; " + "if it's not a recursion limit exceeded and we're not " + "running in a Symbian environment, it's a bug:\n%s" % e) + return (trefs, nrefs) + + def _changeAKAencoding(self, akanotes, akatitle): + """Return akatitle in the correct charset, as specified in + the akanotes field; if akatitle doesn't need to be modified, + return None.""" + oti = akanotes.find('(original ') + if oti == -1: return None + ote = akanotes[oti+10:].find(' title)') + if ote != -1: + cs_info = akanotes[oti+10:oti+10+ote].lower().split() + for e in cs_info: + # excludes some strings that clearly are not encoding. + if e in ('script', '', 'cyrillic', 'greek'): continue + if e.startswith('iso-') and e.find('latin') != -1: + e = e[4:].replace('-', '') + try: + lookup(e) + lat1 = akatitle.encode('latin_1', 'replace') + return unicode(lat1, e, 'replace') + except (LookupError, ValueError, TypeError): + continue + return None + + def _buildNULLCondition(self, col, val): + """Build a comparison for columns where values can be NULL.""" + if val is None: + return ISNULL(col) + else: + if isinstance(val, (int, long)): + return col == val + else: + return col == self.toUTF8(val) + + def _getTitleID(self, title): + """Given a long imdb canonical title, returns a movieID or + None if not found.""" + td = analyze_title(title) + condition = None + if td['kind'] == 'episode': + epof = td['episode of'] + seriesID = [s.id for s in Title.select( + AND(Title.q.title == self.toUTF8(epof['title']), + self._buildNULLCondition(Title.q.imdbIndex, + epof.get('imdbIndex')), + Title.q.kindID == self._kindRev[epof['kind']], + self._buildNULLCondition(Title.q.productionYear, + epof.get('year'))))] + if seriesID: + condition = AND(IN(Title.q.episodeOfID, seriesID), + Title.q.title == self.toUTF8(td['title']), + self._buildNULLCondition(Title.q.imdbIndex, + td.get('imdbIndex')), + Title.q.kindID == self._kindRev[td['kind']], + self._buildNULLCondition(Title.q.productionYear, + td.get('year'))) + if condition is None: + condition = AND(Title.q.title == self.toUTF8(td['title']), + self._buildNULLCondition(Title.q.imdbIndex, + td.get('imdbIndex')), + Title.q.kindID == self._kindRev[td['kind']], + self._buildNULLCondition(Title.q.productionYear, + td.get('year'))) + res = Title.select(condition) + try: + if res.count() != 1: + return None + except (UnicodeDecodeError, TypeError): + return None + return res[0].id + + def _getNameID(self, name): + """Given a long imdb canonical name, returns a personID or + None if not found.""" + nd = analyze_name(name) + res = Name.select(AND(Name.q.name == self.toUTF8(nd['name']), + self._buildNULLCondition(Name.q.imdbIndex, + nd.get('imdbIndex')))) + try: + c = res.count() + if res.count() != 1: + return None + except (UnicodeDecodeError, TypeError): + return None + return res[0].id + + def _normalize_movieID(self, movieID): + """Normalize the given movieID.""" + try: + return int(movieID) + except (ValueError, OverflowError): + raise IMDbError('movieID "%s" can\'t be converted to integer' % \ + movieID) + + def _normalize_personID(self, personID): + """Normalize the given personID.""" + try: + return int(personID) + except (ValueError, OverflowError): + raise IMDbError('personID "%s" can\'t be converted to integer' % \ + personID) + + def _normalize_characterID(self, characterID): + """Normalize the given characterID.""" + try: + return int(characterID) + except (ValueError, OverflowError): + raise IMDbError('characterID "%s" can\'t be converted to integer' \ + % characterID) + + def _normalize_companyID(self, companyID): + """Normalize the given companyID.""" + try: + return int(companyID) + except (ValueError, OverflowError): + raise IMDbError('companyID "%s" can\'t be converted to integer' \ + % companyID) + + def get_imdbMovieID(self, movieID): + """Translate a movieID in an imdbID. + If not in the database, try an Exact Primary Title search on IMDb; + return None if it's unable to get the imdbID. + """ + try: movie = Title.get(movieID) + except NotFoundError: return None + imdbID = movie.imdbID + if imdbID is not None: return '%07d' % imdbID + m_dict = get_movie_data(movie.id, self._kind) + titline = build_title(m_dict, ptdf=1) + imdbID = self.title2imdbID(titline) + # If the imdbID was retrieved from the web and was not in the + # database, update the database (ignoring errors, because it's + # possibile that the current user has not update privileges). + # There're times when I think I'm a genius; this one of + # those times... + if imdbID is not None: + try: movie.imdbID = int(imdbID) + except: pass + return imdbID + + def get_imdbPersonID(self, personID): + """Translate a personID in an imdbID. + If not in the database, try an Exact Primary Name search on IMDb; + return None if it's unable to get the imdbID. + """ + try: person = Name.get(personID) + except NotFoundError: return None + imdbID = person.imdbID + if imdbID is not None: return '%07d' % imdbID + n_dict = {'name': person.name, 'imdbIndex': person.imdbIndex} + namline = build_name(n_dict, canonical=1) + imdbID = self.name2imdbID(namline) + if imdbID is not None: + try: person.imdbID = int(imdbID) + except: pass + return imdbID + + def get_imdbCharacterID(self, characterID): + """Translate a characterID in an imdbID. + If not in the database, try an Exact Primary Name search on IMDb; + return None if it's unable to get the imdbID. + """ + try: character = CharName.get(characterID) + except NotFoundError: return None + imdbID = character.imdbID + if imdbID is not None: return '%07d' % imdbID + n_dict = {'name': character.name, 'imdbIndex': character.imdbIndex} + namline = build_name(n_dict, canonical=1) + imdbID = self.character2imdbID(namline) + if imdbID is not None: + try: character.imdbID = int(imdbID) + except: pass + return imdbID + + def get_imdbCompanyID(self, companyID): + """Translate a companyID in an imdbID. + If not in the database, try an Exact Primary Name search on IMDb; + return None if it's unable to get the imdbID. + """ + try: company = CompanyName.get(companyID) + except NotFoundError: return None + imdbID = company.imdbID + if imdbID is not None: return '%07d' % imdbID + n_dict = {'name': company.name, 'country': company.countryCode} + namline = build_company_name(n_dict) + imdbID = self.company2imdbID(namline) + if imdbID is not None: + try: company.imdbID = int(imdbID) + except: pass + return imdbID + + def do_adult_search(self, doAdult): + """If set to 0 or False, movies in the Adult category are not + episodeOf = title_dict.get('episode of') + shown in the results of a search.""" + self.doAdult = doAdult + + def _search_movie(self, title, results, _episodes=False): + title = title.strip() + if not title: return [] + title_dict = analyze_title(title, canonical=1) + s_title = title_dict['title'] + if not s_title: return [] + episodeOf = title_dict.get('episode of') + if episodeOf: + _episodes = False + s_title_split = s_title.split(', ') + if len(s_title_split) > 1 and \ + s_title_split[-1].lower() in _unicodeArticles: + s_title_rebuilt = ', '.join(s_title_split[:-1]) + if s_title_rebuilt: + s_title = s_title_rebuilt + #if not episodeOf: + # if not _episodes: + # s_title_split = s_title.split(', ') + # if len(s_title_split) > 1 and \ + # s_title_split[-1].lower() in _articles: + # s_title_rebuilt = ', '.join(s_title_split[:-1]) + # if s_title_rebuilt: + # s_title = s_title_rebuilt + #else: + # _episodes = False + if isinstance(s_title, unicode): + s_title = s_title.encode('ascii', 'ignore') + + soundexCode = soundex(s_title) + + # XXX: improve the search restricting the kindID if the + # "kind" of the input differs from "movie"? + condition = conditionAka = None + if _episodes: + condition = AND(Title.q.phoneticCode == soundexCode, + Title.q.kindID == self._kindRev['episode']) + conditionAka = AND(AkaTitle.q.phoneticCode == soundexCode, + AkaTitle.q.kindID == self._kindRev['episode']) + elif title_dict['kind'] == 'episode' and episodeOf is not None: + # set canonical=0 ? Should not make much difference. + series_title = build_title(episodeOf, canonical=1) + # XXX: is it safe to get "results" results? + # Too many? Too few? + serRes = results + if serRes < 3 or serRes > 10: + serRes = 10 + searchSeries = self._search_movie(series_title, serRes) + seriesIDs = [result[0] for result in searchSeries] + if seriesIDs: + condition = AND(Title.q.phoneticCode == soundexCode, + IN(Title.q.episodeOfID, seriesIDs), + Title.q.kindID == self._kindRev['episode']) + conditionAka = AND(AkaTitle.q.phoneticCode == soundexCode, + IN(AkaTitle.q.episodeOfID, seriesIDs), + AkaTitle.q.kindID == self._kindRev['episode']) + else: + # XXX: bad situation: we have found no matching series; + # try searching everything (both episodes and + # non-episodes) for the title. + condition = AND(Title.q.phoneticCode == soundexCode, + IN(Title.q.episodeOfID, seriesIDs)) + conditionAka = AND(AkaTitle.q.phoneticCode == soundexCode, + IN(AkaTitle.q.episodeOfID, seriesIDs)) + if condition is None: + # XXX: excludes episodes? + condition = AND(Title.q.kindID != self._kindRev['episode'], + Title.q.phoneticCode == soundexCode) + conditionAka = AND(AkaTitle.q.kindID != self._kindRev['episode'], + AkaTitle.q.phoneticCode == soundexCode) + + # Up to 3 variations of the title are searched, plus the + # long imdb canonical title, if provided. + if not _episodes: + title1, title2, title3 = titleVariations(title) + else: + title1 = title + title2 = '' + title3 = '' + try: + qr = [(q.id, get_movie_data(q.id, self._kind)) + for q in Title.select(condition)] + q2 = [(q.movieID, get_movie_data(q.id, self._kind, fromAka=1)) + for q in AkaTitle.select(conditionAka)] + qr += q2 + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to search the database: "%s"' % str(e)) + + resultsST = results * 3 + res = scan_titles(qr, title1, title2, title3, resultsST, + searchingEpisode=episodeOf is not None, + onlyEpisodes=_episodes, + ro_thresold=0.0) + res[:] = [x[1] for x in res] + + if res and not self.doAdult: + mids = [x[0] for x in res] + genreID = self._infoRev['genres'] + adultlist = [al.movieID for al + in MovieInfo.select( + AND(MovieInfo.q.infoTypeID == genreID, + MovieInfo.q.info == 'Adult', + IN(MovieInfo.q.movieID, mids)))] + res[:] = [x for x in res if x[0] not in adultlist] + + new_res = [] + # XXX: can there be duplicates? + for r in res: + if r not in q2: + new_res.append(r) + continue + mdict = r[1] + aka_title = build_title(mdict, ptdf=1) + orig_dict = get_movie_data(r[0], self._kind) + orig_title = build_title(orig_dict, ptdf=1) + if aka_title == orig_title: + new_res.append(r) + continue + orig_dict['akas'] = [aka_title] + new_res.append((r[0], orig_dict)) + if results > 0: new_res[:] = new_res[:results] + return new_res + + def _search_episode(self, title, results): + return self._search_movie(title, results, _episodes=True) + + def get_movie_main(self, movieID): + # Every movie information is retrieved from here. + infosets = self.get_movie_infoset() + try: + res = get_movie_data(movieID, self._kind) + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to get movieID "%s": "%s"' % (movieID, str(e))) + if not res: + raise IMDbDataAccessError('unable to get movieID "%s"' % movieID) + # Collect cast information. + castdata = [[cd.personID, cd.personRoleID, cd.note, cd.nrOrder, + self._role[cd.roleID]] + for cd in CastInfo.select(CastInfo.q.movieID == movieID)] + for p in castdata: + person = Name.get(p[0]) + p += [person.name, person.imdbIndex] + if p[4] in ('actor', 'actress'): + p[4] = 'cast' + # Regroup by role/duty (cast, writer, director, ...) + castdata[:] = _groupListBy(castdata, 4) + for group in castdata: + duty = group[0][4] + for pdata in group: + curRole = pdata[1] + curRoleID = None + if curRole is not None: + robj = CharName.get(curRole) + curRole = robj.name + curRoleID = robj.id + p = Person(personID=pdata[0], name=pdata[5], + currentRole=curRole or u'', + roleID=curRoleID, + notes=pdata[2] or u'', + accessSystem='sql') + if pdata[6]: p['imdbIndex'] = pdata[6] + p.billingPos = pdata[3] + res.setdefault(duty, []).append(p) + if duty == 'cast': + res[duty] = merge_roles(res[duty]) + res[duty].sort() + # Info about the movie. + minfo = [(self._info[m.infoTypeID], m.info, m.note) + for m in MovieInfo.select(MovieInfo.q.movieID == movieID)] + minfo += [(self._info[m.infoTypeID], m.info, m.note) + for m in MovieInfoIdx.select(MovieInfoIdx.q.movieID == movieID)] + minfo += [('keywords', Keyword.get(m.keywordID).keyword, None) + for m in MovieKeyword.select(MovieKeyword.q.movieID == movieID)] + minfo = _groupListBy(minfo, 0) + for group in minfo: + sect = group[0][0] + for mdata in group: + data = mdata[1] + if mdata[2]: data += '::%s' % mdata[2] + res.setdefault(sect, []).append(data) + # Companies info about a movie. + cinfo = [(self._compType[m.companyTypeID], m.companyID, m.note) for m + in MovieCompanies.select(MovieCompanies.q.movieID == movieID)] + cinfo = _groupListBy(cinfo, 0) + for group in cinfo: + sect = group[0][0] + for mdata in group: + cDb = CompanyName.get(mdata[1]) + cDbTxt = cDb.name + if cDb.countryCode: + cDbTxt += ' %s' % cDb.countryCode + company = Company(name=cDbTxt, + companyID=mdata[1], + notes=mdata[2] or u'', + accessSystem=self.accessSystem) + res.setdefault(sect, []).append(company) + # AKA titles. + akat = [(get_movie_data(at.id, self._kind, fromAka=1), at.note) + for at in AkaTitle.select(AkaTitle.q.movieID == movieID)] + if akat: + res['akas'] = [] + for td, note in akat: + nt = build_title(td, ptdf=1) + if note: + net = self._changeAKAencoding(note, nt) + if net is not None: nt = net + nt += '::%s' % note + if nt not in res['akas']: res['akas'].append(nt) + # Complete cast/crew. + compcast = [(self._compcast[cc.subjectID], self._compcast[cc.statusID]) + for cc in CompleteCast.select(CompleteCast.q.movieID == movieID)] + if compcast: + for entry in compcast: + val = unicode(entry[1]) + res[u'complete %s' % entry[0]] = val + # Movie connections. + mlinks = [[ml.linkedMovieID, self._link[ml.linkTypeID]] + for ml in MovieLink.select(MovieLink.q.movieID == movieID)] + if mlinks: + for ml in mlinks: + lmovieData = get_movie_data(ml[0], self._kind) + m = Movie(movieID=ml[0], data=lmovieData, accessSystem='sql') + ml[0] = m + res['connections'] = {} + mlinks[:] = _groupListBy(mlinks, 1) + for group in mlinks: + lt = group[0][1] + res['connections'][lt] = [i[0] for i in group] + # Episodes. + episodes = {} + eps_list = list(Title.select(Title.q.episodeOfID == movieID)) + eps_list.sort() + if eps_list: + ps_data = {'title': res['title'], 'kind': res['kind'], + 'year': res.get('year'), + 'imdbIndex': res.get('imdbIndex')} + parentSeries = Movie(movieID=movieID, data=ps_data, + accessSystem='sql') + for episode in eps_list: + episodeID = episode.id + episode_data = get_movie_data(episodeID, self._kind) + m = Movie(movieID=episodeID, data=episode_data, + accessSystem='sql') + m['episode of'] = parentSeries + season = episode_data.get('season', 'UNKNOWN') + if season not in episodes: episodes[season] = {} + ep_number = episode_data.get('episode') + if ep_number is None: + ep_number = max((episodes[season].keys() or [0])) + 1 + episodes[season][ep_number] = m + res['episodes'] = episodes + res['number of episodes'] = sum([len(x) for x in episodes.values()]) + res['number of seasons'] = len(episodes.keys()) + # Regroup laserdisc information. + res = _reGroupDict(res, self._moviesubs) + # Do some transformation to preserve consistency with other + # data access systems. + if 'quotes' in res: + for idx, quote in enumerate(res['quotes']): + res['quotes'][idx] = quote.split('::') + if 'runtimes' in res and len(res['runtimes']) > 0: + rt = res['runtimes'][0] + episodes = re_episodes.findall(rt) + if episodes: + res['runtimes'][0] = re_episodes.sub('', rt) + if res['runtimes'][0][-2:] == '::': + res['runtimes'][0] = res['runtimes'][0][:-2] + if 'votes' in res: + res['votes'] = int(res['votes'][0]) + if 'rating' in res: + res['rating'] = float(res['rating'][0]) + if 'votes distribution' in res: + res['votes distribution'] = res['votes distribution'][0] + if 'mpaa' in res: + res['mpaa'] = res['mpaa'][0] + if 'top 250 rank' in res: + try: res['top 250 rank'] = int(res['top 250 rank']) + except: pass + if 'bottom 10 rank' in res: + try: res['bottom 100 rank'] = int(res['bottom 10 rank']) + except: pass + del res['bottom 10 rank'] + for old, new in [('guest', 'guests'), ('trademarks', 'trade-mark'), + ('articles', 'article'), ('pictorials', 'pictorial'), + ('magazine-covers', 'magazine-cover-photo')]: + if old in res: + res[new] = res[old] + del res[old] + trefs,nrefs = {}, {} + trefs,nrefs = self._extractRefs(sub_dict(res,Movie.keys_tomodify_list)) + return {'data': res, 'titlesRefs': trefs, 'namesRefs': nrefs, + 'info sets': infosets} + + # Just to know what kind of information are available. + get_movie_alternate_versions = get_movie_main + get_movie_business = get_movie_main + get_movie_connections = get_movie_main + get_movie_crazy_credits = get_movie_main + get_movie_goofs = get_movie_main + get_movie_keywords = get_movie_main + get_movie_literature = get_movie_main + get_movie_locations = get_movie_main + get_movie_plot = get_movie_main + get_movie_quotes = get_movie_main + get_movie_release_dates = get_movie_main + get_movie_soundtrack = get_movie_main + get_movie_taglines = get_movie_main + get_movie_technical = get_movie_main + get_movie_trivia = get_movie_main + get_movie_vote_details = get_movie_main + get_movie_episodes = get_movie_main + + def _search_person(self, name, results): + name = name.strip() + if not name: return [] + s_name = analyze_name(name)['name'] + if not s_name: return [] + if isinstance(s_name, unicode): + s_name = s_name.encode('ascii', 'ignore') + soundexCode = soundex(s_name) + name1, name2, name3 = nameVariations(name) + + # If the soundex is None, compare only with the first + # phoneticCode column. + if soundexCode is not None: + condition = IN(soundexCode, [Name.q.namePcodeCf, + Name.q.namePcodeNf, + Name.q.surnamePcode]) + conditionAka = IN(soundexCode, [AkaName.q.namePcodeCf, + AkaName.q.namePcodeNf, + AkaName.q.surnamePcode]) + else: + condition = ISNULL(Name.q.namePcodeCf) + conditionAka = ISNULL(AkaName.q.namePcodeCf) + + try: + qr = [(q.id, {'name': q.name, 'imdbIndex': q.imdbIndex}) + for q in Name.select(condition)] + + q2 = [(q.personID, {'name': q.name, 'imdbIndex': q.imdbIndex}) + for q in AkaName.select(conditionAka)] + qr += q2 + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to search the database: "%s"' % str(e)) + + res = scan_names(qr, name1, name2, name3, results) + res[:] = [x[1] for x in res] + # Purge empty imdbIndex. + returnl = [] + for x in res: + tmpd = x[1] + if tmpd['imdbIndex'] is None: + del tmpd['imdbIndex'] + returnl.append((x[0], tmpd)) + + new_res = [] + # XXX: can there be duplicates? + for r in returnl: + if r not in q2: + new_res.append(r) + continue + pdict = r[1] + aka_name = build_name(pdict, canonical=1) + p = Name.get(r[0]) + orig_dict = {'name': p.name, 'imdbIndex': p.imdbIndex} + if orig_dict['imdbIndex'] is None: + del orig_dict['imdbIndex'] + orig_name = build_name(orig_dict, canonical=1) + if aka_name == orig_name: + new_res.append(r) + continue + orig_dict['akas'] = [aka_name] + new_res.append((r[0], orig_dict)) + if results > 0: new_res[:] = new_res[:results] + + return new_res + + def get_person_main(self, personID): + # Every person information is retrieved from here. + infosets = self.get_person_infoset() + try: + p = Name.get(personID) + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to get personID "%s": "%s"' % (personID, str(e))) + res = {'name': p.name, 'imdbIndex': p.imdbIndex} + if res['imdbIndex'] is None: del res['imdbIndex'] + if not res: + raise IMDbDataAccessError('unable to get personID "%s"' % personID) + # Collect cast information. + castdata = [(cd.movieID, cd.personRoleID, cd.note, + self._role[cd.roleID], + get_movie_data(cd.movieID, self._kind)) + for cd in CastInfo.select(CastInfo.q.personID == personID)] + # Regroup by role/duty (cast, writer, director, ...) + castdata[:] = _groupListBy(castdata, 3) + episodes = {} + seenDuties = [] + for group in castdata: + for mdata in group: + duty = orig_duty = group[0][3] + if duty not in seenDuties: seenDuties.append(orig_duty) + note = mdata[2] or u'' + if 'episode of' in mdata[4]: + duty = 'episodes' + if orig_duty not in ('actor', 'actress'): + if note: note = ' %s' % note + note = '[%s]%s' % (orig_duty, note) + curRole = mdata[1] + curRoleID = None + if curRole is not None: + robj = CharName.get(curRole) + curRole = robj.name + curRoleID = robj.id + m = Movie(movieID=mdata[0], data=mdata[4], + currentRole=curRole or u'', + roleID=curRoleID, + notes=note, accessSystem='sql') + if duty != 'episodes': + res.setdefault(duty, []).append(m) + else: + episodes.setdefault(m['episode of'], []).append(m) + if episodes: + for k in episodes: + episodes[k].sort() + episodes[k].reverse() + res['episodes'] = episodes + for duty in seenDuties: + if duty in res: + if duty in ('actor', 'actress', 'himself', 'herself', + 'themselves'): + res[duty] = merge_roles(res[duty]) + res[duty].sort() + # Info about the person. + pinfo = [(self._info[pi.infoTypeID], pi.info, pi.note) + for pi in PersonInfo.select(PersonInfo.q.personID == personID)] + # Regroup by duty. + pinfo = _groupListBy(pinfo, 0) + for group in pinfo: + sect = group[0][0] + for pdata in group: + data = pdata[1] + if pdata[2]: data += '::%s' % pdata[2] + res.setdefault(sect, []).append(data) + # AKA names. + akan = [(an.name, an.imdbIndex) + for an in AkaName.select(AkaName.q.personID == personID)] + if akan: + res['akas'] = [] + for n in akan: + nd = {'name': n[0]} + if n[1]: nd['imdbIndex'] = n[1] + nt = build_name(nd, canonical=1) + res['akas'].append(nt) + # Do some transformation to preserve consistency with other + # data access systems. + for key in ('birth date', 'birth notes', 'death date', 'death notes', + 'birth name', 'height'): + if key in res: + res[key] = res[key][0] + if 'guest' in res: + res['notable tv guest appearances'] = res['guest'] + del res['guest'] + miscnames = res.get('nick names', []) + if 'birth name' in res: miscnames.append(res['birth name']) + if 'akas' in res: + for mname in miscnames: + if mname in res['akas']: res['akas'].remove(mname) + if not res['akas']: del res['akas'] + trefs,nrefs = self._extractRefs(sub_dict(res,Person.keys_tomodify_list)) + return {'data': res, 'titlesRefs': trefs, 'namesRefs': nrefs, + 'info sets': infosets} + + # Just to know what kind of information are available. + get_person_filmography = get_person_main + get_person_biography = get_person_main + get_person_other_works = get_person_main + get_person_episodes = get_person_main + + def _search_character(self, name, results): + name = name.strip() + if not name: return [] + s_name = analyze_name(name)['name'] + if not s_name: return [] + if isinstance(s_name, unicode): + s_name = s_name.encode('ascii', 'ignore') + s_name = normalizeName(s_name) + soundexCode = soundex(s_name) + surname = s_name.split(' ')[-1] + surnameSoundex = soundex(surname) + name2 = '' + soundexName2 = None + nsplit = s_name.split() + if len(nsplit) > 1: + name2 = '%s %s' % (nsplit[-1], ' '.join(nsplit[:-1])) + if s_name == name2: + name2 = '' + else: + soundexName2 = soundex(name2) + # If the soundex is None, compare only with the first + # phoneticCode column. + if soundexCode is not None: + if soundexName2 is not None: + condition = OR(surnameSoundex == CharName.q.surnamePcode, + IN(CharName.q.namePcodeNf, [soundexCode, + soundexName2]), + IN(CharName.q.surnamePcode, [soundexCode, + soundexName2])) + else: + condition = OR(surnameSoundex == CharName.q.surnamePcode, + IN(soundexCode, [CharName.q.namePcodeNf, + CharName.q.surnamePcode])) + else: + condition = ISNULL(Name.q.namePcodeNf) + try: + qr = [(q.id, {'name': q.name, 'imdbIndex': q.imdbIndex}) + for q in CharName.select(condition)] + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to search the database: "%s"' % str(e)) + res = scan_names(qr, s_name, name2, '', results, + _scan_character=True) + res[:] = [x[1] for x in res] + # Purge empty imdbIndex. + returnl = [] + for x in res: + tmpd = x[1] + if tmpd['imdbIndex'] is None: + del tmpd['imdbIndex'] + returnl.append((x[0], tmpd)) + return returnl + + def get_character_main(self, characterID, results=1000): + # Every character information is retrieved from here. + infosets = self.get_character_infoset() + try: + c = CharName.get(characterID) + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to get characterID "%s": "%s"' % (characterID, e)) + res = {'name': c.name, 'imdbIndex': c.imdbIndex} + if res['imdbIndex'] is None: del res['imdbIndex'] + if not res: + raise IMDbDataAccessError('unable to get characterID "%s"' % \ + characterID) + # Collect filmography information. + items = CastInfo.select(CastInfo.q.personRoleID == characterID) + if results > 0: + items = items[:results] + filmodata = [(cd.movieID, cd.personID, cd.note, + get_movie_data(cd.movieID, self._kind)) for cd in items + if self._role[cd.roleID] in ('actor', 'actress')] + fdata = [] + for f in filmodata: + curRole = None + curRoleID = f[1] + note = f[2] or u'' + if curRoleID is not None: + robj = Name.get(curRoleID) + curRole = robj.name + m = Movie(movieID=f[0], data=f[3], + currentRole=curRole or u'', + roleID=curRoleID, roleIsPerson=True, + notes=note, accessSystem='sql') + fdata.append(m) + fdata = merge_roles(fdata) + fdata.sort() + if fdata: + res['filmography'] = fdata + return {'data': res, 'info sets': infosets} + + get_character_filmography = get_character_main + get_character_biography = get_character_main + + def _search_company(self, name, results): + name = name.strip() + if not name: return [] + if isinstance(name, unicode): + name = name.encode('ascii', 'ignore') + soundexCode = soundex(name) + # If the soundex is None, compare only with the first + # phoneticCode column. + if soundexCode is None: + condition = ISNULL(CompanyName.q.namePcodeNf) + else: + if name.endswith(']'): + condition = CompanyName.q.namePcodeSf == soundexCode + else: + condition = CompanyName.q.namePcodeNf == soundexCode + try: + qr = [(q.id, {'name': q.name, 'country': q.countryCode}) + for q in CompanyName.select(condition)] + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to search the database: "%s"' % str(e)) + qr[:] = [(x[0], build_company_name(x[1])) for x in qr] + res = scan_company_names(qr, name, results) + res[:] = [x[1] for x in res] + # Purge empty country keys. + returnl = [] + for x in res: + tmpd = x[1] + country = tmpd.get('country') + if country is None and 'country' in tmpd: + del tmpd['country'] + returnl.append((x[0], tmpd)) + return returnl + + def get_company_main(self, companyID, results=0): + # Every company information is retrieved from here. + infosets = self.get_company_infoset() + try: + c = CompanyName.get(companyID) + except NotFoundError, e: + raise IMDbDataAccessError( \ + 'unable to get companyID "%s": "%s"' % (companyID, e)) + res = {'name': c.name, 'country': c.countryCode} + if res['country'] is None: del res['country'] + if not res: + raise IMDbDataAccessError('unable to get companyID "%s"' % \ + companyID) + # Collect filmography information. + items = MovieCompanies.select(MovieCompanies.q.companyID == companyID) + if results > 0: + items = items[:results] + filmodata = [(cd.movieID, cd.companyID, + self._compType[cd.companyTypeID], cd.note, + get_movie_data(cd.movieID, self._kind)) for cd in items] + filmodata = _groupListBy(filmodata, 2) + for group in filmodata: + ctype = group[0][2] + for movieID, companyID, ctype, note, movieData in group: + movie = Movie(data=movieData, movieID=movieID, + notes=note or u'', accessSystem=self.accessSystem) + res.setdefault(ctype, []).append(movie) + res.get(ctype, []).sort() + return {'data': res, 'info sets': infosets} + + def _search_keyword(self, keyword, results): + constr = OR(Keyword.q.phoneticCode == + soundex(keyword.encode('ascii', 'ignore')), + CONTAINSSTRING(Keyword.q.keyword, self.toUTF8(keyword))) + return filterSimilarKeywords(keyword, + _iterKeywords(Keyword.select(constr)))[:results] + + def _get_keyword(self, keyword, results): + keyID = Keyword.select(Keyword.q.keyword == keyword) + if keyID.count() == 0: + return [] + keyID = keyID[0].id + movies = MovieKeyword.select(MovieKeyword.q.keywordID == + keyID)[:results] + return [(m.movieID, get_movie_data(m.movieID, self._kind)) + for m in movies] + + def _get_top_bottom_movies(self, kind): + if kind == 'top': + kind = 'top 250 rank' + elif kind == 'bottom': + # Not a refuse: the plain text data files contains only + # the bottom 10 movies. + kind = 'bottom 10 rank' + else: + return [] + infoID = InfoType.select(InfoType.q.info == kind) + if infoID.count() == 0: + return [] + infoID = infoID[0].id + movies = MovieInfoIdx.select(MovieInfoIdx.q.infoTypeID == infoID) + ml = [] + for m in movies: + minfo = get_movie_data(m.movieID, self._kind) + for k in kind, 'votes', 'rating', 'votes distribution': + valueDict = getSingleInfo(MovieInfoIdx, m.movieID, + k, notAList=True) + if k in (kind, 'votes') and k in valueDict: + valueDict[k] = int(valueDict[k]) + elif k == 'rating' and k in valueDict: + valueDict[k] = float(valueDict[k]) + minfo.update(valueDict) + ml.append((m.movieID, minfo)) + sorter = (_cmpBottom, _cmpTop)[kind == 'top 250 rank'] + ml.sort(sorter) + return ml + + def __del__(self): + """Ensure that the connection is closed.""" + if not hasattr(self, '_connection'): return + self._sql_logger.debug('closing connection to the database') + self._connection.close() + diff --git a/lib/imdb/parser/sql/alchemyadapter.py b/lib/imdb/parser/sql/alchemyadapter.py new file mode 100644 index 0000000000..9b5c79e49b --- /dev/null +++ b/lib/imdb/parser/sql/alchemyadapter.py @@ -0,0 +1,508 @@ +""" +parser.sql.alchemyadapter module (imdb.parser.sql package). + +This module adapts the SQLAlchemy ORM to the internal mechanism. + +Copyright 2008-2010 Davide Alberani + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import re +import sys +import logging +from sqlalchemy import * +from sqlalchemy import schema +try: from sqlalchemy import exc # 0.5 +except ImportError: from sqlalchemy import exceptions as exc # 0.4 + +_alchemy_logger = logging.getLogger('imdbpy.parser.sql.alchemy') + +try: + import migrate.changeset + HAS_MC = True +except ImportError: + HAS_MC = False + _alchemy_logger.warn('Unable to import migrate.changeset: Foreign ' \ + 'Keys will not be created.') + +from imdb._exceptions import IMDbDataAccessError +from dbschema import * + +# Used to convert table and column names. +re_upper = re.compile(r'([A-Z])') + +# XXX: I'm not sure at all that this is the best method to connect +# to the database and bind that connection to every table. +metadata = MetaData() + +# Maps our placeholders to SQLAlchemy's column types. +MAP_COLS = { + INTCOL: Integer, + UNICODECOL: UnicodeText, + STRINGCOL: String +} + + +class NotFoundError(IMDbDataAccessError): + """Exception raised when Table.get(id) returns no value.""" + pass + + +def _renameTable(tname): + """Build the name of a table, as done by SQLObject.""" + tname = re_upper.sub(r'_\1', tname) + if tname.startswith('_'): + tname = tname[1:] + return tname.lower() + +def _renameColumn(cname): + """Build the name of a column, as done by SQLObject.""" + cname = cname.replace('ID', 'Id') + return _renameTable(cname) + + +class DNNameObj(object): + """Used to access table.sqlmeta.columns[column].dbName (a string).""" + def __init__(self, dbName): + self.dbName = dbName + + def __repr__(self): + return '' % (self.dbName, id(self)) + + +class DNNameDict(object): + """Used to access table.sqlmeta.columns (a dictionary).""" + def __init__(self, colMap): + self.colMap = colMap + + def __getitem__(self, key): + return DNNameObj(self.colMap[key]) + + def __repr__(self): + return '' % (self.colMap, id(self)) + + +class SQLMetaAdapter(object): + """Used to access table.sqlmeta (an object with .table, .columns and + .idName attributes).""" + def __init__(self, table, colMap=None): + self.table = table + if colMap is None: + colMap = {} + self.colMap = colMap + + def __getattr__(self, name): + if name == 'table': + return getattr(self.table, name) + if name == 'columns': + return DNNameDict(self.colMap) + if name == 'idName': + return self.colMap.get('id', 'id') + return None + + def __repr__(self): + return '' % \ + (repr(self.table), repr(self.colMap), id(self)) + + +class QAdapter(object): + """Used to access table.q attribute (remapped to SQLAlchemy table.c).""" + def __init__(self, table, colMap=None): + self.table = table + if colMap is None: + colMap = {} + self.colMap = colMap + + def __getattr__(self, name): + try: return getattr(self.table.c, self.colMap[name]) + except KeyError, e: raise AttributeError("unable to get '%s'" % name) + + def __repr__(self): + return '' % \ + (repr(self.table), repr(self.colMap), id(self)) + + +class RowAdapter(object): + """Adapter for a SQLAlchemy RowProxy object.""" + def __init__(self, row, table, colMap=None): + self.row = row + # FIXME: it's OBSCENE that 'table' should be passed from + # TableAdapter through ResultAdapter only to land here, + # where it's used to directly update a row item. + self.table = table + if colMap is None: + colMap = {} + self.colMap = colMap + self.colMapKeys = colMap.keys() + + def __getattr__(self, name): + try: return getattr(self.row, self.colMap[name]) + except KeyError, e: raise AttributeError("unable to get '%s'" % name) + + def __setattr__(self, name, value): + # FIXME: I can't even think about how much performances suffer, + # for this horrible hack (and it's used so rarely...) + # For sure something like a "property" to map column names + # to getter/setter functions would be much better, but it's + # not possible (or at least not easy) to build them for a + # single instance. + if name in self.__dict__.get('colMapKeys', ()): + # Trying to update a value in the database. + row = self.__dict__['row'] + table = self.__dict__['table'] + colMap = self.__dict__['colMap'] + params = {colMap[name]: value} + table.update(table.c.id==row.id).execute(**params) + # XXX: minor bug: after a value is assigned with the + # 'rowAdapterInstance.colName = value' syntax, for some + # reason rowAdapterInstance.colName still returns the + # previous value (even if the database is updated). + # Fix it? I'm not even sure it's ever used. + return + # For every other attribute. + object.__setattr__(self, name, value) + + def __repr__(self): + return '' % \ + (repr(self.row), repr(self.table), repr(self.colMap), id(self)) + + +class ResultAdapter(object): + """Adapter for a SQLAlchemy ResultProxy object.""" + def __init__(self, result, table, colMap=None): + self.result = result + self.table = table + if colMap is None: + colMap = {} + self.colMap = colMap + + def count(self): + return len(self) + + def __len__(self): + # FIXME: why sqlite returns -1? (that's wrooong!) + if self.result.rowcount == -1: + return 0 + return self.result.rowcount + + def __getitem__(self, key): + res = list(self.result)[key] + if not isinstance(key, slice): + # A single item. + return RowAdapter(res, self.table, colMap=self.colMap) + else: + # A (possible empty) list of items. + return [RowAdapter(x, self.table, colMap=self.colMap) + for x in res] + + def __iter__(self): + for item in self.result: + yield RowAdapter(item, self.table, colMap=self.colMap) + + def __repr__(self): + return '' % \ + (repr(self.result), repr(self.table), + repr(self.colMap), id(self)) + + +class TableAdapter(object): + """Adapter for a SQLAlchemy Table object, to mimic a SQLObject class.""" + def __init__(self, table, uri=None): + """Initialize a TableAdapter object.""" + self._imdbpySchema = table + self._imdbpyName = table.name + self.connectionURI = uri + self.colMap = {} + columns = [] + for col in table.cols: + # Column's paramters. + params = {'nullable': True} + params.update(col.params) + if col.name == 'id': + params['primary_key'] = True + if 'notNone' in params: + params['nullable'] = not params['notNone'] + del params['notNone'] + cname = _renameColumn(col.name) + self.colMap[col.name] = cname + colClass = MAP_COLS[col.kind] + colKindParams = {} + if 'length' in params: + colKindParams['length'] = params['length'] + del params['length'] + elif colClass is UnicodeText and col.index: + # XXX: limit length for UNICODECOLs that will have an index. + # this can result in name.name and title.title truncations! + colClass = Unicode + # Should work for most of the database servers. + length = 511 + if self.connectionURI: + if self.connectionURI.startswith('mysql'): + # To stay compatible with MySQL 4.x. + length = 255 + colKindParams['length'] = length + elif self._imdbpyName == 'PersonInfo' and col.name == 'info': + if self.connectionURI: + if self.connectionURI.startswith('ibm'): + # There are some entries longer than 32KB. + colClass = CLOB + # I really do hope that this space isn't wasted + # for each other shorter entry... + colKindParams['length'] = 68*1024 + colKind = colClass(**colKindParams) + if 'alternateID' in params: + # There's no need to handle them here. + del params['alternateID'] + # Create a column. + colObj = Column(cname, colKind, **params) + columns.append(colObj) + self.tableName = _renameTable(table.name) + # Create the table. + self.table = Table(self.tableName, metadata, *columns) + self._ta_insert = self.table.insert() + self._ta_select = self.table.select + # Adapters for special attributes. + self.q = QAdapter(self.table, colMap=self.colMap) + self.sqlmeta = SQLMetaAdapter(self.table, colMap=self.colMap) + + def select(self, conditions=None): + """Return a list of results.""" + result = self._ta_select(conditions).execute() + return ResultAdapter(result, self.table, colMap=self.colMap) + + def get(self, theID): + """Get an object given its ID.""" + result = self.select(self.table.c.id == theID) + #if not result: + # raise NotFoundError, 'no data for ID %s' % theID + # FIXME: isn't this a bit risky? We can't check len(result), + # because sqlite returns -1... + # What about converting it to a list and getting the first item? + try: + return result[0] + except KeyError: + raise NotFoundError('no data for ID %s' % theID) + + def dropTable(self, checkfirst=True): + """Drop the table.""" + dropParams = {'checkfirst': checkfirst} + # Guess what? Another work-around for a ibm_db bug. + if self.table.bind.engine.url.drivername.startswith('ibm_db'): + del dropParams['checkfirst'] + try: + self.table.drop(**dropParams) + except exc.ProgrammingError: + # As above: re-raise the exception, but only if it's not ibm_db. + if not self.table.bind.engine.url.drivername.startswith('ibm_db'): + raise + + def createTable(self, checkfirst=True): + """Create the table.""" + self.table.create(checkfirst=checkfirst) + # Create indexes for alternateID columns (other indexes will be + # created later, at explicit request for performances reasons). + for col in self._imdbpySchema.cols: + if col.name == 'id': + continue + if col.params.get('alternateID', False): + self._createIndex(col, checkfirst=checkfirst) + + def _createIndex(self, col, checkfirst=True): + """Create an index for a given (schema) column.""" + # XXX: indexLen is ignored in SQLAlchemy, and that means that + # indexes will be over the whole 255 chars strings... + # NOTE: don't use a dot as a separator, or DB2 will do + # nasty things. + idx_name = '%s_%s' % (self.table.name, col.index or col.name) + if checkfirst: + for index in self.table.indexes: + if index.name == idx_name: + return + idx = Index(idx_name, getattr(self.table.c, self.colMap[col.name])) + # XXX: beware that exc.OperationalError can be raised, is some + # strange circumstances; that's why the index name doesn't + # follow the SQLObject convention, but includes the table name: + # sqlite, for example, expects index names to be unique at + # db-level. + try: + idx.create() + except exc.OperationalError, e: + _alchemy_logger.warn('Skipping creation of the %s.%s index: %s' % + (self.sqlmeta.table, col.name, e)) + + def addIndexes(self, ifNotExists=True): + """Create all required indexes.""" + for col in self._imdbpySchema.cols: + if col.index: + self._createIndex(col, checkfirst=ifNotExists) + + def addForeignKeys(self, mapTables, ifNotExists=True): + """Create all required foreign keys.""" + if not HAS_MC: + return + # It seems that there's no reason to prevent the creation of + # indexes for columns with FK constrains: if there's already + # an index, the FK index is not created. + countCols = 0 + for col in self._imdbpySchema.cols: + countCols += 1 + if not col.foreignKey: + continue + fks = col.foreignKey.split('.', 1) + foreignTableName = fks[0] + if len(fks) == 2: + foreignColName = fks[1] + else: + foreignColName = 'id' + foreignColName = mapTables[foreignTableName].colMap.get( + foreignColName, foreignColName) + thisColName = self.colMap.get(col.name, col.name) + thisCol = self.table.columns[thisColName] + foreignTable = mapTables[foreignTableName].table + foreignCol = getattr(foreignTable.c, foreignColName) + # Need to explicitly set an unique name, otherwise it will + # explode, if two cols points to the same table. + fkName = 'fk_%s_%s_%d' % (foreignTable.name, foreignColName, + countCols) + constrain = migrate.changeset.ForeignKeyConstraint([thisCol], + [foreignCol], + name=fkName) + try: + constrain.create() + except exc.OperationalError: + continue + + def __call__(self, *args, **kwds): + """To insert a new row with the syntax: TableClass(key=value, ...)""" + taArgs = {} + for key, value in kwds.items(): + taArgs[self.colMap.get(key, key)] = value + self._ta_insert.execute(*args, **taArgs) + + def __repr__(self): + return '' % (repr(self.table), id(self)) + + +# Module-level "cache" for SQLObject classes, to prevent +# "Table 'tableName' is already defined for this MetaData instance" errors, +# when two or more connections to the database are made. +# XXX: is this the best way to act? +TABLES_REPOSITORY = {} + +def getDBTables(uri=None): + """Return a list of TableAdapter objects to be used to access the + database through the SQLAlchemy ORM. The connection uri is optional, and + can be used to tailor the db schema to specific needs.""" + DB_TABLES = [] + for table in DB_SCHEMA: + if table.name in TABLES_REPOSITORY: + DB_TABLES.append(TABLES_REPOSITORY[table.name]) + continue + tableAdapter = TableAdapter(table, uri) + DB_TABLES.append(tableAdapter) + TABLES_REPOSITORY[table.name] = tableAdapter + return DB_TABLES + + +# Functions used to emulate SQLObject's logical operators. +def AND(*params): + """Emulate SQLObject's AND.""" + return and_(*params) + +def OR(*params): + """Emulate SQLObject's OR.""" + return or_(*params) + +def IN(item, inList): + """Emulate SQLObject's IN.""" + if not isinstance(item, schema.Column): + return OR(*[x == item for x in inList]) + else: + return item.in_(inList) + +def ISNULL(x): + """Emulate SQLObject's ISNULL.""" + # XXX: Should we use null()? Can null() be a global instance? + # XXX: Is it safe to test None with the == operator, in this case? + return x == None + +def ISNOTNULL(x): + """Emulate SQLObject's ISNOTNULL.""" + return x != None + +def CONTAINSSTRING(expr, pattern): + """Emulate SQLObject's CONTAINSSTRING.""" + return expr.like('%%%s%%' % pattern) + + +def toUTF8(s): + """For some strange reason, sometimes SQLObject wants utf8 strings + instead of unicode; with SQLAlchemy we just return the unicode text.""" + return s + + +class _AlchemyConnection(object): + """A proxy for the connection object, required since _ConnectionFairy + uses __slots__.""" + def __init__(self, conn): + self.conn = conn + + def __getattr__(self, name): + return getattr(self.conn, name) + + +def setConnection(uri, tables, encoding='utf8', debug=False): + """Set connection for every table.""" + # FIXME: why on earth MySQL requires an additional parameter, + # is well beyond my understanding... + if uri.startswith('mysql'): + if '?' in uri: + uri += '&' + else: + uri += '?' + uri += 'charset=%s' % encoding + params = {'encoding': encoding} + if debug: + params['echo'] = True + if uri.startswith('ibm_db'): + # Try to work-around a possible bug of the ibm_db DB2 driver. + params['convert_unicode'] = True + # XXX: is this the best way to connect? + engine = create_engine(uri, **params) + metadata.bind = engine + eng_conn = engine.connect() + if uri.startswith('sqlite'): + major = sys.version_info[0] + minor = sys.version_info[1] + if major > 2 or (major == 2 and minor > 5): + eng_conn.connection.connection.text_factory = str + # XXX: OH MY, THAT'S A MESS! + # We need to return a "connection" object, with the .dbName + # attribute set to the db engine name (e.g. "mysql"), .paramstyle + # set to the style of the paramters for query() calls, and the + # .module attribute set to a module (?) with .OperationalError and + # .IntegrityError attributes. + # Another attribute of "connection" is the getConnection() function, + # used to return an object with a .cursor() method. + connection = _AlchemyConnection(eng_conn.connection) + paramstyle = eng_conn.dialect.paramstyle + connection.module = eng_conn.dialect.dbapi + connection.paramstyle = paramstyle + connection.getConnection = lambda: connection.connection + connection.dbName = engine.url.drivername + return connection + + diff --git a/lib/imdb/parser/sql/cutils.c b/lib/imdb/parser/sql/cutils.c new file mode 100644 index 0000000000..677c1b1e0a --- /dev/null +++ b/lib/imdb/parser/sql/cutils.c @@ -0,0 +1,269 @@ +/* + * cutils.c module. + * + * Miscellaneous functions to speed up the IMDbPY package. + * + * Contents: + * - pyratcliff(): + * Function that implements the Ratcliff-Obershelp comparison + * amongst Python strings. + * + * - pysoundex(): + * Return a soundex code string, for the given string. + * + * Copyright 2004-2009 Davide Alberani + * Released under the GPL license. + * + * NOTE: The Ratcliff-Obershelp part was heavily based on code from the + * "simil" Python module. + * The "simil" module is copyright of Luca Montecchiani + * and can be found here: http://spazioinwind.libero.it/montecchiani/ + * It was released under the GPL license; original comments are leaved + * below. + * + */ + + +/*========== Ratcliff-Obershelp ==========*/ +/***************************************************************************** + * + * Stolen code from : + * + * [Python-Dev] Why is soundex marked obsolete? + * by Eric S. Raymond [4]esr@thyrsus.com + * on Sun, 14 Jan 2001 14:09:01 -0500 + * + *****************************************************************************/ + +/***************************************************************************** + * + * Ratcliff-Obershelp common-subpattern similarity. + * + * This code first appeared in a letter to the editor in Doctor + * Dobbs's Journal, 11/1988. The original article on the algorithm, + * "Pattern Matching by Gestalt" by John Ratcliff, had appeared in the + * July 1988 issue (#181) but the algorithm was presented in assembly. + * The main drawback of the Ratcliff-Obershelp algorithm is the cost + * of the pairwise comparisons. It is significantly more expensive + * than stemming, Hamming distance, soundex, and the like. + * + * Running time quadratic in the data size, memory usage constant. + * + *****************************************************************************/ + +#include + +#define DONTCOMPARE_NULL 0.0 +#define DONTCOMPARE_SAME 1.0 +#define COMPARE 2.0 +#define STRING_MAXLENDIFFER 0.7 + +/* As of 05 Mar 2008, the longest title is ~600 chars. */ +#define MXLINELEN 1023 + +#define MAX(a,b) ((a) > (b) ? (a) : (b)) + + +//***************************************** +// preliminary check.... +//***************************************** +static float +strings_check(char const *s, char const *t) +{ + float threshold; // lenght difference + int s_len = strlen(s); // length of s + int t_len = strlen(t); // length of t + + // NULL strings ? + if ((t_len * s_len) == 0) + return (DONTCOMPARE_NULL); + + // the same ? + if (strcmp(s, t) == 0) + return (DONTCOMPARE_SAME); + + // string lenght difference threshold + // we don't want to compare too different lenght strings ;) + if (s_len < t_len) + threshold = (float) s_len / (float) t_len; + else + threshold = (float) t_len / (float) s_len; + if (threshold < STRING_MAXLENDIFFER) + return (DONTCOMPARE_NULL); + + // proceed + return (COMPARE); +} + + +static int +RatcliffObershelp(char *st1, char *end1, char *st2, char *end2) +{ + register char *a1, *a2; + char *b1, *b2; + char *s1 = st1, *s2 = st2; /* initializations are just to pacify GCC */ + short max, i; + + if (end1 <= st1 || end2 <= st2) + return (0); + if (end1 == st1 + 1 && end2 == st2 + 1) + return (0); + + max = 0; + b1 = end1; + b2 = end2; + + for (a1 = st1; a1 < b1; a1++) { + for (a2 = st2; a2 < b2; a2++) { + if (*a1 == *a2) { + /* determine length of common substring */ + for (i = 1; a1[i] && (a1[i] == a2[i]); i++) + continue; + if (i > max) { + max = i; + s1 = a1; + s2 = a2; + b1 = end1 - max; + b2 = end2 - max; + } + } + } + } + if (!max) + return (0); + max += RatcliffObershelp(s1 + max, end1, s2 + max, end2); /* rhs */ + max += RatcliffObershelp(st1, s1, st2, s2); /* lhs */ + return max; +} + + +static float +ratcliff(char *s1, char *s2) +/* compute Ratcliff-Obershelp similarity of two strings */ +{ + int l1, l2; + float res; + + // preliminary tests + res = strings_check(s1, s2); + if (res != COMPARE) + return(res); + + l1 = strlen(s1); + l2 = strlen(s2); + + return 2.0 * RatcliffObershelp(s1, s1 + l1, s2, s2 + l2) / (l1 + l2); +} + + +/* Change a string to lowercase. */ +static void +strtolower(char *s1) +{ + int i; + for (i=0; i < strlen(s1); i++) s1[i] = tolower(s1[i]); +} + + +/* Ratcliff-Obershelp for two python strings; returns a python float. */ +static PyObject* +pyratcliff(PyObject *self, PyObject *pArgs) +{ + char *s1 = NULL; + char *s2 = NULL; + PyObject *discard = NULL; + char s1copy[MXLINELEN+1]; + char s2copy[MXLINELEN+1]; + + /* The optional PyObject parameter is here to be compatible + * with the pure python implementation, which uses a + * difflib.SequenceMatcher object. */ + if (!PyArg_ParseTuple(pArgs, "ss|O", &s1, &s2, &discard)) + return NULL; + + strncpy(s1copy, s1, MXLINELEN); + strncpy(s2copy, s2, MXLINELEN); + /* Work on copies. */ + strtolower(s1copy); + strtolower(s2copy); + + return Py_BuildValue("f", ratcliff(s1copy, s2copy)); +} + + +/*========== soundex ==========*/ +/* Max length of the soundex code to output (an uppercase char and + * _at most_ 4 digits). */ +#define SOUNDEX_LEN 5 + +/* Group Number Lookup Table */ +static char soundTable[26] = +{ 0 /* A */, '1' /* B */, '2' /* C */, '3' /* D */, 0 /* E */, '1' /* F */, + '2' /* G */, 0 /* H */, 0 /* I */, '2' /* J */, '2' /* K */, '4' /* L */, + '5' /* M */, '5' /* N */, 0 /* O */, '1' /* P */, '2' /* Q */, '6' /* R */, + '2' /* S */, '3' /* T */, 0 /* U */, '1' /* V */, 0 /* W */, '2' /* X */, + 0 /* Y */, '2' /* Z */}; + +static PyObject* +pysoundex(PyObject *self, PyObject *pArgs) +{ + int i, j, n; + char *s = NULL; + char word[MXLINELEN+1]; + char soundCode[SOUNDEX_LEN+1]; + char c; + + if (!PyArg_ParseTuple(pArgs, "s", &s)) + return NULL; + + j = 0; + n = strlen(s); + + /* Convert to uppercase and exclude non-ascii chars. */ + for (i = 0; i < n; i++) { + c = toupper(s[i]); + if (c < 91 && c > 64) { + word[j] = c; + j++; + } + } + word[j] = '\0'; + + n = strlen(word); + if (n == 0) { + /* If the string is empty, returns None. */ + return Py_BuildValue(""); + } + soundCode[0] = word[0]; + + /* Build the soundCode string. */ + j = 1; + for (i = 1; j < SOUNDEX_LEN && i < n; i++) { + c = soundTable[(word[i]-65)]; + /* Compact zeroes and equal consecutive digits ("12234112"->"123412") */ + if (c != 0 && c != soundCode[j-1]) { + soundCode[j++] = c; + } + } + soundCode[j] = '\0'; + + return Py_BuildValue("s", soundCode); +} + + +static PyMethodDef cutils_methods[] = { + {"ratcliff", pyratcliff, + METH_VARARGS, "Ratcliff-Obershelp similarity."}, + {"soundex", pysoundex, + METH_VARARGS, "Soundex code for strings."}, + {NULL} +}; + + +void +initcutils(void) +{ + Py_InitModule("cutils", cutils_methods); +} + + diff --git a/lib/imdb/parser/sql/cutils.so b/lib/imdb/parser/sql/cutils.so new file mode 100644 index 0000000000000000000000000000000000000000..80246ffd19f5f796323f034f03f263b390710a1f GIT binary patch literal 28469 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 literal 0 HcmV?d00001 diff --git a/lib/imdb/parser/sql/dbschema.py b/lib/imdb/parser/sql/dbschema.py new file mode 100644 index 0000000000..2f359fba72 --- /dev/null +++ b/lib/imdb/parser/sql/dbschema.py @@ -0,0 +1,476 @@ +#-*- encoding: utf-8 -*- +""" +parser.sql.dbschema module (imdb.parser.sql package). + +This module provides the schema used to describe the layout of the +database used by the imdb.parser.sql package; functions to create/drop +tables and indexes are also provided. + +Copyright 2005-2012 Davide Alberani + 2006 Giuseppe "Cowo" Corbelli lugbs.linux.it> + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import logging + +_dbschema_logger = logging.getLogger('imdbpy.parser.sql.dbschema') + + +# Placeholders for column types. +INTCOL = 1 +UNICODECOL = 2 +STRINGCOL = 3 +_strMap = {1: 'INTCOL', 2: 'UNICODECOL', 3: 'STRINGCOL'} + +class DBCol(object): + """Define column objects.""" + def __init__(self, name, kind, **params): + self.name = name + self.kind = kind + self.index = None + self.indexLen = None + # If not None, two notations are accepted: 'TableName' + # and 'TableName.ColName'; in the first case, 'id' is assumed + # as the name of the pointed column. + self.foreignKey = None + if 'index' in params: + self.index = params['index'] + del params['index'] + if 'indexLen' in params: + self.indexLen = params['indexLen'] + del params['indexLen'] + if 'foreignKey' in params: + self.foreignKey = params['foreignKey'] + del params['foreignKey'] + self.params = params + + def __str__(self): + """Class representation.""" + s = '' % (self.name, + len(self.cols), sum([len(v) for v in self.values.values()])) + + def __repr__(self): + """Class representation.""" + s = '').lstrip('<') + for col in self.cols]) + if col_s: + s += ', %s' % col_s + if self.values: + s += ', values=%s' % self.values + s += ')>' + return s + + +# Default values to insert in some tables: {'column': (list, of, values, ...)} +kindTypeDefs = {'kind': ('movie', 'tv series', 'tv movie', 'video movie', + 'tv mini series', 'video game', 'episode')} +companyTypeDefs = {'kind': ('distributors', 'production companies', + 'special effects companies', 'miscellaneous companies')} +infoTypeDefs = {'info': ('runtimes', 'color info', 'genres', 'languages', + 'certificates', 'sound mix', 'tech info', 'countries', 'taglines', + 'keywords', 'alternate versions', 'crazy credits', 'goofs', + 'soundtrack', 'quotes', 'release dates', 'trivia', 'locations', + 'mini biography', 'birth notes', 'birth date', 'height', + 'death date', 'spouse', 'other works', 'birth name', + 'salary history', 'nick names', 'books', 'agent address', + 'biographical movies', 'portrayed in', 'where now', 'trade mark', + 'interviews', 'article', 'magazine cover photo', 'pictorial', + 'death notes', 'LD disc format', 'LD year', 'LD digital sound', + 'LD official retail price', 'LD frequency response', 'LD pressing plant', + 'LD length', 'LD language', 'LD review', 'LD spaciality', 'LD release date', + 'LD production country', 'LD contrast', 'LD color rendition', + 'LD picture format', 'LD video noise', 'LD video artifacts', + 'LD release country', 'LD sharpness', 'LD dynamic range', + 'LD audio noise', 'LD color information', 'LD group genre', + 'LD quality program', 'LD close captions-teletext-ld-g', + 'LD category', 'LD analog left', 'LD certification', + 'LD audio quality', 'LD video quality', 'LD aspect ratio', + 'LD analog right', 'LD additional information', + 'LD number of chapter stops', 'LD dialogue intellegibility', + 'LD disc size', 'LD master format', 'LD subtitles', + 'LD status of availablility', 'LD quality of source', + 'LD number of sides', 'LD video standard', 'LD supplement', + 'LD original title', 'LD sound encoding', 'LD number', 'LD label', + 'LD catalog number', 'LD laserdisc title', 'screenplay-teleplay', + 'novel', 'adaption', 'book', 'production process protocol', + 'printed media reviews', 'essays', 'other literature', 'mpaa', + 'plot', 'votes distribution', 'votes', 'rating', + 'production dates', 'copyright holder', 'filming dates', 'budget', + 'weekend gross', 'gross', 'opening weekend', 'rentals', + 'admissions', 'studios', 'top 250 rank', 'bottom 10 rank')} +compCastTypeDefs = {'kind': ('cast', 'crew', 'complete', 'complete+verified')} +linkTypeDefs = {'link': ('follows', 'followed by', 'remake of', 'remade as', + 'references', 'referenced in', 'spoofs', 'spoofed in', + 'features', 'featured in', 'spin off from', 'spin off', + 'version of', 'similar to', 'edited into', + 'edited from', 'alternate language version of', + 'unknown link')} +roleTypeDefs = {'role': ('actor', 'actress', 'producer', 'writer', + 'cinematographer', 'composer', 'costume designer', + 'director', 'editor', 'miscellaneous crew', + 'production designer', 'guest')} + +# Schema of tables in our database. +# XXX: Foreign keys can be used to create constrains between tables, +# but they create indexes in the database, and this +# means poor performances at insert-time. +DB_SCHEMA = [ + DBTable('Name', + # namePcodeCf is the soundex of the name in the canonical format. + # namePcodeNf is the soundex of the name in the normal format, if + # different from namePcodeCf. + # surnamePcode is the soundex of the surname, if different from the + # other two values. + + # The 'id' column is simply skipped by SQLObject (it's a default); + # the alternateID attribute here will be ignored by SQLAlchemy. + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('name', UNICODECOL, notNone=True, index='idx_name', indexLen=6), + DBCol('imdbIndex', UNICODECOL, length=12, default=None), + DBCol('imdbID', INTCOL, default=None, index='idx_imdb_id'), + DBCol('gender', STRINGCOL, length=1, default=None), + DBCol('namePcodeCf', STRINGCOL, length=5, default=None, + index='idx_pcodecf'), + DBCol('namePcodeNf', STRINGCOL, length=5, default=None, + index='idx_pcodenf'), + DBCol('surnamePcode', STRINGCOL, length=5, default=None, + index='idx_pcode'), + DBCol('md5sum', STRINGCOL, length=32, default=None, index='idx_md5') + ), + + DBTable('CharName', + # namePcodeNf is the soundex of the name in the normal format. + # surnamePcode is the soundex of the surname, if different + # from namePcodeNf. + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('name', UNICODECOL, notNone=True, index='idx_name', indexLen=6), + DBCol('imdbIndex', UNICODECOL, length=12, default=None), + DBCol('imdbID', INTCOL, default=None), + DBCol('namePcodeNf', STRINGCOL, length=5, default=None, + index='idx_pcodenf'), + DBCol('surnamePcode', STRINGCOL, length=5, default=None, + index='idx_pcode'), + DBCol('md5sum', STRINGCOL, length=32, default=None, index='idx_md5') + ), + + DBTable('CompanyName', + # namePcodeNf is the soundex of the name in the normal format. + # namePcodeSf is the soundex of the name plus the country code. + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('name', UNICODECOL, notNone=True, index='idx_name', indexLen=6), + DBCol('countryCode', UNICODECOL, length=255, default=None), + DBCol('imdbID', INTCOL, default=None), + DBCol('namePcodeNf', STRINGCOL, length=5, default=None, + index='idx_pcodenf'), + DBCol('namePcodeSf', STRINGCOL, length=5, default=None, + index='idx_pcodesf'), + DBCol('md5sum', STRINGCOL, length=32, default=None, index='idx_md5') + ), + + DBTable('KindType', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('kind', STRINGCOL, length=15, default=None, alternateID=True), + values=kindTypeDefs + ), + + DBTable('Title', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('title', UNICODECOL, notNone=True, + index='idx_title', indexLen=10), + DBCol('imdbIndex', UNICODECOL, length=12, default=None), + DBCol('kindID', INTCOL, notNone=True, foreignKey='KindType'), + DBCol('productionYear', INTCOL, default=None), + DBCol('imdbID', INTCOL, default=None, index="idx_imdb_id"), + DBCol('phoneticCode', STRINGCOL, length=5, default=None, + index='idx_pcode'), + DBCol('episodeOfID', INTCOL, default=None, index='idx_epof', + foreignKey='Title'), + DBCol('seasonNr', INTCOL, default=None, index="idx_season_nr"), + DBCol('episodeNr', INTCOL, default=None, index="idx_episode_nr"), + # Maximum observed length is 44; 49 can store 5 comma-separated + # year-year pairs. + DBCol('seriesYears', STRINGCOL, length=49, default=None), + DBCol('md5sum', STRINGCOL, length=32, default=None, index='idx_md5') + ), + + DBTable('CompanyType', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('kind', STRINGCOL, length=32, default=None, alternateID=True), + values=companyTypeDefs + ), + + DBTable('AkaName', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('personID', INTCOL, notNone=True, index='idx_person', + foreignKey='Name'), + DBCol('name', UNICODECOL, notNone=True), + DBCol('imdbIndex', UNICODECOL, length=12, default=None), + DBCol('namePcodeCf', STRINGCOL, length=5, default=None, + index='idx_pcodecf'), + DBCol('namePcodeNf', STRINGCOL, length=5, default=None, + index='idx_pcodenf'), + DBCol('surnamePcode', STRINGCOL, length=5, default=None, + index='idx_pcode'), + DBCol('md5sum', STRINGCOL, length=32, default=None, index='idx_md5') + ), + + DBTable('AkaTitle', + # XXX: It's safer to set notNone to False, here. + # alias for akas are stored completely in the AkaTitle table; + # this means that episodes will set also a "tv series" alias name. + # Reading the aka-title.list file it looks like there are + # episode titles with aliases to different titles for both + # the episode and the series title, while for just the series + # there are no aliases. + # E.g.: + # aka title original title + # "Series, The" (2005) {The Episode} "Other Title" (2005) {Other Title} + # But there is no: + # "Series, The" (2005) "Other Title" (2005) + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, notNone=True, index='idx_movieid', + foreignKey='Title'), + DBCol('title', UNICODECOL, notNone=True), + DBCol('imdbIndex', UNICODECOL, length=12, default=None), + DBCol('kindID', INTCOL, notNone=True, foreignKey='KindType'), + DBCol('productionYear', INTCOL, default=None), + DBCol('phoneticCode', STRINGCOL, length=5, default=None, + index='idx_pcode'), + DBCol('episodeOfID', INTCOL, default=None, index='idx_epof', + foreignKey='AkaTitle'), + DBCol('seasonNr', INTCOL, default=None), + DBCol('episodeNr', INTCOL, default=None), + DBCol('note', UNICODECOL, default=None), + DBCol('md5sum', STRINGCOL, length=32, default=None, index='idx_md5') + ), + + DBTable('RoleType', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('role', STRINGCOL, length=32, notNone=True, alternateID=True), + values=roleTypeDefs + ), + + DBTable('CastInfo', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('personID', INTCOL, notNone=True, index='idx_pid', + foreignKey='Name'), + DBCol('movieID', INTCOL, notNone=True, index='idx_mid', + foreignKey='Title'), + DBCol('personRoleID', INTCOL, default=None, index='idx_cid', + foreignKey='CharName'), + DBCol('note', UNICODECOL, default=None), + DBCol('nrOrder', INTCOL, default=None), + DBCol('roleID', INTCOL, notNone=True, foreignKey='RoleType') + ), + + DBTable('CompCastType', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('kind', STRINGCOL, length=32, notNone=True, alternateID=True), + values=compCastTypeDefs + ), + + DBTable('CompleteCast', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, index='idx_mid', foreignKey='Title'), + DBCol('subjectID', INTCOL, notNone=True, foreignKey='CompCastType'), + DBCol('statusID', INTCOL, notNone=True, foreignKey='CompCastType') + ), + + DBTable('InfoType', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('info', STRINGCOL, length=32, notNone=True, alternateID=True), + values=infoTypeDefs + ), + + DBTable('LinkType', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('link', STRINGCOL, length=32, notNone=True, alternateID=True), + values=linkTypeDefs + ), + + DBTable('Keyword', + DBCol('id', INTCOL, notNone=True, alternateID=True), + # XXX: can't use alternateID=True, because it would create + # a UNIQUE index; unfortunately (at least with a common + # collation like utf8_unicode_ci) MySQL will consider + # some different keywords identical - like + # "fiancée" and "fiancee". + DBCol('keyword', UNICODECOL, notNone=True, + index='idx_keyword', indexLen=5), + DBCol('phoneticCode', STRINGCOL, length=5, default=None, + index='idx_pcode') + ), + + DBTable('MovieKeyword', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, notNone=True, index='idx_mid', + foreignKey='Title'), + DBCol('keywordID', INTCOL, notNone=True, index='idx_keywordid', + foreignKey='Keyword') + ), + + DBTable('MovieLink', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, notNone=True, index='idx_mid', + foreignKey='Title'), + DBCol('linkedMovieID', INTCOL, notNone=True, foreignKey='Title'), + DBCol('linkTypeID', INTCOL, notNone=True, foreignKey='LinkType') + ), + + DBTable('MovieInfo', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, notNone=True, index='idx_mid', + foreignKey='Title'), + DBCol('infoTypeID', INTCOL, notNone=True, foreignKey='InfoType'), + DBCol('info', UNICODECOL, notNone=True), + DBCol('note', UNICODECOL, default=None) + ), + + # This table is identical to MovieInfo, except that both 'infoTypeID' + # and 'info' are indexed. + DBTable('MovieInfoIdx', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, notNone=True, index='idx_mid', + foreignKey='Title'), + DBCol('infoTypeID', INTCOL, notNone=True, index='idx_infotypeid', + foreignKey='InfoType'), + DBCol('info', UNICODECOL, notNone=True, index='idx_info', indexLen=10), + DBCol('note', UNICODECOL, default=None) + ), + + DBTable('MovieCompanies', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('movieID', INTCOL, notNone=True, index='idx_mid', + foreignKey='Title'), + DBCol('companyID', INTCOL, notNone=True, index='idx_cid', + foreignKey='CompanyName'), + DBCol('companyTypeID', INTCOL, notNone=True, foreignKey='CompanyType'), + DBCol('note', UNICODECOL, default=None) + ), + + DBTable('PersonInfo', + DBCol('id', INTCOL, notNone=True, alternateID=True), + DBCol('personID', INTCOL, notNone=True, index='idx_pid', + foreignKey='Name'), + DBCol('infoTypeID', INTCOL, notNone=True, foreignKey='InfoType'), + DBCol('info', UNICODECOL, notNone=True), + DBCol('note', UNICODECOL, default=None) + ) +] + + +# Functions to manage tables. +def dropTables(tables, ifExists=True): + """Drop the tables.""" + # In reverse order (useful to avoid errors about foreign keys). + DB_TABLES_DROP = list(tables) + DB_TABLES_DROP.reverse() + for table in DB_TABLES_DROP: + _dbschema_logger.info('dropping table %s', table._imdbpyName) + table.dropTable(ifExists) + +def createTables(tables, ifNotExists=True): + """Create the tables and insert default values.""" + for table in tables: + # Create the table. + _dbschema_logger.info('creating table %s', table._imdbpyName) + table.createTable(ifNotExists) + # Insert default values, if any. + if table._imdbpySchema.values: + _dbschema_logger.info('inserting values into table %s', + table._imdbpyName) + for key in table._imdbpySchema.values: + for value in table._imdbpySchema.values[key]: + table(**{key: unicode(value)}) + +def createIndexes(tables, ifNotExists=True): + """Create the indexes in the database. + Return a list of errors, if any.""" + errors = [] + for table in tables: + _dbschema_logger.info('creating indexes for table %s', + table._imdbpyName) + try: + table.addIndexes(ifNotExists) + except Exception, e: + errors.append(e) + continue + return errors + +def createForeignKeys(tables, ifNotExists=True): + """Create Foreign Keys. + Return a list of errors, if any.""" + errors = [] + mapTables = {} + for table in tables: + mapTables[table._imdbpyName] = table + for table in tables: + _dbschema_logger.info('creating foreign keys for table %s', + table._imdbpyName) + try: + table.addForeignKeys(mapTables, ifNotExists) + except Exception, e: + errors.append(e) + continue + return errors + diff --git a/lib/imdb/parser/sql/objectadapter.py b/lib/imdb/parser/sql/objectadapter.py new file mode 100644 index 0000000000..9797104267 --- /dev/null +++ b/lib/imdb/parser/sql/objectadapter.py @@ -0,0 +1,207 @@ +""" +parser.sql.objectadapter module (imdb.parser.sql package). + +This module adapts the SQLObject ORM to the internal mechanism. + +Copyright 2008-2010 Davide Alberani + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +import sys +import logging + +from sqlobject import * +from sqlobject.sqlbuilder import ISNULL, ISNOTNULL, AND, OR, IN, CONTAINSSTRING + +from dbschema import * + +_object_logger = logging.getLogger('imdbpy.parser.sql.object') + + +# Maps our placeholders to SQLAlchemy's column types. +MAP_COLS = { + INTCOL: IntCol, + UNICODECOL: UnicodeCol, + STRINGCOL: StringCol +} + + +# Exception raised when Table.get(id) returns no value. +NotFoundError = SQLObjectNotFound + + +# class method to be added to the SQLObject class. +def addIndexes(cls, ifNotExists=True): + """Create all required indexes.""" + for col in cls._imdbpySchema.cols: + if col.index: + idxName = col.index + colToIdx = col.name + if col.indexLen: + colToIdx = {'column': col.name, 'length': col.indexLen} + if idxName in [i.name for i in cls.sqlmeta.indexes]: + # Check if the index is already present. + continue + idx = DatabaseIndex(colToIdx, name=idxName) + cls.sqlmeta.addIndex(idx) + try: + cls.createIndexes(ifNotExists) + except dberrors.OperationalError, e: + _object_logger.warn('Skipping creation of the %s.%s index: %s' % + (cls.sqlmeta.table, col.name, e)) +addIndexes = classmethod(addIndexes) + + +# Global repository for "fake" tables with Foreign Keys - need to +# prevent troubles if addForeignKeys is called more than one time. +FAKE_TABLES_REPOSITORY = {} + +def _buildFakeFKTable(cls, fakeTableName): + """Return a "fake" table, with foreign keys where needed.""" + countCols = 0 + attrs = {} + for col in cls._imdbpySchema.cols: + countCols += 1 + if col.name == 'id': + continue + if not col.foreignKey: + # A non-foreign key column - add it as usual. + attrs[col.name] = MAP_COLS[col.kind](**col.params) + continue + # XXX: Foreign Keys pointing to TableName.ColName not yet supported. + thisColName = col.name + if thisColName.endswith('ID'): + thisColName = thisColName[:-2] + + fks = col.foreignKey.split('.', 1) + foreignTableName = fks[0] + if len(fks) == 2: + foreignColName = fks[1] + else: + foreignColName = 'id' + # Unused... + #fkName = 'fk_%s_%s_%d' % (foreignTableName, foreignColName, + # countCols) + # Create a Foreign Key column, with the correct references. + fk = ForeignKey(foreignTableName, name=thisColName, default=None) + attrs[thisColName] = fk + # Build a _NEW_ SQLObject subclass, with foreign keys, if needed. + newcls = type(fakeTableName, (SQLObject,), attrs) + return newcls + +def addForeignKeys(cls, mapTables, ifNotExists=True): + """Create all required foreign keys.""" + # Do not even try, if there are no FK, in this table. + if not filter(None, [col.foreignKey for col in cls._imdbpySchema.cols]): + return + fakeTableName = 'myfaketable%s' % cls.sqlmeta.table + if fakeTableName in FAKE_TABLES_REPOSITORY: + newcls = FAKE_TABLES_REPOSITORY[fakeTableName] + else: + newcls = _buildFakeFKTable(cls, fakeTableName) + FAKE_TABLES_REPOSITORY[fakeTableName] = newcls + # Connect the class with foreign keys. + newcls.setConnection(cls._connection) + for col in cls._imdbpySchema.cols: + if col.name == 'id': + continue + if not col.foreignKey: + continue + # Get the SQL that _WOULD BE_ run, if we had to create + # this "fake" table. + fkQuery = newcls._connection.createReferenceConstraint(newcls, + newcls.sqlmeta.columns[col.name]) + if not fkQuery: + # Probably the db doesn't support foreign keys (SQLite). + continue + # Remove "myfaketable" to get references to _real_ tables. + fkQuery = fkQuery.replace('myfaketable', '') + # Execute the query. + newcls._connection.query(fkQuery) + # Disconnect it. + newcls._connection.close() +addForeignKeys = classmethod(addForeignKeys) + + +# Module-level "cache" for SQLObject classes, to prevent +# "class TheClass is already in the registry" errors, when +# two or more connections to the database are made. +# XXX: is this the best way to act? +TABLES_REPOSITORY = {} + +def getDBTables(uri=None): + """Return a list of classes to be used to access the database + through the SQLObject ORM. The connection uri is optional, and + can be used to tailor the db schema to specific needs.""" + DB_TABLES = [] + for table in DB_SCHEMA: + if table.name in TABLES_REPOSITORY: + DB_TABLES.append(TABLES_REPOSITORY[table.name]) + continue + attrs = {'_imdbpyName': table.name, '_imdbpySchema': table, + 'addIndexes': addIndexes, 'addForeignKeys': addForeignKeys} + for col in table.cols: + if col.name == 'id': + continue + attrs[col.name] = MAP_COLS[col.kind](**col.params) + # Create a subclass of SQLObject. + # XXX: use a metaclass? I can't see any advantage. + cls = type(table.name, (SQLObject,), attrs) + DB_TABLES.append(cls) + TABLES_REPOSITORY[table.name] = cls + return DB_TABLES + + +def toUTF8(s): + """For some strange reason, sometimes SQLObject wants utf8 strings + instead of unicode.""" + return s.encode('utf_8') + + +def setConnection(uri, tables, encoding='utf8', debug=False): + """Set connection for every table.""" + kw = {} + # FIXME: it's absolutely unclear what we should do to correctly + # support unicode in MySQL; with some versions of SQLObject, + # it seems that setting use_unicode=1 is the _wrong_ thing to do. + _uriLower = uri.lower() + if _uriLower.startswith('mysql'): + kw['use_unicode'] = 1 + #kw['sqlobject_encoding'] = encoding + kw['charset'] = encoding + conn = connectionForURI(uri, **kw) + conn.debug = debug + # XXX: doesn't work and a work-around was put in imdbpy2sql.py; + # is there any way to modify the text_factory parameter of + # a SQLite connection? + #if uri.startswith('sqlite'): + # major = sys.version_info[0] + # minor = sys.version_info[1] + # if major > 2 or (major == 2 and minor > 5): + # sqliteConn = conn.getConnection() + # sqliteConn.text_factory = str + for table in tables: + table.setConnection(conn) + #table.sqlmeta.cacheValues = False + # FIXME: is it safe to set table._cacheValue to False? Looks like + # we can't retrieve correct values after an update (I think + # it's never needed, but...) Anyway, these are set to False + # for performance reason at insert time (see imdbpy2sql.py). + table._cacheValue = False + # Required by imdbpy2sql.py. + conn.paramstyle = conn.module.paramstyle + return conn + diff --git a/lib/imdb/utils.py b/lib/imdb/utils.py new file mode 100644 index 0000000000..9c300b0852 --- /dev/null +++ b/lib/imdb/utils.py @@ -0,0 +1,1572 @@ +""" +utils module (imdb package). + +This module provides basic utilities for the imdb package. + +Copyright 2004-2012 Davide Alberani + 2009 H. Turgut Uyar + +This program 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 2 of the License, or +(at your option) any later version. + +This program 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 this program; if not, write to the Free Software +Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +""" + +from __future__ import generators +import re +import string +import logging +from copy import copy, deepcopy +from time import strptime, strftime + +from imdb import VERSION +from imdb import linguistics +from imdb._exceptions import IMDbParserError + + +# Logger for imdb.utils module. +_utils_logger = logging.getLogger('imdbpy.utils') + +# The regular expression for the "long" year format of IMDb, like +# "(1998)" and "(1986/II)", where the optional roman number (that I call +# "imdbIndex" after the slash is used for movies with the same title +# and year of release. +# XXX: probably L, C, D and M are far too much! ;-) +re_year_index = re.compile(r'\(([0-9\?]{4}(/[IVXLCDM]+)?)\)') +re_extended_year_index = re.compile(r'\((TV episode|TV Series|TV mini-series|TV|Video|Video Game)? ?((?:[0-9\?]{4})(?:-[0-9\?]{4})?)(?:/([IVXLCDM]+)?)?\)') +re_remove_kind = re.compile(r'\((TV episode|TV Series|TV mini-series|TV|Video|Video Game)? ?') + +# Match only the imdbIndex (for name strings). +re_index = re.compile(r'^\(([IVXLCDM]+)\)$') + +# Match things inside parentheses. +re_parentheses = re.compile(r'(\(.*\))') + +# Match the number of episodes. +re_episodes = re.compile('\s?\((\d+) episodes\)', re.I) +re_episode_info = re.compile(r'{\s*(.+?)?\s?(\([0-9\?]{4}-[0-9\?]{1,2}-[0-9\?]{1,2}\))?\s?(\(#[0-9]+\.[0-9]+\))?}') + +# Common suffixes in surnames. +_sname_suffixes = ('de', 'la', 'der', 'den', 'del', 'y', 'da', 'van', + 'e', 'von', 'the', 'di', 'du', 'el', 'al') + +def canonicalName(name): + """Return the given name in canonical "Surname, Name" format. + It assumes that name is in the 'Name Surname' format.""" + # XXX: some statistics (as of 17 Apr 2008, over 2288622 names): + # - just a surname: 69476 + # - single surname, single name: 2209656 + # - composed surname, composed name: 9490 + # - composed surname, single name: 67606 + # (2: 59764, 3: 6862, 4: 728) + # - single surname, composed name: 242310 + # (2: 229467, 3: 9901, 4: 2041, 5: 630) + # - Jr.: 8025 + # Don't convert names already in the canonical format. + if name.find(', ') != -1: return name + if isinstance(name, unicode): + joiner = u'%s, %s' + sur_joiner = u'%s %s' + sur_space = u' %s' + space = u' ' + else: + joiner = '%s, %s' + sur_joiner = '%s %s' + sur_space = ' %s' + space = ' ' + sname = name.split(' ') + snl = len(sname) + if snl == 2: + # Just a name and a surname: how boring... + name = joiner % (sname[1], sname[0]) + elif snl > 2: + lsname = [x.lower() for x in sname] + if snl == 3: _indexes = (0, snl-2) + else: _indexes = (0, snl-2, snl-3) + # Check for common surname prefixes at the beginning and near the end. + for index in _indexes: + if lsname[index] not in _sname_suffixes: continue + try: + # Build the surname. + surn = sur_joiner % (sname[index], sname[index+1]) + del sname[index] + del sname[index] + try: + # Handle the "Jr." after the name. + if lsname[index+2].startswith('jr'): + surn += sur_space % sname[index] + del sname[index] + except (IndexError, ValueError): + pass + name = joiner % (surn, space.join(sname)) + break + except ValueError: + continue + else: + name = joiner % (sname[-1], space.join(sname[:-1])) + return name + +def normalizeName(name): + """Return a name in the normal "Name Surname" format.""" + if isinstance(name, unicode): + joiner = u'%s %s' + else: + joiner = '%s %s' + sname = name.split(', ') + if len(sname) == 2: + name = joiner % (sname[1], sname[0]) + return name + +def analyze_name(name, canonical=None): + """Return a dictionary with the name and the optional imdbIndex + keys, from the given string. + + If canonical is None (default), the name is stored in its own style. + If canonical is True, the name is converted to canonical style. + If canonical is False, the name is converted to normal format. + + raise an IMDbParserError exception if the name is not valid. + """ + original_n = name + name = name.strip() + res = {} + imdbIndex = '' + opi = name.rfind('(') + cpi = name.rfind(')') + # Strip notes (but not if the name starts with a parenthesis). + if opi not in (-1, 0) and cpi > opi: + if re_index.match(name[opi:cpi+1]): + imdbIndex = name[opi+1:cpi] + name = name[:opi].rstrip() + else: + # XXX: for the birth and death dates case like " (1926-2004)" + name = re_parentheses.sub('', name).strip() + if not name: + raise IMDbParserError('invalid name: "%s"' % original_n) + if canonical is not None: + if canonical: + name = canonicalName(name) + else: + name = normalizeName(name) + res['name'] = name + if imdbIndex: + res['imdbIndex'] = imdbIndex + return res + + +def build_name(name_dict, canonical=None): + """Given a dictionary that represents a "long" IMDb name, + return a string. + If canonical is None (default), the name is returned in the stored style. + If canonical is True, the name is converted to canonical style. + If canonical is False, the name is converted to normal format. + """ + name = name_dict.get('canonical name') or name_dict.get('name', '') + if not name: return '' + if canonical is not None: + if canonical: + name = canonicalName(name) + else: + name = normalizeName(name) + imdbIndex = name_dict.get('imdbIndex') + if imdbIndex: + name += ' (%s)' % imdbIndex + return name + + +# XXX: here only for backward compatibility. Find and remove any dependency. +_articles = linguistics.GENERIC_ARTICLES +_unicodeArticles = linguistics.toUnicode(_articles) +articlesDicts = linguistics.articlesDictsForLang(None) +spArticles = linguistics.spArticlesForLang(None) + +def canonicalTitle(title, lang=None): + """Return the title in the canonic format 'Movie Title, The'; + beware that it doesn't handle long imdb titles, but only the + title portion, without year[/imdbIndex] or special markup. + The 'lang' argument can be used to specify the language of the title. + """ + isUnicode = isinstance(title, unicode) + articlesDicts = linguistics.articlesDictsForLang(lang) + try: + if title.split(', ')[-1].lower() in articlesDicts[isUnicode]: + return title + except IndexError: + pass + if isUnicode: + _format = u'%s, %s' + else: + _format = '%s, %s' + ltitle = title.lower() + spArticles = linguistics.spArticlesForLang(lang) + for article in spArticles[isUnicode]: + if ltitle.startswith(article): + lart = len(article) + title = _format % (title[lart:], title[:lart]) + if article[-1] == ' ': + title = title[:-1] + break + ## XXX: an attempt using a dictionary lookup. + ##for artSeparator in (' ', "'", '-'): + ## article = _articlesDict.get(ltitle.split(artSeparator)[0]) + ## if article is not None: + ## lart = len(article) + ## # check titles like "una", "I'm Mad" and "L'abbacchio". + ## if title[lart:] == '' or (artSeparator != ' ' and + ## title[lart:][1] != artSeparator): continue + ## title = '%s, %s' % (title[lart:], title[:lart]) + ## if artSeparator == ' ': title = title[1:] + ## break + return title + +def normalizeTitle(title, lang=None): + """Return the title in the normal "The Title" format; + beware that it doesn't handle long imdb titles, but only the + title portion, without year[/imdbIndex] or special markup. + The 'lang' argument can be used to specify the language of the title. + """ + isUnicode = isinstance(title, unicode) + stitle = title.split(', ') + articlesDicts = linguistics.articlesDictsForLang(lang) + if len(stitle) > 1 and stitle[-1].lower() in articlesDicts[isUnicode]: + sep = ' ' + if stitle[-1][-1] in ("'", '-'): + sep = '' + if isUnicode: + _format = u'%s%s%s' + _joiner = u', ' + else: + _format = '%s%s%s' + _joiner = ', ' + title = _format % (stitle[-1], sep, _joiner.join(stitle[:-1])) + return title + + +def _split_series_episode(title): + """Return the series and the episode titles; if this is not a + series' episode, the returned series title is empty. + This function recognize two different styles: + "The Series" An Episode (2005) + "The Series" (2004) {An Episode (2005) (#season.episode)}""" + series_title = '' + episode_or_year = '' + if title[-1:] == '}': + # Title of the episode, as in the plain text data files. + begin_eps = title.rfind('{') + if begin_eps == -1: return '', '' + series_title = title[:begin_eps].rstrip() + # episode_or_year is returned with the {...} + episode_or_year = title[begin_eps:].strip() + if episode_or_year[:12] == '{SUSPENDED}}': return '', '' + # XXX: works only with tv series; it's still unclear whether + # IMDb will support episodes for tv mini series and tv movies... + elif title[0:1] == '"': + second_quot = title[1:].find('"') + 2 + if second_quot != 1: # a second " was found. + episode_or_year = title[second_quot:].lstrip() + first_char = episode_or_year[0:1] + if not first_char: return '', '' + if first_char != '(': + # There is not a (year) but the title of the episode; + # that means this is an episode title, as returned by + # the web server. + series_title = title[:second_quot] + ##elif episode_or_year[-1:] == '}': + ## # Title of the episode, as in the plain text data files. + ## begin_eps = episode_or_year.find('{') + ## if begin_eps == -1: return series_title, episode_or_year + ## series_title = title[:second_quot+begin_eps].rstrip() + ## # episode_or_year is returned with the {...} + ## episode_or_year = episode_or_year[begin_eps:] + return series_title, episode_or_year + + +def is_series_episode(title): + """Return True if 'title' is an series episode.""" + title = title.strip() + if _split_series_episode(title)[0]: return 1 + return 0 + + +def analyze_title(title, canonical=None, canonicalSeries=None, + canonicalEpisode=None, _emptyString=u''): + """Analyze the given title and return a dictionary with the + "stripped" title, the kind of the show ("movie", "tv series", etc.), + the year of production and the optional imdbIndex (a roman number + used to distinguish between movies with the same title and year). + + If canonical is None (default), the title is stored in its own style. + If canonical is True, the title is converted to canonical style. + If canonical is False, the title is converted to normal format. + + raise an IMDbParserError exception if the title is not valid. + """ + # XXX: introduce the 'lang' argument? + if canonical is not None: + canonicalSeries = canonicalEpisode = canonical + original_t = title + result = {} + title = title.strip() + year = _emptyString + kind = _emptyString + imdbIndex = _emptyString + series_title, episode_or_year = _split_series_episode(title) + if series_title: + # It's an episode of a series. + series_d = analyze_title(series_title, canonical=canonicalSeries) + oad = sen = ep_year = _emptyString + # Plain text data files format. + if episode_or_year[0:1] == '{' and episode_or_year[-1:] == '}': + match = re_episode_info.findall(episode_or_year) + if match: + # Episode title, original air date and #season.episode + episode_or_year, oad, sen = match[0] + episode_or_year = episode_or_year.strip() + if not oad: + # No year, but the title is something like (2005-04-12) + if episode_or_year and episode_or_year[0] == '(' and \ + episode_or_year[-1:] == ')' and \ + episode_or_year[1:2] != '#': + oad = episode_or_year + if oad[1:5] and oad[5:6] == '-': + try: + ep_year = int(oad[1:5]) + except (TypeError, ValueError): + pass + if not oad and not sen and episode_or_year.startswith('(#'): + sen = episode_or_year + elif episode_or_year.startswith('Episode dated'): + oad = episode_or_year[14:] + if oad[-4:].isdigit(): + try: + ep_year = int(oad[-4:]) + except (TypeError, ValueError): + pass + episode_d = analyze_title(episode_or_year, canonical=canonicalEpisode) + episode_d['kind'] = u'episode' + episode_d['episode of'] = series_d + if oad: + episode_d['original air date'] = oad[1:-1] + if ep_year and episode_d.get('year') is None: + episode_d['year'] = ep_year + if sen and sen[2:-1].find('.') != -1: + seas, epn = sen[2:-1].split('.') + if seas: + # Set season and episode. + try: seas = int(seas) + except: pass + try: epn = int(epn) + except: pass + episode_d['season'] = seas + if epn: + episode_d['episode'] = epn + return episode_d + # First of all, search for the kind of show. + # XXX: Number of entries at 17 Apr 2008: + # movie: 379,871 + # episode: 483,832 + # tv movie: 61,119 + # tv series: 44,795 + # video movie: 57,915 + # tv mini series: 5,497 + # video game: 5,490 + # More up-to-date statistics: http://us.imdb.com/database_statistics + if title.endswith('(TV)'): + kind = u'tv movie' + title = title[:-4].rstrip() + elif title.endswith('(V)'): + kind = u'video movie' + title = title[:-3].rstrip() + elif title.endswith('(video)'): + kind = u'video movie' + title = title[:-7].rstrip() + elif title.endswith('(mini)'): + kind = u'tv mini series' + title = title[:-6].rstrip() + elif title.endswith('(VG)'): + kind = u'video game' + title = title[:-4].rstrip() + # Search for the year and the optional imdbIndex (a roman number). + yi = re_year_index.findall(title) + if not yi: + yi = re_extended_year_index.findall(title) + if yi: + yk, yiy, yii = yi[-1] + yi = [(yiy, yii)] + if yk == 'TV episode': + kind = u'episode' + elif yk == 'TV': + kind = u'tv movie' + elif yk == 'TV Series': + kind = u'tv series' + elif yk == 'Video': + kind = u'video movie' + elif yk == 'TV mini-series': + kind = u'tv mini series' + elif yk == 'Video Game': + kind = u'video game' + title = re_remove_kind.sub('(', title) + if yi: + last_yi = yi[-1] + year = last_yi[0] + if last_yi[1]: + imdbIndex = last_yi[1][1:] + year = year[:-len(imdbIndex)-1] + i = title.rfind('(%s)' % last_yi[0]) + if i != -1: + title = title[:i-1].rstrip() + # This is a tv (mini) series: strip the '"' at the begin and at the end. + # XXX: strip('"') is not used for compatibility with Python 2.0. + if title and title[0] == title[-1] == '"': + if not kind: + kind = u'tv series' + title = title[1:-1].strip() + elif title.endswith('(TV series)'): + kind = u'tv series' + title = title[:-11].rstrip() + if not title: + raise IMDbParserError('invalid title: "%s"' % original_t) + if canonical is not None: + if canonical: + title = canonicalTitle(title) + else: + title = normalizeTitle(title) + # 'kind' is one in ('movie', 'episode', 'tv series', 'tv mini series', + # 'tv movie', 'video movie', 'video game') + result['title'] = title + result['kind'] = kind or u'movie' + if year and year != '????': + if '-' in year: + result['series years'] = year + year = year[:4] + try: + result['year'] = int(year) + except (TypeError, ValueError): + pass + if imdbIndex: + result['imdbIndex'] = imdbIndex + if isinstance(_emptyString, str): + result['kind'] = str(kind or 'movie') + return result + + +_web_format = '%d %B %Y' +_ptdf_format = '(%Y-%m-%d)' +def _convertTime(title, fromPTDFtoWEB=1, _emptyString=u''): + """Convert a time expressed in the pain text data files, to + the 'Episode dated ...' format used on the web site; if + fromPTDFtoWEB is false, the inverted conversion is applied.""" + try: + if fromPTDFtoWEB: + from_format = _ptdf_format + to_format = _web_format + else: + from_format = u'Episode dated %s' % _web_format + to_format = _ptdf_format + t = strptime(title, from_format) + title = strftime(to_format, t) + if fromPTDFtoWEB: + if title[0] == '0': title = title[1:] + title = u'Episode dated %s' % title + except ValueError: + pass + if isinstance(_emptyString, str): + try: + title = str(title) + except UnicodeDecodeError: + pass + return title + + +def build_title(title_dict, canonical=None, canonicalSeries=None, + canonicalEpisode=None, ptdf=0, lang=None, _doYear=1, + _emptyString=u''): + """Given a dictionary that represents a "long" IMDb title, + return a string. + + If canonical is None (default), the title is returned in the stored style. + If canonical is True, the title is converted to canonical style. + If canonical is False, the title is converted to normal format. + + lang can be used to specify the language of the title. + + If ptdf is true, the plain text data files format is used. + """ + if canonical is not None: + canonicalSeries = canonical + pre_title = _emptyString + kind = title_dict.get('kind') + episode_of = title_dict.get('episode of') + if kind == 'episode' and episode_of is not None: + # Works with both Movie instances and plain dictionaries. + doYear = 0 + if ptdf: + doYear = 1 + pre_title = build_title(episode_of, canonical=canonicalSeries, + ptdf=0, _doYear=doYear, + _emptyString=_emptyString) + ep_dict = {'title': title_dict.get('title', ''), + 'imdbIndex': title_dict.get('imdbIndex')} + ep_title = ep_dict['title'] + if not ptdf: + doYear = 1 + ep_dict['year'] = title_dict.get('year', '????') + if ep_title[0:1] == '(' and ep_title[-1:] == ')' and \ + ep_title[1:5].isdigit(): + ep_dict['title'] = _convertTime(ep_title, fromPTDFtoWEB=1, + _emptyString=_emptyString) + else: + doYear = 0 + if ep_title.startswith('Episode dated'): + ep_dict['title'] = _convertTime(ep_title, fromPTDFtoWEB=0, + _emptyString=_emptyString) + episode_title = build_title(ep_dict, + canonical=canonicalEpisode, ptdf=ptdf, + _doYear=doYear, _emptyString=_emptyString) + if ptdf: + oad = title_dict.get('original air date', _emptyString) + if len(oad) == 10 and oad[4] == '-' and oad[7] == '-' and \ + episode_title.find(oad) == -1: + episode_title += ' (%s)' % oad + seas = title_dict.get('season') + if seas is not None: + episode_title += ' (#%s' % seas + episode = title_dict.get('episode') + if episode is not None: + episode_title += '.%s' % episode + episode_title += ')' + episode_title = '{%s}' % episode_title + return '%s %s' % (pre_title, episode_title) + title = title_dict.get('title', '') + if not title: return _emptyString + if canonical is not None: + if canonical: + title = canonicalTitle(title, lang=lang) + else: + title = normalizeTitle(title, lang=lang) + if pre_title: + title = '%s %s' % (pre_title, title) + if kind in (u'tv series', u'tv mini series'): + title = '"%s"' % title + if _doYear: + imdbIndex = title_dict.get('imdbIndex') + year = title_dict.get('year') or u'????' + if isinstance(_emptyString, str): + year = str(year) + title += ' (%s' % year + if imdbIndex: + title += '/%s' % imdbIndex + title += ')' + if kind: + if kind == 'tv movie': + title += ' (TV)' + elif kind == 'video movie': + title += ' (V)' + elif kind == 'tv mini series': + title += ' (mini)' + elif kind == 'video game': + title += ' (VG)' + return title + + +def split_company_name_notes(name): + """Return two strings, the first representing the company name, + and the other representing the (optional) notes.""" + name = name.strip() + notes = u'' + if name.endswith(')'): + fpidx = name.find('(') + if fpidx != -1: + notes = name[fpidx:] + name = name[:fpidx].rstrip() + return name, notes + + +def analyze_company_name(name, stripNotes=False): + """Return a dictionary with the name and the optional 'country' + keys, from the given string. + If stripNotes is true, tries to not consider optional notes. + + raise an IMDbParserError exception if the name is not valid. + """ + if stripNotes: + name = split_company_name_notes(name)[0] + o_name = name + name = name.strip() + country = None + if name.endswith(']'): + idx = name.rfind('[') + if idx != -1: + country = name[idx:] + name = name[:idx].rstrip() + if not name: + raise IMDbParserError('invalid name: "%s"' % o_name) + result = {'name': name} + if country: + result['country'] = country + return result + + +def build_company_name(name_dict, _emptyString=u''): + """Given a dictionary that represents a "long" IMDb company name, + return a string. + """ + name = name_dict.get('name') + if not name: + return _emptyString + country = name_dict.get('country') + if country is not None: + name += ' %s' % country + return name + + +class _LastC: + """Size matters.""" + def __cmp__(self, other): + if isinstance(other, self.__class__): return 0 + return 1 + +_last = _LastC() + +def cmpMovies(m1, m2): + """Compare two movies by year, in reverse order; the imdbIndex is checked + for movies with the same year of production and title.""" + # Sort tv series' episodes. + m1e = m1.get('episode of') + m2e = m2.get('episode of') + if m1e is not None and m2e is not None: + cmp_series = cmpMovies(m1e, m2e) + if cmp_series != 0: + return cmp_series + m1s = m1.get('season') + m2s = m2.get('season') + if m1s is not None and m2s is not None: + if m1s < m2s: + return 1 + elif m1s > m2s: + return -1 + m1p = m1.get('episode') + m2p = m2.get('episode') + if m1p < m2p: + return 1 + elif m1p > m2p: + return -1 + try: + if m1e is None: m1y = int(m1.get('year', 0)) + else: m1y = int(m1e.get('year', 0)) + except ValueError: + m1y = 0 + try: + if m2e is None: m2y = int(m2.get('year', 0)) + else: m2y = int(m2e.get('year', 0)) + except ValueError: + m2y = 0 + if m1y > m2y: return -1 + if m1y < m2y: return 1 + # Ok, these movies have the same production year... + #m1t = m1.get('canonical title', _last) + #m2t = m2.get('canonical title', _last) + # It should works also with normal dictionaries (returned from searches). + #if m1t is _last and m2t is _last: + m1t = m1.get('title', _last) + m2t = m2.get('title', _last) + if m1t < m2t: return -1 + if m1t > m2t: return 1 + # Ok, these movies have the same title... + m1i = m1.get('imdbIndex', _last) + m2i = m2.get('imdbIndex', _last) + if m1i > m2i: return -1 + if m1i < m2i: return 1 + m1id = getattr(m1, 'movieID', None) + # Introduce this check even for other comparisons functions? + # XXX: is it safe to check without knowning the data access system? + # probably not a great idea. Check for 'kind', instead? + if m1id is not None: + m2id = getattr(m2, 'movieID', None) + if m1id > m2id: return -1 + elif m1id < m2id: return 1 + return 0 + + +def cmpPeople(p1, p2): + """Compare two people by billingPos, name and imdbIndex.""" + p1b = getattr(p1, 'billingPos', None) or _last + p2b = getattr(p2, 'billingPos', None) or _last + if p1b > p2b: return 1 + if p1b < p2b: return -1 + p1n = p1.get('canonical name', _last) + p2n = p2.get('canonical name', _last) + if p1n is _last and p2n is _last: + p1n = p1.get('name', _last) + p2n = p2.get('name', _last) + if p1n > p2n: return 1 + if p1n < p2n: return -1 + p1i = p1.get('imdbIndex', _last) + p2i = p2.get('imdbIndex', _last) + if p1i > p2i: return 1 + if p1i < p2i: return -1 + return 0 + + +def cmpCompanies(p1, p2): + """Compare two companies.""" + p1n = p1.get('long imdb name', _last) + p2n = p2.get('long imdb name', _last) + if p1n is _last and p2n is _last: + p1n = p1.get('name', _last) + p2n = p2.get('name', _last) + if p1n > p2n: return 1 + if p1n < p2n: return -1 + p1i = p1.get('country', _last) + p2i = p2.get('country', _last) + if p1i > p2i: return 1 + if p1i < p2i: return -1 + return 0 + + +# References to titles, names and characters. +# XXX: find better regexp! +re_titleRef = re.compile(r'_(.+?(?: \([0-9\?]{4}(?:/[IVXLCDM]+)?\))?(?: \(mini\)| \(TV\)| \(V\)| \(VG\))?)_ \(qv\)') +# FIXME: doesn't match persons with ' in the name. +re_nameRef = re.compile(r"'([^']+?)' \(qv\)") +# XXX: good choice? Are there characters with # in the name? +re_characterRef = re.compile(r"#([^']+?)# \(qv\)") + +# Functions used to filter the text strings. +def modNull(s, titlesRefs, namesRefs, charactersRefs): + """Do nothing.""" + return s + +def modClearTitleRefs(s, titlesRefs, namesRefs, charactersRefs): + """Remove titles references.""" + return re_titleRef.sub(r'\1', s) + +def modClearNameRefs(s, titlesRefs, namesRefs, charactersRefs): + """Remove names references.""" + return re_nameRef.sub(r'\1', s) + +def modClearCharacterRefs(s, titlesRefs, namesRefs, charactersRefs): + """Remove characters references""" + return re_characterRef.sub(r'\1', s) + +def modClearRefs(s, titlesRefs, namesRefs, charactersRefs): + """Remove titles, names and characters references.""" + s = modClearTitleRefs(s, {}, {}, {}) + s = modClearCharacterRefs(s, {}, {}, {}) + return modClearNameRefs(s, {}, {}, {}) + + +def modifyStrings(o, modFunct, titlesRefs, namesRefs, charactersRefs): + """Modify a string (or string values in a dictionary or strings + in a list), using the provided modFunct function and titlesRefs + namesRefs and charactersRefs references dictionaries.""" + # Notice that it doesn't go any deeper than the first two levels in a list. + if isinstance(o, (unicode, str)): + return modFunct(o, titlesRefs, namesRefs, charactersRefs) + elif isinstance(o, (list, tuple, dict)): + _stillorig = 1 + if isinstance(o, (list, tuple)): keys = xrange(len(o)) + else: keys = o.keys() + for i in keys: + v = o[i] + if isinstance(v, (unicode, str)): + if _stillorig: + o = copy(o) + _stillorig = 0 + o[i] = modFunct(v, titlesRefs, namesRefs, charactersRefs) + elif isinstance(v, (list, tuple)): + modifyStrings(o[i], modFunct, titlesRefs, namesRefs, + charactersRefs) + return o + + +def date_and_notes(s): + """Parse (birth|death) date and notes; returns a tuple in the + form (date, notes).""" + s = s.strip() + if not s: return (u'', u'') + notes = u'' + if s[0].isdigit() or s.split()[0].lower() in ('c.', 'january', 'february', + 'march', 'april', 'may', 'june', + 'july', 'august', 'september', + 'october', 'november', + 'december', 'ca.', 'circa', + '????,'): + i = s.find(',') + if i != -1: + notes = s[i+1:].strip() + s = s[:i] + else: + notes = s + s = u'' + if s == '????': s = u'' + return s, notes + + +class RolesList(list): + """A list of Person or Character instances, used for the currentRole + property.""" + def __unicode__(self): + return u' / '.join([unicode(x) for x in self]) + + def __str__(self): + # FIXME: does it make sense at all? Return a unicode doesn't + # seem right, in __str__. + return u' / '.join([unicode(x).encode('utf8') for x in self]) + + +# Replace & with &, but only if it's not already part of a charref. +#_re_amp = re.compile(r'(&)(?!\w+;)', re.I) +#_re_amp = re.compile(r'(?<=\W)&(?=[^a-zA-Z0-9_#])') +_re_amp = re.compile(r'&(?![^a-zA-Z0-9_#]{1,5};)') + +def escape4xml(value): + """Escape some chars that can't be present in a XML value.""" + if isinstance(value, int): + value = str(value) + value = _re_amp.sub('&', value) + value = value.replace('"', '"').replace("'", ''') + value = value.replace('<', '<').replace('>', '>') + if isinstance(value, unicode): + value = value.encode('ascii', 'xmlcharrefreplace') + return value + + +def _refsToReplace(value, modFunct, titlesRefs, namesRefs, charactersRefs): + """Return three lists - for movie titles, persons and characters names - + with two items tuples: the first item is the reference once escaped + by the user-provided modFunct function, the second is the same + reference un-escaped.""" + mRefs = [] + for refRe, refTemplate in [(re_titleRef, u'_%s_ (qv)'), + (re_nameRef, u"'%s' (qv)"), + (re_characterRef, u'#%s# (qv)')]: + theseRefs = [] + for theRef in refRe.findall(value): + # refTemplate % theRef values don't change for a single + # _Container instance, so this is a good candidate for a + # cache or something - even if it's so rarely used that... + # Moreover, it can grow - ia.update(...) - and change if + # modFunct is modified. + goodValue = modFunct(refTemplate % theRef, titlesRefs, namesRefs, + charactersRefs) + # Prevents problems with crap in plain text data files. + # We should probably exclude invalid chars and string that + # are too long in the re_*Ref expressions. + if '_' in goodValue or len(goodValue) > 128: + continue + toReplace = escape4xml(goodValue) + # Only the 'value' portion is replaced. + replaceWith = goodValue.replace(theRef, escape4xml(theRef)) + theseRefs.append((toReplace, replaceWith)) + mRefs.append(theseRefs) + return mRefs + + +def _handleTextNotes(s): + """Split text::notes strings.""" + ssplit = s.split('::', 1) + if len(ssplit) == 1: + return s + return u'%s%s' % (ssplit[0], ssplit[1]) + + +def _normalizeValue(value, withRefs=False, modFunct=None, titlesRefs=None, + namesRefs=None, charactersRefs=None): + """Replace some chars that can't be present in a XML text.""" + # XXX: use s.encode(encoding, 'xmlcharrefreplace') ? Probably not + # a great idea: after all, returning a unicode is safe. + if isinstance(value, (unicode, str)): + if not withRefs: + value = _handleTextNotes(escape4xml(value)) + else: + # Replace references that were accidentally escaped. + replaceLists = _refsToReplace(value, modFunct, titlesRefs, + namesRefs, charactersRefs) + value = modFunct(value, titlesRefs or {}, namesRefs or {}, + charactersRefs or {}) + value = _handleTextNotes(escape4xml(value)) + for replaceList in replaceLists: + for toReplace, replaceWith in replaceList: + value = value.replace(toReplace, replaceWith) + else: + value = unicode(value) + return value + + +def _tag4TON(ton, addAccessSystem=False, _containerOnly=False): + """Build a tag for the given _Container instance; + both open and close tags are returned.""" + tag = ton.__class__.__name__.lower() + what = 'name' + if tag == 'movie': + value = ton.get('long imdb title') or ton.get('title', '') + what = 'title' + else: + value = ton.get('long imdb name') or ton.get('name', '') + value = _normalizeValue(value) + extras = u'' + crl = ton.currentRole + if crl: + if not isinstance(crl, list): + crl = [crl] + for cr in crl: + crTag = cr.__class__.__name__.lower() + crValue = cr['long imdb name'] + crValue = _normalizeValue(crValue) + crID = cr.getID() + if crID is not None: + extras += u'<%s id="%s">' \ + u'%s' % (crTag, crID, + crValue, crTag) + else: + extras += u'<%s>%s' % \ + (crTag, crValue, crTag) + if cr.notes: + extras += u'%s' % _normalizeValue(cr.notes) + extras += u'' + theID = ton.getID() + if theID is not None: + beginTag = u'<%s id="%s"' % (tag, theID) + if addAccessSystem and ton.accessSystem: + beginTag += ' access-system="%s"' % ton.accessSystem + if not _containerOnly: + beginTag += u'><%s>%s' % (what, value, what) + else: + beginTag += u'>' + else: + if not _containerOnly: + beginTag = u'<%s><%s>%s' % (tag, what, value, what) + else: + beginTag = u'<%s>' % tag + beginTag += extras + if ton.notes: + beginTag += u'%s' % _normalizeValue(ton.notes) + return (beginTag, u'' % tag) + + +TAGS_TO_MODIFY = { + 'movie.parents-guide': ('item', True), + 'movie.number-of-votes': ('item', True), + 'movie.soundtrack.item': ('item', True), + 'movie.quotes': ('quote', False), + 'movie.quotes.quote': ('line', False), + 'movie.demographic': ('item', True), + 'movie.episodes': ('season', True), + 'movie.episodes.season': ('episode', True), + 'person.merchandising-links': ('item', True), + 'person.genres': ('item', True), + 'person.quotes': ('quote', False), + 'person.keywords': ('item', True), + 'character.quotes': ('item', True), + 'character.quotes.item': ('quote', False), + 'character.quotes.item.quote': ('line', False) + } + +_allchars = string.maketrans('', '') +_keepchars = _allchars.translate(_allchars, string.ascii_lowercase + '-' + + string.digits) + +def _tagAttr(key, fullpath): + """Return a tuple with a tag name and a (possibly empty) attribute, + applying the conversions specified in TAGS_TO_MODIFY and checking + that the tag is safe for a XML document.""" + attrs = {} + _escapedKey = escape4xml(key) + if fullpath in TAGS_TO_MODIFY: + tagName, useTitle = TAGS_TO_MODIFY[fullpath] + if useTitle: + attrs['key'] = _escapedKey + elif not isinstance(key, unicode): + if isinstance(key, str): + tagName = unicode(key, 'ascii', 'ignore') + else: + strType = str(type(key)).replace("", "") + attrs['keytype'] = strType + tagName = unicode(key) + else: + tagName = key + if isinstance(key, int): + attrs['keytype'] = 'int' + origTagName = tagName + tagName = tagName.lower().replace(' ', '-') + tagName = str(tagName).translate(_allchars, _keepchars) + if origTagName != tagName: + if 'key' not in attrs: + attrs['key'] = _escapedKey + if (not tagName) or tagName[0].isdigit() or tagName[0] == '-': + # This is a fail-safe: we should never be here, since unpredictable + # keys must be listed in TAGS_TO_MODIFY. + # This will proably break the DTD/schema, but at least it will + # produce a valid XML. + tagName = 'item' + _utils_logger.error('invalid tag: %s [%s]' % (_escapedKey, fullpath)) + attrs['key'] = _escapedKey + return tagName, u' '.join([u'%s="%s"' % i for i in attrs.items()]) + + +def _seq2xml(seq, _l=None, withRefs=False, modFunct=None, + titlesRefs=None, namesRefs=None, charactersRefs=None, + _topLevel=True, key2infoset=None, fullpath=''): + """Convert a sequence or a dictionary to a list of XML + unicode strings.""" + if _l is None: + _l = [] + if isinstance(seq, dict): + for key in seq: + value = seq[key] + if isinstance(key, _Container): + # Here we're assuming that a _Container is never a top-level + # key (otherwise we should handle key2infoset). + openTag, closeTag = _tag4TON(key) + # So that fullpath will contains something meaningful. + tagName = key.__class__.__name__.lower() + else: + tagName, attrs = _tagAttr(key, fullpath) + openTag = u'<%s' % tagName + if attrs: + openTag += ' %s' % attrs + if _topLevel and key2infoset and key in key2infoset: + openTag += u' infoset="%s"' % key2infoset[key] + if isinstance(value, int): + openTag += ' type="int"' + elif isinstance(value, float): + openTag += ' type="float"' + openTag += u'>' + closeTag = u'' % tagName + _l.append(openTag) + _seq2xml(value, _l, withRefs, modFunct, titlesRefs, + namesRefs, charactersRefs, _topLevel=False, + fullpath='%s.%s' % (fullpath, tagName)) + _l.append(closeTag) + elif isinstance(seq, (list, tuple)): + tagName, attrs = _tagAttr('item', fullpath) + beginTag = u'<%s' % tagName + if attrs: + beginTag += u' %s' % attrs + #beginTag += u'>' + closeTag = u'' % tagName + for item in seq: + if isinstance(item, _Container): + _seq2xml(item, _l, withRefs, modFunct, titlesRefs, + namesRefs, charactersRefs, _topLevel=False, + fullpath='%s.%s' % (fullpath, + item.__class__.__name__.lower())) + else: + openTag = beginTag + if isinstance(item, int): + openTag += ' type="int"' + elif isinstance(item, float): + openTag += ' type="float"' + openTag += u'>' + _l.append(openTag) + _seq2xml(item, _l, withRefs, modFunct, titlesRefs, + namesRefs, charactersRefs, _topLevel=False, + fullpath='%s.%s' % (fullpath, tagName)) + _l.append(closeTag) + else: + if isinstance(seq, _Container): + _l.extend(_tag4TON(seq)) + else: + # Text, ints, floats and the like. + _l.append(_normalizeValue(seq, withRefs=withRefs, + modFunct=modFunct, + titlesRefs=titlesRefs, + namesRefs=namesRefs, + charactersRefs=charactersRefs)) + return _l + + +_xmlHead = u""" + + +""" +_xmlHead = _xmlHead.replace('{VERSION}', + VERSION.replace('.', '').split('dev')[0][:2]) + + +class _Container(object): + """Base class for Movie, Person, Character and Company classes.""" + # The default sets of information retrieved. + default_info = () + + # Aliases for some not-so-intuitive keys. + keys_alias = {} + + # List of keys to modify. + keys_tomodify_list = () + + # Function used to compare two instances of this class. + cmpFunct = None + + # Regular expression used to build the 'full-size (headshot|cover url)'. + _re_fullsizeURL = re.compile(r'\._V1\._SX(\d+)_SY(\d+)_') + + def __init__(self, myID=None, data=None, notes=u'', + currentRole=u'', roleID=None, roleIsPerson=False, + accessSystem=None, titlesRefs=None, namesRefs=None, + charactersRefs=None, modFunct=None, *args, **kwds): + """Initialize a Movie, Person, Character or Company object. + *myID* -- your personal identifier for this object. + *data* -- a dictionary used to initialize the object. + *notes* -- notes for the person referred in the currentRole + attribute; e.g.: '(voice)' or the alias used in the + movie credits. + *accessSystem* -- a string representing the data access system used. + *currentRole* -- a Character instance representing the current role + or duty of a person in this movie, or a Person + object representing the actor/actress who played + a given character in a Movie. If a string is + passed, an object is automatically build. + *roleID* -- if available, the characterID/personID of the currentRole + object. + *roleIsPerson* -- when False (default) the currentRole is assumed + to be a Character object, otherwise a Person. + *titlesRefs* -- a dictionary with references to movies. + *namesRefs* -- a dictionary with references to persons. + *charactersRefs* -- a dictionary with references to characters. + *modFunct* -- function called returning text fields. + """ + self.reset() + self.accessSystem = accessSystem + self.myID = myID + if data is None: data = {} + self.set_data(data, override=1) + self.notes = notes + if titlesRefs is None: titlesRefs = {} + self.update_titlesRefs(titlesRefs) + if namesRefs is None: namesRefs = {} + self.update_namesRefs(namesRefs) + if charactersRefs is None: charactersRefs = {} + self.update_charactersRefs(charactersRefs) + self.set_mod_funct(modFunct) + self.keys_tomodify = {} + for item in self.keys_tomodify_list: + self.keys_tomodify[item] = None + self._roleIsPerson = roleIsPerson + if not roleIsPerson: + from imdb.Character import Character + self._roleClass = Character + else: + from imdb.Person import Person + self._roleClass = Person + self.currentRole = currentRole + if roleID: + self.roleID = roleID + self._init(*args, **kwds) + + def _get_roleID(self): + """Return the characterID or personID of the currentRole object.""" + if not self.__role: + return None + if isinstance(self.__role, list): + return [x.getID() for x in self.__role] + return self.currentRole.getID() + + def _set_roleID(self, roleID): + """Set the characterID or personID of the currentRole object.""" + if not self.__role: + # XXX: needed? Just ignore it? It's probably safer to + # ignore it, to prevent some bugs in the parsers. + #raise IMDbError,"Can't set ID of an empty Character/Person object." + pass + if not self._roleIsPerson: + if not isinstance(roleID, (list, tuple)): + self.currentRole.characterID = roleID + else: + for index, item in enumerate(roleID): + self.__role[index].characterID = item + else: + if not isinstance(roleID, (list, tuple)): + self.currentRole.personID = roleID + else: + for index, item in enumerate(roleID): + self.__role[index].personID = item + + roleID = property(_get_roleID, _set_roleID, + doc="the characterID or personID of the currentRole object.") + + def _get_currentRole(self): + """Return a Character or Person instance.""" + if self.__role: + return self.__role + return self._roleClass(name=u'', accessSystem=self.accessSystem, + modFunct=self.modFunct) + + def _set_currentRole(self, role): + """Set self.currentRole to a Character or Person instance.""" + if isinstance(role, (unicode, str)): + if not role: + self.__role = None + else: + self.__role = self._roleClass(name=role, modFunct=self.modFunct, + accessSystem=self.accessSystem) + elif isinstance(role, (list, tuple)): + self.__role = RolesList() + for item in role: + if isinstance(item, (unicode, str)): + self.__role.append(self._roleClass(name=item, + accessSystem=self.accessSystem, + modFunct=self.modFunct)) + else: + self.__role.append(item) + if not self.__role: + self.__role = None + else: + self.__role = role + + currentRole = property(_get_currentRole, _set_currentRole, + doc="The role of a Person in a Movie" + \ + " or the interpreter of a Character in a Movie.") + + def _init(self, **kwds): pass + + def reset(self): + """Reset the object.""" + self.data = {} + self.myID = None + self.notes = u'' + self.titlesRefs = {} + self.namesRefs = {} + self.charactersRefs = {} + self.modFunct = modClearRefs + self.current_info = [] + self.infoset2keys = {} + self.key2infoset = {} + self.__role = None + self._reset() + + def _reset(self): pass + + def clear(self): + """Reset the dictionary.""" + self.data.clear() + self.notes = u'' + self.titlesRefs = {} + self.namesRefs = {} + self.charactersRefs = {} + self.current_info = [] + self.infoset2keys = {} + self.key2infoset = {} + self.__role = None + self._clear() + + def _clear(self): pass + + def get_current_info(self): + """Return the current set of information retrieved.""" + return self.current_info + + def update_infoset_map(self, infoset, keys, mainInfoset): + """Update the mappings between infoset and keys.""" + if keys is None: + keys = [] + if mainInfoset is not None: + theIS = mainInfoset + else: + theIS = infoset + self.infoset2keys[theIS] = keys + for key in keys: + self.key2infoset[key] = theIS + + def set_current_info(self, ci): + """Set the current set of information retrieved.""" + # XXX:Remove? It's never used and there's no way to update infoset2keys. + self.current_info = ci + + def add_to_current_info(self, val, keys=None, mainInfoset=None): + """Add a set of information to the current list.""" + if val not in self.current_info: + self.current_info.append(val) + self.update_infoset_map(val, keys, mainInfoset) + + def has_current_info(self, val): + """Return true if the given set of information is in the list.""" + return val in self.current_info + + def set_mod_funct(self, modFunct): + """Set the fuction used to modify the strings.""" + if modFunct is None: modFunct = modClearRefs + self.modFunct = modFunct + + def update_titlesRefs(self, titlesRefs): + """Update the dictionary with the references to movies.""" + self.titlesRefs.update(titlesRefs) + + def get_titlesRefs(self): + """Return the dictionary with the references to movies.""" + return self.titlesRefs + + def update_namesRefs(self, namesRefs): + """Update the dictionary with the references to names.""" + self.namesRefs.update(namesRefs) + + def get_namesRefs(self): + """Return the dictionary with the references to names.""" + return self.namesRefs + + def update_charactersRefs(self, charactersRefs): + """Update the dictionary with the references to characters.""" + self.charactersRefs.update(charactersRefs) + + def get_charactersRefs(self): + """Return the dictionary with the references to characters.""" + return self.charactersRefs + + def set_data(self, data, override=0): + """Set the movie data to the given dictionary; if 'override' is + set, the previous data is removed, otherwise the two dictionary + are merged. + """ + if not override: + self.data.update(data) + else: + self.data = data + + def getID(self): + """Return movieID, personID, characterID or companyID.""" + raise NotImplementedError('override this method') + + def __cmp__(self, other): + """Compare two Movie, Person, Character or Company objects.""" + # XXX: raise an exception? + if self.cmpFunct is None: return -1 + if not isinstance(other, self.__class__): return -1 + return self.cmpFunct(other) + + def __hash__(self): + """Hash for this object.""" + # XXX: does it always work correctly? + theID = self.getID() + if theID is not None and self.accessSystem not in ('UNKNOWN', None): + # Handle 'http' and 'mobile' as they are the same access system. + acs = self.accessSystem + if acs in ('mobile', 'httpThin'): + acs = 'http' + # There must be some indication of the kind of the object, too. + s4h = '%s:%s[%s]' % (self.__class__.__name__, theID, acs) + else: + s4h = repr(self) + return hash(s4h) + + def isSame(self, other): + """Return True if the two represent the same object.""" + if not isinstance(other, self.__class__): return 0 + if hash(self) == hash(other): return 1 + return 0 + + def __len__(self): + """Number of items in the data dictionary.""" + return len(self.data) + + def getAsXML(self, key, _with_add_keys=True): + """Return a XML representation of the specified key, or None + if empty. If _with_add_keys is False, dinamically generated + keys are excluded.""" + # Prevent modifyStrings in __getitem__ to be called; if needed, + # it will be called by the _normalizeValue function. + origModFunct = self.modFunct + self.modFunct = modNull + # XXX: not totally sure it's a good idea, but could prevent + # problems (i.e.: the returned string always contains + # a DTD valid tag, and not something that can be only in + # the keys_alias map). + key = self.keys_alias.get(key, key) + if (not _with_add_keys) and (key in self._additional_keys()): + self.modFunct = origModFunct + return None + try: + withRefs = False + if key in self.keys_tomodify and \ + origModFunct not in (None, modNull): + withRefs = True + value = self.get(key) + if value is None: + return None + tag = self.__class__.__name__.lower() + return u''.join(_seq2xml({key: value}, withRefs=withRefs, + modFunct=origModFunct, + titlesRefs=self.titlesRefs, + namesRefs=self.namesRefs, + charactersRefs=self.charactersRefs, + key2infoset=self.key2infoset, + fullpath=tag)) + finally: + self.modFunct = origModFunct + + def asXML(self, _with_add_keys=True): + """Return a XML representation of the whole object. + If _with_add_keys is False, dinamically generated keys are excluded.""" + beginTag, endTag = _tag4TON(self, addAccessSystem=True, + _containerOnly=True) + resList = [beginTag] + for key in self.keys(): + value = self.getAsXML(key, _with_add_keys=_with_add_keys) + if not value: + continue + resList.append(value) + resList.append(endTag) + head = _xmlHead % self.__class__.__name__.lower() + return head + u''.join(resList) + + def _getitem(self, key): + """Handle special keys.""" + return None + + def __getitem__(self, key): + """Return the value for a given key, checking key aliases; + a KeyError exception is raised if the key is not found. + """ + value = self._getitem(key) + if value is not None: return value + # Handle key aliases. + key = self.keys_alias.get(key, key) + rawData = self.data[key] + if key in self.keys_tomodify and \ + self.modFunct not in (None, modNull): + try: + return modifyStrings(rawData, self.modFunct, self.titlesRefs, + self.namesRefs, self.charactersRefs) + except RuntimeError, e: + # Symbian/python 2.2 has a poor regexp implementation. + import warnings + warnings.warn('RuntimeError in ' + "imdb.utils._Container.__getitem__; if it's not " + "a recursion limit exceeded and we're not running " + "in a Symbian environment, it's a bug:\n%s" % e) + return rawData + + def __setitem__(self, key, item): + """Directly store the item with the given key.""" + self.data[key] = item + + def __delitem__(self, key): + """Remove the given section or key.""" + # XXX: how to remove an item of a section? + del self.data[key] + + def _additional_keys(self): + """Valid keys to append to the data.keys() list.""" + return [] + + def keys(self): + """Return a list of valid keys.""" + return self.data.keys() + self._additional_keys() + + def items(self): + """Return the items in the dictionary.""" + return [(k, self.get(k)) for k in self.keys()] + + # XXX: is this enough? + def iteritems(self): return self.data.iteritems() + def iterkeys(self): return self.data.iterkeys() + def itervalues(self): return self.data.itervalues() + + def values(self): + """Return the values in the dictionary.""" + return [self.get(k) for k in self.keys()] + + def has_key(self, key): + """Return true if a given section is defined.""" + try: + self.__getitem__(key) + except KeyError: + return 0 + return 1 + + # XXX: really useful??? + # consider also that this will confuse people who meant to + # call ia.update(movieObject, 'data set') instead. + def update(self, dict): + self.data.update(dict) + + def get(self, key, failobj=None): + """Return the given section, or default if it's not found.""" + try: + return self.__getitem__(key) + except KeyError: + return failobj + + def setdefault(self, key, failobj=None): + if not self.has_key(key): + self[key] = failobj + return self[key] + + def pop(self, key, *args): + return self.data.pop(key, *args) + + def popitem(self): + return self.data.popitem() + + def __repr__(self): + """String representation of an object.""" + raise NotImplementedError('override this method') + + def __str__(self): + """Movie title or person name.""" + raise NotImplementedError('override this method') + + def __contains__(self, key): + raise NotImplementedError('override this method') + + def append_item(self, key, item): + """The item is appended to the list identified by the given key.""" + self.data.setdefault(key, []).append(item) + + def set_item(self, key, item): + """Directly store the item with the given key.""" + self.data[key] = item + + def __nonzero__(self): + """Return true if self.data contains something.""" + if self.data: return 1 + return 0 + + def __deepcopy__(self, memo): + raise NotImplementedError('override this method') + + def copy(self): + """Return a deep copy of the object itself.""" + return deepcopy(self) + + +def flatten(seq, toDescend=(list, dict, tuple), yieldDictKeys=0, + onlyKeysType=(_Container,), scalar=None): + """Iterate over nested lists and dictionaries; toDescend is a list + or a tuple of types to be considered non-scalar; if yieldDictKeys is + true, also dictionaries' keys are yielded; if scalar is not None, only + items of the given type(s) are yielded.""" + if scalar is None or isinstance(seq, scalar): + yield seq + if isinstance(seq, toDescend): + if isinstance(seq, (dict, _Container)): + if yieldDictKeys: + # Yield also the keys of the dictionary. + for key in seq.iterkeys(): + for k in flatten(key, toDescend=toDescend, + yieldDictKeys=yieldDictKeys, + onlyKeysType=onlyKeysType, scalar=scalar): + if onlyKeysType and isinstance(k, onlyKeysType): + yield k + for value in seq.itervalues(): + for v in flatten(value, toDescend=toDescend, + yieldDictKeys=yieldDictKeys, + onlyKeysType=onlyKeysType, scalar=scalar): + yield v + elif not isinstance(seq, (str, unicode, int, float)): + for item in seq: + for i in flatten(item, toDescend=toDescend, + yieldDictKeys=yieldDictKeys, + onlyKeysType=onlyKeysType, scalar=scalar): + yield i + + diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index d8ea48fd13..6896846437 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -1,1543 +1,1605 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from __future__ import with_statement - -import cherrypy -import webbrowser -import sqlite3 -import datetime -import socket -import os, sys, subprocess, re -import urllib - -from threading import Lock - -# apparently py2exe won't build these unless they're imported somewhere -from sickbeard import providers, metadata -from providers import ezrss, tvtorrents, torrentleech, btn, nzbsrus, newznab, womble, nzbx, omgwtfnzbs, binnewz, t411, cpasbien, piratebay, gks, kat -from sickbeard.config import CheckSection, check_setting_int, check_setting_str, ConfigMigrator - -from sickbeard import searchCurrent, searchBacklog, showUpdater, versionChecker, properFinder, autoPostProcesser, subtitles, traktWatchListChecker -from sickbeard import helpers, db, exceptions, show_queue, search_queue, scheduler -from sickbeard import logger -from sickbeard import naming - -from common import SD, SKIPPED, NAMING_REPEAT - -from sickbeard.databases import mainDB, cache_db - -from lib.configobj import ConfigObj - -invoked_command = None - -SOCKET_TIMEOUT = 30 - -PID = None - -CFG = None -CONFIG_FILE = None - -# this is the version of the config we EXPECT to find -CONFIG_VERSION = 1 - -PROG_DIR = '.' -MY_FULLNAME = None -MY_NAME = None -MY_ARGS = [] -SYS_ENCODING = '' -DATA_DIR = '' -CREATEPID = False -PIDFILE = '' - -DAEMON = None -NO_RESIZE = False - -backlogSearchScheduler = None -currentSearchScheduler = None -showUpdateScheduler = None -versionCheckScheduler = None -showQueueScheduler = None -searchQueueScheduler = None -properFinderScheduler = None -autoPostProcesserScheduler = None -autoTorrentPostProcesserScheduler = None -subtitlesFinderScheduler = None -traktWatchListCheckerSchedular = None - -showList = None -loadingShowList = None - -providerList = [] -newznabProviderList = [] -metadata_provider_dict = {} - -NEWEST_VERSION = None -NEWEST_VERSION_STRING = None -VERSION_NOTIFY = None - -INIT_LOCK = Lock() -__INITIALIZED__ = False -started = False - -LOG_DIR = None - -WEB_PORT = None -WEB_LOG = None -WEB_ROOT = None -WEB_USERNAME = None -WEB_PASSWORD = None -WEB_HOST = None -WEB_IPV6 = None - -USE_API = False -API_KEY = None - -ENABLE_HTTPS = False -HTTPS_CERT = None -HTTPS_KEY = None - -LAUNCH_BROWSER = None -CACHE_DIR = None -ACTUAL_CACHE_DIR = None -ROOT_DIRS = None - -USE_BANNER = None -USE_LISTVIEW = None -METADATA_XBMC = None -METADATA_XBMCFRODO = None -METADATA_MEDIABROWSER = None -METADATA_PS3 = None -METADATA_WDTV = None -METADATA_TIVO = None -METADATA_SYNOLOGY = None - -QUALITY_DEFAULT = None -STATUS_DEFAULT = None -FLATTEN_FOLDERS_DEFAULT = None -AUDIO_SHOW_DEFAULT = None -SUBTITLES_DEFAULT = None -PROVIDER_ORDER = [] - -NAMING_MULTI_EP = None -NAMING_PATTERN = None -NAMING_ABD_PATTERN = None -NAMING_CUSTOM_ABD = None -NAMING_FORCE_FOLDERS = False - -TVDB_API_KEY = '9DAF49C96CBF8DAC' -TVDB_BASE_URL = None -TVDB_API_PARMS = {} - -USE_NZBS = None -USE_TORRENTS = None - -NZB_METHOD = None -NZB_DIR = None -USENET_RETENTION = None -TORRENT_METHOD = None -TORRENT_DIR = None -DOWNLOAD_PROPERS = None -PREFERED_METHOD = None -SEARCH_FREQUENCY = None -BACKLOG_SEARCH_FREQUENCY = 1 - -MIN_SEARCH_FREQUENCY = 10 - -DEFAULT_SEARCH_FREQUENCY = 60 - -EZRSS = False -TVTORRENTS = False -TVTORRENTS_DIGEST = None -TVTORRENTS_HASH = None - -TORRENTLEECH = False -TORRENTLEECH_KEY = None - -BTN = False -BTN_API_KEY = None - -TORRENT_DIR = None - -ADD_SHOWS_WO_DIR = None -CREATE_MISSING_SHOW_DIRS = None -RENAME_EPISODES = False -PROCESS_AUTOMATICALLY = False -PROCESS_AUTOMATICALLY_TORRENT = False -KEEP_PROCESSED_DIR = False -MOVE_ASSOCIATED_FILES = False -TV_DOWNLOAD_DIR = None -TORRENT_DOWNLOAD_DIR = None - -NZBS = False -NZBS_UID = None -NZBS_HASH = None - -WOMBLE = False - -NZBX = False -NZBX_COMPLETION = 100 - -OMGWTFNZBS = False -OMGWTFNZBS_UID = None -OMGWTFNZBS_KEY = None - -NZBSRUS = False -NZBSRUS_UID = None -NZBSRUS_HASH = None - -BINNEWZ = False - -T411 = False -T411_USERNAME = None -T411_PASSWORD = None - -THEPIRATEBAY = False -THEPIRATEBAY_TRUSTED = True -THEPIRATEBAY_PROXY = False -THEPIRATEBAY_PROXY_URL = None - -Cpasbien = False -kat = False - -NZBMATRIX = False -NZBMATRIX_USERNAME = None -NZBMATRIX_APIKEY = None - -NEWZBIN = False -NEWZBIN_USERNAME = None -NEWZBIN_PASSWORD = None - -SAB_USERNAME = None -SAB_PASSWORD = None -SAB_APIKEY = None -SAB_CATEGORY = None -SAB_HOST = '' - -NZBGET_PASSWORD = None -NZBGET_CATEGORY = None -NZBGET_HOST = None - -GKS = False -GKS_KEY = None - -TORRENT_USERNAME = None -TORRENT_PASSWORD = None -TORRENT_HOST = '' -TORRENT_PATH = '' -TORRENT_RATIO = '' -TORRENT_PAUSED = False -TORRENT_LABEL = '' - -USE_XBMC = False -XBMC_NOTIFY_ONSNATCH = False -XBMC_NOTIFY_ONDOWNLOAD = False -XBMC_NOTIFY_ONSUBTITLEDOWNLOAD = False -XBMC_UPDATE_LIBRARY = False -XBMC_UPDATE_FULL = False -XBMC_UPDATE_ONLYFIRST = False -XBMC_HOST = '' -XBMC_USERNAME = None -XBMC_PASSWORD = None - -USE_PLEX = False -PLEX_NOTIFY_ONSNATCH = False -PLEX_NOTIFY_ONDOWNLOAD = False -PLEX_NOTIFY_ONSUBTITLEDOWNLOAD = False -PLEX_UPDATE_LIBRARY = False -PLEX_SERVER_HOST = None -PLEX_HOST = None -PLEX_USERNAME = None -PLEX_PASSWORD = None - -USE_GROWL = False -GROWL_NOTIFY_ONSNATCH = False -GROWL_NOTIFY_ONDOWNLOAD = False -GROWL_NOTIFY_ONSUBTITLEDOWNLOAD = False -GROWL_HOST = '' -GROWL_PASSWORD = None - -USE_PROWL = False -PROWL_NOTIFY_ONSNATCH = False -PROWL_NOTIFY_ONDOWNLOAD = False -PROWL_NOTIFY_ONSUBTITLEDOWNLOAD = False -PROWL_API = None -PROWL_PRIORITY = 0 - -USE_TWITTER = False -TWITTER_NOTIFY_ONSNATCH = False -TWITTER_NOTIFY_ONDOWNLOAD = False -TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD = False -TWITTER_USERNAME = None -TWITTER_PASSWORD = None -TWITTER_PREFIX = None - -USE_NOTIFO = False -NOTIFO_NOTIFY_ONSNATCH = False -NOTIFO_NOTIFY_ONDOWNLOAD = False -NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD = False -NOTIFO_USERNAME = None -NOTIFO_APISECRET = None -NOTIFO_PREFIX = None - -USE_BOXCAR = False -BOXCAR_NOTIFY_ONSNATCH = False -BOXCAR_NOTIFY_ONDOWNLOAD = False -BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD = False -BOXCAR_USERNAME = None -BOXCAR_PASSWORD = None -BOXCAR_PREFIX = None - -USE_PUSHOVER = False -PUSHOVER_NOTIFY_ONSNATCH = False -PUSHOVER_NOTIFY_ONDOWNLOAD = False -PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD = False -PUSHOVER_USERKEY = None - -USE_LIBNOTIFY = False -LIBNOTIFY_NOTIFY_ONSNATCH = False -LIBNOTIFY_NOTIFY_ONDOWNLOAD = False -LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD = False - -USE_NMJ = False -NMJ_HOST = None -NMJ_DATABASE = None -NMJ_MOUNT = None - -USE_SYNOINDEX = False - -USE_NMJv2 = False -NMJv2_HOST = None -NMJv2_DATABASE = None -NMJv2_DBLOC = None - -USE_TRAKT = False -TRAKT_USERNAME = None -TRAKT_PASSWORD = None -TRAKT_API = '' -TRAKT_REMOVE_WATCHLIST = False -TRAKT_USE_WATCHLIST = False -TRAKT_METHOD_ADD = 0 -TRAKT_START_PAUSED = False - -USE_PYTIVO = False -PYTIVO_NOTIFY_ONSNATCH = False -PYTIVO_NOTIFY_ONDOWNLOAD = False -PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD = False -PYTIVO_UPDATE_LIBRARY = False -PYTIVO_HOST = '' -PYTIVO_SHARE_NAME = '' -PYTIVO_TIVO_NAME = '' - -USE_NMA = False -NMA_NOTIFY_ONSNATCH = False -NMA_NOTIFY_ONDOWNLOAD = False -NMA_NOTIFY_ONSUBTITLEDOWNLOAD = False -NMA_API = None -NMA_PRIORITY = 0 - -USE_MAIL = False -MAIL_USERNAME = None -MAIL_PASSWORD = None -MAIL_SERVER = None -MAIL_SSL = False -MAIL_FROM = None -MAIL_TO = None -MAIL_NOTIFY_ONSNATCH = False - -COMING_EPS_LAYOUT = None -COMING_EPS_DISPLAY_PAUSED = None -COMING_EPS_SORT = None -COMING_EPS_MISSED_RANGE = None - -USE_SUBTITLES = False -SUBTITLES_LANGUAGES = [] -SUBTITLES_DIR = '' -SUBTITLES_DIR_SUB = False -SUBSNOLANG = False -SUBTITLES_SERVICES_LIST = [] -SUBTITLES_SERVICES_ENABLED = [] -SUBTITLES_HISTORY = False - -DISPLAY_POSTERS = None - -EXTRA_SCRIPTS = [] - -GIT_PATH = None - -IGNORE_WORDS = "german,spanish,core2hd,dutch,swedish" - -__INITIALIZED__ = False - - -def get_backlog_cycle_time(): - cycletime = SEARCH_FREQUENCY * 2 + 7 - return max([cycletime, 120]) - - -def initialize(consoleLogging=True): - - with INIT_LOCK: - - global LOG_DIR, WEB_PORT, WEB_LOG, WEB_ROOT, WEB_USERNAME, WEB_PASSWORD, WEB_HOST, WEB_IPV6, USE_API, API_KEY, ENABLE_HTTPS, HTTPS_CERT, HTTPS_KEY, \ - USE_NZBS, USE_TORRENTS, NZB_METHOD, NZB_DIR, DOWNLOAD_PROPERS, TORRENT_METHOD, PREFERED_METHOD, \ - SAB_USERNAME, SAB_PASSWORD, SAB_APIKEY, SAB_CATEGORY, SAB_HOST, \ - NZBGET_PASSWORD, NZBGET_CATEGORY, NZBGET_HOST, currentSearchScheduler, backlogSearchScheduler, \ - TORRENT_USERNAME, TORRENT_PASSWORD, TORRENT_HOST, TORRENT_PATH, TORRENT_RATIO, TORRENT_PAUSED, TORRENT_LABEL, \ - USE_XBMC, XBMC_NOTIFY_ONSNATCH, XBMC_NOTIFY_ONDOWNLOAD, XBMC_NOTIFY_ONSUBTITLEDOWNLOAD, XBMC_UPDATE_FULL, XBMC_UPDATE_ONLYFIRST, \ - XBMC_UPDATE_LIBRARY, XBMC_HOST, XBMC_USERNAME, XBMC_PASSWORD, \ - USE_TRAKT, TRAKT_USERNAME, TRAKT_PASSWORD, TRAKT_API,TRAKT_REMOVE_WATCHLIST,TRAKT_USE_WATCHLIST,TRAKT_METHOD_ADD,TRAKT_START_PAUSED,traktWatchListCheckerSchedular, \ - USE_PLEX, PLEX_NOTIFY_ONSNATCH, PLEX_NOTIFY_ONDOWNLOAD, PLEX_NOTIFY_ONSUBTITLEDOWNLOAD, PLEX_UPDATE_LIBRARY, \ - PLEX_SERVER_HOST, PLEX_HOST, PLEX_USERNAME, PLEX_PASSWORD, \ - showUpdateScheduler, __INITIALIZED__, LAUNCH_BROWSER, DISPLAY_POSTERS, showList, loadingShowList, \ - NZBS, NZBS_UID, NZBS_HASH, EZRSS, TVTORRENTS, TVTORRENTS_DIGEST, TVTORRENTS_HASH, BTN, BTN_API_KEY, TORRENTLEECH, TORRENTLEECH_KEY, TORRENT_DIR, USENET_RETENTION, SOCKET_TIMEOUT, \ - BINNEWZ, \ - T411, T411_USERNAME, T411_PASSWORD, \ - THEPIRATEBAY, THEPIRATEBAY_PROXY, THEPIRATEBAY_PROXY_URL, THEPIRATEBAY_TRUSTED, \ - Cpasbien, \ - kat, \ - SEARCH_FREQUENCY, DEFAULT_SEARCH_FREQUENCY, BACKLOG_SEARCH_FREQUENCY, \ - QUALITY_DEFAULT, FLATTEN_FOLDERS_DEFAULT, SUBTITLES_DEFAULT, STATUS_DEFAULT, AUDIO_SHOW_DEFAULT, \ - GROWL_NOTIFY_ONSNATCH, GROWL_NOTIFY_ONDOWNLOAD, GROWL_NOTIFY_ONSUBTITLEDOWNLOAD, TWITTER_NOTIFY_ONSNATCH, TWITTER_NOTIFY_ONDOWNLOAD, TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD, \ - USE_GROWL, GROWL_HOST, GROWL_PASSWORD, USE_PROWL, PROWL_NOTIFY_ONSNATCH, PROWL_NOTIFY_ONDOWNLOAD, PROWL_NOTIFY_ONSUBTITLEDOWNLOAD, PROWL_API, PROWL_PRIORITY, PROG_DIR, NZBMATRIX, NZBMATRIX_USERNAME, \ - USE_PYTIVO, PYTIVO_NOTIFY_ONSNATCH, PYTIVO_NOTIFY_ONDOWNLOAD, PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD, PYTIVO_UPDATE_LIBRARY, PYTIVO_HOST, PYTIVO_SHARE_NAME, PYTIVO_TIVO_NAME, \ - USE_NMA, NMA_NOTIFY_ONSNATCH, NMA_NOTIFY_ONDOWNLOAD, NMA_NOTIFY_ONSUBTITLEDOWNLOAD, NMA_API, NMA_PRIORITY, \ - USE_MAIL, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SERVER, MAIL_SSL, MAIL_FROM, MAIL_TO, MAIL_NOTIFY_ONSNATCH, \ - NZBMATRIX_APIKEY, versionCheckScheduler, VERSION_NOTIFY, PROCESS_AUTOMATICALLY, PROCESS_AUTOMATICALLY_TORRENT, \ - KEEP_PROCESSED_DIR, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ - showQueueScheduler, searchQueueScheduler, ROOT_DIRS, CACHE_DIR, ACTUAL_CACHE_DIR, TVDB_API_PARMS, \ - NAMING_PATTERN, NAMING_MULTI_EP, NAMING_FORCE_FOLDERS, NAMING_ABD_PATTERN, NAMING_CUSTOM_ABD, \ - RENAME_EPISODES, properFinderScheduler, PROVIDER_ORDER, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, \ - NZBSRUS, NZBSRUS_UID, NZBSRUS_HASH, WOMBLE, NZBX, NZBX_COMPLETION, OMGWTFNZBS, OMGWTFNZBS_UID, OMGWTFNZBS_KEY, providerList, newznabProviderList, \ - EXTRA_SCRIPTS, USE_TWITTER, TWITTER_USERNAME, TWITTER_PASSWORD, TWITTER_PREFIX, \ - USE_NOTIFO, NOTIFO_USERNAME, NOTIFO_APISECRET, NOTIFO_NOTIFY_ONDOWNLOAD, NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD, NOTIFO_NOTIFY_ONSNATCH, \ - USE_BOXCAR, BOXCAR_USERNAME, BOXCAR_PASSWORD, BOXCAR_NOTIFY_ONDOWNLOAD, BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD, BOXCAR_NOTIFY_ONSNATCH, \ - USE_PUSHOVER, PUSHOVER_USERKEY, PUSHOVER_NOTIFY_ONDOWNLOAD, PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD, PUSHOVER_NOTIFY_ONSNATCH, \ - USE_LIBNOTIFY, LIBNOTIFY_NOTIFY_ONSNATCH, LIBNOTIFY_NOTIFY_ONDOWNLOAD, LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD, USE_NMJ, NMJ_HOST, NMJ_DATABASE, NMJ_MOUNT, USE_NMJv2, NMJv2_HOST, NMJv2_DATABASE, NMJv2_DBLOC, USE_SYNOINDEX, \ - USE_BANNER, USE_LISTVIEW, METADATA_XBMC, METADATA_XBMCFRODO, METADATA_MEDIABROWSER, METADATA_PS3, METADATA_SYNOLOGY, metadata_provider_dict, \ - NEWZBIN, NEWZBIN_USERNAME, NEWZBIN_PASSWORD, GIT_PATH, MOVE_ASSOCIATED_FILES, \ - GKS, GKS_KEY, \ - COMING_EPS_LAYOUT, COMING_EPS_SORT, COMING_EPS_DISPLAY_PAUSED, COMING_EPS_MISSED_RANGE, METADATA_WDTV, METADATA_TIVO, IGNORE_WORDS, CREATE_MISSING_SHOW_DIRS, \ - ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler - - - if __INITIALIZED__: - return False - - socket.setdefaulttimeout(SOCKET_TIMEOUT) - - CheckSection(CFG, 'General') - LOG_DIR = check_setting_str(CFG, 'General', 'log_dir', 'Logs') - if not helpers.makeDir(LOG_DIR): - logger.log(u"!!! No log folder, logging to screen only!", logger.ERROR) - - try: - WEB_PORT = check_setting_int(CFG, 'General', 'web_port', 8081) - except: - WEB_PORT = 8081 - - if WEB_PORT < 21 or WEB_PORT > 65535: - WEB_PORT = 8081 - - WEB_HOST = check_setting_str(CFG, 'General', 'web_host', '0.0.0.0') - WEB_IPV6 = bool(check_setting_int(CFG, 'General', 'web_ipv6', 0)) - WEB_ROOT = check_setting_str(CFG, 'General', 'web_root', '').rstrip("/") - WEB_LOG = bool(check_setting_int(CFG, 'General', 'web_log', 0)) - WEB_USERNAME = check_setting_str(CFG, 'General', 'web_username', '') - WEB_PASSWORD = check_setting_str(CFG, 'General', 'web_password', '') - LAUNCH_BROWSER = bool(check_setting_int(CFG, 'General', 'launch_browser', 1)) - DISPLAY_POSTERS = bool(check_setting_int(CFG, 'General', 'display_posters', 1)) - - USE_API = bool(check_setting_int(CFG, 'General', 'use_api', 0)) - API_KEY = check_setting_str(CFG, 'General', 'api_key', '') - - ENABLE_HTTPS = bool(check_setting_int(CFG, 'General', 'enable_https', 0)) - HTTPS_CERT = check_setting_str(CFG, 'General', 'https_cert', 'server.crt') - HTTPS_KEY = check_setting_str(CFG, 'General', 'https_key', 'server.key') - - ACTUAL_CACHE_DIR = check_setting_str(CFG, 'General', 'cache_dir', 'cache') - # fix bad configs due to buggy code - if ACTUAL_CACHE_DIR == 'None': - ACTUAL_CACHE_DIR = 'cache' - - # unless they specify, put the cache dir inside the data dir - if not os.path.isabs(ACTUAL_CACHE_DIR): - CACHE_DIR = os.path.join(DATA_DIR, ACTUAL_CACHE_DIR) - else: - CACHE_DIR = ACTUAL_CACHE_DIR - - if not helpers.makeDir(CACHE_DIR): - logger.log(u"!!! Creating local cache dir failed, using system default", logger.ERROR) - CACHE_DIR = None - - ROOT_DIRS = check_setting_str(CFG, 'General', 'root_dirs', '') - if not re.match(r'\d+\|[^|]+(?:\|[^|]+)*', ROOT_DIRS): - ROOT_DIRS = '' - - proxies = urllib.getproxies() - proxy_url = None # @UnusedVariable - if 'http' in proxies: - proxy_url = proxies['http'] # @UnusedVariable - elif 'ftp' in proxies: - proxy_url = proxies['ftp'] # @UnusedVariable - - # Set our common tvdb_api options here - TVDB_API_PARMS = {'apikey': TVDB_API_KEY, - 'language': 'en', - 'useZip': True} - - if CACHE_DIR: - TVDB_API_PARMS['cache'] = os.path.join(CACHE_DIR, 'tvdb') - - TVDB_BASE_URL = 'http://thetvdb.com/api/' + TVDB_API_KEY - - QUALITY_DEFAULT = check_setting_int(CFG, 'General', 'quality_default', SD) - STATUS_DEFAULT = check_setting_int(CFG, 'General', 'status_default', SKIPPED) - AUDIO_SHOW_DEFAULT = check_setting_str(CFG, 'General', 'audio_show_default', 'fr' ) - VERSION_NOTIFY = check_setting_int(CFG, 'General', 'version_notify', 1) - FLATTEN_FOLDERS_DEFAULT = bool(check_setting_int(CFG, 'General', 'flatten_folders_default', 0)) - - PROVIDER_ORDER = check_setting_str(CFG, 'General', 'provider_order', '').split() - - NAMING_PATTERN = check_setting_str(CFG, 'General', 'naming_pattern', '') - NAMING_ABD_PATTERN = check_setting_str(CFG, 'General', 'naming_abd_pattern', '') - NAMING_CUSTOM_ABD = check_setting_int(CFG, 'General', 'naming_custom_abd', 0) - NAMING_MULTI_EP = check_setting_int(CFG, 'General', 'naming_multi_ep', 1) - NAMING_FORCE_FOLDERS = naming.check_force_season_folders() - - USE_NZBS = bool(check_setting_int(CFG, 'General', 'use_nzbs', 1)) - USE_TORRENTS = bool(check_setting_int(CFG, 'General', 'use_torrents', 0)) - - NZB_METHOD = check_setting_str(CFG, 'General', 'nzb_method', 'blackhole') - if NZB_METHOD not in ('blackhole', 'sabnzbd', 'nzbget'): - NZB_METHOD = 'blackhole' - - TORRENT_METHOD = check_setting_str(CFG, 'General', 'torrent_method', 'blackhole') - if TORRENT_METHOD not in ('blackhole', 'utorrent', 'transmission', 'deluge', 'download_station'): - TORRENT_METHOD = 'blackhole' - - PREFERED_METHOD = check_setting_str(CFG, 'General', 'prefered_method', 'nzb') - if PREFERED_METHOD not in ('torrent', 'nzb'): - PREFERED_METHOD = 'nzb' - - DOWNLOAD_PROPERS = bool(check_setting_int(CFG, 'General', 'download_propers', 1)) - USENET_RETENTION = check_setting_int(CFG, 'General', 'usenet_retention', 500) - SEARCH_FREQUENCY = check_setting_int(CFG, 'General', 'search_frequency', DEFAULT_SEARCH_FREQUENCY) - if SEARCH_FREQUENCY < MIN_SEARCH_FREQUENCY: - SEARCH_FREQUENCY = MIN_SEARCH_FREQUENCY - - TORRENT_DIR = check_setting_str(CFG, 'Blackhole', 'torrent_dir', '') - - TV_DOWNLOAD_DIR = check_setting_str(CFG, 'General', 'tv_download_dir', '') - TORRENT_DOWNLOAD_DIR = check_setting_str(CFG, 'General', 'torrent_download_dir', '') - PROCESS_AUTOMATICALLY = check_setting_int(CFG, 'General', 'process_automatically', 0) - PROCESS_AUTOMATICALLY_TORRENT = check_setting_int(CFG, 'General', 'process_automatically_torrent', 0) - RENAME_EPISODES = check_setting_int(CFG, 'General', 'rename_episodes', 1) - KEEP_PROCESSED_DIR = check_setting_int(CFG, 'General', 'keep_processed_dir', 1) - MOVE_ASSOCIATED_FILES = check_setting_int(CFG, 'General', 'move_associated_files', 0) - CREATE_MISSING_SHOW_DIRS = check_setting_int(CFG, 'General', 'create_missing_show_dirs', 0) - ADD_SHOWS_WO_DIR = check_setting_int(CFG, 'General', 'add_shows_wo_dir', 0) - - EZRSS = bool(check_setting_int(CFG, 'General', 'use_torrent', 0)) - if not EZRSS: - CheckSection(CFG, 'EZRSS') - EZRSS = bool(check_setting_int(CFG, 'EZRSS', 'ezrss', 0)) - - GIT_PATH = check_setting_str(CFG, 'General', 'git_path', '') - IGNORE_WORDS = check_setting_str(CFG, 'General', 'ignore_words', '') - EXTRA_SCRIPTS = [x for x in check_setting_str(CFG, 'General', 'extra_scripts', '').split('|') if x] - - USE_BANNER = bool(check_setting_int(CFG, 'General', 'use_banner', 0)) - USE_LISTVIEW = bool(check_setting_int(CFG, 'General', 'use_listview', 0)) - METADATA_TYPE = check_setting_str(CFG, 'General', 'metadata_type', '') - - metadata_provider_dict = metadata.get_metadata_generator_dict() - - # if this exists it's legacy, use the info to upgrade metadata to the new settings - if METADATA_TYPE: - - old_metadata_class = None - - if METADATA_TYPE == 'xbmc': - old_metadata_class = metadata.xbmc.metadata_class - elif METADATA_TYPE == 'xbmcfrodo': - old_metadata_class = metadata.xbmcfrodo.metadata_class - elif METADATA_TYPE == 'mediabrowser': - old_metadata_class = metadata.mediabrowser.metadata_class - elif METADATA_TYPE == 'ps3': - old_metadata_class = metadata.ps3.metadata_class - - if old_metadata_class: - - METADATA_SHOW = bool(check_setting_int(CFG, 'General', 'metadata_show', 1)) - METADATA_EPISODE = bool(check_setting_int(CFG, 'General', 'metadata_episode', 1)) - - ART_POSTER = bool(check_setting_int(CFG, 'General', 'art_poster', 1)) - ART_FANART = bool(check_setting_int(CFG, 'General', 'art_fanart', 1)) - ART_THUMBNAILS = bool(check_setting_int(CFG, 'General', 'art_thumbnails', 1)) - ART_SEASON_THUMBNAILS = bool(check_setting_int(CFG, 'General', 'art_season_thumbnails', 1)) - - new_metadata_class = old_metadata_class(METADATA_SHOW, - METADATA_EPISODE, - ART_POSTER, - ART_FANART, - ART_THUMBNAILS, - ART_SEASON_THUMBNAILS) - - metadata_provider_dict[new_metadata_class.name] = new_metadata_class - - # this is the normal codepath for metadata config - else: - METADATA_XBMC = check_setting_str(CFG, 'General', 'metadata_xbmc', '0|0|0|0|0|0') - METADATA_XBMCFRODO = check_setting_str(CFG, 'General', 'metadata_xbmcfrodo', '0|0|0|0|0|0') - METADATA_MEDIABROWSER = check_setting_str(CFG, 'General', 'metadata_mediabrowser', '0|0|0|0|0|0') - METADATA_PS3 = check_setting_str(CFG, 'General', 'metadata_ps3', '0|0|0|0|0|0') - METADATA_WDTV = check_setting_str(CFG, 'General', 'metadata_wdtv', '0|0|0|0|0|0') - METADATA_TIVO = check_setting_str(CFG, 'General', 'metadata_tivo', '0|0|0|0|0|0') - METADATA_SYNOLOGY = check_setting_str(CFG, 'General', 'metadata_synology', '0|0|0|0|0|0') - - for cur_metadata_tuple in [(METADATA_XBMC, metadata.xbmc), - (METADATA_XBMCFRODO, metadata.xbmcfrodo), - (METADATA_MEDIABROWSER, metadata.mediabrowser), - (METADATA_PS3, metadata.ps3), - (METADATA_WDTV, metadata.wdtv), - (METADATA_TIVO, metadata.tivo), - (METADATA_SYNOLOGY, metadata.synology), - ]: - - (cur_metadata_config, cur_metadata_class) = cur_metadata_tuple - tmp_provider = cur_metadata_class.metadata_class() - tmp_provider.set_config(cur_metadata_config) - metadata_provider_dict[tmp_provider.name] = tmp_provider - - CheckSection(CFG, 'GUI') - COMING_EPS_LAYOUT = check_setting_str(CFG, 'GUI', 'coming_eps_layout', 'banner') - COMING_EPS_DISPLAY_PAUSED = bool(check_setting_int(CFG, 'GUI', 'coming_eps_display_paused', 0)) - COMING_EPS_SORT = check_setting_str(CFG, 'GUI', 'coming_eps_sort', 'date') - - CheckSection(CFG, 'Newznab') - newznabData = check_setting_str(CFG, 'Newznab', 'newznab_data', '') - newznabProviderList = providers.getNewznabProviderList(newznabData) - providerList = providers.makeProviderList() - - CheckSection(CFG, 'Blackhole') - NZB_DIR = check_setting_str(CFG, 'Blackhole', 'nzb_dir', '') - TORRENT_DIR = check_setting_str(CFG, 'Blackhole', 'torrent_dir', '') - - CheckSection(CFG, 'TVTORRENTS') - TVTORRENTS = bool(check_setting_int(CFG, 'TVTORRENTS', 'tvtorrents', 0)) - TVTORRENTS_DIGEST = check_setting_str(CFG, 'TVTORRENTS', 'tvtorrents_digest', '') - TVTORRENTS_HASH = check_setting_str(CFG, 'TVTORRENTS', 'tvtorrents_hash', '') - - CheckSection(CFG, 'BTN') - BTN = bool(check_setting_int(CFG, 'BTN', 'btn', 0)) - BTN_API_KEY = check_setting_str(CFG, 'BTN', 'btn_api_key', '') - - CheckSection(CFG, 'TorrentLeech') - TORRENTLEECH = bool(check_setting_int(CFG, 'TorrentLeech', 'torrentleech', 0)) - TORRENTLEECH_KEY = check_setting_str(CFG, 'TorrentLeech', 'torrentleech_key', '') - - CheckSection(CFG, 'NZBs') - NZBS = bool(check_setting_int(CFG, 'NZBs', 'nzbs', 0)) - NZBS_UID = check_setting_str(CFG, 'NZBs', 'nzbs_uid', '') - NZBS_HASH = check_setting_str(CFG, 'NZBs', 'nzbs_hash', '') - - CheckSection(CFG, 'NZBsRUS') - NZBSRUS = bool(check_setting_int(CFG, 'NZBsRUS', 'nzbsrus', 0)) - NZBSRUS_UID = check_setting_str(CFG, 'NZBsRUS', 'nzbsrus_uid', '') - NZBSRUS_HASH = check_setting_str(CFG, 'NZBsRUS', 'nzbsrus_hash', '') - - CheckSection(CFG, 'NZBMatrix') - NZBMATRIX = bool(check_setting_int(CFG, 'NZBMatrix', 'nzbmatrix', 0)) - NZBMATRIX_USERNAME = check_setting_str(CFG, 'NZBMatrix', 'nzbmatrix_username', '') - NZBMATRIX_APIKEY = check_setting_str(CFG, 'NZBMatrix', 'nzbmatrix_apikey', '') - - CheckSection(CFG, 'BinNewz') - BINNEWZ = bool(check_setting_int(CFG, 'BinNewz', 'binnewz', 0)) - - CheckSection(CFG, 'T411') - T411 = bool(check_setting_int(CFG, 'T411', 't411', 0)) - T411_USERNAME = check_setting_str(CFG, 'T411', 'username', '') - T411_PASSWORD = check_setting_str(CFG, 'T411', 'password', '') - - CheckSection(CFG, 'PirateBay') - THEPIRATEBAY = bool(check_setting_int(CFG, 'PirateBay', 'piratebay', 0)) - THEPIRATEBAY_PROXY = bool(check_setting_int(CFG, 'PirateBay', 'piratebay_proxy', 0)) - THEPIRATEBAY_PROXY_URL = check_setting_str(CFG, 'PirateBay', 'piratebay_proxy_url', '') - THEPIRATEBAY_TRUSTED = bool(check_setting_int(CFG, 'PirateBay', 'piratebay_trusted', 0)) - - CheckSection(CFG, 'Cpasbien') - Cpasbien = bool(check_setting_int(CFG, 'Cpasbien', 'cpasbien', 0)) - - CheckSection(CFG, 'kat') - kat = bool(check_setting_int(CFG, 'kat', 'kat', 0)) - - CheckSection(CFG, 'Newzbin') - NEWZBIN = bool(check_setting_int(CFG, 'Newzbin', 'newzbin', 0)) - NEWZBIN_USERNAME = check_setting_str(CFG, 'Newzbin', 'newzbin_username', '') - NEWZBIN_PASSWORD = check_setting_str(CFG, 'Newzbin', 'newzbin_password', '') - - CheckSection(CFG, 'Womble') - WOMBLE = bool(check_setting_int(CFG, 'Womble', 'womble', 1)) - - CheckSection(CFG, 'nzbX') - NZBX = bool(check_setting_int(CFG, 'nzbX', 'nzbx', 0)) - NZBX_COMPLETION = check_setting_int(CFG, 'nzbX', 'nzbx_completion', 100) - - CheckSection(CFG, 'omgwtfnzbs') - OMGWTFNZBS = bool(check_setting_int(CFG, 'omgwtfnzbs', 'omgwtfnzbs', 0)) - OMGWTFNZBS_UID = check_setting_str(CFG, 'omgwtfnzbs', 'omgwtfnzbs_uid', '') - OMGWTFNZBS_KEY = check_setting_str(CFG, 'omgwtfnzbs', 'omgwtfnzbs_key', '') - - CheckSection(CFG, 'GKS') - GKS = bool(check_setting_int(CFG, 'GKS', 'gks', 0)) - GKS_KEY = check_setting_str(CFG, 'GKS', 'gks_key', '') - - CheckSection(CFG, 'SABnzbd') - SAB_USERNAME = check_setting_str(CFG, 'SABnzbd', 'sab_username', '') - SAB_PASSWORD = check_setting_str(CFG, 'SABnzbd', 'sab_password', '') - SAB_APIKEY = check_setting_str(CFG, 'SABnzbd', 'sab_apikey', '') - SAB_CATEGORY = check_setting_str(CFG, 'SABnzbd', 'sab_category', 'tv') - SAB_HOST = check_setting_str(CFG, 'SABnzbd', 'sab_host', '') - - CheckSection(CFG, 'NZBget') - NZBGET_PASSWORD = check_setting_str(CFG, 'NZBget', 'nzbget_password', 'tegbzn6789') - NZBGET_CATEGORY = check_setting_str(CFG, 'NZBget', 'nzbget_category', 'tv') - NZBGET_HOST = check_setting_str(CFG, 'NZBget', 'nzbget_host', '') - - CheckSection(CFG, 'XBMC') - TORRENT_USERNAME = check_setting_str(CFG, 'TORRENT', 'torrent_username', '') - TORRENT_PASSWORD = check_setting_str(CFG, 'TORRENT', 'torrent_password', '') - TORRENT_HOST = check_setting_str(CFG, 'TORRENT', 'torrent_host', '') - TORRENT_PATH = check_setting_str(CFG, 'TORRENT', 'torrent_path', '') - TORRENT_RATIO = check_setting_str(CFG, 'TORRENT', 'torrent_ratio', '') - TORRENT_PAUSED = bool(check_setting_int(CFG, 'TORRENT', 'torrent_paused', 0)) - TORRENT_LABEL = check_setting_str(CFG, 'TORRENT', 'torrent_label', '') - - USE_XBMC = bool(check_setting_int(CFG, 'XBMC', 'use_xbmc', 0)) - XBMC_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'XBMC', 'xbmc_notify_onsnatch', 0)) - XBMC_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'XBMC', 'xbmc_notify_ondownload', 0)) - XBMC_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'XBMC', 'xbmc_notify_onsubtitledownload', 0)) - XBMC_UPDATE_LIBRARY = bool(check_setting_int(CFG, 'XBMC', 'xbmc_update_library', 0)) - XBMC_UPDATE_FULL = bool(check_setting_int(CFG, 'XBMC', 'xbmc_update_full', 0)) - XBMC_UPDATE_ONLYFIRST = bool(check_setting_int(CFG, 'XBMC', 'xbmc_update_onlyfirst', 0)) - XBMC_HOST = check_setting_str(CFG, 'XBMC', 'xbmc_host', '') - XBMC_USERNAME = check_setting_str(CFG, 'XBMC', 'xbmc_username', '') - XBMC_PASSWORD = check_setting_str(CFG, 'XBMC', 'xbmc_password', '') - - CheckSection(CFG, 'Plex') - USE_PLEX = bool(check_setting_int(CFG, 'Plex', 'use_plex', 0)) - PLEX_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Plex', 'plex_notify_onsnatch', 0)) - PLEX_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Plex', 'plex_notify_ondownload', 0)) - PLEX_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Plex', 'plex_notify_onsubtitledownload', 0)) - PLEX_UPDATE_LIBRARY = bool(check_setting_int(CFG, 'Plex', 'plex_update_library', 0)) - PLEX_SERVER_HOST = check_setting_str(CFG, 'Plex', 'plex_server_host', '') - PLEX_HOST = check_setting_str(CFG, 'Plex', 'plex_host', '') - PLEX_USERNAME = check_setting_str(CFG, 'Plex', 'plex_username', '') - PLEX_PASSWORD = check_setting_str(CFG, 'Plex', 'plex_password', '') - - CheckSection(CFG, 'Growl') - USE_GROWL = bool(check_setting_int(CFG, 'Growl', 'use_growl', 0)) - GROWL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Growl', 'growl_notify_onsnatch', 0)) - GROWL_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Growl', 'growl_notify_ondownload', 0)) - GROWL_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Growl', 'growl_notify_onsubtitledownload', 0)) - GROWL_HOST = check_setting_str(CFG, 'Growl', 'growl_host', '') - GROWL_PASSWORD = check_setting_str(CFG, 'Growl', 'growl_password', '') - - CheckSection(CFG, 'Prowl') - USE_PROWL = bool(check_setting_int(CFG, 'Prowl', 'use_prowl', 0)) - PROWL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Prowl', 'prowl_notify_onsnatch', 0)) - PROWL_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Prowl', 'prowl_notify_ondownload', 0)) - PROWL_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Prowl', 'prowl_notify_onsubtitledownload', 0)) - PROWL_API = check_setting_str(CFG, 'Prowl', 'prowl_api', '') - PROWL_PRIORITY = check_setting_str(CFG, 'Prowl', 'prowl_priority', "0") - - CheckSection(CFG, 'Twitter') - USE_TWITTER = bool(check_setting_int(CFG, 'Twitter', 'use_twitter', 0)) - TWITTER_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Twitter', 'twitter_notify_onsnatch', 0)) - TWITTER_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Twitter', 'twitter_notify_ondownload', 0)) - TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Twitter', 'twitter_notify_onsubtitledownload', 0)) - TWITTER_USERNAME = check_setting_str(CFG, 'Twitter', 'twitter_username', '') - TWITTER_PASSWORD = check_setting_str(CFG, 'Twitter', 'twitter_password', '') - TWITTER_PREFIX = check_setting_str(CFG, 'Twitter', 'twitter_prefix', 'Sick Beard') - - CheckSection(CFG, 'Notifo') - USE_NOTIFO = bool(check_setting_int(CFG, 'Notifo', 'use_notifo', 0)) - NOTIFO_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Notifo', 'notifo_notify_onsnatch', 0)) - NOTIFO_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Notifo', 'notifo_notify_ondownload', 0)) - NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Notifo', 'notifo_notify_onsubtitledownload', 0)) - NOTIFO_USERNAME = check_setting_str(CFG, 'Notifo', 'notifo_username', '') - NOTIFO_APISECRET = check_setting_str(CFG, 'Notifo', 'notifo_apisecret', '') - - CheckSection(CFG, 'Boxcar') - USE_BOXCAR = bool(check_setting_int(CFG, 'Boxcar', 'use_boxcar', 0)) - BOXCAR_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Boxcar', 'boxcar_notify_onsnatch', 0)) - BOXCAR_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Boxcar', 'boxcar_notify_ondownload', 0)) - BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Boxcar', 'boxcar_notify_onsubtitledownload', 0)) - BOXCAR_USERNAME = check_setting_str(CFG, 'Boxcar', 'boxcar_username', '') - - CheckSection(CFG, 'Pushover') - USE_PUSHOVER = bool(check_setting_int(CFG, 'Pushover', 'use_pushover', 0)) - PUSHOVER_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Pushover', 'pushover_notify_onsnatch', 0)) - PUSHOVER_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Pushover', 'pushover_notify_ondownload', 0)) - PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Pushover', 'pushover_notify_onsubtitledownload', 0)) - PUSHOVER_USERKEY = check_setting_str(CFG, 'Pushover', 'pushover_userkey', '') - - CheckSection(CFG, 'Libnotify') - USE_LIBNOTIFY = bool(check_setting_int(CFG, 'Libnotify', 'use_libnotify', 0)) - LIBNOTIFY_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Libnotify', 'libnotify_notify_onsnatch', 0)) - LIBNOTIFY_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Libnotify', 'libnotify_notify_ondownload', 0)) - LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Libnotify', 'libnotify_notify_onsubtitledownload', 0)) - - CheckSection(CFG, 'NMJ') - USE_NMJ = bool(check_setting_int(CFG, 'NMJ', 'use_nmj', 0)) - NMJ_HOST = check_setting_str(CFG, 'NMJ', 'nmj_host', '') - NMJ_DATABASE = check_setting_str(CFG, 'NMJ', 'nmj_database', '') - NMJ_MOUNT = check_setting_str(CFG, 'NMJ', 'nmj_mount', '') - - CheckSection(CFG, 'NMJv2') - USE_NMJv2 = bool(check_setting_int(CFG, 'NMJv2', 'use_nmjv2', 0)) - NMJv2_HOST = check_setting_str(CFG, 'NMJv2', 'nmjv2_host', '') - NMJv2_DATABASE = check_setting_str(CFG, 'NMJv2', 'nmjv2_database', '') - NMJ_DBLOC = check_setting_str(CFG, 'NMJv2', 'nmjv2_dbloc', '') - - CheckSection(CFG, 'Synology') - USE_SYNOINDEX = bool(check_setting_int(CFG, 'Synology', 'use_synoindex', 0)) - - CheckSection(CFG, 'Trakt') - USE_TRAKT = bool(check_setting_int(CFG, 'Trakt', 'use_trakt', 0)) - TRAKT_USERNAME = check_setting_str(CFG, 'Trakt', 'trakt_username', '') - TRAKT_PASSWORD = check_setting_str(CFG, 'Trakt', 'trakt_password', '') - TRAKT_API = check_setting_str(CFG, 'Trakt', 'trakt_api', '') - TRAKT_REMOVE_WATCHLIST = bool(check_setting_int(CFG, 'Trakt', 'trakt_remove_watchlist', 0)) - TRAKT_USE_WATCHLIST = bool(check_setting_int(CFG, 'Trakt', 'trakt_use_watchlist', 0)) - TRAKT_METHOD_ADD = check_setting_str(CFG, 'Trakt', 'trakt_method_add', "0") - TRAKT_START_PAUSED = bool(check_setting_int(CFG, 'Trakt', 'trakt_start_paused', 0)) - - CheckSection(CFG, 'pyTivo') - USE_PYTIVO = bool(check_setting_int(CFG, 'pyTivo', 'use_pytivo', 0)) - PYTIVO_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'pyTivo', 'pytivo_notify_onsnatch', 0)) - PYTIVO_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'pyTivo', 'pytivo_notify_ondownload', 0)) - PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'pyTivo', 'pytivo_notify_onsubtitledownload', 0)) - PYTIVO_UPDATE_LIBRARY = bool(check_setting_int(CFG, 'pyTivo', 'pyTivo_update_library', 0)) - PYTIVO_HOST = check_setting_str(CFG, 'pyTivo', 'pytivo_host', '') - PYTIVO_SHARE_NAME = check_setting_str(CFG, 'pyTivo', 'pytivo_share_name', '') - PYTIVO_TIVO_NAME = check_setting_str(CFG, 'pyTivo', 'pytivo_tivo_name', '') - - CheckSection(CFG, 'NMA') - USE_NMA = bool(check_setting_int(CFG, 'NMA', 'use_nma', 0)) - NMA_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'NMA', 'nma_notify_onsnatch', 0)) - NMA_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'NMA', 'nma_notify_ondownload', 0)) - NMA_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'NMA', 'nma_notify_onsubtitledownload', 0)) - NMA_API = check_setting_str(CFG, 'NMA', 'nma_api', '') - NMA_PRIORITY = check_setting_str(CFG, 'NMA', 'nma_priority', "0") - - CheckSection(CFG, 'Mail') - USE_MAIL = bool(check_setting_int(CFG, 'Mail', 'use_mail', 0)) - MAIL_USERNAME = check_setting_str(CFG, 'Mail', 'mail_username', '') - MAIL_PASSWORD = check_setting_str(CFG, 'Mail', 'mail_password', '') - MAIL_SERVER = check_setting_str(CFG, 'Mail', 'mail_server', '') - MAIL_SSL = bool(check_setting_int(CFG, 'Mail', 'mail_ssl', 0)) - MAIL_FROM = check_setting_str(CFG, 'Mail', 'mail_from', '') - MAIL_TO = check_setting_str(CFG, 'Mail', 'mail_to', '') - MAIL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Mail', 'mail_notify_onsnatch', 0)) - - - USE_SUBTITLES = bool(check_setting_int(CFG, 'Subtitles', 'use_subtitles', 0)) - SUBTITLES_LANGUAGES = check_setting_str(CFG, 'Subtitles', 'subtitles_languages', '').split(',') - if SUBTITLES_LANGUAGES[0] == '': - SUBTITLES_LANGUAGES = [] - SUBTITLES_DIR = check_setting_str(CFG, 'Subtitles', 'subtitles_dir', '') - SUBTITLES_DIR_SUB = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_dir_sub', 0)) - SUBSNOLANG = bool(check_setting_int(CFG, 'Subtitles', 'subsnolang', 0)) - SUBTITLES_SERVICES_LIST = check_setting_str(CFG, 'Subtitles', 'SUBTITLES_SERVICES_LIST', '').split(',') - SUBTITLES_SERVICES_ENABLED = [int(x) for x in check_setting_str(CFG, 'Subtitles', 'SUBTITLES_SERVICES_ENABLED', '').split('|') if x] - SUBTITLES_DEFAULT = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_default', 0)) - SUBTITLES_HISTORY = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_history', 0)) - # start up all the threads - logger.sb_log_instance.initLogging(consoleLogging=consoleLogging) - - # initialize the main SB database - db.upgradeDatabase(db.DBConnection(), mainDB.InitialSchema) - - # initialize the cache database - db.upgradeDatabase(db.DBConnection("cache.db"), cache_db.InitialSchema) - - # fix up any db problems - db.sanityCheckDatabase(db.DBConnection(), mainDB.MainSanityCheck) - - # migrate the config if it needs it - migrator = ConfigMigrator(CFG) - migrator.migrate_config() - - currentSearchScheduler = scheduler.Scheduler(searchCurrent.CurrentSearcher(), - cycleTime=datetime.timedelta(minutes=SEARCH_FREQUENCY), - threadName="SEARCH", - runImmediately=True) - - # the interval for this is stored inside the ShowUpdater class - showUpdaterInstance = showUpdater.ShowUpdater() - showUpdateScheduler = scheduler.Scheduler(showUpdaterInstance, - cycleTime=showUpdaterInstance.updateInterval, - threadName="SHOWUPDATER", - runImmediately=False) - - versionCheckScheduler = scheduler.Scheduler(versionChecker.CheckVersion(), - cycleTime=datetime.timedelta(hours=12), - threadName="CHECKVERSION", - runImmediately=True) - - showQueueScheduler = scheduler.Scheduler(show_queue.ShowQueue(), - cycleTime=datetime.timedelta(seconds=3), - threadName="SHOWQUEUE", - silent=True) - - searchQueueScheduler = scheduler.Scheduler(search_queue.SearchQueue(), - cycleTime=datetime.timedelta(seconds=3), - threadName="SEARCHQUEUE", - silent=True) - - properFinderInstance = properFinder.ProperFinder() - properFinderScheduler = scheduler.Scheduler(properFinderInstance, - cycleTime=properFinderInstance.updateInterval, - threadName="FINDPROPERS", - runImmediately=False) - - if PROCESS_AUTOMATICALLY: - autoPostProcesserScheduler = scheduler.Scheduler(autoPostProcesser.PostProcesser( TV_DOWNLOAD_DIR ), - cycleTime=datetime.timedelta(minutes=10), - threadName="NZB_POSTPROCESSER", - runImmediately=True) - - if PROCESS_AUTOMATICALLY_TORRENT: - autoTorrentPostProcesserScheduler = scheduler.Scheduler(autoPostProcesser.PostProcesser( TORRENT_DOWNLOAD_DIR ), - cycleTime=datetime.timedelta(minutes=10), - threadName="TORRENT_POSTPROCESSER", - runImmediately=True) - - traktWatchListCheckerSchedular = scheduler.Scheduler(traktWatchListChecker.TraktChecker(), - cycleTime=datetime.timedelta(minutes=10), - threadName="TRAKTWATCHLIST", - runImmediately=True) - - backlogSearchScheduler = searchBacklog.BacklogSearchScheduler(searchBacklog.BacklogSearcher(), - cycleTime=datetime.timedelta(minutes=get_backlog_cycle_time()), - threadName="BACKLOG", - runImmediately=True) - backlogSearchScheduler.action.cycleTime = BACKLOG_SEARCH_FREQUENCY - - - subtitlesFinderScheduler = scheduler.Scheduler(subtitles.SubtitlesFinder(), - cycleTime=datetime.timedelta(hours=1), - threadName="FINDSUBTITLES", - runImmediately=True) - - showList = [] - loadingShowList = {} - - __INITIALIZED__ = True - return True - - -def start(): - - global __INITIALIZED__, currentSearchScheduler, backlogSearchScheduler, \ - showUpdateScheduler, versionCheckScheduler, showQueueScheduler, \ - properFinderScheduler, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, searchQueueScheduler, \ - subtitlesFinderScheduler, started, USE_SUBTITLES, \ - traktWatchListCheckerSchedular, started - - with INIT_LOCK: - - if __INITIALIZED__: - - # start the search scheduler - currentSearchScheduler.thread.start() - - # start the backlog scheduler - backlogSearchScheduler.thread.start() - - # start the show updater - showUpdateScheduler.thread.start() - - # start the version checker - versionCheckScheduler.thread.start() - - # start the queue checker - showQueueScheduler.thread.start() - - # start the search queue checker - searchQueueScheduler.thread.start() - - # start the queue checker - properFinderScheduler.thread.start() - - if autoPostProcesserScheduler: - autoPostProcesserScheduler.thread.start() - - if autoTorrentPostProcesserScheduler: - autoTorrentPostProcesserScheduler.thread.start() - - # start the subtitles finder - if USE_SUBTITLES: - subtitlesFinderScheduler.thread.start() - - # start the trakt watchlist - traktWatchListCheckerSchedular.thread.start() - - started = True - -def halt(): - - global __INITIALIZED__, currentSearchScheduler, backlogSearchScheduler, showUpdateScheduler, \ - showQueueScheduler, properFinderScheduler, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, searchQueueScheduler, \ - subtitlesFinderScheduler, started, \ - traktWatchListCheckerSchedular - - with INIT_LOCK: - - if __INITIALIZED__: - - logger.log(u"Aborting all threads") - - # abort all the threads - - currentSearchScheduler.abort = True - logger.log(u"Waiting for the SEARCH thread to exit") - try: - currentSearchScheduler.thread.join(10) - except: - pass - - backlogSearchScheduler.abort = True - logger.log(u"Waiting for the BACKLOG thread to exit") - try: - backlogSearchScheduler.thread.join(10) - except: - pass - - showUpdateScheduler.abort = True - logger.log(u"Waiting for the SHOWUPDATER thread to exit") - try: - showUpdateScheduler.thread.join(10) - except: - pass - - versionCheckScheduler.abort = True - logger.log(u"Waiting for the VERSIONCHECKER thread to exit") - try: - versionCheckScheduler.thread.join(10) - except: - pass - - showQueueScheduler.abort = True - logger.log(u"Waiting for the SHOWQUEUE thread to exit") - try: - showQueueScheduler.thread.join(10) - except: - pass - - searchQueueScheduler.abort = True - logger.log(u"Waiting for the SEARCHQUEUE thread to exit") - try: - searchQueueScheduler.thread.join(10) - except: - pass - - if autoPostProcesserScheduler: - autoPostProcesserScheduler.abort = True - logger.log(u"Waiting for the NZB_POSTPROCESSER thread to exit") - try: - autoPostProcesserScheduler.thread.join(10) - except: - pass - - if autoTorrentPostProcesserScheduler: - autoTorrentPostProcesserScheduler.abort = True - logger.log(u"Waiting for the TORRENT_POSTPROCESSER thread to exit") - try: - autoTorrentPostProcesserScheduler.thread.join(10) - except: - pass - traktWatchListCheckerSchedular.abort = True - logger.log(u"Waiting for the TRAKTWATCHLIST thread to exit") - try: - traktWatchListCheckerSchedular.thread.join(10) - except: - pass - - properFinderScheduler.abort = True - logger.log(u"Waiting for the PROPERFINDER thread to exit") - try: - properFinderScheduler.thread.join(10) - except: - pass - - subtitlesFinderScheduler.abort = True - logger.log(u"Waiting for the SUBTITLESFINDER thread to exit") - try: - subtitlesFinderScheduler.thread.join(10) - except: - pass - - - __INITIALIZED__ = False - - -def sig_handler(signum=None, frame=None): - if type(signum) != type(None): - logger.log(u"Signal %i caught, saving and exiting..." % int(signum)) - saveAndShutdown() - - -def saveAll(): - - global showList - - # write all shows - logger.log(u"Saving all shows to the database") - for show in showList: - show.saveToDB() - - # save config - logger.log(u"Saving config file to disk") - save_config() - - -def saveAndShutdown(restart=False): - - halt() - - saveAll() - - logger.log(u"Killing cherrypy") - cherrypy.engine.exit() - - if CREATEPID: - logger.log(u"Removing pidfile " + str(PIDFILE)) - os.remove(PIDFILE) - - if restart: - install_type = versionCheckScheduler.action.install_type - - popen_list = [] - - if install_type in ('git', 'source'): - popen_list = [sys.executable, MY_FULLNAME] - elif install_type == 'win': - if hasattr(sys, 'frozen'): - # c:\dir\to\updater.exe 12345 c:\dir\to\sickbeard.exe - popen_list = [os.path.join(PROG_DIR, 'updater.exe'), str(PID), sys.executable] - else: - logger.log(u"Unknown SB launch method, please file a bug report about this", logger.ERROR) - popen_list = [sys.executable, os.path.join(PROG_DIR, 'updater.py'), str(PID), sys.executable, MY_FULLNAME ] - - if popen_list: - popen_list += MY_ARGS - if '--nolaunch' not in popen_list: - popen_list += ['--nolaunch'] - logger.log(u"Restarting Sick Beard with " + str(popen_list)) - subprocess.Popen(popen_list, cwd=os.getcwd()) - - os._exit(0) - - -def invoke_command(to_call, *args, **kwargs): - global invoked_command - - def delegate(): - to_call(*args, **kwargs) - invoked_command = delegate - logger.log(u"Placed invoked command: " + repr(invoked_command) + " for " + repr(to_call) + " with " + repr(args) + " and " + repr(kwargs), logger.DEBUG) - - -def invoke_restart(soft=True): - invoke_command(restart, soft=soft) - - -def invoke_shutdown(): - invoke_command(saveAndShutdown) - - -def restart(soft=True): - if soft: - halt() - saveAll() - #logger.log(u"Restarting cherrypy") - #cherrypy.engine.restart() - logger.log(u"Re-initializing all data") - initialize() - - else: - saveAndShutdown(restart=True) - - -def save_config(): - - new_config = ConfigObj() - new_config.filename = CONFIG_FILE - - new_config['General'] = {} - new_config['General']['log_dir'] = LOG_DIR - new_config['General']['web_port'] = WEB_PORT - new_config['General']['web_host'] = WEB_HOST - new_config['General']['web_ipv6'] = int(WEB_IPV6) - new_config['General']['web_log'] = int(WEB_LOG) - new_config['General']['web_root'] = WEB_ROOT - new_config['General']['web_username'] = WEB_USERNAME - new_config['General']['web_password'] = WEB_PASSWORD - new_config['General']['use_api'] = int(USE_API) - new_config['General']['api_key'] = API_KEY - new_config['General']['enable_https'] = int(ENABLE_HTTPS) - new_config['General']['https_cert'] = HTTPS_CERT - new_config['General']['https_key'] = HTTPS_KEY - new_config['General']['use_nzbs'] = int(USE_NZBS) - new_config['General']['use_torrents'] = int(USE_TORRENTS) - new_config['General']['nzb_method'] = NZB_METHOD - new_config['General']['torrent_method'] = TORRENT_METHOD - new_config['General']['prefered_method'] = PREFERED_METHOD - new_config['General']['usenet_retention'] = int(USENET_RETENTION) - new_config['General']['search_frequency'] = int(SEARCH_FREQUENCY) - new_config['General']['download_propers'] = int(DOWNLOAD_PROPERS) - new_config['General']['quality_default'] = int(QUALITY_DEFAULT) - new_config['General']['status_default'] = int(STATUS_DEFAULT) - new_config['General']['audio_show_default'] = AUDIO_SHOW_DEFAULT - new_config['General']['flatten_folders_default'] = int(FLATTEN_FOLDERS_DEFAULT) - new_config['General']['provider_order'] = ' '.join([x.getID() for x in providers.sortedProviderList()]) - new_config['General']['version_notify'] = int(VERSION_NOTIFY) - new_config['General']['naming_pattern'] = NAMING_PATTERN - new_config['General']['naming_custom_abd'] = int(NAMING_CUSTOM_ABD) - new_config['General']['naming_abd_pattern'] = NAMING_ABD_PATTERN - new_config['General']['naming_multi_ep'] = int(NAMING_MULTI_EP) - new_config['General']['launch_browser'] = int(LAUNCH_BROWSER) - new_config['General']['display_posters'] = int(DISPLAY_POSTERS) - - new_config['General']['use_banner'] = int(USE_BANNER) - new_config['General']['use_listview'] = int(USE_LISTVIEW) - new_config['General']['metadata_xbmc'] = metadata_provider_dict['XBMC'].get_config() - new_config['General']['metadata_xbmcfrodo'] = metadata_provider_dict['XBMC (Frodo)'].get_config() - new_config['General']['metadata_mediabrowser'] = metadata_provider_dict['MediaBrowser'].get_config() - new_config['General']['metadata_ps3'] = metadata_provider_dict['Sony PS3'].get_config() - new_config['General']['metadata_wdtv'] = metadata_provider_dict['WDTV'].get_config() - new_config['General']['metadata_tivo'] = metadata_provider_dict['TIVO'].get_config() - new_config['General']['metadata_synology'] = metadata_provider_dict['Synology'].get_config() - - new_config['General']['cache_dir'] = ACTUAL_CACHE_DIR if ACTUAL_CACHE_DIR else 'cache' - new_config['General']['root_dirs'] = ROOT_DIRS if ROOT_DIRS else '' - new_config['General']['tv_download_dir'] = TV_DOWNLOAD_DIR - new_config['General']['torrent_download_dir'] = TORRENT_DOWNLOAD_DIR - new_config['General']['keep_processed_dir'] = int(KEEP_PROCESSED_DIR) - new_config['General']['move_associated_files'] = int(MOVE_ASSOCIATED_FILES) - new_config['General']['process_automatically'] = int(PROCESS_AUTOMATICALLY) - new_config['General']['process_automatically_torrent'] = int(PROCESS_AUTOMATICALLY_TORRENT) - new_config['General']['rename_episodes'] = int(RENAME_EPISODES) - new_config['General']['create_missing_show_dirs'] = CREATE_MISSING_SHOW_DIRS - new_config['General']['add_shows_wo_dir'] = ADD_SHOWS_WO_DIR - - new_config['General']['extra_scripts'] = '|'.join(EXTRA_SCRIPTS) - new_config['General']['git_path'] = GIT_PATH - new_config['General']['ignore_words'] = IGNORE_WORDS - - new_config['Blackhole'] = {} - new_config['Blackhole']['nzb_dir'] = NZB_DIR - new_config['Blackhole']['torrent_dir'] = TORRENT_DIR - - new_config['EZRSS'] = {} - new_config['EZRSS']['ezrss'] = int(EZRSS) - - new_config['TVTORRENTS'] = {} - new_config['TVTORRENTS']['tvtorrents'] = int(TVTORRENTS) - new_config['TVTORRENTS']['tvtorrents_digest'] = TVTORRENTS_DIGEST - new_config['TVTORRENTS']['tvtorrents_hash'] = TVTORRENTS_HASH - - new_config['BTN'] = {} - new_config['BTN']['btn'] = int(BTN) - new_config['BTN']['btn_api_key'] = BTN_API_KEY - - new_config['TorrentLeech'] = {} - new_config['TorrentLeech']['torrentleech'] = int(TORRENTLEECH) - new_config['TorrentLeech']['torrentleech_key'] = TORRENTLEECH_KEY - - new_config['NZBs'] = {} - new_config['NZBs']['nzbs'] = int(NZBS) - new_config['NZBs']['nzbs_uid'] = NZBS_UID - new_config['NZBs']['nzbs_hash'] = NZBS_HASH - - new_config['NZBsRUS'] = {} - new_config['NZBsRUS']['nzbsrus'] = int(NZBSRUS) - new_config['NZBsRUS']['nzbsrus_uid'] = NZBSRUS_UID - new_config['NZBsRUS']['nzbsrus_hash'] = NZBSRUS_HASH - - new_config['NZBMatrix'] = {} - new_config['NZBMatrix']['nzbmatrix'] = int(NZBMATRIX) - new_config['NZBMatrix']['nzbmatrix_username'] = NZBMATRIX_USERNAME - new_config['NZBMatrix']['nzbmatrix_apikey'] = NZBMATRIX_APIKEY - - new_config['Newzbin'] = {} - new_config['Newzbin']['newzbin'] = int(NEWZBIN) - new_config['Newzbin']['newzbin_username'] = NEWZBIN_USERNAME - new_config['Newzbin']['newzbin_password'] = NEWZBIN_PASSWORD - - new_config['BinNewz'] = {} - new_config['BinNewz']['binnewz'] = int(BINNEWZ) - - new_config['T411'] = {} - new_config['T411']['t411'] = int(T411) - new_config['T411']['username'] = T411_USERNAME - new_config['T411']['password'] = T411_PASSWORD - - new_config['Cpasbien'] = {} - new_config['Cpasbien']['cpasbien'] = int(Cpasbien) - - new_config['kat'] = {} - new_config['kat']['kat'] = int(kat) - - new_config['PirateBay'] = {} - new_config['PirateBay']['piratebay'] = int(THEPIRATEBAY) - new_config['PirateBay']['piratebay_proxy'] = THEPIRATEBAY_PROXY - new_config['PirateBay']['piratebay_proxy_url'] = THEPIRATEBAY_PROXY_URL - new_config['PirateBay']['piratebay_trusted'] = THEPIRATEBAY_TRUSTED - - new_config['Womble'] = {} - new_config['Womble']['womble'] = int(WOMBLE) - - new_config['nzbX'] = {} - new_config['nzbX']['nzbx'] = int(NZBX) - new_config['nzbX']['nzbx_completion'] = int(NZBX_COMPLETION) - - new_config['omgwtfnzbs'] = {} - new_config['omgwtfnzbs']['omgwtfnzbs'] = int(OMGWTFNZBS) - new_config['omgwtfnzbs']['omgwtfnzbs_uid'] = OMGWTFNZBS_UID - new_config['omgwtfnzbs']['omgwtfnzbs_key'] = OMGWTFNZBS_KEY - - new_config['GKS'] = {} - new_config['GKS']['gks'] = int(GKS) - new_config['GKS']['gks_key'] = GKS_KEY - - new_config['SABnzbd'] = {} - new_config['SABnzbd']['sab_username'] = SAB_USERNAME - new_config['SABnzbd']['sab_password'] = SAB_PASSWORD - new_config['SABnzbd']['sab_apikey'] = SAB_APIKEY - new_config['SABnzbd']['sab_category'] = SAB_CATEGORY - new_config['SABnzbd']['sab_host'] = SAB_HOST - - new_config['NZBget'] = {} - new_config['NZBget']['nzbget_password'] = NZBGET_PASSWORD - new_config['NZBget']['nzbget_category'] = NZBGET_CATEGORY - new_config['NZBget']['nzbget_host'] = NZBGET_HOST - - new_config['TORRENT'] = {} - new_config['TORRENT']['torrent_username'] = TORRENT_USERNAME - new_config['TORRENT']['torrent_password'] = TORRENT_PASSWORD - new_config['TORRENT']['torrent_host'] = TORRENT_HOST - new_config['TORRENT']['torrent_path'] = TORRENT_PATH - new_config['TORRENT']['torrent_ratio'] = TORRENT_RATIO - new_config['TORRENT']['torrent_paused'] = int(TORRENT_PAUSED) - new_config['TORRENT']['torrent_label'] = TORRENT_LABEL - - new_config['XBMC'] = {} - new_config['XBMC']['use_xbmc'] = int(USE_XBMC) - new_config['XBMC']['xbmc_notify_onsnatch'] = int(XBMC_NOTIFY_ONSNATCH) - new_config['XBMC']['xbmc_notify_ondownload'] = int(XBMC_NOTIFY_ONDOWNLOAD) - new_config['XBMC']['xbmc_notify_onsubtitledownload'] = int(XBMC_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['XBMC']['xbmc_update_library'] = int(XBMC_UPDATE_LIBRARY) - new_config['XBMC']['xbmc_update_full'] = int(XBMC_UPDATE_FULL) - new_config['XBMC']['xbmc_update_onlyfirst'] = int(XBMC_UPDATE_ONLYFIRST) - new_config['XBMC']['xbmc_host'] = XBMC_HOST - new_config['XBMC']['xbmc_username'] = XBMC_USERNAME - new_config['XBMC']['xbmc_password'] = XBMC_PASSWORD - - new_config['Plex'] = {} - new_config['Plex']['use_plex'] = int(USE_PLEX) - new_config['Plex']['plex_notify_onsnatch'] = int(PLEX_NOTIFY_ONSNATCH) - new_config['Plex']['plex_notify_ondownload'] = int(PLEX_NOTIFY_ONDOWNLOAD) - new_config['Plex']['plex_notify_onsubtitledownload'] = int(PLEX_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Plex']['plex_update_library'] = int(PLEX_UPDATE_LIBRARY) - new_config['Plex']['plex_server_host'] = PLEX_SERVER_HOST - new_config['Plex']['plex_host'] = PLEX_HOST - new_config['Plex']['plex_username'] = PLEX_USERNAME - new_config['Plex']['plex_password'] = PLEX_PASSWORD - - new_config['Growl'] = {} - new_config['Growl']['use_growl'] = int(USE_GROWL) - new_config['Growl']['growl_notify_onsnatch'] = int(GROWL_NOTIFY_ONSNATCH) - new_config['Growl']['growl_notify_ondownload'] = int(GROWL_NOTIFY_ONDOWNLOAD) - new_config['Growl']['growl_notify_onsubtitledownload'] = int(GROWL_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Growl']['growl_host'] = GROWL_HOST - new_config['Growl']['growl_password'] = GROWL_PASSWORD - - new_config['Prowl'] = {} - new_config['Prowl']['use_prowl'] = int(USE_PROWL) - new_config['Prowl']['prowl_notify_onsnatch'] = int(PROWL_NOTIFY_ONSNATCH) - new_config['Prowl']['prowl_notify_ondownload'] = int(PROWL_NOTIFY_ONDOWNLOAD) - new_config['Prowl']['prowl_notify_onsubtitledownload'] = int(PROWL_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Prowl']['prowl_api'] = PROWL_API - new_config['Prowl']['prowl_priority'] = PROWL_PRIORITY - - new_config['Twitter'] = {} - new_config['Twitter']['use_twitter'] = int(USE_TWITTER) - new_config['Twitter']['twitter_notify_onsnatch'] = int(TWITTER_NOTIFY_ONSNATCH) - new_config['Twitter']['twitter_notify_ondownload'] = int(TWITTER_NOTIFY_ONDOWNLOAD) - new_config['Twitter']['twitter_notify_onsubtitledownload'] = int(TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Twitter']['twitter_username'] = TWITTER_USERNAME - new_config['Twitter']['twitter_password'] = TWITTER_PASSWORD - new_config['Twitter']['twitter_prefix'] = TWITTER_PREFIX - - new_config['Notifo'] = {} - new_config['Notifo']['use_notifo'] = int(USE_NOTIFO) - new_config['Notifo']['notifo_notify_onsnatch'] = int(NOTIFO_NOTIFY_ONSNATCH) - new_config['Notifo']['notifo_notify_ondownload'] = int(NOTIFO_NOTIFY_ONDOWNLOAD) - new_config['Notifo']['notifo_notify_onsubtitledownload'] = int(NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Notifo']['notifo_username'] = NOTIFO_USERNAME - new_config['Notifo']['notifo_apisecret'] = NOTIFO_APISECRET - - new_config['Boxcar'] = {} - new_config['Boxcar']['use_boxcar'] = int(USE_BOXCAR) - new_config['Boxcar']['boxcar_notify_onsnatch'] = int(BOXCAR_NOTIFY_ONSNATCH) - new_config['Boxcar']['boxcar_notify_ondownload'] = int(BOXCAR_NOTIFY_ONDOWNLOAD) - new_config['Boxcar']['boxcar_notify_onsubtitledownload'] = int(BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Boxcar']['boxcar_username'] = BOXCAR_USERNAME - - new_config['Pushover'] = {} - new_config['Pushover']['use_pushover'] = int(USE_PUSHOVER) - new_config['Pushover']['pushover_notify_onsnatch'] = int(PUSHOVER_NOTIFY_ONSNATCH) - new_config['Pushover']['pushover_notify_ondownload'] = int(PUSHOVER_NOTIFY_ONDOWNLOAD) - new_config['Pushover']['pushover_notify_onsubtitledownload'] = int(PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['Pushover']['pushover_userkey'] = PUSHOVER_USERKEY - - new_config['Libnotify'] = {} - new_config['Libnotify']['use_libnotify'] = int(USE_LIBNOTIFY) - new_config['Libnotify']['libnotify_notify_onsnatch'] = int(LIBNOTIFY_NOTIFY_ONSNATCH) - new_config['Libnotify']['libnotify_notify_ondownload'] = int(LIBNOTIFY_NOTIFY_ONDOWNLOAD) - new_config['Libnotify']['libnotify_notify_onsubtitledownload'] = int(LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD) - - new_config['NMJ'] = {} - new_config['NMJ']['use_nmj'] = int(USE_NMJ) - new_config['NMJ']['nmj_host'] = NMJ_HOST - new_config['NMJ']['nmj_database'] = NMJ_DATABASE - new_config['NMJ']['nmj_mount'] = NMJ_MOUNT - - new_config['Synology'] = {} - new_config['Synology']['use_synoindex'] = int(USE_SYNOINDEX) - - new_config['NMJv2'] = {} - new_config['NMJv2']['use_nmjv2'] = int(USE_NMJv2) - new_config['NMJv2']['nmjv2_host'] = NMJv2_HOST - new_config['NMJv2']['nmjv2_database'] = NMJv2_DATABASE - new_config['NMJv2']['nmjv2_dbloc'] = NMJv2_DBLOC - - new_config['Trakt'] = {} - new_config['Trakt']['use_trakt'] = int(USE_TRAKT) - new_config['Trakt']['trakt_username'] = TRAKT_USERNAME - new_config['Trakt']['trakt_password'] = TRAKT_PASSWORD - new_config['Trakt']['trakt_api'] = TRAKT_API - new_config['Trakt']['trakt_remove_watchlist'] = int(TRAKT_REMOVE_WATCHLIST) - new_config['Trakt']['trakt_use_watchlist'] = int(TRAKT_USE_WATCHLIST) - new_config['Trakt']['trakt_method_add'] = TRAKT_METHOD_ADD - new_config['Trakt']['trakt_start_paused'] = int(TRAKT_START_PAUSED) - - new_config['pyTivo'] = {} - new_config['pyTivo']['use_pytivo'] = int(USE_PYTIVO) - new_config['pyTivo']['pytivo_notify_onsnatch'] = int(PYTIVO_NOTIFY_ONSNATCH) - new_config['pyTivo']['pytivo_notify_ondownload'] = int(PYTIVO_NOTIFY_ONDOWNLOAD) - new_config['pyTivo']['pytivo_notify_onsubtitledownload'] = int(PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['pyTivo']['pyTivo_update_library'] = int(PYTIVO_UPDATE_LIBRARY) - new_config['pyTivo']['pytivo_host'] = PYTIVO_HOST - new_config['pyTivo']['pytivo_share_name'] = PYTIVO_SHARE_NAME - new_config['pyTivo']['pytivo_tivo_name'] = PYTIVO_TIVO_NAME - - new_config['NMA'] = {} - new_config['NMA']['use_nma'] = int(USE_NMA) - new_config['NMA']['nma_notify_onsnatch'] = int(NMA_NOTIFY_ONSNATCH) - new_config['NMA']['nma_notify_ondownload'] = int(NMA_NOTIFY_ONDOWNLOAD) - new_config['NMA']['nma_notify_onsubtitledownload'] = int(NMA_NOTIFY_ONSUBTITLEDOWNLOAD) - new_config['NMA']['nma_api'] = NMA_API - new_config['NMA']['nma_priority'] = NMA_PRIORITY - - new_config['Mail'] = {} - new_config['Mail']['use_mail'] = int(USE_MAIL) - new_config['Mail']['mail_username'] = MAIL_USERNAME - new_config['Mail']['mail_password'] = MAIL_PASSWORD - new_config['Mail']['mail_server'] = MAIL_SERVER - new_config['Mail']['mail_ssl'] = int(MAIL_SSL) - new_config['Mail']['mail_from'] = MAIL_FROM - new_config['Mail']['mail_to'] = MAIL_TO - new_config['Mail']['mail_notify_onsnatch'] = int(MAIL_NOTIFY_ONSNATCH) - - new_config['Newznab'] = {} - new_config['Newznab']['newznab_data'] = '!!!'.join([x.configStr() for x in newznabProviderList]) - - new_config['GUI'] = {} - new_config['GUI']['coming_eps_layout'] = COMING_EPS_LAYOUT - new_config['GUI']['coming_eps_display_paused'] = int(COMING_EPS_DISPLAY_PAUSED) - new_config['GUI']['coming_eps_sort'] = COMING_EPS_SORT - - new_config['Subtitles'] = {} - new_config['Subtitles']['use_subtitles'] = int(USE_SUBTITLES) - new_config['Subtitles']['subtitles_languages'] = ','.join(SUBTITLES_LANGUAGES) - new_config['Subtitles']['SUBTITLES_SERVICES_LIST'] = ','.join(SUBTITLES_SERVICES_LIST) - new_config['Subtitles']['SUBTITLES_SERVICES_ENABLED'] = '|'.join([str(x) for x in SUBTITLES_SERVICES_ENABLED]) - new_config['Subtitles']['subtitles_dir'] = SUBTITLES_DIR - new_config['Subtitles']['subtitles_dir_sub'] = int(SUBTITLES_DIR_SUB) - new_config['Subtitles']['subsnolang'] = int(SUBSNOLANG) - new_config['Subtitles']['subtitles_default'] = int(SUBTITLES_DEFAULT) - new_config['Subtitles']['subtitles_history'] = int(SUBTITLES_HISTORY) - - new_config['General']['config_version'] = CONFIG_VERSION - - new_config.write() - - -def launchBrowser(startPort=None): - if not startPort: - startPort = WEB_PORT - if ENABLE_HTTPS: - browserURL = 'https://localhost:%d%s' % (startPort, WEB_ROOT) - else: - browserURL = 'http://localhost:%d%s' % (startPort, WEB_ROOT) - try: - webbrowser.open(browserURL, 2, 1) - except: - try: - webbrowser.open(browserURL, 1, 1) - except: - logger.log(u"Unable to launch a browser", logger.ERROR) - - -def getEpList(epIDs, showid=None): - if epIDs == None or len(epIDs) == 0: - return [] - - query = "SELECT * FROM tv_episodes WHERE tvdbid in (%s)" % (",".join(['?'] * len(epIDs)),) - params = epIDs - - if showid != None: - query += " AND showid = ?" - params.append(showid) - - myDB = db.DBConnection() - sqlResults = myDB.select(query, params) - - epList = [] - - for curEp in sqlResults: - curShowObj = helpers.findCertainShow(showList, int(curEp["showid"])) - curEpObj = curShowObj.getEpisode(int(curEp["season"]), int(curEp["episode"])) - epList.append(curEpObj) - - return epList +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from __future__ import with_statement + +import cherrypy +import webbrowser +import sqlite3 +import datetime +import socket +import os, sys, subprocess, re +import urllib + +from threading import Lock + +# apparently py2exe won't build these unless they're imported somewhere +from sickbeard import providers, metadata +from providers import ezrss, tvtorrents, torrentleech, btn, nzbsrus, newznab, womble, nzbx, omgwtfnzbs, binnewz, t411, cpasbien, piratebay, gks, kat +from sickbeard.config import CheckSection, check_setting_int, check_setting_str, ConfigMigrator + +from sickbeard import searchCurrent, searchBacklog, showUpdater, versionChecker, properFinder, autoPostProcesser, subtitles, traktWatchListChecker +from sickbeard import helpers, db, exceptions, show_queue, search_queue, scheduler +from sickbeard import logger +from sickbeard import naming + +from common import SD, SKIPPED, NAMING_REPEAT + +from sickbeard.databases import mainDB, cache_db + +from lib.configobj import ConfigObj + +invoked_command = None + +SOCKET_TIMEOUT = 30 + +PID = None + +CFG = None +CONFIG_FILE = None + +# this is the version of the config we EXPECT to find +CONFIG_VERSION = 1 + +PROG_DIR = '.' +MY_FULLNAME = None +MY_NAME = None +MY_ARGS = [] +SYS_ENCODING = '' +DATA_DIR = '' +CREATEPID = False +PIDFILE = '' + +DAEMON = None +NO_RESIZE = False + +backlogSearchScheduler = None +currentSearchScheduler = None +showUpdateScheduler = None +versionCheckScheduler = None +showQueueScheduler = None +searchQueueScheduler = None +properFinderScheduler = None +autoPostProcesserScheduler = None +autoTorrentPostProcesserScheduler = None +subtitlesFinderScheduler = None +traktWatchListCheckerSchedular = None + +showList = None +loadingShowList = None + +providerList = [] +newznabProviderList = [] +metadata_provider_dict = {} + +NEWEST_VERSION = None +NEWEST_VERSION_STRING = None +VERSION_NOTIFY = None + +INIT_LOCK = Lock() +__INITIALIZED__ = False +started = False + +LOG_DIR = None + +WEB_PORT = None +WEB_LOG = None +WEB_ROOT = None +WEB_USERNAME = None +WEB_PASSWORD = None +WEB_HOST = None +WEB_IPV6 = None + +USE_API = False +API_KEY = None + +ENABLE_HTTPS = False +HTTPS_CERT = None +HTTPS_KEY = None + +LAUNCH_BROWSER = None +CACHE_DIR = None +ACTUAL_CACHE_DIR = None +ROOT_DIRS = None +UPDATE_SHOWS_ON_START = None +SORT_ARTICLE = None + +USE_BANNER = None +USE_LISTVIEW = None +METADATA_XBMC = None +METADATA_XBMCFRODO = None +METADATA_MEDIABROWSER = None +METADATA_PS3 = None +METADATA_WDTV = None +METADATA_TIVO = None +METADATA_SYNOLOGY = None + +QUALITY_DEFAULT = None +STATUS_DEFAULT = None +FLATTEN_FOLDERS_DEFAULT = None +AUDIO_SHOW_DEFAULT = None +SUBTITLES_DEFAULT = None +PROVIDER_ORDER = [] + +NAMING_MULTI_EP = None +NAMING_PATTERN = None +NAMING_ABD_PATTERN = None +NAMING_CUSTOM_ABD = None +NAMING_FORCE_FOLDERS = False + +TVDB_API_KEY = '9DAF49C96CBF8DAC' +TVDB_BASE_URL = None +TVDB_API_PARMS = {} + +USE_NZBS = None +USE_TORRENTS = None + +NZB_METHOD = None +NZB_DIR = None +USENET_RETENTION = None +TORRENT_METHOD = None +TORRENT_DIR = None +DOWNLOAD_PROPERS = None +PREFERED_METHOD = None +SEARCH_FREQUENCY = None +BACKLOG_SEARCH_FREQUENCY = 1 + +MIN_SEARCH_FREQUENCY = 10 + +DEFAULT_SEARCH_FREQUENCY = 60 + +EZRSS = False +TVTORRENTS = False +TVTORRENTS_DIGEST = None +TVTORRENTS_HASH = None + +TORRENTLEECH = False +TORRENTLEECH_KEY = None + +BTN = False +BTN_API_KEY = None + +TORRENT_DIR = None + +ADD_SHOWS_WO_DIR = None +CREATE_MISSING_SHOW_DIRS = None +RENAME_EPISODES = False +PROCESS_AUTOMATICALLY = False +PROCESS_AUTOMATICALLY_TORRENT = False +KEEP_PROCESSED_DIR = False +MOVE_ASSOCIATED_FILES = False +TV_DOWNLOAD_DIR = None +TORRENT_DOWNLOAD_DIR = None + +NZBS = False +NZBS_UID = None +NZBS_HASH = None + +WOMBLE = False + +NZBX = False +NZBX_COMPLETION = 100 + +OMGWTFNZBS = False +OMGWTFNZBS_UID = None +OMGWTFNZBS_KEY = None + +NZBSRUS = False +NZBSRUS_UID = None +NZBSRUS_HASH = None + +BINNEWZ = False + +T411 = False +T411_USERNAME = None +T411_PASSWORD = None + +THEPIRATEBAY = False +THEPIRATEBAY_TRUSTED = True +THEPIRATEBAY_PROXY = False +THEPIRATEBAY_PROXY_URL = None + +Cpasbien = False +kat = False + +NZBMATRIX = False +NZBMATRIX_USERNAME = None +NZBMATRIX_APIKEY = None + +NEWZBIN = False +NEWZBIN_USERNAME = None +NEWZBIN_PASSWORD = None + +SAB_USERNAME = None +SAB_PASSWORD = None +SAB_APIKEY = None +SAB_CATEGORY = None +SAB_HOST = '' + +NZBGET_PASSWORD = None +NZBGET_CATEGORY = None +NZBGET_HOST = None + +GKS = False +GKS_KEY = None + +TORRENT_USERNAME = None +TORRENT_PASSWORD = None +TORRENT_HOST = '' +TORRENT_PATH = '' +TORRENT_RATIO = '' +TORRENT_PAUSED = False +TORRENT_LABEL = '' + +USE_XBMC = False +XBMC_NOTIFY_ONSNATCH = False +XBMC_NOTIFY_ONDOWNLOAD = False +XBMC_NOTIFY_ONSUBTITLEDOWNLOAD = False +XBMC_UPDATE_LIBRARY = False +XBMC_UPDATE_FULL = False +XBMC_UPDATE_ONLYFIRST = False +XBMC_HOST = '' +XBMC_USERNAME = None +XBMC_PASSWORD = None + +USE_PLEX = False +PLEX_NOTIFY_ONSNATCH = False +PLEX_NOTIFY_ONDOWNLOAD = False +PLEX_NOTIFY_ONSUBTITLEDOWNLOAD = False +PLEX_UPDATE_LIBRARY = False +PLEX_SERVER_HOST = None +PLEX_HOST = None +PLEX_USERNAME = None +PLEX_PASSWORD = None + +USE_GROWL = False +GROWL_NOTIFY_ONSNATCH = False +GROWL_NOTIFY_ONDOWNLOAD = False +GROWL_NOTIFY_ONSUBTITLEDOWNLOAD = False +GROWL_HOST = '' +GROWL_PASSWORD = None + +USE_PROWL = False +PROWL_NOTIFY_ONSNATCH = False +PROWL_NOTIFY_ONDOWNLOAD = False +PROWL_NOTIFY_ONSUBTITLEDOWNLOAD = False +PROWL_API = None +PROWL_PRIORITY = 0 + +USE_TWITTER = False +TWITTER_NOTIFY_ONSNATCH = False +TWITTER_NOTIFY_ONDOWNLOAD = False +TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD = False +TWITTER_USERNAME = None +TWITTER_PASSWORD = None +TWITTER_PREFIX = None + +USE_NOTIFO = False +NOTIFO_NOTIFY_ONSNATCH = False +NOTIFO_NOTIFY_ONDOWNLOAD = False +NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD = False +NOTIFO_USERNAME = None +NOTIFO_APISECRET = None +NOTIFO_PREFIX = None + +USE_BOXCAR = False +BOXCAR_NOTIFY_ONSNATCH = False +BOXCAR_NOTIFY_ONDOWNLOAD = False +BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD = False +BOXCAR_USERNAME = None +BOXCAR_PASSWORD = None +BOXCAR_PREFIX = None + +USE_PUSHOVER = False +PUSHOVER_NOTIFY_ONSNATCH = False +PUSHOVER_NOTIFY_ONDOWNLOAD = False +PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD = False +PUSHOVER_USERKEY = None + +USE_LIBNOTIFY = False +LIBNOTIFY_NOTIFY_ONSNATCH = False +LIBNOTIFY_NOTIFY_ONDOWNLOAD = False +LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD = False + +USE_NMJ = False +NMJ_HOST = None +NMJ_DATABASE = None +NMJ_MOUNT = None + +USE_SYNOINDEX = False + +USE_NMJv2 = False +NMJv2_HOST = None +NMJv2_DATABASE = None +NMJv2_DBLOC = None + +USE_SYNOLOGYNOTIFIER = False +SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH = False +SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD = False +SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD = False + +USE_TRAKT = False +TRAKT_USERNAME = None +TRAKT_PASSWORD = None +TRAKT_API = '' +TRAKT_REMOVE_WATCHLIST = False +TRAKT_USE_WATCHLIST = False +TRAKT_METHOD_ADD = 0 +TRAKT_START_PAUSED = False + +USE_PYTIVO = False +PYTIVO_NOTIFY_ONSNATCH = False +PYTIVO_NOTIFY_ONDOWNLOAD = False +PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD = False +PYTIVO_UPDATE_LIBRARY = False +PYTIVO_HOST = '' +PYTIVO_SHARE_NAME = '' +PYTIVO_TIVO_NAME = '' + +USE_NMA = False +NMA_NOTIFY_ONSNATCH = False +NMA_NOTIFY_ONDOWNLOAD = False +NMA_NOTIFY_ONSUBTITLEDOWNLOAD = False +NMA_API = None +NMA_PRIORITY = 0 + +USE_PUSHALOT = False +PUSHALOT_NOTIFY_ONSNATCH = False +PUSHALOT_NOTIFY_ONDOWNLOAD = False +PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD = False +PUSHALOT_AUTHORIZATIONTOKEN = None + +USE_MAIL = False +MAIL_USERNAME = None +MAIL_PASSWORD = None +MAIL_SERVER = None +MAIL_SSL = False +MAIL_FROM = None +MAIL_TO = None +MAIL_NOTIFY_ONSNATCH = False + +HOME_LAYOUT = None +DISPLAY_SHOW_SPECIALS = None +COMING_EPS_LAYOUT = None +COMING_EPS_DISPLAY_PAUSED = None +COMING_EPS_SORT = None +COMING_EPS_MISSED_RANGE = None + +USE_SUBTITLES = False +SUBTITLES_LANGUAGES = [] +SUBTITLES_DIR = '' +SUBTITLES_DIR_SUB = False +SUBSNOLANG = False +SUBTITLES_SERVICES_LIST = [] +SUBTITLES_SERVICES_ENABLED = [] +SUBTITLES_HISTORY = False + +DISPLAY_POSTERS = None + +EXTRA_SCRIPTS = [] + +GIT_PATH = None + +IGNORE_WORDS = "german,spanish,core2hd,dutch,swedish" + +__INITIALIZED__ = False + + +def get_backlog_cycle_time(): + cycletime = SEARCH_FREQUENCY * 2 + 7 + return max([cycletime, 120]) + + +def initialize(consoleLogging=True): + + with INIT_LOCK: + + global LOG_DIR, WEB_PORT, WEB_LOG, WEB_ROOT, WEB_USERNAME, WEB_PASSWORD, WEB_HOST, WEB_IPV6, USE_API, API_KEY, ENABLE_HTTPS, HTTPS_CERT, HTTPS_KEY, \ + USE_NZBS, USE_TORRENTS, NZB_METHOD, NZB_DIR, DOWNLOAD_PROPERS, TORRENT_METHOD, PREFERED_METHOD, \ + SAB_USERNAME, SAB_PASSWORD, SAB_APIKEY, SAB_CATEGORY, SAB_HOST, \ + NZBGET_PASSWORD, NZBGET_CATEGORY, NZBGET_HOST, currentSearchScheduler, backlogSearchScheduler, \ + TORRENT_USERNAME, TORRENT_PASSWORD, TORRENT_HOST, TORRENT_PATH, TORRENT_RATIO, TORRENT_PAUSED, TORRENT_LABEL, \ + USE_XBMC, XBMC_NOTIFY_ONSNATCH, XBMC_NOTIFY_ONDOWNLOAD, XBMC_NOTIFY_ONSUBTITLEDOWNLOAD, XBMC_UPDATE_FULL, XBMC_UPDATE_ONLYFIRST, \ + XBMC_UPDATE_LIBRARY, XBMC_HOST, XBMC_USERNAME, XBMC_PASSWORD, \ + USE_TRAKT, TRAKT_USERNAME, TRAKT_PASSWORD, TRAKT_API,TRAKT_REMOVE_WATCHLIST,TRAKT_USE_WATCHLIST,TRAKT_METHOD_ADD,TRAKT_START_PAUSED,traktWatchListCheckerSchedular, \ + USE_PLEX, PLEX_NOTIFY_ONSNATCH, PLEX_NOTIFY_ONDOWNLOAD, PLEX_NOTIFY_ONSUBTITLEDOWNLOAD, PLEX_UPDATE_LIBRARY, \ + PLEX_SERVER_HOST, PLEX_HOST, PLEX_USERNAME, PLEX_PASSWORD, \ + showUpdateScheduler, __INITIALIZED__, LAUNCH_BROWSER, UPDATE_SHOWS_ON_START, SORT_ARTICLE, showList, loadingShowList, \ + NZBS, NZBS_UID, NZBS_HASH, EZRSS, TVTORRENTS, TVTORRENTS_DIGEST, TVTORRENTS_HASH, BTN, BTN_API_KEY, TORRENTLEECH, TORRENTLEECH_KEY, TORRENT_DIR, USENET_RETENTION, SOCKET_TIMEOUT, \ + BINNEWZ, \ + T411, T411_USERNAME, T411_PASSWORD, \ + THEPIRATEBAY, THEPIRATEBAY_PROXY, THEPIRATEBAY_PROXY_URL, THEPIRATEBAY_TRUSTED, \ + Cpasbien, \ + kat, \ + SEARCH_FREQUENCY, DEFAULT_SEARCH_FREQUENCY, BACKLOG_SEARCH_FREQUENCY, \ + QUALITY_DEFAULT, FLATTEN_FOLDERS_DEFAULT, SUBTITLES_DEFAULT, STATUS_DEFAULT, AUDIO_SHOW_DEFAULT, \ + GROWL_NOTIFY_ONSNATCH, GROWL_NOTIFY_ONDOWNLOAD, GROWL_NOTIFY_ONSUBTITLEDOWNLOAD, TWITTER_NOTIFY_ONSNATCH, TWITTER_NOTIFY_ONDOWNLOAD, TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD, \ + USE_GROWL, GROWL_HOST, GROWL_PASSWORD, USE_PROWL, PROWL_NOTIFY_ONSNATCH, PROWL_NOTIFY_ONDOWNLOAD, PROWL_NOTIFY_ONSUBTITLEDOWNLOAD, PROWL_API, PROWL_PRIORITY, PROG_DIR, NZBMATRIX, NZBMATRIX_USERNAME, \ + USE_PYTIVO, PYTIVO_NOTIFY_ONSNATCH, PYTIVO_NOTIFY_ONDOWNLOAD, PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD, PYTIVO_UPDATE_LIBRARY, PYTIVO_HOST, PYTIVO_SHARE_NAME, PYTIVO_TIVO_NAME, \ + USE_NMA, NMA_NOTIFY_ONSNATCH, NMA_NOTIFY_ONDOWNLOAD, NMA_NOTIFY_ONSUBTITLEDOWNLOAD, NMA_API, NMA_PRIORITY, \ + USE_PUSHALOT, PUSHALOT_NOTIFY_ONSNATCH, PUSHALOT_NOTIFY_ONDOWNLOAD, PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD, PUSHALOT_AUTHORIZATIONTOKEN, \ + USE_SYNOLOGYNOTIFIER, SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH, SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD, SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD, \ + USE_MAIL, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SERVER, MAIL_SSL, MAIL_FROM, MAIL_TO, MAIL_NOTIFY_ONSNATCH, \ + NZBMATRIX_APIKEY, versionCheckScheduler, VERSION_NOTIFY, PROCESS_AUTOMATICALLY, PROCESS_AUTOMATICALLY_TORRENT, \ + KEEP_PROCESSED_DIR, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ + showQueueScheduler, searchQueueScheduler, ROOT_DIRS, CACHE_DIR, ACTUAL_CACHE_DIR, TVDB_API_PARMS, \ + NAMING_PATTERN, NAMING_MULTI_EP, NAMING_FORCE_FOLDERS, NAMING_ABD_PATTERN, NAMING_CUSTOM_ABD, \ + RENAME_EPISODES, properFinderScheduler, PROVIDER_ORDER, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, \ + NZBSRUS, NZBSRUS_UID, NZBSRUS_HASH, WOMBLE, NZBX, NZBX_COMPLETION, OMGWTFNZBS, OMGWTFNZBS_UID, OMGWTFNZBS_KEY, providerList, newznabProviderList, \ + EXTRA_SCRIPTS, USE_TWITTER, TWITTER_USERNAME, TWITTER_PASSWORD, TWITTER_PREFIX, \ + USE_NOTIFO, NOTIFO_USERNAME, NOTIFO_APISECRET, NOTIFO_NOTIFY_ONDOWNLOAD, NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD, NOTIFO_NOTIFY_ONSNATCH, \ + USE_BOXCAR, BOXCAR_USERNAME, BOXCAR_PASSWORD, BOXCAR_NOTIFY_ONDOWNLOAD, BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD, BOXCAR_NOTIFY_ONSNATCH, \ + USE_PUSHOVER, PUSHOVER_USERKEY, PUSHOVER_NOTIFY_ONDOWNLOAD, PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD, PUSHOVER_NOTIFY_ONSNATCH, \ + USE_LIBNOTIFY, LIBNOTIFY_NOTIFY_ONSNATCH, LIBNOTIFY_NOTIFY_ONDOWNLOAD, LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD, USE_NMJ, NMJ_HOST, NMJ_DATABASE, NMJ_MOUNT, USE_NMJv2, NMJv2_HOST, NMJv2_DATABASE, NMJv2_DBLOC, USE_SYNOINDEX, \ + USE_BANNER, USE_LISTVIEW, METADATA_XBMC, METADATA_XBMCFRODO, METADATA_MEDIABROWSER, METADATA_PS3, METADATA_SYNOLOGY, metadata_provider_dict, \ + NEWZBIN, NEWZBIN_USERNAME, NEWZBIN_PASSWORD, GIT_PATH, MOVE_ASSOCIATED_FILES, \ + GKS, GKS_KEY, \ + HOME_LAYOUT, DISPLAY_SHOW_SPECIALS, COMING_EPS_LAYOUT, COMING_EPS_SORT, COMING_EPS_DISPLAY_PAUSED, COMING_EPS_MISSED_RANGE, METADATA_WDTV, METADATA_TIVO, IGNORE_WORDS, CREATE_MISSING_SHOW_DIRS, \ + ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler + + + if __INITIALIZED__: + return False + + socket.setdefaulttimeout(SOCKET_TIMEOUT) + + CheckSection(CFG, 'General') + LOG_DIR = check_setting_str(CFG, 'General', 'log_dir', 'Logs') + if not helpers.makeDir(LOG_DIR): + logger.log(u"!!! No log folder, logging to screen only!", logger.ERROR) + + try: + WEB_PORT = check_setting_int(CFG, 'General', 'web_port', 8081) + except: + WEB_PORT = 8081 + + if WEB_PORT < 21 or WEB_PORT > 65535: + WEB_PORT = 8081 + + WEB_HOST = check_setting_str(CFG, 'General', 'web_host', '0.0.0.0') + WEB_IPV6 = bool(check_setting_int(CFG, 'General', 'web_ipv6', 0)) + WEB_ROOT = check_setting_str(CFG, 'General', 'web_root', '').rstrip("/") + WEB_LOG = bool(check_setting_int(CFG, 'General', 'web_log', 0)) + WEB_USERNAME = check_setting_str(CFG, 'General', 'web_username', '') + WEB_PASSWORD = check_setting_str(CFG, 'General', 'web_password', '') + LAUNCH_BROWSER = bool(check_setting_int(CFG, 'General', 'launch_browser', 1)) + + UPDATE_SHOWS_ON_START = bool(check_setting_int(CFG, 'General', 'update_shows_on_start', 1)) + SORT_ARTICLE = bool(check_setting_int(CFG, 'General', 'sort_article', 0)) + + USE_API = bool(check_setting_int(CFG, 'General', 'use_api', 0)) + API_KEY = check_setting_str(CFG, 'General', 'api_key', '') + + ENABLE_HTTPS = bool(check_setting_int(CFG, 'General', 'enable_https', 0)) + HTTPS_CERT = check_setting_str(CFG, 'General', 'https_cert', 'server.crt') + HTTPS_KEY = check_setting_str(CFG, 'General', 'https_key', 'server.key') + + ACTUAL_CACHE_DIR = check_setting_str(CFG, 'General', 'cache_dir', 'cache') + # fix bad configs due to buggy code + if ACTUAL_CACHE_DIR == 'None': + ACTUAL_CACHE_DIR = 'cache' + + # unless they specify, put the cache dir inside the data dir + if not os.path.isabs(ACTUAL_CACHE_DIR): + CACHE_DIR = os.path.join(DATA_DIR, ACTUAL_CACHE_DIR) + else: + CACHE_DIR = ACTUAL_CACHE_DIR + + if not helpers.makeDir(CACHE_DIR): + logger.log(u"!!! Creating local cache dir failed, using system default", logger.ERROR) + CACHE_DIR = None + + ROOT_DIRS = check_setting_str(CFG, 'General', 'root_dirs', '') + if not re.match(r'\d+\|[^|]+(?:\|[^|]+)*', ROOT_DIRS): + ROOT_DIRS = '' + + proxies = urllib.getproxies() + proxy_url = None # @UnusedVariable + if 'http' in proxies: + proxy_url = proxies['http'] # @UnusedVariable + elif 'ftp' in proxies: + proxy_url = proxies['ftp'] # @UnusedVariable + + # Set our common tvdb_api options here + TVDB_API_PARMS = {'apikey': TVDB_API_KEY, + 'language': 'en', + 'useZip': True} + + if CACHE_DIR: + TVDB_API_PARMS['cache'] = os.path.join(CACHE_DIR, 'tvdb') + + TVDB_BASE_URL = 'http://thetvdb.com/api/' + TVDB_API_KEY + + QUALITY_DEFAULT = check_setting_int(CFG, 'General', 'quality_default', SD) + STATUS_DEFAULT = check_setting_int(CFG, 'General', 'status_default', SKIPPED) + AUDIO_SHOW_DEFAULT = check_setting_str(CFG, 'General', 'audio_show_default', 'fr' ) + VERSION_NOTIFY = check_setting_int(CFG, 'General', 'version_notify', 1) + FLATTEN_FOLDERS_DEFAULT = bool(check_setting_int(CFG, 'General', 'flatten_folders_default', 0)) + + PROVIDER_ORDER = check_setting_str(CFG, 'General', 'provider_order', '').split() + + NAMING_PATTERN = check_setting_str(CFG, 'General', 'naming_pattern', '') + NAMING_ABD_PATTERN = check_setting_str(CFG, 'General', 'naming_abd_pattern', '') + NAMING_CUSTOM_ABD = check_setting_int(CFG, 'General', 'naming_custom_abd', 0) + NAMING_MULTI_EP = check_setting_int(CFG, 'General', 'naming_multi_ep', 1) + NAMING_FORCE_FOLDERS = naming.check_force_season_folders() + + USE_NZBS = bool(check_setting_int(CFG, 'General', 'use_nzbs', 1)) + USE_TORRENTS = bool(check_setting_int(CFG, 'General', 'use_torrents', 0)) + + NZB_METHOD = check_setting_str(CFG, 'General', 'nzb_method', 'blackhole') + if NZB_METHOD not in ('blackhole', 'sabnzbd', 'nzbget'): + NZB_METHOD = 'blackhole' + + TORRENT_METHOD = check_setting_str(CFG, 'General', 'torrent_method', 'blackhole') + if TORRENT_METHOD not in ('blackhole', 'utorrent', 'transmission', 'deluge', 'download_station'): + TORRENT_METHOD = 'blackhole' + + PREFERED_METHOD = check_setting_str(CFG, 'General', 'prefered_method', 'nzb') + if PREFERED_METHOD not in ('torrent', 'nzb'): + PREFERED_METHOD = 'nzb' + + DOWNLOAD_PROPERS = bool(check_setting_int(CFG, 'General', 'download_propers', 1)) + USENET_RETENTION = check_setting_int(CFG, 'General', 'usenet_retention', 500) + SEARCH_FREQUENCY = check_setting_int(CFG, 'General', 'search_frequency', DEFAULT_SEARCH_FREQUENCY) + if SEARCH_FREQUENCY < MIN_SEARCH_FREQUENCY: + SEARCH_FREQUENCY = MIN_SEARCH_FREQUENCY + + TORRENT_DIR = check_setting_str(CFG, 'Blackhole', 'torrent_dir', '') + + TV_DOWNLOAD_DIR = check_setting_str(CFG, 'General', 'tv_download_dir', '') + TORRENT_DOWNLOAD_DIR = check_setting_str(CFG, 'General', 'torrent_download_dir', '') + PROCESS_AUTOMATICALLY = check_setting_int(CFG, 'General', 'process_automatically', 0) + PROCESS_AUTOMATICALLY_TORRENT = check_setting_int(CFG, 'General', 'process_automatically_torrent', 0) + RENAME_EPISODES = check_setting_int(CFG, 'General', 'rename_episodes', 1) + KEEP_PROCESSED_DIR = check_setting_int(CFG, 'General', 'keep_processed_dir', 1) + MOVE_ASSOCIATED_FILES = check_setting_int(CFG, 'General', 'move_associated_files', 0) + CREATE_MISSING_SHOW_DIRS = check_setting_int(CFG, 'General', 'create_missing_show_dirs', 0) + ADD_SHOWS_WO_DIR = check_setting_int(CFG, 'General', 'add_shows_wo_dir', 0) + + EZRSS = bool(check_setting_int(CFG, 'General', 'use_torrent', 0)) + if not EZRSS: + CheckSection(CFG, 'EZRSS') + EZRSS = bool(check_setting_int(CFG, 'EZRSS', 'ezrss', 0)) + + GIT_PATH = check_setting_str(CFG, 'General', 'git_path', '') + IGNORE_WORDS = check_setting_str(CFG, 'General', 'ignore_words', '') + EXTRA_SCRIPTS = [x for x in check_setting_str(CFG, 'General', 'extra_scripts', '').split('|') if x] + + USE_BANNER = bool(check_setting_int(CFG, 'General', 'use_banner', 0)) + USE_LISTVIEW = bool(check_setting_int(CFG, 'General', 'use_listview', 0)) + METADATA_TYPE = check_setting_str(CFG, 'General', 'metadata_type', '') + + metadata_provider_dict = metadata.get_metadata_generator_dict() + + # if this exists it's legacy, use the info to upgrade metadata to the new settings + if METADATA_TYPE: + + old_metadata_class = None + + if METADATA_TYPE == 'xbmc': + old_metadata_class = metadata.xbmc.metadata_class + elif METADATA_TYPE == 'xbmcfrodo': + old_metadata_class = metadata.xbmcfrodo.metadata_class + elif METADATA_TYPE == 'mediabrowser': + old_metadata_class = metadata.mediabrowser.metadata_class + elif METADATA_TYPE == 'ps3': + old_metadata_class = metadata.ps3.metadata_class + + if old_metadata_class: + + METADATA_SHOW = bool(check_setting_int(CFG, 'General', 'metadata_show', 1)) + METADATA_EPISODE = bool(check_setting_int(CFG, 'General', 'metadata_episode', 1)) + + ART_POSTER = bool(check_setting_int(CFG, 'General', 'art_poster', 1)) + ART_FANART = bool(check_setting_int(CFG, 'General', 'art_fanart', 1)) + ART_THUMBNAILS = bool(check_setting_int(CFG, 'General', 'art_thumbnails', 1)) + ART_SEASON_THUMBNAILS = bool(check_setting_int(CFG, 'General', 'art_season_thumbnails', 1)) + + new_metadata_class = old_metadata_class(METADATA_SHOW, + METADATA_EPISODE, + ART_POSTER, + ART_FANART, + ART_THUMBNAILS, + ART_SEASON_THUMBNAILS) + + metadata_provider_dict[new_metadata_class.name] = new_metadata_class + + # this is the normal codepath for metadata config + else: + METADATA_XBMC = check_setting_str(CFG, 'General', 'metadata_xbmc', '0|0|0|0|0|0') + METADATA_XBMCFRODO = check_setting_str(CFG, 'General', 'metadata_xbmcfrodo', '0|0|0|0|0|0') + METADATA_MEDIABROWSER = check_setting_str(CFG, 'General', 'metadata_mediabrowser', '0|0|0|0|0|0') + METADATA_PS3 = check_setting_str(CFG, 'General', 'metadata_ps3', '0|0|0|0|0|0') + METADATA_WDTV = check_setting_str(CFG, 'General', 'metadata_wdtv', '0|0|0|0|0|0') + METADATA_TIVO = check_setting_str(CFG, 'General', 'metadata_tivo', '0|0|0|0|0|0') + METADATA_SYNOLOGY = check_setting_str(CFG, 'General', 'metadata_synology', '0|0|0|0|0|0') + + for cur_metadata_tuple in [(METADATA_XBMC, metadata.xbmc), + (METADATA_XBMCFRODO, metadata.xbmcfrodo), + (METADATA_MEDIABROWSER, metadata.mediabrowser), + (METADATA_PS3, metadata.ps3), + (METADATA_WDTV, metadata.wdtv), + (METADATA_TIVO, metadata.tivo), + (METADATA_SYNOLOGY, metadata.synology), + ]: + + (cur_metadata_config, cur_metadata_class) = cur_metadata_tuple + tmp_provider = cur_metadata_class.metadata_class() + tmp_provider.set_config(cur_metadata_config) + metadata_provider_dict[tmp_provider.name] = tmp_provider + + CheckSection(CFG, 'GUI') + HOME_LAYOUT = check_setting_str(CFG, 'GUI', 'home_layout', 'poster') + DISPLAY_SHOW_SPECIALS = bool(check_setting_int(CFG, 'GUI', 'display_show_specials', 1)) + COMING_EPS_LAYOUT = check_setting_str(CFG, 'GUI', 'coming_eps_layout', 'banner') + COMING_EPS_DISPLAY_PAUSED = bool(check_setting_int(CFG, 'GUI', 'coming_eps_display_paused', 0)) + COMING_EPS_SORT = check_setting_str(CFG, 'GUI', 'coming_eps_sort', 'date') + COMING_EPS_MISSED_RANGE = check_setting_int(CFG, 'GUI', 'coming_eps_missed_range', 7) + + CheckSection(CFG, 'Newznab') + newznabData = check_setting_str(CFG, 'Newznab', 'newznab_data', '') + newznabProviderList = providers.getNewznabProviderList(newznabData) + providerList = providers.makeProviderList() + + CheckSection(CFG, 'Blackhole') + NZB_DIR = check_setting_str(CFG, 'Blackhole', 'nzb_dir', '') + TORRENT_DIR = check_setting_str(CFG, 'Blackhole', 'torrent_dir', '') + + CheckSection(CFG, 'TVTORRENTS') + TVTORRENTS = bool(check_setting_int(CFG, 'TVTORRENTS', 'tvtorrents', 0)) + TVTORRENTS_DIGEST = check_setting_str(CFG, 'TVTORRENTS', 'tvtorrents_digest', '') + TVTORRENTS_HASH = check_setting_str(CFG, 'TVTORRENTS', 'tvtorrents_hash', '') + + CheckSection(CFG, 'BTN') + BTN = bool(check_setting_int(CFG, 'BTN', 'btn', 0)) + BTN_API_KEY = check_setting_str(CFG, 'BTN', 'btn_api_key', '') + + CheckSection(CFG, 'TorrentLeech') + TORRENTLEECH = bool(check_setting_int(CFG, 'TorrentLeech', 'torrentleech', 0)) + TORRENTLEECH_KEY = check_setting_str(CFG, 'TorrentLeech', 'torrentleech_key', '') + + CheckSection(CFG, 'NZBs') + NZBS = bool(check_setting_int(CFG, 'NZBs', 'nzbs', 0)) + NZBS_UID = check_setting_str(CFG, 'NZBs', 'nzbs_uid', '') + NZBS_HASH = check_setting_str(CFG, 'NZBs', 'nzbs_hash', '') + + CheckSection(CFG, 'NZBsRUS') + NZBSRUS = bool(check_setting_int(CFG, 'NZBsRUS', 'nzbsrus', 0)) + NZBSRUS_UID = check_setting_str(CFG, 'NZBsRUS', 'nzbsrus_uid', '') + NZBSRUS_HASH = check_setting_str(CFG, 'NZBsRUS', 'nzbsrus_hash', '') + + CheckSection(CFG, 'NZBMatrix') + NZBMATRIX = bool(check_setting_int(CFG, 'NZBMatrix', 'nzbmatrix', 0)) + NZBMATRIX_USERNAME = check_setting_str(CFG, 'NZBMatrix', 'nzbmatrix_username', '') + NZBMATRIX_APIKEY = check_setting_str(CFG, 'NZBMatrix', 'nzbmatrix_apikey', '') + + CheckSection(CFG, 'BinNewz') + BINNEWZ = bool(check_setting_int(CFG, 'BinNewz', 'binnewz', 0)) + + CheckSection(CFG, 'T411') + T411 = bool(check_setting_int(CFG, 'T411', 't411', 0)) + T411_USERNAME = check_setting_str(CFG, 'T411', 'username', '') + T411_PASSWORD = check_setting_str(CFG, 'T411', 'password', '') + + CheckSection(CFG, 'PirateBay') + THEPIRATEBAY = bool(check_setting_int(CFG, 'PirateBay', 'piratebay', 0)) + THEPIRATEBAY_PROXY = bool(check_setting_int(CFG, 'PirateBay', 'piratebay_proxy', 0)) + THEPIRATEBAY_PROXY_URL = check_setting_str(CFG, 'PirateBay', 'piratebay_proxy_url', '') + THEPIRATEBAY_TRUSTED = bool(check_setting_int(CFG, 'PirateBay', 'piratebay_trusted', 0)) + + CheckSection(CFG, 'Cpasbien') + Cpasbien = bool(check_setting_int(CFG, 'Cpasbien', 'cpasbien', 0)) + + CheckSection(CFG, 'kat') + kat = bool(check_setting_int(CFG, 'kat', 'kat', 0)) + + CheckSection(CFG, 'Newzbin') + NEWZBIN = bool(check_setting_int(CFG, 'Newzbin', 'newzbin', 0)) + NEWZBIN_USERNAME = check_setting_str(CFG, 'Newzbin', 'newzbin_username', '') + NEWZBIN_PASSWORD = check_setting_str(CFG, 'Newzbin', 'newzbin_password', '') + + CheckSection(CFG, 'Womble') + WOMBLE = bool(check_setting_int(CFG, 'Womble', 'womble', 1)) + + CheckSection(CFG, 'nzbX') + NZBX = bool(check_setting_int(CFG, 'nzbX', 'nzbx', 0)) + NZBX_COMPLETION = check_setting_int(CFG, 'nzbX', 'nzbx_completion', 100) + + CheckSection(CFG, 'omgwtfnzbs') + OMGWTFNZBS = bool(check_setting_int(CFG, 'omgwtfnzbs', 'omgwtfnzbs', 0)) + OMGWTFNZBS_UID = check_setting_str(CFG, 'omgwtfnzbs', 'omgwtfnzbs_uid', '') + OMGWTFNZBS_KEY = check_setting_str(CFG, 'omgwtfnzbs', 'omgwtfnzbs_key', '') + + CheckSection(CFG, 'GKS') + GKS = bool(check_setting_int(CFG, 'GKS', 'gks', 0)) + GKS_KEY = check_setting_str(CFG, 'GKS', 'gks_key', '') + + CheckSection(CFG, 'SABnzbd') + SAB_USERNAME = check_setting_str(CFG, 'SABnzbd', 'sab_username', '') + SAB_PASSWORD = check_setting_str(CFG, 'SABnzbd', 'sab_password', '') + SAB_APIKEY = check_setting_str(CFG, 'SABnzbd', 'sab_apikey', '') + SAB_CATEGORY = check_setting_str(CFG, 'SABnzbd', 'sab_category', 'tv') + SAB_HOST = check_setting_str(CFG, 'SABnzbd', 'sab_host', '') + + CheckSection(CFG, 'NZBget') + NZBGET_PASSWORD = check_setting_str(CFG, 'NZBget', 'nzbget_password', 'tegbzn6789') + NZBGET_CATEGORY = check_setting_str(CFG, 'NZBget', 'nzbget_category', 'tv') + NZBGET_HOST = check_setting_str(CFG, 'NZBget', 'nzbget_host', '') + + CheckSection(CFG, 'XBMC') + TORRENT_USERNAME = check_setting_str(CFG, 'TORRENT', 'torrent_username', '') + TORRENT_PASSWORD = check_setting_str(CFG, 'TORRENT', 'torrent_password', '') + TORRENT_HOST = check_setting_str(CFG, 'TORRENT', 'torrent_host', '') + TORRENT_PATH = check_setting_str(CFG, 'TORRENT', 'torrent_path', '') + TORRENT_RATIO = check_setting_str(CFG, 'TORRENT', 'torrent_ratio', '') + TORRENT_PAUSED = bool(check_setting_int(CFG, 'TORRENT', 'torrent_paused', 0)) + TORRENT_LABEL = check_setting_str(CFG, 'TORRENT', 'torrent_label', '') + + USE_XBMC = bool(check_setting_int(CFG, 'XBMC', 'use_xbmc', 0)) + XBMC_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'XBMC', 'xbmc_notify_onsnatch', 0)) + XBMC_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'XBMC', 'xbmc_notify_ondownload', 0)) + XBMC_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'XBMC', 'xbmc_notify_onsubtitledownload', 0)) + XBMC_UPDATE_LIBRARY = bool(check_setting_int(CFG, 'XBMC', 'xbmc_update_library', 0)) + XBMC_UPDATE_FULL = bool(check_setting_int(CFG, 'XBMC', 'xbmc_update_full', 0)) + XBMC_UPDATE_ONLYFIRST = bool(check_setting_int(CFG, 'XBMC', 'xbmc_update_onlyfirst', 0)) + XBMC_HOST = check_setting_str(CFG, 'XBMC', 'xbmc_host', '') + XBMC_USERNAME = check_setting_str(CFG, 'XBMC', 'xbmc_username', '') + XBMC_PASSWORD = check_setting_str(CFG, 'XBMC', 'xbmc_password', '') + + CheckSection(CFG, 'Plex') + USE_PLEX = bool(check_setting_int(CFG, 'Plex', 'use_plex', 0)) + PLEX_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Plex', 'plex_notify_onsnatch', 0)) + PLEX_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Plex', 'plex_notify_ondownload', 0)) + PLEX_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Plex', 'plex_notify_onsubtitledownload', 0)) + PLEX_UPDATE_LIBRARY = bool(check_setting_int(CFG, 'Plex', 'plex_update_library', 0)) + PLEX_SERVER_HOST = check_setting_str(CFG, 'Plex', 'plex_server_host', '') + PLEX_HOST = check_setting_str(CFG, 'Plex', 'plex_host', '') + PLEX_USERNAME = check_setting_str(CFG, 'Plex', 'plex_username', '') + PLEX_PASSWORD = check_setting_str(CFG, 'Plex', 'plex_password', '') + + CheckSection(CFG, 'Growl') + USE_GROWL = bool(check_setting_int(CFG, 'Growl', 'use_growl', 0)) + GROWL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Growl', 'growl_notify_onsnatch', 0)) + GROWL_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Growl', 'growl_notify_ondownload', 0)) + GROWL_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Growl', 'growl_notify_onsubtitledownload', 0)) + GROWL_HOST = check_setting_str(CFG, 'Growl', 'growl_host', '') + GROWL_PASSWORD = check_setting_str(CFG, 'Growl', 'growl_password', '') + + CheckSection(CFG, 'Prowl') + USE_PROWL = bool(check_setting_int(CFG, 'Prowl', 'use_prowl', 0)) + PROWL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Prowl', 'prowl_notify_onsnatch', 0)) + PROWL_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Prowl', 'prowl_notify_ondownload', 0)) + PROWL_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Prowl', 'prowl_notify_onsubtitledownload', 0)) + PROWL_API = check_setting_str(CFG, 'Prowl', 'prowl_api', '') + PROWL_PRIORITY = check_setting_str(CFG, 'Prowl', 'prowl_priority', "0") + + CheckSection(CFG, 'Twitter') + USE_TWITTER = bool(check_setting_int(CFG, 'Twitter', 'use_twitter', 0)) + TWITTER_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Twitter', 'twitter_notify_onsnatch', 0)) + TWITTER_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Twitter', 'twitter_notify_ondownload', 0)) + TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Twitter', 'twitter_notify_onsubtitledownload', 0)) + TWITTER_USERNAME = check_setting_str(CFG, 'Twitter', 'twitter_username', '') + TWITTER_PASSWORD = check_setting_str(CFG, 'Twitter', 'twitter_password', '') + TWITTER_PREFIX = check_setting_str(CFG, 'Twitter', 'twitter_prefix', 'Sick Beard') + + CheckSection(CFG, 'Notifo') + USE_NOTIFO = bool(check_setting_int(CFG, 'Notifo', 'use_notifo', 0)) + NOTIFO_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Notifo', 'notifo_notify_onsnatch', 0)) + NOTIFO_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Notifo', 'notifo_notify_ondownload', 0)) + NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Notifo', 'notifo_notify_onsubtitledownload', 0)) + NOTIFO_USERNAME = check_setting_str(CFG, 'Notifo', 'notifo_username', '') + NOTIFO_APISECRET = check_setting_str(CFG, 'Notifo', 'notifo_apisecret', '') + + CheckSection(CFG, 'Boxcar') + USE_BOXCAR = bool(check_setting_int(CFG, 'Boxcar', 'use_boxcar', 0)) + BOXCAR_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Boxcar', 'boxcar_notify_onsnatch', 0)) + BOXCAR_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Boxcar', 'boxcar_notify_ondownload', 0)) + BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Boxcar', 'boxcar_notify_onsubtitledownload', 0)) + BOXCAR_USERNAME = check_setting_str(CFG, 'Boxcar', 'boxcar_username', '') + + CheckSection(CFG, 'Pushover') + USE_PUSHOVER = bool(check_setting_int(CFG, 'Pushover', 'use_pushover', 0)) + PUSHOVER_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Pushover', 'pushover_notify_onsnatch', 0)) + PUSHOVER_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Pushover', 'pushover_notify_ondownload', 0)) + PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Pushover', 'pushover_notify_onsubtitledownload', 0)) + PUSHOVER_USERKEY = check_setting_str(CFG, 'Pushover', 'pushover_userkey', '') + + CheckSection(CFG, 'Libnotify') + USE_LIBNOTIFY = bool(check_setting_int(CFG, 'Libnotify', 'use_libnotify', 0)) + LIBNOTIFY_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Libnotify', 'libnotify_notify_onsnatch', 0)) + LIBNOTIFY_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Libnotify', 'libnotify_notify_ondownload', 0)) + LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Libnotify', 'libnotify_notify_onsubtitledownload', 0)) + + CheckSection(CFG, 'NMJ') + USE_NMJ = bool(check_setting_int(CFG, 'NMJ', 'use_nmj', 0)) + NMJ_HOST = check_setting_str(CFG, 'NMJ', 'nmj_host', '') + NMJ_DATABASE = check_setting_str(CFG, 'NMJ', 'nmj_database', '') + NMJ_MOUNT = check_setting_str(CFG, 'NMJ', 'nmj_mount', '') + + CheckSection(CFG, 'NMJv2') + USE_NMJv2 = bool(check_setting_int(CFG, 'NMJv2', 'use_nmjv2', 0)) + NMJv2_HOST = check_setting_str(CFG, 'NMJv2', 'nmjv2_host', '') + NMJv2_DATABASE = check_setting_str(CFG, 'NMJv2', 'nmjv2_database', '') + NMJv2_DBLOC = check_setting_str(CFG, 'NMJv2', 'nmjv2_dbloc', '') + + CheckSection(CFG, 'Synology') + USE_SYNOINDEX = bool(check_setting_int(CFG, 'Synology', 'use_synoindex', 0)) + + CheckSection(CFG, 'SynologyNotifier') + USE_SYNOLOGYNOTIFIER = bool(check_setting_int(CFG, 'SynologyNotifier', 'use_synologynotifier', 0)) + SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'SynologyNotifier', 'synologynotifier_notify_onsnatch', 0)) + SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'SynologyNotifier', 'synologynotifier_notify_ondownload', 0)) + SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'SynologyNotifier', 'synologynotifier_notify_onsubtitledownload', 0)) + + + CheckSection(CFG, 'Trakt') + USE_TRAKT = bool(check_setting_int(CFG, 'Trakt', 'use_trakt', 0)) + TRAKT_USERNAME = check_setting_str(CFG, 'Trakt', 'trakt_username', '') + TRAKT_PASSWORD = check_setting_str(CFG, 'Trakt', 'trakt_password', '') + TRAKT_API = check_setting_str(CFG, 'Trakt', 'trakt_api', '') + TRAKT_REMOVE_WATCHLIST = bool(check_setting_int(CFG, 'Trakt', 'trakt_remove_watchlist', 0)) + TRAKT_USE_WATCHLIST = bool(check_setting_int(CFG, 'Trakt', 'trakt_use_watchlist', 0)) + TRAKT_METHOD_ADD = check_setting_str(CFG, 'Trakt', 'trakt_method_add', "0") + TRAKT_START_PAUSED = bool(check_setting_int(CFG, 'Trakt', 'trakt_start_paused', 0)) + + CheckSection(CFG, 'pyTivo') + USE_PYTIVO = bool(check_setting_int(CFG, 'pyTivo', 'use_pytivo', 0)) + PYTIVO_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'pyTivo', 'pytivo_notify_onsnatch', 0)) + PYTIVO_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'pyTivo', 'pytivo_notify_ondownload', 0)) + PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'pyTivo', 'pytivo_notify_onsubtitledownload', 0)) + PYTIVO_UPDATE_LIBRARY = bool(check_setting_int(CFG, 'pyTivo', 'pyTivo_update_library', 0)) + PYTIVO_HOST = check_setting_str(CFG, 'pyTivo', 'pytivo_host', '') + PYTIVO_SHARE_NAME = check_setting_str(CFG, 'pyTivo', 'pytivo_share_name', '') + PYTIVO_TIVO_NAME = check_setting_str(CFG, 'pyTivo', 'pytivo_tivo_name', '') + + CheckSection(CFG, 'NMA') + USE_NMA = bool(check_setting_int(CFG, 'NMA', 'use_nma', 0)) + NMA_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'NMA', 'nma_notify_onsnatch', 0)) + NMA_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'NMA', 'nma_notify_ondownload', 0)) + NMA_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'NMA', 'nma_notify_onsubtitledownload', 0)) + NMA_API = check_setting_str(CFG, 'NMA', 'nma_api', '') + NMA_PRIORITY = check_setting_str(CFG, 'NMA', 'nma_priority', "0") + + CheckSection(CFG, 'Pushalot') + USE_PUSHALOT = bool(check_setting_int(CFG, 'Pushalot', 'use_pushalot', 0)) + PUSHALOT_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Pushalot', 'pushalot_notify_onsnatch', 0)) + PUSHALOT_NOTIFY_ONDOWNLOAD = bool(check_setting_int(CFG, 'Pushalot', 'pushalot_notify_ondownload', 0)) + PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD = bool(check_setting_int(CFG, 'Pushalot', 'pushalot_notify_onsubtitledownload', 0)) + PUSHALOT_AUTHORIZATIONTOKEN = check_setting_str(CFG, 'Pushalot', 'pushalot_authorizationtoken', '') + + CheckSection(CFG, 'Mail') + USE_MAIL = bool(check_setting_int(CFG, 'Mail', 'use_mail', 0)) + MAIL_USERNAME = check_setting_str(CFG, 'Mail', 'mail_username', '') + MAIL_PASSWORD = check_setting_str(CFG, 'Mail', 'mail_password', '') + MAIL_SERVER = check_setting_str(CFG, 'Mail', 'mail_server', '') + MAIL_SSL = bool(check_setting_int(CFG, 'Mail', 'mail_ssl', 0)) + MAIL_FROM = check_setting_str(CFG, 'Mail', 'mail_from', '') + MAIL_TO = check_setting_str(CFG, 'Mail', 'mail_to', '') + MAIL_NOTIFY_ONSNATCH = bool(check_setting_int(CFG, 'Mail', 'mail_notify_onsnatch', 0)) + + + USE_SUBTITLES = bool(check_setting_int(CFG, 'Subtitles', 'use_subtitles', 0)) + SUBTITLES_LANGUAGES = check_setting_str(CFG, 'Subtitles', 'subtitles_languages', '').split(',') + if SUBTITLES_LANGUAGES[0] == '': + SUBTITLES_LANGUAGES = [] + SUBTITLES_DIR = check_setting_str(CFG, 'Subtitles', 'subtitles_dir', '') + SUBTITLES_DIR_SUB = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_dir_sub', 0)) + SUBSNOLANG = bool(check_setting_int(CFG, 'Subtitles', 'subsnolang', 0)) + SUBTITLES_SERVICES_LIST = check_setting_str(CFG, 'Subtitles', 'SUBTITLES_SERVICES_LIST', '').split(',') + SUBTITLES_SERVICES_ENABLED = [int(x) for x in check_setting_str(CFG, 'Subtitles', 'SUBTITLES_SERVICES_ENABLED', '').split('|') if x] + SUBTITLES_DEFAULT = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_default', 0)) + SUBTITLES_HISTORY = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_history', 0)) + # start up all the threads + logger.sb_log_instance.initLogging(consoleLogging=consoleLogging) + + # initialize the main SB database + db.upgradeDatabase(db.DBConnection(), mainDB.InitialSchema) + + # initialize the cache database + db.upgradeDatabase(db.DBConnection("cache.db"), cache_db.InitialSchema) + + # fix up any db problems + db.sanityCheckDatabase(db.DBConnection(), mainDB.MainSanityCheck) + + # migrate the config if it needs it + migrator = ConfigMigrator(CFG) + migrator.migrate_config() + + currentSearchScheduler = scheduler.Scheduler(searchCurrent.CurrentSearcher(), + cycleTime=datetime.timedelta(minutes=SEARCH_FREQUENCY), + threadName="SEARCH", + runImmediately=True) + + # the interval for this is stored inside the ShowUpdater class + showUpdaterInstance = showUpdater.ShowUpdater() + if UPDATE_SHOWS_ON_START == True: + showUpdateScheduler = scheduler.Scheduler(showUpdaterInstance, + cycleTime=showUpdaterInstance.updateInterval, + threadName="SHOWUPDATER", + runImmediately=True) + + else: + showUpdateScheduler = scheduler.Scheduler(showUpdaterInstance, + cycleTime=showUpdaterInstance.updateInterval, + threadName="SHOWUPDATER", + runImmediately=False) + + + versionCheckScheduler = scheduler.Scheduler(versionChecker.CheckVersion(), + cycleTime=datetime.timedelta(hours=12), + threadName="CHECKVERSION", + runImmediately=True) + + showQueueScheduler = scheduler.Scheduler(show_queue.ShowQueue(), + cycleTime=datetime.timedelta(seconds=3), + threadName="SHOWQUEUE", + silent=True) + + searchQueueScheduler = scheduler.Scheduler(search_queue.SearchQueue(), + cycleTime=datetime.timedelta(seconds=3), + threadName="SEARCHQUEUE", + silent=True) + + properFinderInstance = properFinder.ProperFinder() + properFinderScheduler = scheduler.Scheduler(properFinderInstance, + cycleTime=properFinderInstance.updateInterval, + threadName="FINDPROPERS", + runImmediately=False) + + if PROCESS_AUTOMATICALLY: + autoPostProcesserScheduler = scheduler.Scheduler(autoPostProcesser.PostProcesser( TV_DOWNLOAD_DIR ), + cycleTime=datetime.timedelta(minutes=10), + threadName="NZB_POSTPROCESSER", + runImmediately=True) + + if PROCESS_AUTOMATICALLY_TORRENT: + autoTorrentPostProcesserScheduler = scheduler.Scheduler(autoPostProcesser.PostProcesser( TORRENT_DOWNLOAD_DIR ), + cycleTime=datetime.timedelta(minutes=10), + threadName="TORRENT_POSTPROCESSER", + runImmediately=True) + + traktWatchListCheckerSchedular = scheduler.Scheduler(traktWatchListChecker.TraktChecker(), + cycleTime=datetime.timedelta(minutes=10), + threadName="TRAKTWATCHLIST", + runImmediately=True) + + backlogSearchScheduler = searchBacklog.BacklogSearchScheduler(searchBacklog.BacklogSearcher(), + cycleTime=datetime.timedelta(minutes=get_backlog_cycle_time()), + threadName="BACKLOG", + runImmediately=True) + backlogSearchScheduler.action.cycleTime = BACKLOG_SEARCH_FREQUENCY + + + subtitlesFinderScheduler = scheduler.Scheduler(subtitles.SubtitlesFinder(), + cycleTime=datetime.timedelta(hours=1), + threadName="FINDSUBTITLES", + runImmediately=True) + + showList = [] + loadingShowList = {} + + __INITIALIZED__ = True + return True + + +def start(): + + global __INITIALIZED__, currentSearchScheduler, backlogSearchScheduler, \ + showUpdateScheduler, versionCheckScheduler, showQueueScheduler, \ + properFinderScheduler, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, searchQueueScheduler, \ + subtitlesFinderScheduler, started, USE_SUBTITLES, \ + traktWatchListCheckerSchedular, started + + with INIT_LOCK: + + if __INITIALIZED__: + + # start the search scheduler + currentSearchScheduler.thread.start() + + # start the backlog scheduler + backlogSearchScheduler.thread.start() + + # start the show updater + showUpdateScheduler.thread.start() + + # start the version checker + versionCheckScheduler.thread.start() + + # start the queue checker + showQueueScheduler.thread.start() + + # start the search queue checker + searchQueueScheduler.thread.start() + + # start the queue checker + properFinderScheduler.thread.start() + + if autoPostProcesserScheduler: + autoPostProcesserScheduler.thread.start() + + if autoTorrentPostProcesserScheduler: + autoTorrentPostProcesserScheduler.thread.start() + + # start the subtitles finder + if USE_SUBTITLES: + subtitlesFinderScheduler.thread.start() + + # start the trakt watchlist + traktWatchListCheckerSchedular.thread.start() + + started = True + +def halt(): + + global __INITIALIZED__, currentSearchScheduler, backlogSearchScheduler, showUpdateScheduler, \ + showQueueScheduler, properFinderScheduler, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, searchQueueScheduler, \ + subtitlesFinderScheduler, started, \ + traktWatchListCheckerSchedular + + with INIT_LOCK: + + if __INITIALIZED__: + + logger.log(u"Aborting all threads") + + # abort all the threads + + currentSearchScheduler.abort = True + logger.log(u"Waiting for the SEARCH thread to exit") + try: + currentSearchScheduler.thread.join(10) + except: + pass + + backlogSearchScheduler.abort = True + logger.log(u"Waiting for the BACKLOG thread to exit") + try: + backlogSearchScheduler.thread.join(10) + except: + pass + + showUpdateScheduler.abort = True + logger.log(u"Waiting for the SHOWUPDATER thread to exit") + try: + showUpdateScheduler.thread.join(10) + except: + pass + + versionCheckScheduler.abort = True + logger.log(u"Waiting for the VERSIONCHECKER thread to exit") + try: + versionCheckScheduler.thread.join(10) + except: + pass + + showQueueScheduler.abort = True + logger.log(u"Waiting for the SHOWQUEUE thread to exit") + try: + showQueueScheduler.thread.join(10) + except: + pass + + searchQueueScheduler.abort = True + logger.log(u"Waiting for the SEARCHQUEUE thread to exit") + try: + searchQueueScheduler.thread.join(10) + except: + pass + + if autoPostProcesserScheduler: + autoPostProcesserScheduler.abort = True + logger.log(u"Waiting for the NZB_POSTPROCESSER thread to exit") + try: + autoPostProcesserScheduler.thread.join(10) + except: + pass + + if autoTorrentPostProcesserScheduler: + autoTorrentPostProcesserScheduler.abort = True + logger.log(u"Waiting for the TORRENT_POSTPROCESSER thread to exit") + try: + autoTorrentPostProcesserScheduler.thread.join(10) + except: + pass + traktWatchListCheckerSchedular.abort = True + logger.log(u"Waiting for the TRAKTWATCHLIST thread to exit") + try: + traktWatchListCheckerSchedular.thread.join(10) + except: + pass + + properFinderScheduler.abort = True + logger.log(u"Waiting for the PROPERFINDER thread to exit") + try: + properFinderScheduler.thread.join(10) + except: + pass + + subtitlesFinderScheduler.abort = True + logger.log(u"Waiting for the SUBTITLESFINDER thread to exit") + try: + subtitlesFinderScheduler.thread.join(10) + except: + pass + + + __INITIALIZED__ = False + + +def sig_handler(signum=None, frame=None): + if type(signum) != type(None): + logger.log(u"Signal %i caught, saving and exiting..." % int(signum)) + saveAndShutdown() + + +def saveAll(): + + global showList + + # write all shows + logger.log(u"Saving all shows to the database") + for show in showList: + show.saveToDB() + + # save config + logger.log(u"Saving config file to disk") + save_config() + + +def saveAndShutdown(restart=False): + + halt() + + saveAll() + + logger.log(u"Killing cherrypy") + cherrypy.engine.exit() + + if CREATEPID: + logger.log(u"Removing pidfile " + str(PIDFILE)) + os.remove(PIDFILE) + + if restart: + install_type = versionCheckScheduler.action.install_type + + popen_list = [] + + if install_type in ('git', 'source'): + popen_list = [sys.executable, MY_FULLNAME] + elif install_type == 'win': + if hasattr(sys, 'frozen'): + # c:\dir\to\updater.exe 12345 c:\dir\to\sickbeard.exe + popen_list = [os.path.join(PROG_DIR, 'updater.exe'), str(PID), sys.executable] + else: + logger.log(u"Unknown SB launch method, please file a bug report about this", logger.ERROR) + popen_list = [sys.executable, os.path.join(PROG_DIR, 'updater.py'), str(PID), sys.executable, MY_FULLNAME ] + + if popen_list: + popen_list += MY_ARGS + if '--nolaunch' not in popen_list: + popen_list += ['--nolaunch'] + logger.log(u"Restarting Sick Beard with " + str(popen_list)) + subprocess.Popen(popen_list, cwd=os.getcwd()) + + os._exit(0) + + +def invoke_command(to_call, *args, **kwargs): + global invoked_command + + def delegate(): + to_call(*args, **kwargs) + invoked_command = delegate + logger.log(u"Placed invoked command: " + repr(invoked_command) + " for " + repr(to_call) + " with " + repr(args) + " and " + repr(kwargs), logger.DEBUG) + + +def invoke_restart(soft=True): + invoke_command(restart, soft=soft) + + +def invoke_shutdown(): + invoke_command(saveAndShutdown) + + +def restart(soft=True): + if soft: + halt() + saveAll() + #logger.log(u"Restarting cherrypy") + #cherrypy.engine.restart() + logger.log(u"Re-initializing all data") + initialize() + + else: + saveAndShutdown(restart=True) + + +def save_config(): + + new_config = ConfigObj() + new_config.filename = CONFIG_FILE + + new_config['General'] = {} + new_config['General']['log_dir'] = LOG_DIR + new_config['General']['web_port'] = WEB_PORT + new_config['General']['web_host'] = WEB_HOST + new_config['General']['web_ipv6'] = int(WEB_IPV6) + new_config['General']['web_log'] = int(WEB_LOG) + new_config['General']['web_root'] = WEB_ROOT + new_config['General']['web_username'] = WEB_USERNAME + new_config['General']['web_password'] = WEB_PASSWORD + new_config['General']['use_api'] = int(USE_API) + new_config['General']['api_key'] = API_KEY + new_config['General']['enable_https'] = int(ENABLE_HTTPS) + new_config['General']['https_cert'] = HTTPS_CERT + new_config['General']['https_key'] = HTTPS_KEY + new_config['General']['use_nzbs'] = int(USE_NZBS) + new_config['General']['use_torrents'] = int(USE_TORRENTS) + new_config['General']['nzb_method'] = NZB_METHOD + new_config['General']['torrent_method'] = TORRENT_METHOD + new_config['General']['prefered_method'] = PREFERED_METHOD + new_config['General']['usenet_retention'] = int(USENET_RETENTION) + new_config['General']['search_frequency'] = int(SEARCH_FREQUENCY) + new_config['General']['download_propers'] = int(DOWNLOAD_PROPERS) + new_config['General']['quality_default'] = int(QUALITY_DEFAULT) + new_config['General']['status_default'] = int(STATUS_DEFAULT) + new_config['General']['audio_show_default'] = AUDIO_SHOW_DEFAULT + new_config['General']['flatten_folders_default'] = int(FLATTEN_FOLDERS_DEFAULT) + new_config['General']['provider_order'] = ' '.join([x.getID() for x in providers.sortedProviderList()]) + new_config['General']['version_notify'] = int(VERSION_NOTIFY) + new_config['General']['naming_pattern'] = NAMING_PATTERN + new_config['General']['naming_custom_abd'] = int(NAMING_CUSTOM_ABD) + new_config['General']['naming_abd_pattern'] = NAMING_ABD_PATTERN + new_config['General']['naming_multi_ep'] = int(NAMING_MULTI_EP) + new_config['General']['launch_browser'] = int(LAUNCH_BROWSER) + new_config['General']['update_shows_on_start'] = int(UPDATE_SHOWS_ON_START) + new_config['General']['sort_article'] = int(SORT_ARTICLE) + + new_config['General']['use_banner'] = int(USE_BANNER) + new_config['General']['use_listview'] = int(USE_LISTVIEW) + new_config['General']['metadata_xbmc'] = metadata_provider_dict['XBMC'].get_config() + new_config['General']['metadata_xbmcfrodo'] = metadata_provider_dict['XBMC (Frodo)'].get_config() + new_config['General']['metadata_mediabrowser'] = metadata_provider_dict['MediaBrowser'].get_config() + new_config['General']['metadata_ps3'] = metadata_provider_dict['Sony PS3'].get_config() + new_config['General']['metadata_wdtv'] = metadata_provider_dict['WDTV'].get_config() + new_config['General']['metadata_tivo'] = metadata_provider_dict['TIVO'].get_config() + new_config['General']['metadata_synology'] = metadata_provider_dict['Synology'].get_config() + + new_config['General']['cache_dir'] = ACTUAL_CACHE_DIR if ACTUAL_CACHE_DIR else 'cache' + new_config['General']['root_dirs'] = ROOT_DIRS if ROOT_DIRS else '' + new_config['General']['tv_download_dir'] = TV_DOWNLOAD_DIR + new_config['General']['torrent_download_dir'] = TORRENT_DOWNLOAD_DIR + new_config['General']['keep_processed_dir'] = int(KEEP_PROCESSED_DIR) + new_config['General']['move_associated_files'] = int(MOVE_ASSOCIATED_FILES) + new_config['General']['process_automatically'] = int(PROCESS_AUTOMATICALLY) + new_config['General']['process_automatically_torrent'] = int(PROCESS_AUTOMATICALLY_TORRENT) + new_config['General']['rename_episodes'] = int(RENAME_EPISODES) + new_config['General']['create_missing_show_dirs'] = CREATE_MISSING_SHOW_DIRS + new_config['General']['add_shows_wo_dir'] = ADD_SHOWS_WO_DIR + + new_config['General']['extra_scripts'] = '|'.join(EXTRA_SCRIPTS) + new_config['General']['git_path'] = GIT_PATH + new_config['General']['ignore_words'] = IGNORE_WORDS + + new_config['Blackhole'] = {} + new_config['Blackhole']['nzb_dir'] = NZB_DIR + new_config['Blackhole']['torrent_dir'] = TORRENT_DIR + + new_config['EZRSS'] = {} + new_config['EZRSS']['ezrss'] = int(EZRSS) + + new_config['TVTORRENTS'] = {} + new_config['TVTORRENTS']['tvtorrents'] = int(TVTORRENTS) + new_config['TVTORRENTS']['tvtorrents_digest'] = TVTORRENTS_DIGEST + new_config['TVTORRENTS']['tvtorrents_hash'] = TVTORRENTS_HASH + + new_config['BTN'] = {} + new_config['BTN']['btn'] = int(BTN) + new_config['BTN']['btn_api_key'] = BTN_API_KEY + + new_config['TorrentLeech'] = {} + new_config['TorrentLeech']['torrentleech'] = int(TORRENTLEECH) + new_config['TorrentLeech']['torrentleech_key'] = TORRENTLEECH_KEY + + new_config['NZBs'] = {} + new_config['NZBs']['nzbs'] = int(NZBS) + new_config['NZBs']['nzbs_uid'] = NZBS_UID + new_config['NZBs']['nzbs_hash'] = NZBS_HASH + + new_config['NZBsRUS'] = {} + new_config['NZBsRUS']['nzbsrus'] = int(NZBSRUS) + new_config['NZBsRUS']['nzbsrus_uid'] = NZBSRUS_UID + new_config['NZBsRUS']['nzbsrus_hash'] = NZBSRUS_HASH + + new_config['NZBMatrix'] = {} + new_config['NZBMatrix']['nzbmatrix'] = int(NZBMATRIX) + new_config['NZBMatrix']['nzbmatrix_username'] = NZBMATRIX_USERNAME + new_config['NZBMatrix']['nzbmatrix_apikey'] = NZBMATRIX_APIKEY + + new_config['Newzbin'] = {} + new_config['Newzbin']['newzbin'] = int(NEWZBIN) + new_config['Newzbin']['newzbin_username'] = NEWZBIN_USERNAME + new_config['Newzbin']['newzbin_password'] = NEWZBIN_PASSWORD + + new_config['BinNewz'] = {} + new_config['BinNewz']['binnewz'] = int(BINNEWZ) + + new_config['T411'] = {} + new_config['T411']['t411'] = int(T411) + new_config['T411']['username'] = T411_USERNAME + new_config['T411']['password'] = T411_PASSWORD + + new_config['Cpasbien'] = {} + new_config['Cpasbien']['cpasbien'] = int(Cpasbien) + + new_config['kat'] = {} + new_config['kat']['kat'] = int(kat) + + new_config['PirateBay'] = {} + new_config['PirateBay']['piratebay'] = int(THEPIRATEBAY) + new_config['PirateBay']['piratebay_proxy'] = THEPIRATEBAY_PROXY + new_config['PirateBay']['piratebay_proxy_url'] = THEPIRATEBAY_PROXY_URL + new_config['PirateBay']['piratebay_trusted'] = THEPIRATEBAY_TRUSTED + + new_config['Womble'] = {} + new_config['Womble']['womble'] = int(WOMBLE) + + new_config['nzbX'] = {} + new_config['nzbX']['nzbx'] = int(NZBX) + new_config['nzbX']['nzbx_completion'] = int(NZBX_COMPLETION) + + new_config['omgwtfnzbs'] = {} + new_config['omgwtfnzbs']['omgwtfnzbs'] = int(OMGWTFNZBS) + new_config['omgwtfnzbs']['omgwtfnzbs_uid'] = OMGWTFNZBS_UID + new_config['omgwtfnzbs']['omgwtfnzbs_key'] = OMGWTFNZBS_KEY + + new_config['GKS'] = {} + new_config['GKS']['gks'] = int(GKS) + new_config['GKS']['gks_key'] = GKS_KEY + + new_config['SABnzbd'] = {} + new_config['SABnzbd']['sab_username'] = SAB_USERNAME + new_config['SABnzbd']['sab_password'] = SAB_PASSWORD + new_config['SABnzbd']['sab_apikey'] = SAB_APIKEY + new_config['SABnzbd']['sab_category'] = SAB_CATEGORY + new_config['SABnzbd']['sab_host'] = SAB_HOST + + new_config['NZBget'] = {} + new_config['NZBget']['nzbget_password'] = NZBGET_PASSWORD + new_config['NZBget']['nzbget_category'] = NZBGET_CATEGORY + new_config['NZBget']['nzbget_host'] = NZBGET_HOST + + new_config['TORRENT'] = {} + new_config['TORRENT']['torrent_username'] = TORRENT_USERNAME + new_config['TORRENT']['torrent_password'] = TORRENT_PASSWORD + new_config['TORRENT']['torrent_host'] = TORRENT_HOST + new_config['TORRENT']['torrent_path'] = TORRENT_PATH + new_config['TORRENT']['torrent_ratio'] = TORRENT_RATIO + new_config['TORRENT']['torrent_paused'] = int(TORRENT_PAUSED) + new_config['TORRENT']['torrent_label'] = TORRENT_LABEL + + new_config['XBMC'] = {} + new_config['XBMC']['use_xbmc'] = int(USE_XBMC) + new_config['XBMC']['xbmc_notify_onsnatch'] = int(XBMC_NOTIFY_ONSNATCH) + new_config['XBMC']['xbmc_notify_ondownload'] = int(XBMC_NOTIFY_ONDOWNLOAD) + new_config['XBMC']['xbmc_notify_onsubtitledownload'] = int(XBMC_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['XBMC']['xbmc_update_library'] = int(XBMC_UPDATE_LIBRARY) + new_config['XBMC']['xbmc_update_full'] = int(XBMC_UPDATE_FULL) + new_config['XBMC']['xbmc_update_onlyfirst'] = int(XBMC_UPDATE_ONLYFIRST) + new_config['XBMC']['xbmc_host'] = XBMC_HOST + new_config['XBMC']['xbmc_username'] = XBMC_USERNAME + new_config['XBMC']['xbmc_password'] = XBMC_PASSWORD + + new_config['Plex'] = {} + new_config['Plex']['use_plex'] = int(USE_PLEX) + new_config['Plex']['plex_notify_onsnatch'] = int(PLEX_NOTIFY_ONSNATCH) + new_config['Plex']['plex_notify_ondownload'] = int(PLEX_NOTIFY_ONDOWNLOAD) + new_config['Plex']['plex_notify_onsubtitledownload'] = int(PLEX_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Plex']['plex_update_library'] = int(PLEX_UPDATE_LIBRARY) + new_config['Plex']['plex_server_host'] = PLEX_SERVER_HOST + new_config['Plex']['plex_host'] = PLEX_HOST + new_config['Plex']['plex_username'] = PLEX_USERNAME + new_config['Plex']['plex_password'] = PLEX_PASSWORD + + new_config['Growl'] = {} + new_config['Growl']['use_growl'] = int(USE_GROWL) + new_config['Growl']['growl_notify_onsnatch'] = int(GROWL_NOTIFY_ONSNATCH) + new_config['Growl']['growl_notify_ondownload'] = int(GROWL_NOTIFY_ONDOWNLOAD) + new_config['Growl']['growl_notify_onsubtitledownload'] = int(GROWL_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Growl']['growl_host'] = GROWL_HOST + new_config['Growl']['growl_password'] = GROWL_PASSWORD + + new_config['Prowl'] = {} + new_config['Prowl']['use_prowl'] = int(USE_PROWL) + new_config['Prowl']['prowl_notify_onsnatch'] = int(PROWL_NOTIFY_ONSNATCH) + new_config['Prowl']['prowl_notify_ondownload'] = int(PROWL_NOTIFY_ONDOWNLOAD) + new_config['Prowl']['prowl_notify_onsubtitledownload'] = int(PROWL_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Prowl']['prowl_api'] = PROWL_API + new_config['Prowl']['prowl_priority'] = PROWL_PRIORITY + + new_config['Twitter'] = {} + new_config['Twitter']['use_twitter'] = int(USE_TWITTER) + new_config['Twitter']['twitter_notify_onsnatch'] = int(TWITTER_NOTIFY_ONSNATCH) + new_config['Twitter']['twitter_notify_ondownload'] = int(TWITTER_NOTIFY_ONDOWNLOAD) + new_config['Twitter']['twitter_notify_onsubtitledownload'] = int(TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Twitter']['twitter_username'] = TWITTER_USERNAME + new_config['Twitter']['twitter_password'] = TWITTER_PASSWORD + new_config['Twitter']['twitter_prefix'] = TWITTER_PREFIX + + new_config['Notifo'] = {} + new_config['Notifo']['use_notifo'] = int(USE_NOTIFO) + new_config['Notifo']['notifo_notify_onsnatch'] = int(NOTIFO_NOTIFY_ONSNATCH) + new_config['Notifo']['notifo_notify_ondownload'] = int(NOTIFO_NOTIFY_ONDOWNLOAD) + new_config['Notifo']['notifo_notify_onsubtitledownload'] = int(NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Notifo']['notifo_username'] = NOTIFO_USERNAME + new_config['Notifo']['notifo_apisecret'] = NOTIFO_APISECRET + + new_config['Boxcar'] = {} + new_config['Boxcar']['use_boxcar'] = int(USE_BOXCAR) + new_config['Boxcar']['boxcar_notify_onsnatch'] = int(BOXCAR_NOTIFY_ONSNATCH) + new_config['Boxcar']['boxcar_notify_ondownload'] = int(BOXCAR_NOTIFY_ONDOWNLOAD) + new_config['Boxcar']['boxcar_notify_onsubtitledownload'] = int(BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Boxcar']['boxcar_username'] = BOXCAR_USERNAME + + new_config['Pushover'] = {} + new_config['Pushover']['use_pushover'] = int(USE_PUSHOVER) + new_config['Pushover']['pushover_notify_onsnatch'] = int(PUSHOVER_NOTIFY_ONSNATCH) + new_config['Pushover']['pushover_notify_ondownload'] = int(PUSHOVER_NOTIFY_ONDOWNLOAD) + new_config['Pushover']['pushover_notify_onsubtitledownload'] = int(PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Pushover']['pushover_userkey'] = PUSHOVER_USERKEY + + new_config['Libnotify'] = {} + new_config['Libnotify']['use_libnotify'] = int(USE_LIBNOTIFY) + new_config['Libnotify']['libnotify_notify_onsnatch'] = int(LIBNOTIFY_NOTIFY_ONSNATCH) + new_config['Libnotify']['libnotify_notify_ondownload'] = int(LIBNOTIFY_NOTIFY_ONDOWNLOAD) + new_config['Libnotify']['libnotify_notify_onsubtitledownload'] = int(LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD) + + new_config['NMJ'] = {} + new_config['NMJ']['use_nmj'] = int(USE_NMJ) + new_config['NMJ']['nmj_host'] = NMJ_HOST + new_config['NMJ']['nmj_database'] = NMJ_DATABASE + new_config['NMJ']['nmj_mount'] = NMJ_MOUNT + + new_config['Synology'] = {} + new_config['Synology']['use_synoindex'] = int(USE_SYNOINDEX) + + new_config['SynologyNotifier'] = {} + new_config['SynologyNotifier']['use_synologynotifier'] = int(USE_SYNOLOGYNOTIFIER) + new_config['SynologyNotifier']['synologynotifier_notify_onsnatch'] = int(SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH) + new_config['SynologyNotifier']['synologynotifier_notify_ondownload'] = int(SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD) + new_config['SynologyNotifier']['synologynotifier_notify_onsubtitledownload'] = int(SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD) + + + new_config['NMJv2'] = {} + new_config['NMJv2']['use_nmjv2'] = int(USE_NMJv2) + new_config['NMJv2']['nmjv2_host'] = NMJv2_HOST + new_config['NMJv2']['nmjv2_database'] = NMJv2_DATABASE + new_config['NMJv2']['nmjv2_dbloc'] = NMJv2_DBLOC + + new_config['Trakt'] = {} + new_config['Trakt']['use_trakt'] = int(USE_TRAKT) + new_config['Trakt']['trakt_username'] = TRAKT_USERNAME + new_config['Trakt']['trakt_password'] = TRAKT_PASSWORD + new_config['Trakt']['trakt_api'] = TRAKT_API + new_config['Trakt']['trakt_remove_watchlist'] = int(TRAKT_REMOVE_WATCHLIST) + new_config['Trakt']['trakt_use_watchlist'] = int(TRAKT_USE_WATCHLIST) + new_config['Trakt']['trakt_method_add'] = TRAKT_METHOD_ADD + new_config['Trakt']['trakt_start_paused'] = int(TRAKT_START_PAUSED) + + new_config['pyTivo'] = {} + new_config['pyTivo']['use_pytivo'] = int(USE_PYTIVO) + new_config['pyTivo']['pytivo_notify_onsnatch'] = int(PYTIVO_NOTIFY_ONSNATCH) + new_config['pyTivo']['pytivo_notify_ondownload'] = int(PYTIVO_NOTIFY_ONDOWNLOAD) + new_config['pyTivo']['pytivo_notify_onsubtitledownload'] = int(PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['pyTivo']['pyTivo_update_library'] = int(PYTIVO_UPDATE_LIBRARY) + new_config['pyTivo']['pytivo_host'] = PYTIVO_HOST + new_config['pyTivo']['pytivo_share_name'] = PYTIVO_SHARE_NAME + new_config['pyTivo']['pytivo_tivo_name'] = PYTIVO_TIVO_NAME + + new_config['NMA'] = {} + new_config['NMA']['use_nma'] = int(USE_NMA) + new_config['NMA']['nma_notify_onsnatch'] = int(NMA_NOTIFY_ONSNATCH) + new_config['NMA']['nma_notify_ondownload'] = int(NMA_NOTIFY_ONDOWNLOAD) + new_config['NMA']['nma_notify_onsubtitledownload'] = int(NMA_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['NMA']['nma_api'] = NMA_API + new_config['NMA']['nma_priority'] = NMA_PRIORITY + + new_config['Pushalot'] = {} + new_config['Pushalot']['use_pushalot'] = int(USE_PUSHALOT) + new_config['Pushalot']['pushalot_notify_onsnatch'] = int(PUSHALOT_NOTIFY_ONSNATCH) + new_config['Pushalot']['pushalot_notify_ondownload'] = int(PUSHALOT_NOTIFY_ONDOWNLOAD) + new_config['Pushalot']['pushalot_notify_onsubtitledownload'] = int(PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD) + new_config['Pushalot']['pushalot_authorizationtoken'] = PUSHALOT_AUTHORIZATIONTOKEN + + new_config['Mail'] = {} + new_config['Mail']['use_mail'] = int(USE_MAIL) + new_config['Mail']['mail_username'] = MAIL_USERNAME + new_config['Mail']['mail_password'] = MAIL_PASSWORD + new_config['Mail']['mail_server'] = MAIL_SERVER + new_config['Mail']['mail_ssl'] = int(MAIL_SSL) + new_config['Mail']['mail_from'] = MAIL_FROM + new_config['Mail']['mail_to'] = MAIL_TO + new_config['Mail']['mail_notify_onsnatch'] = int(MAIL_NOTIFY_ONSNATCH) + + new_config['Newznab'] = {} + new_config['Newznab']['newznab_data'] = '!!!'.join([x.configStr() for x in newznabProviderList]) + + new_config['GUI'] = {} + new_config['GUI']['home_layout'] = HOME_LAYOUT + new_config['GUI']['display_show_specials'] = int(DISPLAY_SHOW_SPECIALS) + new_config['GUI']['coming_eps_layout'] = COMING_EPS_LAYOUT + new_config['GUI']['coming_eps_display_paused'] = int(COMING_EPS_DISPLAY_PAUSED) + new_config['GUI']['coming_eps_sort'] = COMING_EPS_SORT + new_config['GUI']['coming_eps_missed_range'] = int(COMING_EPS_MISSED_RANGE) + + new_config['Subtitles'] = {} + new_config['Subtitles']['use_subtitles'] = int(USE_SUBTITLES) + new_config['Subtitles']['subtitles_languages'] = ','.join(SUBTITLES_LANGUAGES) + new_config['Subtitles']['SUBTITLES_SERVICES_LIST'] = ','.join(SUBTITLES_SERVICES_LIST) + new_config['Subtitles']['SUBTITLES_SERVICES_ENABLED'] = '|'.join([str(x) for x in SUBTITLES_SERVICES_ENABLED]) + new_config['Subtitles']['subtitles_dir'] = SUBTITLES_DIR + new_config['Subtitles']['subtitles_dir_sub'] = int(SUBTITLES_DIR_SUB) + new_config['Subtitles']['subsnolang'] = int(SUBSNOLANG) + new_config['Subtitles']['subtitles_default'] = int(SUBTITLES_DEFAULT) + new_config['Subtitles']['subtitles_history'] = int(SUBTITLES_HISTORY) + + new_config['General']['config_version'] = CONFIG_VERSION + + new_config.write() + + +def launchBrowser(startPort=None): + if not startPort: + startPort = WEB_PORT + if ENABLE_HTTPS: + browserURL = 'https://localhost:%d%s' % (startPort, WEB_ROOT) + else: + browserURL = 'http://localhost:%d%s' % (startPort, WEB_ROOT) + try: + webbrowser.open(browserURL, 2, 1) + except: + try: + webbrowser.open(browserURL, 1, 1) + except: + logger.log(u"Unable to launch a browser", logger.ERROR) + + +def getEpList(epIDs, showid=None): + if epIDs == None or len(epIDs) == 0: + return [] + + query = "SELECT * FROM tv_episodes WHERE tvdbid in (%s)" % (",".join(['?'] * len(epIDs)),) + params = epIDs + + if showid != None: + query += " AND showid = ?" + params.append(showid) + + myDB = db.DBConnection() + sqlResults = myDB.select(query, params) + + epList = [] + + for curEp in sqlResults: + curShowObj = helpers.findCertainShow(showList, int(curEp["showid"])) + curEpObj = curShowObj.getEpisode(int(curEp["season"]), int(curEp["episode"])) + epList.append(curEpObj) + + return epList diff --git a/sickbeard/databases/cache_db.py b/sickbeard/databases/cache_db.py index 635e8a0db9..5675ab8b74 100644 --- a/sickbeard/databases/cache_db.py +++ b/sickbeard/databases/cache_db.py @@ -48,4 +48,11 @@ def test(self): return self.hasTable("scene_names") def execute(self): - self.connection.action("CREATE TABLE scene_names (tvdb_id INTEGER, name TEXT)") \ No newline at end of file + self.connection.action("CREATE TABLE scene_names (tvdb_id INTEGER, name TEXT)") + +class AddNetworkTimezones(AddSceneNameCache): + def test(self): + return self.hasTable("network_timezones") + + def execute(self): + self.connection.action("CREATE TABLE network_timezones (network_name TEXT PRIMARY KEY, timezone TEXT)") \ No newline at end of file diff --git a/sickbeard/databases/mainDB.py b/sickbeard/databases/mainDB.py index 56225c7d70..fbed18b5d1 100644 --- a/sickbeard/databases/mainDB.py +++ b/sickbeard/databases/mainDB.py @@ -25,7 +25,7 @@ from sickbeard import encodingKludge as ek from sickbeard.name_parser.parser import NameParser, InvalidNameException -MAX_DB_VERSION = 14 +MAX_DB_VERSION = 15 class MainSanityCheck(db.DBSanityCheck): @@ -101,7 +101,9 @@ def execute(self): "CREATE TABLE tv_episodes (episode_id INTEGER PRIMARY KEY, showid NUMERIC, tvdbid NUMERIC, name TEXT, season NUMERIC, episode NUMERIC, description TEXT, airdate NUMERIC, hasnfo NUMERIC, hastbn NUMERIC, status NUMERIC, location TEXT);", "CREATE TABLE info (last_backlog NUMERIC, last_tvdb NUMERIC);", "CREATE TABLE history (action NUMERIC, date NUMERIC, showid NUMERIC, season NUMERIC, episode NUMERIC, quality NUMERIC, resource TEXT, provider NUMERIC);", - "CREATE TABLE episode_links (episode_id INTEGER, link TEXT);" + "CREATE TABLE episode_links (episode_id INTEGER, link TEXT);", + "CREATE TABLE imdb_info (tvdb_id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, year NUMERIC, akas TEXT, runtimes NUMERIC, genres TEXT, countries TEXT, country_codes TEXT, certificates TEXT, rating TEXT, votes INTEGER, last_update NUMERIC);" + ] for query in queries: self.connection.action(query) @@ -122,6 +124,12 @@ def test(self): def execute(self): self.addColumn("tv_shows", "tvr_name", "TEXT", "") +class AddImdbId (InitialSchema): + def test(self): + return self.hasColumn("tv_shows", "imdb_id") + + def execute(self): + self.addColumn("tv_shows", "imdb_id", "TEXT", "") class AddAirdateIndex (AddTvrName): def test(self): @@ -142,7 +150,9 @@ def test(self): 4: 'eztv', 5: 'nzbmatrix', 6: 'tvnzb', - 7: 'ezrss'} + 7: 'ezrss', + 8: 'thepiratebay', + 9: 'kat'}, def execute(self): self.connection.action("ALTER TABLE history RENAME TO history_old") @@ -707,3 +717,11 @@ def execute(self): if self.hasTable("episode_links") != True: self.connection.action("CREATE TABLE episode_links (episode_id INTEGER, link TEXT)") self.incDBVersion() +class AddIMDbInfo(AddEpisodeLinkTable): + def test(self): + return self.checkDBVersion() >= 15 + + def execute(self): + + self.connection.action("CREATE TABLE imdb_info (tvdb_id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, year NUMERIC, akas TEXT, runtimes NUMERIC, genres TEXT, countries TEXT, country_codes TEXT, certificates TEXT, rating TEXT, votes INTEGER, last_update NUMERIC)") + self.incDBVersion() \ No newline at end of file diff --git a/sickbeard/db.py b/sickbeard/db.py index 34a9b4111b..aace0fb826 100644 --- a/sickbeard/db.py +++ b/sickbeard/db.py @@ -67,15 +67,15 @@ def checkDBVersion(self): return 0 def mass_action(self, querylist, logTransaction=False): - + with db_lock: - + if querylist == None: return - + sqlResult = [] attempt = 0 - + while attempt < 5: try: for qu in querylist: @@ -107,9 +107,9 @@ def mass_action(self, querylist, logTransaction=False): self.connection.rollback() logger.log(u"Fatal error executing query: " + ex(e), logger.ERROR) raise - + return sqlResult - + def action(self, query, args=None): with db_lock: @@ -123,10 +123,10 @@ def action(self, query, args=None): while attempt < 5: try: if args == None: - logger.log(self.filename+": "+query, logger.DEBUG) + logger.log(self.filename+": "+query, logger.DB) sqlResult = self.connection.execute(query) else: - logger.log(self.filename+": "+query+" with args "+str(args), logger.DEBUG) + logger.log(self.filename+": "+query+" with args "+str(args), logger.DB) sqlResult = self.connection.execute(query, args) self.connection.commit() # get out of the connection attempt loop since we were successful diff --git a/sickbeard/helpers.py b/sickbeard/helpers.py index 9621874e0a..93208c7eb6 100644 --- a/sickbeard/helpers.py +++ b/sickbeard/helpers.py @@ -1,751 +1,817 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import StringIO, zlib, gzip -import os -import stat -import urllib, urllib2 -import re, socket -import shutil -import traceback -import time, sys - -from httplib import BadStatusLine - -from xml.dom.minidom import Node - -import sickbeard - -from sickbeard.exceptions import MultipleShowObjectsException, ex -from sickbeard import logger, classes, common -from sickbeard.common import USER_AGENT, mediaExtensions, XML_NSMAP, subtitleExtensions - -from sickbeard import db -from sickbeard import encodingKludge as ek -from sickbeard import notifiers - -from lib.tvdb_api import tvdb_api, tvdb_exceptions - -import xml.etree.cElementTree as etree - -from lib import subliminal -#from sickbeard.subtitles import EXTENSIONS - -urllib._urlopener = classes.SickBeardURLopener() - -def indentXML(elem, level=0): - ''' - Does our pretty printing, makes Matt very happy - ''' - i = "\n" + level*" " - if len(elem): - if not elem.text or not elem.text.strip(): - elem.text = i + " " - if not elem.tail or not elem.tail.strip(): - elem.tail = i - for elem in elem: - indentXML(elem, level+1) - if not elem.tail or not elem.tail.strip(): - elem.tail = i - else: - # Strip out the newlines from text - if elem.text: - elem.text = elem.text.replace('\n', ' ') - if level and (not elem.tail or not elem.tail.strip()): - elem.tail = i - -def replaceExtension(file, newExt): - ''' - >>> replaceExtension('foo.avi', 'mkv') - 'foo.mkv' - >>> replaceExtension('.vimrc', 'arglebargle') - '.vimrc' - >>> replaceExtension('a.b.c', 'd') - 'a.b.d' - >>> replaceExtension('', 'a') - '' - >>> replaceExtension('foo.bar', '') - 'foo.' - ''' - sepFile = file.rpartition(".") - if sepFile[0] == "": - return file - else: - return sepFile[0] + "." + newExt - -def isMediaFile (file): - # ignore samples - if re.search('(^|[\W_])sample\d*[\W_]', file): - return False - - # ignore MAC OS's retarded "resource fork" files - if file.startswith('._'): - return False - - sepFile = file.rpartition(".") - if sepFile[2].lower() in mediaExtensions: - return True - else: - return False - -def sanitizeFileName (name): - ''' - >>> sanitizeFileName('a/b/c') - 'a-b-c' - >>> sanitizeFileName('abc') - 'abc' - >>> sanitizeFileName('a"b') - 'ab' - >>> sanitizeFileName('.a.b..') - 'a.b' - ''' - - # remove bad chars from the filename - name = re.sub(r'[\\/\*]', '-', name) - name = re.sub(r'[:"<>|?]', '', name) - - # remove leading/trailing periods and spaces - name = name.strip(' .') - - return name - - -def getURL (url, headers=[]): - """ - Returns a byte-string retrieved from the url provider. - """ - - opener = urllib2.build_opener() - opener.addheaders = [('User-Agent', USER_AGENT), ('Accept-Encoding', 'gzip,deflate')] - for cur_header in headers: - opener.addheaders.append(cur_header) - - try: - usock = opener.open(url) - url = usock.geturl() - encoding = usock.info().get("Content-Encoding") - - if encoding in ('gzip', 'x-gzip', 'deflate'): - content = usock.read() - if encoding == 'deflate': - data = StringIO.StringIO(zlib.decompress(content)) - else: - data = gzip.GzipFile('', 'rb', 9, StringIO.StringIO(content)) - result = data.read() - - else: - result = usock.read() - - usock.close() - - except urllib2.HTTPError, e: - logger.log(u"HTTP error " + str(e.code) + " while loading URL " + url, logger.WARNING) - return None - except urllib2.URLError, e: - logger.log(u"URL error " + str(e.reason) + " while loading URL " + url, logger.WARNING) - return None - except BadStatusLine: - logger.log(u"BadStatusLine error while loading URL " + url, logger.WARNING) - return None - except socket.timeout: - logger.log(u"Timed out while loading URL " + url, logger.WARNING) - return None - except ValueError: - logger.log(u"Unknown error while loading URL " + url, logger.WARNING) - return None - except Exception: - logger.log(u"Unknown exception while loading URL " + url + ": " + traceback.format_exc(), logger.WARNING) - return None - - return result - -def findCertainShow (showList, tvdbid): - results = filter(lambda x: x.tvdbid == tvdbid, showList) - if len(results) == 0: - return None - elif len(results) > 1: - raise MultipleShowObjectsException() - else: - return results[0] - -def findCertainTVRageShow (showList, tvrid): - - if tvrid == 0: - return None - - results = filter(lambda x: x.tvrid == tvrid, showList) - - if len(results) == 0: - return None - elif len(results) > 1: - raise MultipleShowObjectsException() - else: - return results[0] - - -def makeDir (dir): - if not ek.ek(os.path.isdir, dir): - try: - ek.ek(os.makedirs, dir) - # do the library update for synoindex - notifiers.synoindex_notifier.addFolder(dir) - except OSError: - return False - return True - -def makeShowNFO(showID, showDir): - - logger.log(u"Making NFO for show "+str(showID)+" in dir "+showDir, logger.DEBUG) - - if not makeDir(showDir): - logger.log(u"Unable to create show dir, can't make NFO", logger.ERROR) - return False - - showObj = findCertainShow(sickbeard.showList, showID) - if not showObj: - logger.log(u"This should never have happened, post a bug about this!", logger.ERROR) - raise Exception("BAD STUFF HAPPENED") - - tvdb_lang = showObj.lang - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if tvdb_lang and not tvdb_lang == 'en': - ltvdb_api_parms['language'] = tvdb_lang - - t = tvdb_api.Tvdb(actors=True, **ltvdb_api_parms) - - try: - myShow = t[int(showID)] - except tvdb_exceptions.tvdb_shownotfound: - logger.log(u"Unable to find show with id " + str(showID) + " on tvdb, skipping it", logger.ERROR) - raise - - except tvdb_exceptions.tvdb_error: - logger.log(u"TVDB is down, can't use its data to add this show", logger.ERROR) - raise - - # check for title and id - try: - if myShow["seriesname"] == None or myShow["seriesname"] == "" or myShow["id"] == None or myShow["id"] == "": - logger.log(u"Incomplete info for show with id " + str(showID) + " on tvdb, skipping it", logger.ERROR) - - return False - except tvdb_exceptions.tvdb_attributenotfound: - logger.log(u"Incomplete info for show with id " + str(showID) + " on tvdb, skipping it", logger.ERROR) - - return False - - tvNode = buildNFOXML(myShow) - # Make it purdy - indentXML( tvNode ) - nfo = etree.ElementTree( tvNode ) - - logger.log(u"Writing NFO to "+os.path.join(showDir, "tvshow.nfo"), logger.DEBUG) - nfo_filename = os.path.join(showDir, "tvshow.nfo").encode('utf-8') - nfo_fh = open(nfo_filename, 'w') - nfo.write( nfo_fh, encoding="utf-8" ) - - return True - -def buildNFOXML(myShow): - ''' - Build an etree.Element of the root node of an NFO file with the - data from `myShow`, a TVDB show object. - - >>> from collections import defaultdict - >>> from xml.etree.cElementTree import tostring - >>> show = defaultdict(lambda: None, _actors=[]) - >>> tostring(buildNFOXML(show)) - '<rating /><plot /><episodeguide><url /></episodeguide><mpaa /><id /><genre /><premiered /><studio /></tvshow>' - >>> show['seriesname'] = 'Peaches' - >>> tostring(buildNFOXML(show)) - '<tvshow xsd="http://www.w3.org/2001/XMLSchema" xsi="http://www.w3.org/2001/XMLSchema-instance"><title>Peaches' - >>> show['contentrating'] = 'PG' - >>> tostring(buildNFOXML(show)) - 'PeachesPG' - >>> show['genre'] = 'Fruit|Edibles' - >>> tostring(buildNFOXML(show)) - 'PeachesPGFruit / Edibles' - ''' - tvNode = etree.Element( "tvshow" ) - for ns in XML_NSMAP.keys(): - tvNode.set(ns, XML_NSMAP[ns]) - - title = etree.SubElement( tvNode, "title" ) - if myShow["seriesname"] != None: - title.text = myShow["seriesname"] - - rating = etree.SubElement( tvNode, "rating" ) - if myShow["rating"] != None: - rating.text = myShow["rating"] - - plot = etree.SubElement( tvNode, "plot" ) - if myShow["overview"] != None: - plot.text = myShow["overview"] - - episodeguide = etree.SubElement( tvNode, "episodeguide" ) - episodeguideurl = etree.SubElement( episodeguide, "url" ) - if myShow["id"] != None: - showurl = sickbeard.TVDB_BASE_URL + '/series/' + myShow["id"] + '/all/en.zip' - episodeguideurl.text = showurl - - mpaa = etree.SubElement( tvNode, "mpaa" ) - if myShow["contentrating"] != None: - mpaa.text = myShow["contentrating"] - - tvdbid = etree.SubElement( tvNode, "id" ) - if myShow["id"] != None: - tvdbid.text = myShow["id"] - - genre = etree.SubElement( tvNode, "genre" ) - if myShow["genre"] != None: - genre.text = " / ".join([x for x in myShow["genre"].split('|') if x != '']) - - premiered = etree.SubElement( tvNode, "premiered" ) - if myShow["firstaired"] != None: - premiered.text = myShow["firstaired"] - - studio = etree.SubElement( tvNode, "studio" ) - if myShow["network"] != None: - studio.text = myShow["network"] - - for actor in myShow['_actors']: - - cur_actor = etree.SubElement( tvNode, "actor" ) - - cur_actor_name = etree.SubElement( cur_actor, "name" ) - cur_actor_name.text = actor['name'] - cur_actor_role = etree.SubElement( cur_actor, "role" ) - cur_actor_role_text = actor['role'] - - if cur_actor_role_text != None: - cur_actor_role.text = cur_actor_role_text - - cur_actor_thumb = etree.SubElement( cur_actor, "thumb" ) - cur_actor_thumb_text = actor['image'] - - if cur_actor_thumb_text != None: - cur_actor_thumb.text = cur_actor_thumb_text - - return tvNode - - -def searchDBForShow(regShowName): - - showNames = [re.sub('[. -]', ' ', regShowName)] - - myDB = db.DBConnection() - - yearRegex = "([^()]+?)\s*(\()?(\d{4})(?(2)\))$" - - for showName in showNames: - - sqlResults = myDB.select("SELECT * FROM tv_shows WHERE show_name LIKE ? OR tvr_name LIKE ?", [showName, showName]) - - if len(sqlResults) == 1: - return (int(sqlResults[0]["tvdb_id"]), sqlResults[0]["show_name"]) - - else: - - # if we didn't get exactly one result then try again with the year stripped off if possible - match = re.match(yearRegex, showName) - if match and match.group(1): - logger.log(u"Unable to match original name but trying to manually strip and specify show year", logger.DEBUG) - sqlResults = myDB.select("SELECT * FROM tv_shows WHERE (show_name LIKE ? OR tvr_name LIKE ?) AND startyear = ?", [match.group(1)+'%', match.group(1)+'%', match.group(3)]) - - if len(sqlResults) == 0: - logger.log(u"Unable to match a record in the DB for "+showName, logger.DEBUG) - continue - elif len(sqlResults) > 1: - logger.log(u"Multiple results for "+showName+" in the DB, unable to match show name", logger.DEBUG) - continue - else: - return (int(sqlResults[0]["tvdb_id"]), sqlResults[0]["show_name"]) - - - return None - -def sizeof_fmt(num): - ''' - >>> sizeof_fmt(2) - '2.0 bytes' - >>> sizeof_fmt(1024) - '1.0 KB' - >>> sizeof_fmt(2048) - '2.0 KB' - >>> sizeof_fmt(2**20) - '1.0 MB' - >>> sizeof_fmt(1234567) - '1.2 MB' - ''' - for x in ['bytes','KB','MB','GB','TB']: - if num < 1024.0: - return "%3.1f %s" % (num, x) - num /= 1024.0 - -def listMediaFiles(dir): - - if not dir or not ek.ek(os.path.isdir, dir): - return [] - - files = [] - for curFile in ek.ek(os.listdir, dir): - fullCurFile = ek.ek(os.path.join, dir, curFile) - - # if it's a dir do it recursively - if ek.ek(os.path.isdir, fullCurFile) and not curFile.startswith('.') and not curFile == 'Extras': - files += listMediaFiles(fullCurFile) - - elif isMediaFile(curFile): - files.append(fullCurFile) - - return files - -def copyFile(srcFile, destFile): - ek.ek(shutil.copyfile, srcFile, destFile) - try: - ek.ek(shutil.copymode, srcFile, destFile) - except OSError: - pass - -def moveFile(srcFile, destFile): - try: - ek.ek(os.rename, srcFile, destFile) - fixSetGroupID(destFile) - except OSError: - copyFile(srcFile, destFile) - ek.ek(os.unlink, srcFile) - -def del_empty_dirs(s_dir): - b_empty = True - - for s_target in os.listdir(s_dir): - s_path = os.path.join(s_dir, s_target) - if os.path.isdir(s_path): - if not del_empty_dirs(s_path): - b_empty = False - else: - b_empty = False - - if b_empty: - logger.log(u"Deleting " + s_dir.decode('utf-8')+ ' because it is empty') - os.rmdir(s_dir) - -def make_dirs(path): - """ - Creates any folders that are missing and assigns them the permissions of their - parents - """ - - logger.log(u"Checking if the path " + path + " already exists", logger.DEBUG) - - if not ek.ek(os.path.isdir, path): - # Windows, create all missing folders - if os.name == 'nt' or os.name == 'ce': - try: - logger.log(u"Folder " + path + " didn't exist, creating it", logger.DEBUG) - ek.ek(os.makedirs, path) - except (OSError, IOError), e: - logger.log(u"Failed creating " + path + " : " + ex(e), logger.ERROR) - return False - - # not Windows, create all missing folders and set permissions - else: - sofar = '' - folder_list = path.split(os.path.sep) - - # look through each subfolder and make sure they all exist - for cur_folder in folder_list: - sofar += cur_folder + os.path.sep; - - # if it exists then just keep walking down the line - if ek.ek(os.path.isdir, sofar): - continue - - try: - logger.log(u"Folder " + sofar + " didn't exist, creating it", logger.DEBUG) - ek.ek(os.mkdir, sofar) - # use normpath to remove end separator, otherwise checks permissions against itself - chmodAsParent(ek.ek(os.path.normpath, sofar)) - # do the library update for synoindex - notifiers.synoindex_notifier.addFolder(sofar) - except (OSError, IOError), e: - logger.log(u"Failed creating " + sofar + " : " + ex(e), logger.ERROR) - return False - - return True - - -def rename_ep_file(cur_path, new_path): - """ - Creates all folders needed to move a file to its new location, renames it, then cleans up any folders - left that are now empty. - - cur_path: The absolute path to the file you want to move/rename - new_path: The absolute path to the destination for the file WITHOUT THE EXTENSION - """ - - new_dest_dir, new_dest_name = os.path.split(new_path) #@UnusedVariable - cur_file_name, cur_file_ext = os.path.splitext(cur_path) #@UnusedVariable - - if cur_file_ext[1:] in subtitleExtensions: - #Extract subtitle language from filename - sublang = os.path.splitext(cur_file_name)[1][1:] - - #Check if the language extracted from filename is a valid language - try: - language = subliminal.language.Language(sublang, strict=True) - cur_file_ext = '.'+sublang+cur_file_ext - except ValueError: - pass - - # put the extension on the incoming file - new_path += cur_file_ext - - make_dirs(os.path.dirname(new_path)) - - # move the file - try: - logger.log(u"Renaming file from " + cur_path + " to " + new_path) - ek.ek(os.rename, cur_path, new_path) - except (OSError, IOError), e: - logger.log(u"Failed renaming " + cur_path + " to " + new_path + ": " + ex(e), logger.ERROR) - return False - - # clean up any old folders that are empty - delete_empty_folders(ek.ek(os.path.dirname, cur_path)) - - return True - - -def delete_empty_folders(check_empty_dir, keep_dir=None): - """ - Walks backwards up the path and deletes any empty folders found. - - check_empty_dir: The path to clean (absolute path to a folder) - keep_dir: Clean until this path is reached - """ - - # treat check_empty_dir as empty when it only contains these items - ignore_items = [] - - logger.log(u"Trying to clean any empty folders under " + check_empty_dir) - - # as long as the folder exists and doesn't contain any files, delete it - while ek.ek(os.path.isdir, check_empty_dir) and check_empty_dir != keep_dir: - - check_files = ek.ek(os.listdir, check_empty_dir) - - if not check_files or (len(check_files) <= len(ignore_items) and all([check_file in ignore_items for check_file in check_files])): - # directory is empty or contains only ignore_items - try: - logger.log(u"Deleting empty folder: " + check_empty_dir) - # need shutil.rmtree when ignore_items is really implemented - ek.ek(os.rmdir, check_empty_dir) - # do the library update for synoindex - notifiers.synoindex_notifier.deleteFolder(check_empty_dir) - except (WindowsError, OSError), e: - logger.log(u"Unable to delete " + check_empty_dir + ": " + repr(e) + " / " + str(e), logger.WARNING) - break - check_empty_dir = ek.ek(os.path.dirname, check_empty_dir) - else: - break - - -def chmodAsParent(childPath): - if os.name == 'nt' or os.name == 'ce': - return - - parentPath = ek.ek(os.path.dirname, childPath) - - if not parentPath: - logger.log(u"No parent path provided in "+childPath+", unable to get permissions from it", logger.DEBUG) - return - - parentMode = stat.S_IMODE(os.stat(parentPath)[stat.ST_MODE]) - - childPathStat = ek.ek(os.stat, childPath) - childPath_mode = stat.S_IMODE(childPathStat[stat.ST_MODE]) - - if ek.ek(os.path.isfile, childPath): - childMode = fileBitFilter(parentMode) - else: - childMode = parentMode - - if childPath_mode == childMode: - return - - childPath_owner = childPathStat.st_uid - user_id = os.geteuid() - - if user_id !=0 and user_id != childPath_owner: - logger.log(u"Not running as root or owner of "+childPath+", not trying to set permissions", logger.DEBUG) - return - - try: - ek.ek(os.chmod, childPath, childMode) - logger.log(u"Setting permissions for %s to %o as parent directory has %o" % (childPath, childMode, parentMode), logger.DEBUG) - except OSError: - logger.log(u"Failed to set permission for %s to %o" % (childPath, childMode), logger.ERROR) - -def fileBitFilter(mode): - for bit in [stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH, stat.S_ISUID, stat.S_ISGID]: - if mode & bit: - mode -= bit - - return mode - -def fixSetGroupID(childPath): - if os.name == 'nt' or os.name == 'ce': - return - - parentPath = ek.ek(os.path.dirname, childPath) - parentStat = os.stat(parentPath) - parentMode = stat.S_IMODE(parentStat[stat.ST_MODE]) - - if parentMode & stat.S_ISGID: - parentGID = parentStat[stat.ST_GID] - childStat = ek.ek(os.stat, childPath) - childGID = childStat[stat.ST_GID] - - if childGID == parentGID: - return - - childPath_owner = childStat.st_uid - user_id = os.geteuid() - - if user_id !=0 and user_id != childPath_owner: - logger.log(u"Not running as root or owner of "+childPath+", not trying to set the set-group-ID", logger.DEBUG) - return - - try: - ek.ek(os.chown, childPath, -1, parentGID) #@UndefinedVariable - only available on UNIX - logger.log(u"Respecting the set-group-ID bit on the parent directory for %s" % (childPath), logger.DEBUG) - except OSError: - logger.log(u"Failed to respect the set-group-ID bit on the parent directory for %s (setting group ID %i)" % (childPath, parentGID), logger.ERROR) - -def sanitizeSceneName (name, ezrss=False): - """ - Takes a show name and returns the "scenified" version of it. - - ezrss: If true the scenified version will follow EZRSS's cracksmoker rules as best as possible - - Returns: A string containing the scene version of the show name given. - """ - - if not ezrss: - bad_chars = u",:()'!?\u2019" - # ezrss leaves : and ! in their show names as far as I can tell - else: - bad_chars = u",()'?\u2019" - - # strip out any bad chars - for x in bad_chars: - name = name.replace(x, "") - - # tidy up stuff that doesn't belong in scene names - name = name.replace("- ", ".").replace(" ", ".").replace("&", "and").replace('/','.') - name = re.sub("\.\.*", ".", name) - - if name.endswith('.'): - name = name[:-1] - - return name - -def create_https_certificates(ssl_cert, ssl_key): - """ - Create self-signed HTTPS certificares and store in paths 'ssl_cert' and 'ssl_key' - """ - try: - from OpenSSL import crypto #@UnresolvedImport - from lib.certgen import createKeyPair, createCertRequest, createCertificate, TYPE_RSA, serial #@UnresolvedImport - except: - logger.log(u"pyopenssl module missing, please install for https access", logger.WARNING) - return False - - # Create the CA Certificate - cakey = createKeyPair(TYPE_RSA, 1024) - careq = createCertRequest(cakey, CN='Certificate Authority') - cacert = createCertificate(careq, (careq, cakey), serial, (0, 60*60*24*365*10)) # ten years - - cname = 'SickBeard' - pkey = createKeyPair(TYPE_RSA, 1024) - req = createCertRequest(pkey, CN=cname) - cert = createCertificate(req, (cacert, cakey), serial, (0, 60*60*24*365*10)) # ten years - - # Save the key and certificate to disk - try: - open(ssl_key, 'w').write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)) - open(ssl_cert, 'w').write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) - except: - logger.log(u"Error creating SSL key and certificate", logger.ERROR) - return False - - return True - -if __name__ == '__main__': - import doctest - doctest.testmod() - -def getAllLanguages (): - """ - Returns all show languages where an episode is wanted or unaired - - Returns: A list of all language codes - """ - myDB = db.DBConnection() - - sqlLanguages = myDB.select("SELECT DISTINCT(t.audio_lang) FROM tv_shows t, tv_episodes e WHERE t.tvdb_id = e.showid AND (e.status = ? OR e.status = ?)", [common.UNAIRED,common.WANTED]) - - languages = map(lambda x: str(x["audio_lang"]), sqlLanguages) - - return languages - - -def get_xml_text(node): - text = "" - for child_node in node.childNodes: - if child_node.nodeType in (Node.CDATA_SECTION_NODE, Node.TEXT_NODE): - text += child_node.data - return text.strip() - -def backupVersionedFile(oldFile, version): - numTries = 0 - - newFile = oldFile + '.' + 'v'+str(version) - - while not ek.ek(os.path.isfile, newFile): - if not ek.ek(os.path.isfile, oldFile): - break - - try: - logger.log(u"Attempting to back up "+oldFile+" before migration...") - shutil.copy(oldFile, newFile) - logger.log(u"Done backup, proceeding with migration.") - break - except Exception, e: - logger.log(u"Error while trying to back up "+oldFile+": "+ex(e)) - numTries += 1 - time.sleep(1) - logger.log(u"Trying again.") - - if numTries >= 10: - logger.log(u"Unable to back up "+oldFile+", please do it manually.") - sys.exit(1) +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import StringIO, zlib, gzip +import os +import stat +import urllib, urllib2 +import re, socket +import shutil +import traceback +import time, sys + +import hashlib + +from httplib import BadStatusLine + +from xml.dom.minidom import Node + +import sickbeard + +from sickbeard.exceptions import MultipleShowObjectsException, ex +from sickbeard import logger, classes, common +from sickbeard.common import USER_AGENT, mediaExtensions, XML_NSMAP, subtitleExtensions + +from sickbeard import db +from sickbeard import encodingKludge as ek +from sickbeard import notifiers + +from lib.tvdb_api import tvdb_api, tvdb_exceptions + +import xml.etree.cElementTree as etree + +from lib import subliminal +#from sickbeard.subtitles import EXTENSIONS + +urllib._urlopener = classes.SickBeardURLopener() + +def indentXML(elem, level=0): + ''' + Does our pretty printing, makes Matt very happy + ''' + i = "\n" + level*" " + if len(elem): + if not elem.text or not elem.text.strip(): + elem.text = i + " " + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + indentXML(elem, level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + # Strip out the newlines from text + if elem.text: + elem.text = elem.text.replace('\n', ' ') + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + +def replaceExtension(file, newExt): + ''' + >>> replaceExtension('foo.avi', 'mkv') + 'foo.mkv' + >>> replaceExtension('.vimrc', 'arglebargle') + '.vimrc' + >>> replaceExtension('a.b.c', 'd') + 'a.b.d' + >>> replaceExtension('', 'a') + '' + >>> replaceExtension('foo.bar', '') + 'foo.' + ''' + sepFile = file.rpartition(".") + if sepFile[0] == "": + return file + else: + return sepFile[0] + "." + newExt + +def isMediaFile (file): + # ignore samples + if re.search('(^|[\W_])sample\d*[\W_]', file): + return False + + # ignore MAC OS's retarded "resource fork" files + if file.startswith('._'): + return False + + sepFile = file.rpartition(".") + if sepFile[2].lower() in mediaExtensions: + return True + else: + return False + +def sanitizeFileName (name): + ''' + >>> sanitizeFileName('a/b/c') + 'a-b-c' + >>> sanitizeFileName('abc') + 'abc' + >>> sanitizeFileName('a"b') + 'ab' + >>> sanitizeFileName('.a.b..') + 'a.b' + ''' + + # remove bad chars from the filename + name = re.sub(r'[\\/\*]', '-', name) + name = re.sub(r'[:"<>|?]', '', name) + + # remove leading/trailing periods and spaces + name = name.strip(' .') + + return name + + +def getURL (url, headers=[]): + """ + Returns a byte-string retrieved from the url provider. + """ + + opener = urllib2.build_opener() + opener.addheaders = [('User-Agent', USER_AGENT), ('Accept-Encoding', 'gzip,deflate')] + for cur_header in headers: + opener.addheaders.append(cur_header) + + try: + usock = opener.open(url) + url = usock.geturl() + encoding = usock.info().get("Content-Encoding") + + if encoding in ('gzip', 'x-gzip', 'deflate'): + content = usock.read() + if encoding == 'deflate': + data = StringIO.StringIO(zlib.decompress(content)) + else: + data = gzip.GzipFile('', 'rb', 9, StringIO.StringIO(content)) + result = data.read() + + else: + result = usock.read() + + usock.close() + + except urllib2.HTTPError, e: + logger.log(u"HTTP error " + str(e.code) + " while loading URL " + url, logger.WARNING) + return None + except urllib2.URLError, e: + logger.log(u"URL error " + str(e.reason) + " while loading URL " + url, logger.WARNING) + return None + except BadStatusLine: + logger.log(u"BadStatusLine error while loading URL " + url, logger.WARNING) + return None + except socket.timeout: + logger.log(u"Timed out while loading URL " + url, logger.WARNING) + return None + except ValueError: + logger.log(u"Unknown error while loading URL " + url, logger.WARNING) + return None + except Exception: + logger.log(u"Unknown exception while loading URL " + url + ": " + traceback.format_exc(), logger.WARNING) + return None + + return result + +def _remove_file_failed(file): + try: + os.remove(file) + except: + pass + +def download_file(url, filename): + try: + req = urllib2.urlopen(url) + CHUNK = 16 * 1024 + with open(filename, 'wb') as fp: + while True: + chunk = req.read(CHUNK) + if not chunk: break + fp.write(chunk) + fp.close() + req.close() + + except urllib2.HTTPError, e: + _remove_file_failed(filename) + logger.log(u"HTTP error " + str(e.code) + " while loading URL " + url, logger.WARNING) + return False + except urllib2.URLError, e: + _remove_file_failed(filename) + logger.log(u"URL error " + str(e.reason) + " while loading URL " + url, logger.WARNING) + return False + except BadStatusLine: + _remove_file_failed(filename) + logger.log(u"BadStatusLine error while loading URL " + url, logger.WARNING) + return False + except socket.timeout: + _remove_file_failed(filename) + logger.log(u"Timed out while loading URL " + url, logger.WARNING) + return False + except ValueError: + _remove_file_failed(filename) + logger.log(u"Unknown error while loading URL " + url, logger.WARNING) + return False + except Exception: + _remove_file_failed(filename) + logger.log(u"Unknown exception while loading URL " + url + ": " + traceback.format_exc(), logger.WARNING) + return False + + return True + +def findCertainShow (showList, tvdbid): + results = filter(lambda x: x.tvdbid == tvdbid, showList) + if len(results) == 0: + return None + elif len(results) > 1: + raise MultipleShowObjectsException() + else: + return results[0] + +def findCertainTVRageShow (showList, tvrid): + + if tvrid == 0: + return None + + results = filter(lambda x: x.tvrid == tvrid, showList) + + if len(results) == 0: + return None + elif len(results) > 1: + raise MultipleShowObjectsException() + else: + return results[0] + + +def makeDir (dir): + if not ek.ek(os.path.isdir, dir): + try: + ek.ek(os.makedirs, dir) + # do the library update for synoindex + notifiers.synoindex_notifier.addFolder(dir) + except OSError: + return False + return True + +def makeShowNFO(showID, showDir): + + logger.log(u"Making NFO for show "+str(showID)+" in dir "+showDir, logger.DEBUG) + + if not makeDir(showDir): + logger.log(u"Unable to create show dir, can't make NFO", logger.ERROR) + return False + + showObj = findCertainShow(sickbeard.showList, showID) + if not showObj: + logger.log(u"This should never have happened, post a bug about this!", logger.ERROR) + raise Exception("BAD STUFF HAPPENED") + + tvdb_lang = showObj.lang + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if tvdb_lang and not tvdb_lang == 'en': + ltvdb_api_parms['language'] = tvdb_lang + + t = tvdb_api.Tvdb(actors=True, **ltvdb_api_parms) + + try: + myShow = t[int(showID)] + except tvdb_exceptions.tvdb_shownotfound: + logger.log(u"Unable to find show with id " + str(showID) + " on tvdb, skipping it", logger.ERROR) + raise + + except tvdb_exceptions.tvdb_error: + logger.log(u"TVDB is down, can't use its data to add this show", logger.ERROR) + raise + + # check for title and id + try: + if myShow["seriesname"] == None or myShow["seriesname"] == "" or myShow["id"] == None or myShow["id"] == "": + logger.log(u"Incomplete info for show with id " + str(showID) + " on tvdb, skipping it", logger.ERROR) + + return False + except tvdb_exceptions.tvdb_attributenotfound: + logger.log(u"Incomplete info for show with id " + str(showID) + " on tvdb, skipping it", logger.ERROR) + + return False + + tvNode = buildNFOXML(myShow) + # Make it purdy + indentXML( tvNode ) + nfo = etree.ElementTree( tvNode ) + + logger.log(u"Writing NFO to "+os.path.join(showDir, "tvshow.nfo"), logger.DEBUG) + nfo_filename = os.path.join(showDir, "tvshow.nfo").encode('utf-8') + nfo_fh = open(nfo_filename, 'w') + nfo.write( nfo_fh, encoding="utf-8" ) + + return True + +def buildNFOXML(myShow): + ''' + Build an etree.Element of the root node of an NFO file with the + data from `myShow`, a TVDB show object. + + >>> from collections import defaultdict + >>> from xml.etree.cElementTree import tostring + >>> show = defaultdict(lambda: None, _actors=[]) + >>> tostring(buildNFOXML(show)) + '<rating /><plot /><episodeguide><url /></episodeguide><mpaa /><id /><genre /><premiered /><studio /></tvshow>' + >>> show['seriesname'] = 'Peaches' + >>> tostring(buildNFOXML(show)) + '<tvshow xsd="http://www.w3.org/2001/XMLSchema" xsi="http://www.w3.org/2001/XMLSchema-instance"><title>Peaches' + >>> show['contentrating'] = 'PG' + >>> tostring(buildNFOXML(show)) + 'PeachesPG' + >>> show['genre'] = 'Fruit|Edibles' + >>> tostring(buildNFOXML(show)) + 'PeachesPGFruit / Edibles' + ''' + tvNode = etree.Element( "tvshow" ) + for ns in XML_NSMAP.keys(): + tvNode.set(ns, XML_NSMAP[ns]) + + title = etree.SubElement( tvNode, "title" ) + if myShow["seriesname"] != None: + title.text = myShow["seriesname"] + + rating = etree.SubElement( tvNode, "rating" ) + if myShow["rating"] != None: + rating.text = myShow["rating"] + + plot = etree.SubElement( tvNode, "plot" ) + if myShow["overview"] != None: + plot.text = myShow["overview"] + + episodeguide = etree.SubElement( tvNode, "episodeguide" ) + episodeguideurl = etree.SubElement( episodeguide, "url" ) + if myShow["id"] != None: + showurl = sickbeard.TVDB_BASE_URL + '/series/' + myShow["id"] + '/all/en.zip' + episodeguideurl.text = showurl + + mpaa = etree.SubElement( tvNode, "mpaa" ) + if myShow["contentrating"] != None: + mpaa.text = myShow["contentrating"] + + tvdbid = etree.SubElement( tvNode, "id" ) + if myShow["id"] != None: + tvdbid.text = myShow["id"] + + genre = etree.SubElement( tvNode, "genre" ) + if myShow["genre"] != None: + genre.text = " / ".join([x for x in myShow["genre"].split('|') if x != '']) + + premiered = etree.SubElement( tvNode, "premiered" ) + if myShow["firstaired"] != None: + premiered.text = myShow["firstaired"] + + studio = etree.SubElement( tvNode, "studio" ) + if myShow["network"] != None: + studio.text = myShow["network"] + + for actor in myShow['_actors']: + + cur_actor = etree.SubElement( tvNode, "actor" ) + + cur_actor_name = etree.SubElement( cur_actor, "name" ) + cur_actor_name.text = actor['name'] + cur_actor_role = etree.SubElement( cur_actor, "role" ) + cur_actor_role_text = actor['role'] + + if cur_actor_role_text != None: + cur_actor_role.text = cur_actor_role_text + + cur_actor_thumb = etree.SubElement( cur_actor, "thumb" ) + cur_actor_thumb_text = actor['image'] + + if cur_actor_thumb_text != None: + cur_actor_thumb.text = cur_actor_thumb_text + + return tvNode + + +def searchDBForShow(regShowName): + + showNames = [re.sub('[. -]', ' ', regShowName)] + + myDB = db.DBConnection() + + yearRegex = "([^()]+?)\s*(\()?(\d{4})(?(2)\))$" + + for showName in showNames: + + sqlResults = myDB.select("SELECT * FROM tv_shows WHERE show_name LIKE ? OR tvr_name LIKE ?", [showName, showName]) + + if len(sqlResults) == 1: + return (int(sqlResults[0]["tvdb_id"]), sqlResults[0]["show_name"]) + + else: + + # if we didn't get exactly one result then try again with the year stripped off if possible + match = re.match(yearRegex, showName) + if match and match.group(1): + logger.log(u"Unable to match original name but trying to manually strip and specify show year", logger.DEBUG) + sqlResults = myDB.select("SELECT * FROM tv_shows WHERE (show_name LIKE ? OR tvr_name LIKE ?) AND startyear = ?", [match.group(1)+'%', match.group(1)+'%', match.group(3)]) + + if len(sqlResults) == 0: + logger.log(u"Unable to match a record in the DB for "+showName, logger.DEBUG) + continue + elif len(sqlResults) > 1: + logger.log(u"Multiple results for "+showName+" in the DB, unable to match show name", logger.DEBUG) + continue + else: + return (int(sqlResults[0]["tvdb_id"]), sqlResults[0]["show_name"]) + + + return None + +def sizeof_fmt(num): + ''' + >>> sizeof_fmt(2) + '2.0 bytes' + >>> sizeof_fmt(1024) + '1.0 KB' + >>> sizeof_fmt(2048) + '2.0 KB' + >>> sizeof_fmt(2**20) + '1.0 MB' + >>> sizeof_fmt(1234567) + '1.2 MB' + ''' + for x in ['bytes','KB','MB','GB','TB']: + if num < 1024.0: + return "%3.1f %s" % (num, x) + num /= 1024.0 + +def listMediaFiles(dir): + + if not dir or not ek.ek(os.path.isdir, dir): + return [] + + files = [] + for curFile in ek.ek(os.listdir, dir): + fullCurFile = ek.ek(os.path.join, dir, curFile) + + # if it's a dir do it recursively + if ek.ek(os.path.isdir, fullCurFile) and not curFile.startswith('.') and not curFile == 'Extras': + files += listMediaFiles(fullCurFile) + + elif isMediaFile(curFile): + files.append(fullCurFile) + + return files + +def copyFile(srcFile, destFile): + ek.ek(shutil.copyfile, srcFile, destFile) + try: + ek.ek(shutil.copymode, srcFile, destFile) + except OSError: + pass + +def moveFile(srcFile, destFile): + try: + ek.ek(os.rename, srcFile, destFile) + fixSetGroupID(destFile) + except OSError: + copyFile(srcFile, destFile) + ek.ek(os.unlink, srcFile) + +def del_empty_dirs(s_dir): + b_empty = True + + for s_target in os.listdir(s_dir): + s_path = os.path.join(s_dir, s_target) + if os.path.isdir(s_path): + if not del_empty_dirs(s_path): + b_empty = False + else: + b_empty = False + + if b_empty: + logger.log(u"Deleting " + s_dir.decode('utf-8')+ ' because it is empty') + os.rmdir(s_dir) + +def make_dirs(path): + """ + Creates any folders that are missing and assigns them the permissions of their + parents + """ + + logger.log(u"Checking if the path " + path + " already exists", logger.DEBUG) + + if not ek.ek(os.path.isdir, path): + # Windows, create all missing folders + if os.name == 'nt' or os.name == 'ce': + try: + logger.log(u"Folder " + path + " didn't exist, creating it", logger.DEBUG) + ek.ek(os.makedirs, path) + except (OSError, IOError), e: + logger.log(u"Failed creating " + path + " : " + ex(e), logger.ERROR) + return False + + # not Windows, create all missing folders and set permissions + else: + sofar = '' + folder_list = path.split(os.path.sep) + + # look through each subfolder and make sure they all exist + for cur_folder in folder_list: + sofar += cur_folder + os.path.sep; + + # if it exists then just keep walking down the line + if ek.ek(os.path.isdir, sofar): + continue + + try: + logger.log(u"Folder " + sofar + " didn't exist, creating it", logger.DEBUG) + ek.ek(os.mkdir, sofar) + # use normpath to remove end separator, otherwise checks permissions against itself + chmodAsParent(ek.ek(os.path.normpath, sofar)) + # do the library update for synoindex + notifiers.synoindex_notifier.addFolder(sofar) + except (OSError, IOError), e: + logger.log(u"Failed creating " + sofar + " : " + ex(e), logger.ERROR) + return False + + return True + + +def rename_ep_file(cur_path, new_path): + """ + Creates all folders needed to move a file to its new location, renames it, then cleans up any folders + left that are now empty. + + cur_path: The absolute path to the file you want to move/rename + new_path: The absolute path to the destination for the file WITHOUT THE EXTENSION + """ + + new_dest_dir, new_dest_name = os.path.split(new_path) #@UnusedVariable + cur_file_name, cur_file_ext = os.path.splitext(cur_path) #@UnusedVariable + + if cur_file_ext[1:] in subtitleExtensions: + #Extract subtitle language from filename + sublang = os.path.splitext(cur_file_name)[1][1:] + + #Check if the language extracted from filename is a valid language + try: + language = subliminal.language.Language(sublang, strict=True) + cur_file_ext = '.'+sublang+cur_file_ext + except ValueError: + pass + + # put the extension on the incoming file + new_path += cur_file_ext + + make_dirs(os.path.dirname(new_path)) + + # move the file + try: + logger.log(u"Renaming file from " + cur_path + " to " + new_path) + ek.ek(os.rename, cur_path, new_path) + except (OSError, IOError), e: + logger.log(u"Failed renaming " + cur_path + " to " + new_path + ": " + ex(e), logger.ERROR) + return False + + # clean up any old folders that are empty + delete_empty_folders(ek.ek(os.path.dirname, cur_path)) + + return True + + +def delete_empty_folders(check_empty_dir, keep_dir=None): + """ + Walks backwards up the path and deletes any empty folders found. + + check_empty_dir: The path to clean (absolute path to a folder) + keep_dir: Clean until this path is reached + """ + + # treat check_empty_dir as empty when it only contains these items + ignore_items = [] + + logger.log(u"Trying to clean any empty folders under " + check_empty_dir) + + # as long as the folder exists and doesn't contain any files, delete it + while ek.ek(os.path.isdir, check_empty_dir) and check_empty_dir != keep_dir: + + check_files = ek.ek(os.listdir, check_empty_dir) + + if not check_files or (len(check_files) <= len(ignore_items) and all([check_file in ignore_items for check_file in check_files])): + # directory is empty or contains only ignore_items + try: + logger.log(u"Deleting empty folder: " + check_empty_dir) + # need shutil.rmtree when ignore_items is really implemented + ek.ek(os.rmdir, check_empty_dir) + # do the library update for synoindex + notifiers.synoindex_notifier.deleteFolder(check_empty_dir) + except (WindowsError, OSError), e: + logger.log(u"Unable to delete " + check_empty_dir + ": " + repr(e) + " / " + str(e), logger.WARNING) + break + check_empty_dir = ek.ek(os.path.dirname, check_empty_dir) + else: + break + +def chmodAsParent(childPath): + if os.name == 'nt' or os.name == 'ce': + return + + parentPath = ek.ek(os.path.dirname, childPath) + + if not parentPath: + logger.log(u"No parent path provided in "+childPath+", unable to get permissions from it", logger.DEBUG) + return + + parentMode = stat.S_IMODE(os.stat(parentPath)[stat.ST_MODE]) + + childPathStat = ek.ek(os.stat, childPath) + childPath_mode = stat.S_IMODE(childPathStat[stat.ST_MODE]) + + if ek.ek(os.path.isfile, childPath): + childMode = fileBitFilter(parentMode) + else: + childMode = parentMode + + if childPath_mode == childMode: + return + + childPath_owner = childPathStat.st_uid + user_id = os.geteuid() + + if user_id !=0 and user_id != childPath_owner: + logger.log(u"Not running as root or owner of "+childPath+", not trying to set permissions", logger.DEBUG) + return + + try: + ek.ek(os.chmod, childPath, childMode) + logger.log(u"Setting permissions for %s to %o as parent directory has %o" % (childPath, childMode, parentMode), logger.DEBUG) + except OSError: + logger.log(u"Failed to set permission for %s to %o" % (childPath, childMode), logger.ERROR) + +def fileBitFilter(mode): + for bit in [stat.S_IXUSR, stat.S_IXGRP, stat.S_IXOTH, stat.S_ISUID, stat.S_ISGID]: + if mode & bit: + mode -= bit + + return mode + +def fixSetGroupID(childPath): + if os.name == 'nt' or os.name == 'ce': + return + + parentPath = ek.ek(os.path.dirname, childPath) + parentStat = os.stat(parentPath) + parentMode = stat.S_IMODE(parentStat[stat.ST_MODE]) + + if parentMode & stat.S_ISGID: + parentGID = parentStat[stat.ST_GID] + childStat = ek.ek(os.stat, childPath) + childGID = childStat[stat.ST_GID] + + if childGID == parentGID: + return + + childPath_owner = childStat.st_uid + user_id = os.geteuid() + + if user_id !=0 and user_id != childPath_owner: + logger.log(u"Not running as root or owner of "+childPath+", not trying to set the set-group-ID", logger.DEBUG) + return + + try: + ek.ek(os.chown, childPath, -1, parentGID) #@UndefinedVariable - only available on UNIX + logger.log(u"Respecting the set-group-ID bit on the parent directory for %s" % (childPath), logger.DEBUG) + except OSError: + logger.log(u"Failed to respect the set-group-ID bit on the parent directory for %s (setting group ID %i)" % (childPath, parentGID), logger.ERROR) + +def sanitizeSceneName (name, ezrss=False): + """ + Takes a show name and returns the "scenified" version of it. + + ezrss: If true the scenified version will follow EZRSS's cracksmoker rules as best as possible + + Returns: A string containing the scene version of the show name given. + """ + + if not ezrss: + bad_chars = u",:()'!?\u2019" + # ezrss leaves : and ! in their show names as far as I can tell + else: + bad_chars = u",()'?\u2019" + + # strip out any bad chars + for x in bad_chars: + name = name.replace(x, "") + + # tidy up stuff that doesn't belong in scene names + name = name.replace("- ", ".").replace(" ", ".").replace("&", "and").replace('/','.') + name = re.sub("\.\.*", ".", name) + + if name.endswith('.'): + name = name[:-1] + + return name + +def create_https_certificates(ssl_cert, ssl_key): + """ + Create self-signed HTTPS certificares and store in paths 'ssl_cert' and 'ssl_key' + """ + try: + from OpenSSL import crypto #@UnresolvedImport + from lib.certgen import createKeyPair, createCertRequest, createCertificate, TYPE_RSA, serial #@UnresolvedImport + except: + logger.log(u"pyopenssl module missing, please install for https access", logger.WARNING) + return False + + # Create the CA Certificate + cakey = createKeyPair(TYPE_RSA, 1024) + careq = createCertRequest(cakey, CN='Certificate Authority') + cacert = createCertificate(careq, (careq, cakey), serial, (0, 60*60*24*365*10)) # ten years + + cname = 'SickBeard' + pkey = createKeyPair(TYPE_RSA, 1024) + req = createCertRequest(pkey, CN=cname) + cert = createCertificate(req, (cacert, cakey), serial, (0, 60*60*24*365*10)) # ten years + + # Save the key and certificate to disk + try: + open(ssl_key, 'w').write(crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)) + open(ssl_cert, 'w').write(crypto.dump_certificate(crypto.FILETYPE_PEM, cert)) + except: + logger.log(u"Error creating SSL key and certificate", logger.ERROR) + return False + + return True + +if __name__ == '__main__': + import doctest + doctest.testmod() + +def getAllLanguages (): + """ + Returns all show languages where an episode is wanted or unaired + + Returns: A list of all language codes + """ + myDB = db.DBConnection() + + sqlLanguages = myDB.select("SELECT DISTINCT(t.audio_lang) FROM tv_shows t, tv_episodes e WHERE t.tvdb_id = e.showid AND (e.status = ? OR e.status = ?)", [common.UNAIRED,common.WANTED]) + + languages = map(lambda x: str(x["audio_lang"]), sqlLanguages) + + return languages + + +def get_xml_text(node): + text = "" + for child_node in node.childNodes: + if child_node.nodeType in (Node.CDATA_SECTION_NODE, Node.TEXT_NODE): + text += child_node.data + return text.strip() + +def backupVersionedFile(oldFile, version): + numTries = 0 + + newFile = oldFile + '.' + 'v'+str(version) + + while not ek.ek(os.path.isfile, newFile): + if not ek.ek(os.path.isfile, oldFile): + break + + try: + logger.log(u"Attempting to back up "+oldFile+" before migration...") + shutil.copy(oldFile, newFile) + logger.log(u"Done backup, proceeding with migration.") + break + except Exception, e: + logger.log(u"Error while trying to back up "+oldFile+": "+ex(e)) + numTries += 1 + time.sleep(1) + logger.log(u"Trying again.") + + if numTries >= 10: + logger.log(u"Unable to back up "+oldFile+", please do it manually.") + sys.exit(1) +# try to convert to int, if it fails the default will be returned +def tryInt(s, s_default = 0): + try: return int(s) + except: return s_default + +# generates a md5 hash of a file +def md5_for_file(filename, block_size=2**16): + try: + with open(filename,'rb') as f: + md5 = hashlib.md5() + while True: + data = f.read(block_size) + if not data: + break + md5.update(data) + f.close() + return md5.hexdigest() + except Exception: + return None + diff --git a/sickbeard/image_cache.py b/sickbeard/image_cache.py index fecc41de4f..c413b90544 100644 --- a/sickbeard/image_cache.py +++ b/sickbeard/image_cache.py @@ -39,6 +39,12 @@ def _cache_dir(self): """ return ek.ek(os.path.abspath, ek.ek(os.path.join, sickbeard.CACHE_DIR, 'images')) + def _thumbnails_dir(self): + """ + Builds up the full path to the thumbnails image cache directory + """ + return ek.ek(os.path.abspath, ek.ek(os.path.join, self._cache_dir(), 'thumbnails')) + def poster_path(self, tvdb_id): """ Builds up the path to a poster cache for a given tvdb id @@ -49,7 +55,7 @@ def poster_path(self, tvdb_id): """ poster_file_name = str(tvdb_id) + '.poster.jpg' return ek.ek(os.path.join, self._cache_dir(), poster_file_name) - + def banner_path(self, tvdb_id): """ Builds up the path to a banner cache for a given tvdb id @@ -61,6 +67,28 @@ def banner_path(self, tvdb_id): banner_file_name = str(tvdb_id) + '.banner.jpg' return ek.ek(os.path.join, self._cache_dir(), banner_file_name) + def poster_thumb_path(self, tvdb_id): + """ + Builds up the path to a poster cache for a given tvdb id + + returns: a full path to the cached poster file for the given tvdb id + + tvdb_id: ID of the show to use in the file name + """ + posterthumb_file_name = str(tvdb_id) + '.poster.jpg' + return ek.ek(os.path.join, self._thumbnails_dir(), posterthumb_file_name) + + def banner_thumb_path(self, tvdb_id): + """ + Builds up the path to a poster cache for a given tvdb id + + returns: a full path to the cached poster file for the given tvdb id + + tvdb_id: ID of the show to use in the file name + """ + bannerthumb_file_name = str(tvdb_id) + '.banner.jpg' + return ek.ek(os.path.join, self._thumbnails_dir(), bannerthumb_file_name) + def has_poster(self, tvdb_id): """ Returns true if a cached poster exists for the given tvdb id @@ -77,8 +105,27 @@ def has_banner(self, tvdb_id): logger.log(u"Checking if file "+str(banner_path)+" exists", logger.DEBUG) return ek.ek(os.path.isfile, banner_path) + def has_poster_thumbnail(self, tvdb_id): + """ + Returns true if a cached poster thumbnail exists for the given tvdb id + """ + poster_thumb_path = self.poster_thumb_path(tvdb_id) + logger.log(u"Checking if file "+str(poster_thumb_path)+" exists", logger.DEBUG) + return ek.ek(os.path.isfile, poster_thumb_path) + + def has_banner_thumbnail(self, tvdb_id): + """ + Returns true if a cached banner exists for the given tvdb id + """ + banner_thumb_path = self.banner_thumb_path(tvdb_id) + logger.log(u"Checking if file "+str(banner_thumb_path)+" exists", logger.DEBUG) + return ek.ek(os.path.isfile, banner_thumb_path) + + BANNER = 1 POSTER = 2 + BANNER_THUMB = 3 + POSTER_THUMB = 4 def which_type(self, path): """ @@ -141,6 +188,10 @@ def _cache_image_from_file(self, image_path, img_type, tvdb_id): logger.log(u"Image cache dir didn't exist, creating it at "+str(self._cache_dir())) ek.ek(os.makedirs, self._cache_dir()) + if not ek.ek(os.path.isdir, self._thumbnails_dir()): + logger.log(u"Thumbnails cache dir didn't exist, creating it at "+str(self._thumbnails_dir())) + ek.ek(os.makedirs, self._thumbnails_dir()) + logger.log(u"Copying from "+image_path+" to "+dest_path) helpers.copyFile(image_path, dest_path) @@ -163,6 +214,12 @@ def _cache_image_from_tvdb(self, show_obj, img_type): elif img_type == self.BANNER: img_type_name = 'banner' dest_path = self.banner_path(show_obj.tvdbid) + elif img_type == self.POSTER_THUMB: + img_type_name = 'poster_thumb' + dest_path = self.poster_thumb_path(show_obj.tvdbid) + elif img_type == self.BANNER_THUMB: + img_type_name = 'banner_thumb' + dest_path = self.banner_thumb_path(show_obj.tvdbid) else: logger.log(u"Invalid cache image type: "+str(img_type), logger.ERROR) return False @@ -188,35 +245,37 @@ def fill_cache(self, show_obj): # check if the images are already cached or not need_images = {self.POSTER: not self.has_poster(show_obj.tvdbid), self.BANNER: not self.has_banner(show_obj.tvdbid), - } + self.POSTER_THUMB: not self.has_poster_thumbnail(show_obj.tvdbid), + self.BANNER_THUMB: not self.has_banner_thumbnail(show_obj.tvdbid)} - if not need_images[self.POSTER] and not need_images[self.BANNER]: + if not need_images[self.POSTER] and not need_images[self.BANNER] and not need_images[self.POSTER_THUMB] and not need_images[self.BANNER_THUMB]: logger.log(u"No new cache images needed, not retrieving new ones") return - # check the show dir for images and use them - try: - for cur_provider in sickbeard.metadata_provider_dict.values(): - logger.log(u"Checking if we can use the show image from the "+cur_provider.name+" metadata", logger.DEBUG) - if ek.ek(os.path.isfile, cur_provider.get_poster_path(show_obj)): - cur_file_name = os.path.abspath(cur_provider.get_poster_path(show_obj)) - cur_file_type = self.which_type(cur_file_name) - - if cur_file_type == None: - logger.log(u"Unable to retrieve image type, not using the image from "+str(cur_file_name), logger.WARNING) - continue - - logger.log(u"Checking if image "+cur_file_name+" (type "+str(cur_file_type)+" needs metadata: "+str(need_images[cur_file_type]), logger.DEBUG) - - if cur_file_type in need_images and need_images[cur_file_type]: - logger.log(u"Found an image in the show dir that doesn't exist in the cache, caching it: "+cur_file_name+", type "+str(cur_file_type), logger.DEBUG) - self._cache_image_from_file(cur_file_name, cur_file_type, show_obj.tvdbid) - need_images[cur_file_type] = False - except exceptions.ShowDirNotFoundException: - logger.log(u"Unable to search for images in show dir because it doesn't exist", logger.WARNING) + # check the show dir for poster or banner images and use them + if need_images[self.POSTER] or need_images[self.BANNER]: + try: + for cur_provider in sickbeard.metadata_provider_dict.values(): + logger.log(u"Checking if we can use the show image from the "+cur_provider.name+" metadata", logger.DEBUG) + if ek.ek(os.path.isfile, cur_provider.get_poster_path(show_obj)): + cur_file_name = os.path.abspath(cur_provider.get_poster_path(show_obj)) + cur_file_type = self.which_type(cur_file_name) + + if cur_file_type == None: + logger.log(u"Unable to retrieve image type, not using the image from "+str(cur_file_name), logger.WARNING) + continue + + logger.log(u"Checking if image "+cur_file_name+" (type "+str(cur_file_type)+" needs metadata: "+str(need_images[cur_file_type]), logger.DEBUG) + + if cur_file_type in need_images and need_images[cur_file_type]: + logger.log(u"Found an image in the show dir that doesn't exist in the cache, caching it: "+cur_file_name+", type "+str(cur_file_type), logger.DEBUG) + self._cache_image_from_file(cur_file_name, cur_file_type, show_obj.tvdbid) + need_images[cur_file_type] = False + except exceptions.ShowDirNotFoundException: + logger.log(u"Unable to search for images in show dir because it doesn't exist", logger.WARNING) # download from TVDB for missing ones - for cur_image_type in [self.POSTER, self.BANNER]: + for cur_image_type in [self.POSTER, self.BANNER, self.POSTER_THUMB, self.BANNER_THUMB]: logger.log(u"Seeing if we still need an image of type "+str(cur_image_type)+": "+str(need_images[cur_image_type]), logger.DEBUG) if cur_image_type in need_images and need_images[cur_image_type]: self._cache_image_from_tvdb(show_obj, cur_image_type) diff --git a/sickbeard/logger.py b/sickbeard/logger.py index cb776d2bad..473b69a750 100644 --- a/sickbeard/logger.py +++ b/sickbeard/logger.py @@ -38,11 +38,13 @@ WARNING = logging.WARNING MESSAGE = logging.INFO DEBUG = logging.DEBUG +DB = 5 reverseNames = {u'ERROR': ERROR, u'WARNING': WARNING, u'INFO': MESSAGE, - u'DEBUG': DEBUG} + u'DEBUG': DEBUG, + u'DB' : DB} class SBRotatingLogHandler(object): @@ -62,10 +64,13 @@ def initLogging(self, consoleLogging=True): self.log_file = os.path.join(sickbeard.LOG_DIR, self.log_file) self.cur_handler = self._config_handler() - + + logging.addLevelName(5,'DB') + logging.getLogger('sickbeard').addHandler(self.cur_handler) logging.getLogger('subliminal').addHandler(self.cur_handler) - + logging.getLogger('imdbpy').addHandler(self.cur_handler) + # define a Handler which writes INFO messages or higher to the sys.stderr if consoleLogging: console = logging.StreamHandler() @@ -73,23 +78,34 @@ def initLogging(self, consoleLogging=True): console.setLevel(logging.INFO) # set a format which is simpler for console use - console.setFormatter(logging.Formatter('%(asctime)s %(levelname)s::%(message)s', '%H:%M:%S')) + console.setFormatter(DispatchingFormatter({'sickbeard' : logging.Formatter('%(asctime)s %(levelname)s::%(message)s', '%H:%M:%S'), + 'subliminal' : logging.Formatter('%(asctime)s %(levelname)s::SUBLIMINAL :: %(message)s', '%H:%M:%S'), + 'imdbpy' : logging.Formatter('%(asctime)s %(levelname)s::IMDBPY :: %(message)s', '%H:%M:%S') + }, + logging.Formatter('%(message)s'),)) # add the handler to the root logger logging.getLogger('sickbeard').addHandler(console) logging.getLogger('subliminal').addHandler(console) - - logging.getLogger('sickbeard').setLevel(logging.DEBUG) - logging.getLogger('subliminal').setLevel(logging.ERROR) - + logging.getLogger('imdbpy').addHandler(console) + + logging.getLogger('sickbeard').setLevel(DB) + logging.getLogger('subliminal').setLevel(logging.WARNING) + logging.getLogger('imdbpy').setLevel(logging.WARNING) + def _config_handler(self): """ Configure a file handler to log at file_name and return it. """ file_handler = logging.FileHandler(self.log_file) - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', '%b-%d %H:%M:%S')) + file_handler.setLevel(DB) + file_handler.setFormatter(DispatchingFormatter({'sickbeard' : logging.Formatter('%(asctime)s %(levelname)-8s %(message)s', '%b-%d %H:%M:%S'), + 'subliminal' : logging.Formatter('%(asctime)s %(levelname)-8s SUBLIMINAL :: %(message)s', '%b-%d %H:%M:%S'), + 'imdbpy' : logging.Formatter('%(asctime)s %(levelname)-8s IMDBPY :: %(message)s', '%b-%d %H:%M:%S') + }, + logging.Formatter('%(message)s'),)) + return file_handler def _log_file_name(self, i): @@ -161,6 +177,7 @@ def log(self, toLog, logLevel=MESSAGE): out_line = message.encode('utf-8') sb_logger = logging.getLogger('sickbeard') + setattr(sb_logger, 'db', lambda *args: sb_logger.log(DB, *args)) try: if logLevel == DEBUG: @@ -171,14 +188,29 @@ def log(self, toLog, logLevel=MESSAGE): sb_logger.warning(out_line) elif logLevel == ERROR: sb_logger.error(out_line) - + # add errors to the UI logger classes.ErrorViewer.add(classes.UIError(message)) + elif logLevel == DB: + sb_logger.db(out_line) + else: sb_logger.log(logLevel, out_line) except ValueError: pass + +class DispatchingFormatter: + + def __init__(self, formatters, default_formatter): + self._formatters = formatters + self._default_formatter = default_formatter + + def format(self, record): + formatter = self._formatters.get(record.name, self._default_formatter) + return formatter.format(record) + + sb_log_instance = SBRotatingLogHandler('sickbeard.log', NUM_LOGS, LOG_SIZE) def log(toLog, logLevel=MESSAGE): diff --git a/sickbeard/metadata/generic.py b/sickbeard/metadata/generic.py index b303aca8de..5b476e55a8 100644 --- a/sickbeard/metadata/generic.py +++ b/sickbeard/metadata/generic.py @@ -1,639 +1,648 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import os.path - -import xml.etree.cElementTree as etree - -import re - -import sickbeard - -from sickbeard import exceptions, helpers -from sickbeard.metadata import helpers as metadata_helpers -from sickbeard import logger -from sickbeard import encodingKludge as ek -from sickbeard.exceptions import ex - -from lib.tvdb_api import tvdb_api, tvdb_exceptions - - -class GenericMetadata(): - """ - Base class for all metadata providers. Default behavior is meant to mostly - follow XBMC metadata standards. Has support for: - - - show poster - - show fanart - - show metadata file - - episode thumbnail - - episode metadata file - - season thumbnails - """ - - def __init__(self, - show_metadata=False, - episode_metadata=False, - poster=False, - fanart=False, - episode_thumbnails=False, - season_thumbnails=False): - - self._show_file_name = "tvshow.nfo" - self._ep_nfo_extension = "nfo" - - self.poster_name = "folder.jpg" - self.fanart_name = "fanart.jpg" - - self.generate_show_metadata = True - self.generate_ep_metadata = True - - self.name = 'Generic' - - self.show_metadata = show_metadata - self.episode_metadata = episode_metadata - self.poster = poster - self.fanart = fanart - self.episode_thumbnails = episode_thumbnails - self.season_thumbnails = season_thumbnails - - def get_config(self): - config_list = [self.show_metadata, self.episode_metadata, self.poster, self.fanart, self.episode_thumbnails, self.season_thumbnails] - return '|'.join([str(int(x)) for x in config_list]) - - def get_id(self): - return GenericMetadata.makeID(self.name) - - @staticmethod - def makeID(name): - return re.sub("[^\w\d_]", "_", name).lower() - - def set_config(self, string): - config_list = [bool(int(x)) for x in string.split('|')] - self.show_metadata = config_list[0] - self.episode_metadata = config_list[1] - self.poster = config_list[2] - self.fanart = config_list[3] - self.episode_thumbnails = config_list[4] - self.season_thumbnails = config_list[5] - - def _has_show_metadata(self, show_obj): - result = ek.ek(os.path.isfile, self.get_show_file_path(show_obj)) - logger.log("Checking if "+self.get_show_file_path(show_obj)+" exists: "+str(result), logger.DEBUG) - return result - - def _has_episode_metadata(self, ep_obj): - result = ek.ek(os.path.isfile, self.get_episode_file_path(ep_obj)) - logger.log("Checking if "+self.get_episode_file_path(ep_obj)+" exists: "+str(result), logger.DEBUG) - return result - - def _has_poster(self, show_obj): - result = ek.ek(os.path.isfile, self.get_poster_path(show_obj)) - logger.log("Checking if "+self.get_poster_path(show_obj)+" exists: "+str(result), logger.DEBUG) - return result - - def _has_fanart(self, show_obj): - result = ek.ek(os.path.isfile, self.get_fanart_path(show_obj)) - logger.log("Checking if "+self.get_fanart_path(show_obj)+" exists: "+str(result), logger.DEBUG) - return result - - def _has_episode_thumb(self, ep_obj): - location = self.get_episode_thumb_path(ep_obj) - result = location != None and ek.ek(os.path.isfile, location) - if location: - logger.log("Checking if "+location+" exists: "+str(result), logger.DEBUG) - return result - - def _has_season_thumb(self, show_obj, season): - location = self.get_season_thumb_path(show_obj, season) - result = location != None and ek.ek(os.path.isfile, location) - if location: - logger.log("Checking if "+location+" exists: "+str(result), logger.DEBUG) - return result - - def get_show_file_path(self, show_obj): - return ek.ek(os.path.join, show_obj.location, self._show_file_name) - - def get_episode_file_path(self, ep_obj): - return helpers.replaceExtension(ep_obj.location, self._ep_nfo_extension) - - def get_poster_path(self, show_obj): - return ek.ek(os.path.join, show_obj.location, self.poster_name) - - def get_fanart_path(self, show_obj): - return ek.ek(os.path.join, show_obj.location, self.fanart_name) - - def get_episode_thumb_path(self, ep_obj): - """ - Returns the path where the episode thumbnail should be stored. Defaults to - the same path as the episode file but with a .tbn extension. - - ep_obj: a TVEpisode instance for which to create the thumbnail - """ - if ek.ek(os.path.isfile, ep_obj.location): - tbn_filename = helpers.replaceExtension(ep_obj.location, 'tbn') - else: - return None - - return tbn_filename - - def get_season_thumb_path(self, show_obj, season): - """ - Returns the full path to the file for a given season thumb. - - show_obj: a TVShow instance for which to generate the path - season: a season number to be used for the path. Note that sesaon 0 - means specials. - """ - - # Our specials thumbnail is, well, special - if season == 0: - season_thumb_file_path = 'season-specials' - else: - season_thumb_file_path = 'season' + str(season).zfill(2) - - return ek.ek(os.path.join, show_obj.location, season_thumb_file_path+'.tbn') - - def _show_data(self, show_obj): - """ - This should be overridden by the implementing class. It should - provide the content of the show metadata file. - """ - return None - - def _ep_data(self, ep_obj): - """ - This should be overridden by the implementing class. It should - provide the content of the episode metadata file. - """ - return None - - def create_show_metadata(self, show_obj): - if self.show_metadata and show_obj and not self._has_show_metadata(show_obj): - logger.log("Metadata provider "+self.name+" creating show metadata for "+show_obj.name, logger.DEBUG) - return self.write_show_file(show_obj) - return False - - def create_episode_metadata(self, ep_obj): - if self.episode_metadata and ep_obj and not self._has_episode_metadata(ep_obj): - logger.log("Metadata provider "+self.name+" creating episode metadata for "+ep_obj.prettyName(), logger.DEBUG) - return self.write_ep_file(ep_obj) - return False - - def create_poster(self, show_obj): - if self.poster and show_obj and not self._has_poster(show_obj): - logger.log("Metadata provider "+self.name+" creating poster for "+show_obj.name, logger.DEBUG) - return self.save_poster(show_obj) - return False - - def create_fanart(self, show_obj): - if self.fanart and show_obj and not self._has_fanart(show_obj): - logger.log("Metadata provider "+self.name+" creating fanart for "+show_obj.name, logger.DEBUG) - return self.save_fanart(show_obj) - return False - - def create_episode_thumb(self, ep_obj): - if self.episode_thumbnails and ep_obj and not self._has_episode_thumb(ep_obj): - logger.log("Metadata provider "+self.name+" creating show metadata for "+ep_obj.prettyName(), logger.DEBUG) - return self.save_thumbnail(ep_obj) - return False - - def create_season_thumbs(self, show_obj): - if self.season_thumbnails and show_obj: - logger.log("Metadata provider "+self.name+" creating season thumbnails for "+show_obj.name, logger.DEBUG) - return self.save_season_thumbs(show_obj) - return False - - def _get_episode_thumb_url(self, ep_obj): - """ - Returns the URL to use for downloading an episode's thumbnail. Uses - theTVDB.com data. - - ep_obj: a TVEpisode object for which to grab the thumb URL - """ - all_eps = [ep_obj] + ep_obj.relatedEps - - tvdb_lang = ep_obj.show.lang - - # get a TVDB object - try: - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if tvdb_lang and not tvdb_lang == 'en': - ltvdb_api_parms['language'] = tvdb_lang - - t = tvdb_api.Tvdb(actors=True, **ltvdb_api_parms) - tvdb_show_obj = t[ep_obj.show.tvdbid] - except tvdb_exceptions.tvdb_shownotfound, e: - raise exceptions.ShowNotFoundException(e.message) - except tvdb_exceptions.tvdb_error, e: - logger.log(u"Unable to connect to TVDB while creating meta files - skipping - "+ex(e), logger.ERROR) - return None - - # try all included episodes in case some have thumbs and others don't - for cur_ep in all_eps: - try: - myEp = tvdb_show_obj[cur_ep.season][cur_ep.episode] - except (tvdb_exceptions.tvdb_episodenotfound, tvdb_exceptions.tvdb_seasonnotfound): - logger.log(u"Unable to find episode " + str(cur_ep.season) + "x" + str(cur_ep.episode) + " on tvdb... has it been removed? Should I delete from db?") - continue - - thumb_url = myEp["filename"] - - if thumb_url: - return thumb_url - - return None - - def write_show_file(self, show_obj): - """ - Generates and writes show_obj's metadata under the given path to the - filename given by get_show_file_path() - - show_obj: TVShow object for which to create the metadata - - path: An absolute or relative path where we should put the file. Note that - the file name will be the default show_file_name. - - Note that this method expects that _show_data will return an ElementTree - object. If your _show_data returns data in another format you'll need to - override this method. - """ - - data = self._show_data(show_obj) - - if not data: - return False - - nfo_file_path = self.get_show_file_path(show_obj) - nfo_file_dir = ek.ek(os.path.dirname, nfo_file_path) - - try: - if not ek.ek(os.path.isdir, nfo_file_dir): - logger.log("Metadata dir didn't exist, creating it at "+nfo_file_dir, logger.DEBUG) - ek.ek(os.makedirs, nfo_file_dir) - helpers.chmodAsParent(nfo_file_dir) - - logger.log(u"Writing show nfo file to "+nfo_file_path) - - nfo_file = ek.ek(open, nfo_file_path, 'w') - - data.write(nfo_file, encoding="utf-8") - nfo_file.close() - helpers.chmodAsParent(nfo_file_path) - except IOError, e: - logger.log(u"Unable to write file to "+nfo_file_path+" - are you sure the folder is writable? "+ex(e), logger.ERROR) - return False - - return True - - def write_ep_file(self, ep_obj): - """ - Generates and writes ep_obj's metadata under the given path with the - given filename root. Uses the episode's name with the extension in - _ep_nfo_extension. - - ep_obj: TVEpisode object for which to create the metadata - - file_name_path: The file name to use for this metadata. Note that the extension - will be automatically added based on _ep_nfo_extension. This should - include an absolute path. - - Note that this method expects that _ep_data will return an ElementTree - object. If your _ep_data returns data in another format you'll need to - override this method. - """ - - data = self._ep_data(ep_obj) - - if not data: - return False - - nfo_file_path = self.get_episode_file_path(ep_obj) - nfo_file_dir = ek.ek(os.path.dirname, nfo_file_path) - - try: - if not ek.ek(os.path.isdir, nfo_file_dir): - logger.log("Metadata dir didn't exist, creating it at "+nfo_file_dir, logger.DEBUG) - ek.ek(os.makedirs, nfo_file_dir) - helpers.chmodAsParent(nfo_file_dir) - - logger.log(u"Writing episode nfo file to "+nfo_file_path) - - nfo_file = ek.ek(open, nfo_file_path, 'w') - - data.write(nfo_file, encoding="utf-8") - nfo_file.close() - helpers.chmodAsParent(nfo_file_path) - except IOError, e: - logger.log(u"Unable to write file to "+nfo_file_path+" - are you sure the folder is writable? "+ex(e), logger.ERROR) - return False - - return True - - def save_thumbnail(self, ep_obj): - """ - Retrieves a thumbnail and saves it to the correct spot. This method should not need to - be overridden by implementing classes, changing get_episode_thumb_path and - _get_episode_thumb_url should suffice. - - ep_obj: a TVEpisode object for which to generate a thumbnail - """ - - file_path = self.get_episode_thumb_path(ep_obj) - - if not file_path: - logger.log(u"Unable to find a file path to use for this thumbnail, not generating it", logger.DEBUG) - return False - - thumb_url = self._get_episode_thumb_url(ep_obj) - - # if we can't find one then give up - if not thumb_url: - logger.log("No thumb is available for this episode, not creating a thumb", logger.DEBUG) - return False - - thumb_data = metadata_helpers.getShowImage(thumb_url) - - result = self._write_image(thumb_data, file_path) - - if not result: - return False - - for cur_ep in [ep_obj] + ep_obj.relatedEps: - cur_ep.hastbn = True - - return True - - def save_fanart(self, show_obj, which=None): - """ - Downloads a fanart image and saves it to the filename specified by fanart_name - inside the show's root folder. - - show_obj: a TVShow object for which to download fanart - """ - - # use the default fanart name - fanart_path = self.get_fanart_path(show_obj) - - fanart_data = self._retrieve_show_image('fanart', show_obj, which) - - if not fanart_data: - logger.log(u"No fanart image was retrieved, unable to write fanart", logger.DEBUG) - return False - - return self._write_image(fanart_data, fanart_path) - - - def save_poster(self, show_obj, which=None): - """ - Downloads a poster image and saves it to the filename specified by poster_name - inside the show's root folder. - - show_obj: a TVShow object for which to download a poster - """ - - # use the default poster name - poster_path = self.get_poster_path(show_obj) - - if sickbeard.USE_BANNER: - img_type = 'banner' - else: - img_type = 'poster' - - poster_data = self._retrieve_show_image(img_type, show_obj, which) - - if not poster_data: - logger.log(u"No show folder image was retrieved, unable to write poster", logger.DEBUG) - return False - - return self._write_image(poster_data, poster_path) - - - def save_season_thumbs(self, show_obj): - """ - Saves all season thumbnails to disk for the given show. - - show_obj: a TVShow object for which to save the season thumbs - - Cycles through all seasons and saves the season thumbs if possible. This - method should not need to be overridden by implementing classes, changing - _season_thumb_dict and get_season_thumb_path should be good enough. - """ - - season_dict = self._season_thumb_dict(show_obj) - - # Returns a nested dictionary of season art with the season - # number as primary key. It's really overkill but gives the option - # to present to user via ui to pick down the road. - for cur_season in season_dict: - - cur_season_art = season_dict[cur_season] - - if len(cur_season_art) == 0: - continue - - # Just grab whatever's there for now - art_id, season_url = cur_season_art.popitem() #@UnusedVariable - - season_thumb_file_path = self.get_season_thumb_path(show_obj, cur_season) - - if not season_thumb_file_path: - logger.log(u"Path for season "+str(cur_season)+" came back blank, skipping this season", logger.DEBUG) - continue - - seasonData = metadata_helpers.getShowImage(season_url) - - if not seasonData: - logger.log(u"No season thumb data available, skipping this season", logger.DEBUG) - continue - - self._write_image(seasonData, season_thumb_file_path) - - return True - - def _write_image(self, image_data, image_path): - """ - Saves the data in image_data to the location image_path. Returns True/False - to represent success or failure. - - image_data: binary image data to write to file - image_path: file location to save the image to - """ - - # don't bother overwriting it - if ek.ek(os.path.isfile, image_path): - logger.log(u"Image already exists, not downloading", logger.DEBUG) - return False - - if not image_data: - logger.log(u"Unable to retrieve image, skipping", logger.WARNING) - return False - - image_dir = ek.ek(os.path.dirname, image_path) - - try: - if not ek.ek(os.path.isdir, image_dir): - logger.log("Metadata dir didn't exist, creating it at "+image_dir, logger.DEBUG) - ek.ek(os.makedirs, image_dir) - helpers.chmodAsParent(image_dir) - - outFile = ek.ek(open, image_path, 'wb') - outFile.write(image_data) - outFile.close() - helpers.chmodAsParent(image_path) - except IOError, e: - logger.log(u"Unable to write image to "+image_path+" - are you sure the show folder is writable? "+ex(e), logger.ERROR) - return False - - return True - - def _retrieve_show_image(self, image_type, show_obj, which=None): - """ - Gets an image URL from theTVDB.com, downloads it and returns the data. - - image_type: type of image to retrieve (currently supported: poster, fanart) - show_obj: a TVShow object to use when searching for the image - which: optional, a specific numbered poster to look for - - Returns: the binary image data if available, or else None - """ - - tvdb_lang = show_obj.lang - - try: - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if tvdb_lang and not tvdb_lang == 'en': - ltvdb_api_parms['language'] = tvdb_lang - - t = tvdb_api.Tvdb(banners=True, **ltvdb_api_parms) - tvdb_show_obj = t[show_obj.tvdbid] - except (tvdb_exceptions.tvdb_error, IOError), e: - logger.log(u"Unable to look up show on TVDB, not downloading images: "+ex(e), logger.ERROR) - return None - - if image_type not in ('fanart', 'poster', 'banner'): - logger.log(u"Invalid image type "+str(image_type)+", couldn't find it in the TVDB object", logger.ERROR) - return None - - image_url = tvdb_show_obj[image_type] - - image_data = metadata_helpers.getShowImage(image_url, which) - - return image_data - - def _season_thumb_dict(self, show_obj): - """ - Should return a dict like: - - result = {: - {1: '', 2: , ...},} - """ - - # This holds our resulting dictionary of season art - result = {} - - tvdb_lang = show_obj.lang - - try: - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if tvdb_lang and not tvdb_lang == 'en': - ltvdb_api_parms['language'] = tvdb_lang - - t = tvdb_api.Tvdb(banners=True, **ltvdb_api_parms) - tvdb_show_obj = t[show_obj.tvdbid] - except (tvdb_exceptions.tvdb_error, IOError), e: - logger.log(u"Unable to look up show on TVDB, not downloading images: "+ex(e), logger.ERROR) - return result - - # How many seasons? - num_seasons = len(tvdb_show_obj) - - # if we have no season banners then just finish - if 'season' not in tvdb_show_obj['_banners'] or 'season' not in tvdb_show_obj['_banners']['season']: - return result - - # Give us just the normal poster-style season graphics - seasonsArtObj = tvdb_show_obj['_banners']['season']['season'] - - # Returns a nested dictionary of season art with the season - # number as primary key. It's really overkill but gives the option - # to present to user via ui to pick down the road. - for cur_season in range(num_seasons): - - result[cur_season] = {} - - # find the correct season in the tvdb object and just copy the dict into our result dict - for seasonArtID in seasonsArtObj.keys(): - if int(seasonsArtObj[seasonArtID]['season']) == cur_season and seasonsArtObj[seasonArtID]['language'] == 'en': - result[cur_season][seasonArtID] = seasonsArtObj[seasonArtID]['_bannerpath'] - - if len(result[cur_season]) == 0: - continue - - return result - - def retrieveShowMetadata(self, dir): - - empty_return = (None, None) - - metadata_path = ek.ek(os.path.join, dir, self._show_file_name) - - if not ek.ek(os.path.isdir, dir) or not ek.ek(os.path.isfile, metadata_path): - logger.log(u"Can't load the metadata file from "+repr(metadata_path)+", it doesn't exist", logger.DEBUG) - return empty_return - - logger.log(u"Loading show info from metadata file in "+dir, logger.DEBUG) - - try: - xmlFileObj = ek.ek(open, metadata_path, 'r') - showXML = etree.ElementTree(file = xmlFileObj) - - if showXML.findtext('title') == None or (showXML.findtext('tvdbid') == None and showXML.findtext('id') == None): - logger.log(u"Invalid info in tvshow.nfo (missing name or id):" \ - + str(showXML.findtext('title')) + " " \ - + str(showXML.findtext('tvdbid')) + " " \ - + str(showXML.findtext('id'))) - return empty_return - - name = showXML.findtext('title') - if showXML.findtext('tvdbid') != None: - tvdb_id = int(showXML.findtext('tvdbid')) - elif showXML.findtext('id'): - tvdb_id = int(showXML.findtext('id')) - else: - logger.log(u"Empty or field in NFO, unable to find an ID", logger.WARNING) - return empty_return - - if not tvdb_id: - logger.log(u"Invalid tvdb id ("+str(tvdb_id)+"), not using metadata file", logger.WARNING) - return empty_return - - except (exceptions.NoNFOException, SyntaxError, ValueError), e: - logger.log(u"There was an error parsing your existing metadata file: " + ex(e), logger.WARNING) - return empty_return - - return (tvdb_id, name) +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import os.path + +import xml.etree.cElementTree as etree + +import re + +import sickbeard + +from sickbeard import exceptions, helpers +from sickbeard.metadata import helpers as metadata_helpers +from sickbeard import logger +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex + +from lib.tvdb_api import tvdb_api, tvdb_exceptions + + +class GenericMetadata(): + """ + Base class for all metadata providers. Default behavior is meant to mostly + follow XBMC metadata standards. Has support for: + + - show poster + - show fanart + - show metadata file + - episode thumbnail + - episode metadata file + - season thumbnails + """ + + def __init__(self, + show_metadata=False, + episode_metadata=False, + poster=False, + fanart=False, + episode_thumbnails=False, + season_thumbnails=False): + + self._show_file_name = "tvshow.nfo" + self._ep_nfo_extension = "nfo" + + self.poster_name = "folder.jpg" + self.fanart_name = "fanart.jpg" + + self.generate_show_metadata = True + self.generate_ep_metadata = True + + self.name = 'Generic' + + self.show_metadata = show_metadata + self.episode_metadata = episode_metadata + self.poster = poster + self.fanart = fanart + self.episode_thumbnails = episode_thumbnails + self.season_thumbnails = season_thumbnails + + def get_config(self): + config_list = [self.show_metadata, self.episode_metadata, self.poster, self.fanart, self.episode_thumbnails, self.season_thumbnails] + return '|'.join([str(int(x)) for x in config_list]) + + def get_id(self): + return GenericMetadata.makeID(self.name) + + @staticmethod + def makeID(name): + return re.sub("[^\w\d_]", "_", name).lower() + + def set_config(self, string): + config_list = [bool(int(x)) for x in string.split('|')] + self.show_metadata = config_list[0] + self.episode_metadata = config_list[1] + self.poster = config_list[2] + self.fanart = config_list[3] + self.episode_thumbnails = config_list[4] + self.season_thumbnails = config_list[5] + + def _has_show_metadata(self, show_obj): + result = ek.ek(os.path.isfile, self.get_show_file_path(show_obj)) + logger.log("Checking if "+self.get_show_file_path(show_obj)+" exists: "+str(result), logger.DEBUG) + return result + + def _has_episode_metadata(self, ep_obj): + result = ek.ek(os.path.isfile, self.get_episode_file_path(ep_obj)) + logger.log("Checking if "+self.get_episode_file_path(ep_obj)+" exists: "+str(result), logger.DEBUG) + return result + + def _has_poster(self, show_obj): + result = ek.ek(os.path.isfile, self.get_poster_path(show_obj)) + logger.log("Checking if "+self.get_poster_path(show_obj)+" exists: "+str(result), logger.DEBUG) + return result + + def _has_fanart(self, show_obj): + result = ek.ek(os.path.isfile, self.get_fanart_path(show_obj)) + logger.log("Checking if "+self.get_fanart_path(show_obj)+" exists: "+str(result), logger.DEBUG) + return result + + def _has_episode_thumb(self, ep_obj): + location = self.get_episode_thumb_path(ep_obj) + result = location != None and ek.ek(os.path.isfile, location) + if location: + logger.log("Checking if "+location+" exists: "+str(result), logger.DEBUG) + return result + + def _has_season_thumb(self, show_obj, season): + location = self.get_season_thumb_path(show_obj, season) + result = location != None and ek.ek(os.path.isfile, location) + if location: + logger.log("Checking if "+location+" exists: "+str(result), logger.DEBUG) + return result + + def get_show_file_path(self, show_obj): + return ek.ek(os.path.join, show_obj.location, self._show_file_name) + + def get_episode_file_path(self, ep_obj): + return helpers.replaceExtension(ep_obj.location, self._ep_nfo_extension) + + def get_poster_path(self, show_obj): + return ek.ek(os.path.join, show_obj.location, self.poster_name) + + def get_fanart_path(self, show_obj): + return ek.ek(os.path.join, show_obj.location, self.fanart_name) + + def get_episode_thumb_path(self, ep_obj): + """ + Returns the path where the episode thumbnail should be stored. Defaults to + the same path as the episode file but with a .tbn extension. + + ep_obj: a TVEpisode instance for which to create the thumbnail + """ + if ek.ek(os.path.isfile, ep_obj.location): + tbn_filename = helpers.replaceExtension(ep_obj.location, 'tbn') + else: + return None + + return tbn_filename + + def get_season_thumb_path(self, show_obj, season): + """ + Returns the full path to the file for a given season thumb. + + show_obj: a TVShow instance for which to generate the path + season: a season number to be used for the path. Note that sesaon 0 + means specials. + """ + + # Our specials thumbnail is, well, special + if season == 0: + season_thumb_file_path = 'season-specials' + else: + season_thumb_file_path = 'season' + str(season).zfill(2) + + return ek.ek(os.path.join, show_obj.location, season_thumb_file_path+'.tbn') + + def _show_data(self, show_obj): + """ + This should be overridden by the implementing class. It should + provide the content of the show metadata file. + """ + return None + + def _ep_data(self, ep_obj): + """ + This should be overridden by the implementing class. It should + provide the content of the episode metadata file. + """ + return None + + def create_show_metadata(self, show_obj): + if self.show_metadata and show_obj and not self._has_show_metadata(show_obj): + logger.log("Metadata provider "+self.name+" creating show metadata for "+show_obj.name, logger.DEBUG) + return self.write_show_file(show_obj) + return False + + def create_episode_metadata(self, ep_obj): + if self.episode_metadata and ep_obj and not self._has_episode_metadata(ep_obj): + logger.log("Metadata provider "+self.name+" creating episode metadata for "+ep_obj.prettyName(), logger.DEBUG) + return self.write_ep_file(ep_obj) + return False + + def create_poster(self, show_obj): + if self.poster and show_obj and not self._has_poster(show_obj): + logger.log("Metadata provider "+self.name+" creating poster for "+show_obj.name, logger.DEBUG) + return self.save_poster(show_obj) + return False + + def create_fanart(self, show_obj): + if self.fanart and show_obj and not self._has_fanart(show_obj): + logger.log("Metadata provider "+self.name+" creating fanart for "+show_obj.name, logger.DEBUG) + return self.save_fanart(show_obj) + return False + + def create_episode_thumb(self, ep_obj): + if self.episode_thumbnails and ep_obj and not self._has_episode_thumb(ep_obj): + logger.log("Metadata provider "+self.name+" creating show metadata for "+ep_obj.prettyName(), logger.DEBUG) + return self.save_thumbnail(ep_obj) + return False + + def create_season_thumbs(self, show_obj): + if self.season_thumbnails and show_obj: + logger.log("Metadata provider "+self.name+" creating season thumbnails for "+show_obj.name, logger.DEBUG) + return self.save_season_thumbs(show_obj) + return False + + def _get_episode_thumb_url(self, ep_obj): + """ + Returns the URL to use for downloading an episode's thumbnail. Uses + theTVDB.com data. + + ep_obj: a TVEpisode object for which to grab the thumb URL + """ + all_eps = [ep_obj] + ep_obj.relatedEps + + tvdb_lang = ep_obj.show.lang + + # get a TVDB object + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if tvdb_lang and not tvdb_lang == 'en': + ltvdb_api_parms['language'] = tvdb_lang + + t = tvdb_api.Tvdb(actors=True, **ltvdb_api_parms) + tvdb_show_obj = t[ep_obj.show.tvdbid] + except tvdb_exceptions.tvdb_shownotfound, e: + raise exceptions.ShowNotFoundException(e.message) + except tvdb_exceptions.tvdb_error, e: + logger.log(u"Unable to connect to TVDB while creating meta files - skipping - "+ex(e), logger.ERROR) + return None + + # try all included episodes in case some have thumbs and others don't + for cur_ep in all_eps: + try: + myEp = tvdb_show_obj[cur_ep.season][cur_ep.episode] + except (tvdb_exceptions.tvdb_episodenotfound, tvdb_exceptions.tvdb_seasonnotfound): + logger.log(u"Unable to find episode " + str(cur_ep.season) + "x" + str(cur_ep.episode) + " on tvdb... has it been removed? Should I delete from db?") + continue + + thumb_url = myEp["filename"] + + if thumb_url: + return thumb_url + + return None + + def write_show_file(self, show_obj): + """ + Generates and writes show_obj's metadata under the given path to the + filename given by get_show_file_path() + + show_obj: TVShow object for which to create the metadata + + path: An absolute or relative path where we should put the file. Note that + the file name will be the default show_file_name. + + Note that this method expects that _show_data will return an ElementTree + object. If your _show_data returns data in another format you'll need to + override this method. + """ + + data = self._show_data(show_obj) + + if not data: + return False + + nfo_file_path = self.get_show_file_path(show_obj) + nfo_file_dir = ek.ek(os.path.dirname, nfo_file_path) + + try: + if not ek.ek(os.path.isdir, nfo_file_dir): + logger.log("Metadata dir didn't exist, creating it at "+nfo_file_dir, logger.DEBUG) + ek.ek(os.makedirs, nfo_file_dir) + helpers.chmodAsParent(nfo_file_dir) + + logger.log(u"Writing show nfo file to "+nfo_file_path) + + nfo_file = ek.ek(open, nfo_file_path, 'w') + + data.write(nfo_file, encoding="utf-8") + nfo_file.close() + helpers.chmodAsParent(nfo_file_path) + except IOError, e: + logger.log(u"Unable to write file to "+nfo_file_path+" - are you sure the folder is writable? "+ex(e), logger.ERROR) + return False + + return True + + def write_ep_file(self, ep_obj): + """ + Generates and writes ep_obj's metadata under the given path with the + given filename root. Uses the episode's name with the extension in + _ep_nfo_extension. + + ep_obj: TVEpisode object for which to create the metadata + + file_name_path: The file name to use for this metadata. Note that the extension + will be automatically added based on _ep_nfo_extension. This should + include an absolute path. + + Note that this method expects that _ep_data will return an ElementTree + object. If your _ep_data returns data in another format you'll need to + override this method. + """ + + data = self._ep_data(ep_obj) + + if not data: + return False + + nfo_file_path = self.get_episode_file_path(ep_obj) + nfo_file_dir = ek.ek(os.path.dirname, nfo_file_path) + + try: + if not ek.ek(os.path.isdir, nfo_file_dir): + logger.log("Metadata dir didn't exist, creating it at "+nfo_file_dir, logger.DEBUG) + ek.ek(os.makedirs, nfo_file_dir) + helpers.chmodAsParent(nfo_file_dir) + + logger.log(u"Writing episode nfo file to "+nfo_file_path) + + nfo_file = ek.ek(open, nfo_file_path, 'w') + + data.write(nfo_file, encoding="utf-8") + nfo_file.close() + helpers.chmodAsParent(nfo_file_path) + except IOError, e: + logger.log(u"Unable to write file to "+nfo_file_path+" - are you sure the folder is writable? "+ex(e), logger.ERROR) + return False + + return True + + def save_thumbnail(self, ep_obj): + """ + Retrieves a thumbnail and saves it to the correct spot. This method should not need to + be overridden by implementing classes, changing get_episode_thumb_path and + _get_episode_thumb_url should suffice. + + ep_obj: a TVEpisode object for which to generate a thumbnail + """ + + file_path = self.get_episode_thumb_path(ep_obj) + + if not file_path: + logger.log(u"Unable to find a file path to use for this thumbnail, not generating it", logger.DEBUG) + return False + + thumb_url = self._get_episode_thumb_url(ep_obj) + + # if we can't find one then give up + if not thumb_url: + logger.log("No thumb is available for this episode, not creating a thumb", logger.DEBUG) + return False + + thumb_data = metadata_helpers.getShowImage(thumb_url) + + result = self._write_image(thumb_data, file_path) + + if not result: + return False + + for cur_ep in [ep_obj] + ep_obj.relatedEps: + cur_ep.hastbn = True + + return True + + def save_fanart(self, show_obj, which=None): + """ + Downloads a fanart image and saves it to the filename specified by fanart_name + inside the show's root folder. + + show_obj: a TVShow object for which to download fanart + """ + + # use the default fanart name + fanart_path = self.get_fanart_path(show_obj) + + fanart_data = self._retrieve_show_image('fanart', show_obj, which) + + if not fanart_data: + logger.log(u"No fanart image was retrieved, unable to write fanart", logger.DEBUG) + return False + + return self._write_image(fanart_data, fanart_path) + + + def save_poster(self, show_obj, which=None): + """ + Downloads a poster image and saves it to the filename specified by poster_name + inside the show's root folder. + + show_obj: a TVShow object for which to download a poster + """ + + # use the default poster name + poster_path = self.get_poster_path(show_obj) + + if sickbeard.USE_BANNER: + img_type = 'banner' + else: + img_type = 'poster' + + poster_data = self._retrieve_show_image(img_type, show_obj, which) + + if not poster_data: + logger.log(u"No show folder image was retrieved, unable to write poster", logger.DEBUG) + return False + + return self._write_image(poster_data, poster_path) + + + def save_season_thumbs(self, show_obj): + """ + Saves all season thumbnails to disk for the given show. + + show_obj: a TVShow object for which to save the season thumbs + + Cycles through all seasons and saves the season thumbs if possible. This + method should not need to be overridden by implementing classes, changing + _season_thumb_dict and get_season_thumb_path should be good enough. + """ + + season_dict = self._season_thumb_dict(show_obj) + + # Returns a nested dictionary of season art with the season + # number as primary key. It's really overkill but gives the option + # to present to user via ui to pick down the road. + for cur_season in season_dict: + + cur_season_art = season_dict[cur_season] + + if len(cur_season_art) == 0: + continue + + # Just grab whatever's there for now + art_id, season_url = cur_season_art.popitem() #@UnusedVariable + + season_thumb_file_path = self.get_season_thumb_path(show_obj, cur_season) + + if not season_thumb_file_path: + logger.log(u"Path for season "+str(cur_season)+" came back blank, skipping this season", logger.DEBUG) + continue + + seasonData = metadata_helpers.getShowImage(season_url) + + if not seasonData: + logger.log(u"No season thumb data available, skipping this season", logger.DEBUG) + continue + + self._write_image(seasonData, season_thumb_file_path) + + return True + + def _write_image(self, image_data, image_path): + """ + Saves the data in image_data to the location image_path. Returns True/False + to represent success or failure. + + image_data: binary image data to write to file + image_path: file location to save the image to + """ + + # don't bother overwriting it + if ek.ek(os.path.isfile, image_path): + logger.log(u"Image already exists, not downloading", logger.DEBUG) + return False + + if not image_data: + logger.log(u"Unable to retrieve image, skipping", logger.WARNING) + return False + + image_dir = ek.ek(os.path.dirname, image_path) + + try: + if not ek.ek(os.path.isdir, image_dir): + logger.log("Metadata dir didn't exist, creating it at "+image_dir, logger.DEBUG) + ek.ek(os.makedirs, image_dir) + helpers.chmodAsParent(image_dir) + + outFile = ek.ek(open, image_path, 'wb') + outFile.write(image_data) + outFile.close() + helpers.chmodAsParent(image_path) + except IOError, e: + logger.log(u"Unable to write image to "+image_path+" - are you sure the show folder is writable? "+ex(e), logger.ERROR) + return False + + return True + + def _retrieve_show_image(self, image_type, show_obj, which=None): + """ + Gets an image URL from theTVDB.com, downloads it and returns the data. + + image_type: type of image to retrieve (currently supported: poster, fanart) + show_obj: a TVShow object to use when searching for the image + which: optional, a specific numbered poster to look for + + Returns: the binary image data if available, or else None + """ + + tvdb_lang = show_obj.lang + + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if tvdb_lang and not tvdb_lang == 'en': + ltvdb_api_parms['language'] = tvdb_lang + + t = tvdb_api.Tvdb(banners=True, **ltvdb_api_parms) + tvdb_show_obj = t[show_obj.tvdbid] + except (tvdb_exceptions.tvdb_error, IOError), e: + logger.log(u"Unable to look up show on TVDB, not downloading images: "+ex(e), logger.ERROR) + return None + + if image_type not in ('fanart', 'poster', 'banner', 'poster_thumb', 'banner_thumb'): + logger.log(u"Invalid image type "+str(image_type)+", couldn't find it in the TVDB object", logger.ERROR) + return None + + try: + if image_type == 'poster_thumb': + image_url = re.sub('posters', '_cache/posters', tvdb_show_obj['poster']) + elif image_type == 'banner_thumb': + image_url = re.sub('graphical', '_cache/graphical', tvdb_show_obj['banner']) + else: + image_url = tvdb_show_obj[image_type] + except: + return None + + image_data = metadata_helpers.getShowImage(image_url, which) + + return image_data + + def _season_thumb_dict(self, show_obj): + """ + Should return a dict like: + + result = {: + {1: '', 2: , ...},} + """ + + # This holds our resulting dictionary of season art + result = {} + + tvdb_lang = show_obj.lang + + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if tvdb_lang and not tvdb_lang == 'en': + ltvdb_api_parms['language'] = tvdb_lang + + t = tvdb_api.Tvdb(banners=True, **ltvdb_api_parms) + tvdb_show_obj = t[show_obj.tvdbid] + except (tvdb_exceptions.tvdb_error, IOError), e: + logger.log(u"Unable to look up show on TVDB, not downloading images: "+ex(e), logger.ERROR) + return result + + # How many seasons? + num_seasons = len(tvdb_show_obj) + + # if we have no season banners then just finish + if 'season' not in tvdb_show_obj['_banners'] or 'season' not in tvdb_show_obj['_banners']['season']: + return result + + # Give us just the normal poster-style season graphics + seasonsArtObj = tvdb_show_obj['_banners']['season']['season'] + + # Returns a nested dictionary of season art with the season + # number as primary key. It's really overkill but gives the option + # to present to user via ui to pick down the road. + for cur_season in range(num_seasons+1): + + result[cur_season] = {} + + # find the correct season in the tvdb object and just copy the dict into our result dict + for seasonArtID in seasonsArtObj.keys(): + if int(seasonsArtObj[seasonArtID]['season']) == cur_season and seasonsArtObj[seasonArtID]['language'] == 'en': + result[cur_season][seasonArtID] = seasonsArtObj[seasonArtID]['_bannerpath'] + + if len(result[cur_season]) == 0: +# continue + del result[cur_season] + + return result + + def retrieveShowMetadata(self, dir): + + empty_return = (None, None) + + metadata_path = ek.ek(os.path.join, dir, self._show_file_name) + + if not ek.ek(os.path.isdir, dir) or not ek.ek(os.path.isfile, metadata_path): + logger.log(u"Can't load the metadata file from "+repr(metadata_path)+", it doesn't exist", logger.DEBUG) + return empty_return + + logger.log(u"Loading show info from metadata file in "+dir, logger.DEBUG) + + try: + xmlFileObj = ek.ek(open, metadata_path, 'r') + showXML = etree.ElementTree(file = xmlFileObj) + + if showXML.findtext('title') == None or (showXML.findtext('tvdbid') == None and showXML.findtext('id') == None): + logger.log(u"Invalid info in tvshow.nfo (missing name or id):" \ + + str(showXML.findtext('title')) + " " \ + + str(showXML.findtext('tvdbid')) + " " \ + + str(showXML.findtext('id'))) + return empty_return + + name = showXML.findtext('title') + if showXML.findtext('tvdbid') != None: + tvdb_id = int(showXML.findtext('tvdbid')) + elif showXML.findtext('id'): + tvdb_id = int(showXML.findtext('id')) + else: + logger.log(u"Empty or field in NFO, unable to find an ID", logger.WARNING) + return empty_return + + if not tvdb_id: + logger.log(u"Invalid tvdb id ("+str(tvdb_id)+"), not using metadata file", logger.WARNING) + return empty_return + + except (exceptions.NoNFOException, SyntaxError, ValueError), e: + logger.log(u"There was an error parsing your existing metadata file: " + ex(e), logger.WARNING) + return empty_return + + return (tvdb_id, name) diff --git a/sickbeard/network_timezones.py b/sickbeard/network_timezones.py new file mode 100644 index 0000000000..9b87cff173 --- /dev/null +++ b/sickbeard/network_timezones.py @@ -0,0 +1,166 @@ +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from lib.dateutil import tz +import lib.dateutil.zoneinfo +from sickbeard import db +from sickbeard import helpers +from sickbeard import logger +from sickbeard import encodingKludge as ek +from os.path import basename, realpath +import os +import re + +# helper to remove failed temp download +def _remove_zoneinfo_failed(filename): + try: + os.remove(filename) + except: + pass + +# update the dateutil zoneinfo +def _update_zoneinfo(): + + # now check if the zoneinfo needs update + url_zv = 'http://github.com/Prinz23/sb_network_timezones/raw/master/zoneinfo.txt' + + url_data = helpers.getURL(url_zv) + + if url_data is None: + # When urlData is None, trouble connecting to github + logger.log(u"Loading zoneinfo.txt failed. Unable to get URL: " + url_zv, logger.ERROR) + return + + if (lib.dateutil.zoneinfo.ZONEINFOFILE != None): + cur_zoneinfo = ek.ek(basename, lib.dateutil.zoneinfo.ZONEINFOFILE) + else: + cur_zoneinfo = None + (new_zoneinfo, zoneinfo_md5) = url_data.decode('utf-8').strip().rsplit(u' ') + + if ((cur_zoneinfo != None) and (new_zoneinfo == cur_zoneinfo)): + return + + # now load the new zoneinfo + url_tar = u'http://github.com/Prinz23/sb_network_timezones/raw/master/' + new_zoneinfo + zonefile = ek.ek(realpath, u'lib/dateutil/zoneinfo/' + new_zoneinfo) + zonefile_tmp = re.sub(r"\.tar\.gz$",'.tmp', zonefile) + + if (os.path.exists(zonefile_tmp)): + try: + os.remove(zonefile_tmp) + except: + logger.log(u"Unable to delete: " + zonefile_tmp,logger.ERROR) + return + + if not helpers.download_file(url_tar, zonefile_tmp): + return + + new_hash = str(helpers.md5_for_file(zonefile_tmp)) + + if (zoneinfo_md5.upper() == new_hash.upper()): + logger.log(u"Updating timezone info with new one: " + new_zoneinfo,logger.MESSAGE) + try: + # remove the old zoneinfo file + if (cur_zoneinfo != None): + old_file = ek.ek(realpath, u'lib/dateutil/zoneinfo/' + cur_zoneinfo) + if (os.path.exists(old_file)): + os.remove(old_file) + # rename downloaded file + os.rename(zonefile_tmp,zonefile) + # load the new zoneinfo + reload(lib.dateutil.zoneinfo) + except: + _remove_zoneinfo_failed(zonefile_tmp) + return + else: + _remove_zoneinfo_failed(zonefile_tmp) + logger.log(u"MD5 HASH doesn't match: " + zoneinfo_md5.upper() + ' File: ' + new_hash.upper(),logger.ERROR) + return + +# update the network timezone table +def update_network_dict(): + + _update_zoneinfo() + + d = {} + + # network timezones are stored on github pages + url = 'http://github.com/Prinz23/sb_network_timezones/raw/master/network_timezones.txt' + + url_data = helpers.getURL(url) + + if url_data is None: + # When urlData is None, trouble connecting to github + logger.log(u"Loading Network Timezones update failed. Unable to get URL: " + url, logger.ERROR) + return + + try: + for line in url_data.splitlines(): + (key, val) = line.decode('utf-8').strip().rsplit(u':',1) + if key == None or val == None: + continue + d[key] = val + except (IOError, OSError): + pass + + myDB = db.DBConnection("cache.db") + # load current network timezones + old_d = dict(myDB.select("SELECT * FROM network_timezones")) + + # list of sql commands to update the network_timezones table + ql = [] + for cur_d, cur_t in d.iteritems(): + h_k = old_d.has_key(cur_d) + if h_k and cur_t != old_d[cur_d]: + # update old record + ql.append(["UPDATE network_timezones SET network_name=?, timezone=? WHERE network_name=?", [cur_d, cur_t, cur_d]]) + elif not h_k: + # add new record + ql.append(["INSERT INTO network_timezones (network_name, timezone) VALUES (?,?)", [cur_d, cur_t]]) + if h_k: + del old_d[cur_d] + # remove deleted records + if len(old_d) > 0: + L = list(va for va in old_d) + ql.append(["DELETE FROM network_timezones WHERE network_name IN ("+','.join(['?'] * len(L))+")", L]) + # change all network timezone infos at once (much faster) + myDB.mass_action(ql) + +# load network timezones from db into dict +def load_network_dict(): + d = {} + try: + myDB = db.DBConnection("cache.db") + cur_network_list = myDB.select("SELECT * FROM network_timezones") + if cur_network_list == None or len(cur_network_list) < 1: + update_network_dict() + cur_network_list = myDB.select("SELECT * FROM network_timezones") + d = dict(cur_network_list) + except: + d = {} + return d + +# get timezone of a network or return default timezone +def get_network_timezone(network, network_dict, sb_timezone): + if network == None: + return sb_timezone + + try: + return tz.gettz(network_dict[network]) + except: + return sb_timezone \ No newline at end of file diff --git a/sickbeard/notifiers/__init__.py b/sickbeard/notifiers/__init__.py index fe0cd654f0..130d0acc92 100755 --- a/sickbeard/notifiers/__init__.py +++ b/sickbeard/notifiers/__init__.py @@ -1,92 +1,98 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import sickbeard - -import xbmc -import plex -import nmj -import nmjv2 -import synoindex -import pytivo - -import growl -import prowl -import notifo -from . import libnotify -import pushover -import boxcar -import nma -import mail - -import tweet -import trakt - -from sickbeard.common import * - -# home theater -xbmc_notifier = xbmc.XBMCNotifier() -plex_notifier = plex.PLEXNotifier() -nmj_notifier = nmj.NMJNotifier() -synoindex_notifier = synoindex.synoIndexNotifier() -nmjv2_notifier = nmjv2.NMJv2Notifier() -pytivo_notifier = pytivo.pyTivoNotifier() -# devices -growl_notifier = growl.GrowlNotifier() -prowl_notifier = prowl.ProwlNotifier() -notifo_notifier = notifo.NotifoNotifier() -libnotify_notifier = libnotify.LibnotifyNotifier() -pushover_notifier = pushover.PushoverNotifier() -boxcar_notifier = boxcar.BoxcarNotifier() -nma_notifier = nma.NMA_Notifier() -# online -twitter_notifier = tweet.TwitterNotifier() -trakt_notifier = trakt.TraktNotifier() -mail_notifier = mail.MailNotifier() - -notifiers = [ - libnotify_notifier, # Libnotify notifier goes first because it doesn't involve blocking on network activity. - xbmc_notifier, - plex_notifier, - nmj_notifier, - nmjv2_notifier, - synoindex_notifier, - pytivo_notifier, - growl_notifier, - prowl_notifier, - notifo_notifier, - pushover_notifier, - boxcar_notifier, - nma_notifier, - twitter_notifier, - trakt_notifier, - mail_notifier, -] - - -def notify_download(ep_name): - for n in notifiers: - n.notify_download(ep_name) - -def notify_subtitle_download(ep_name, lang): - for n in notifiers: - n.notify_subtitle_download(ep_name, lang) - -def notify_snatch(ep_name): - for n in notifiers: - n.notify_snatch(ep_name) +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import sickbeard + +import xbmc +import plex +import nmj +import nmjv2 +import synoindex +import synologynotifier +import pytivo + +import growl +import prowl +import notifo +from . import libnotify +import pushover +import boxcar +import nma +import mail +import pushalot + +import tweet +import trakt + +from sickbeard.common import * + +# home theater +xbmc_notifier = xbmc.XBMCNotifier() +plex_notifier = plex.PLEXNotifier() +nmj_notifier = nmj.NMJNotifier() +synoindex_notifier = synoindex.synoIndexNotifier() +nmjv2_notifier = nmjv2.NMJv2Notifier() +synology_notifier = synologynotifier.synologyNotifier() +pytivo_notifier = pytivo.pyTivoNotifier() +# devices +growl_notifier = growl.GrowlNotifier() +prowl_notifier = prowl.ProwlNotifier() +notifo_notifier = notifo.NotifoNotifier() +libnotify_notifier = libnotify.LibnotifyNotifier() +pushover_notifier = pushover.PushoverNotifier() +boxcar_notifier = boxcar.BoxcarNotifier() +nma_notifier = nma.NMA_Notifier() +pushalot_notifier = pushalot.PushalotNotifier() +# online +twitter_notifier = tweet.TwitterNotifier() +trakt_notifier = trakt.TraktNotifier() +mail_notifier = mail.MailNotifier() + +notifiers = [ + libnotify_notifier, # Libnotify notifier goes first because it doesn't involve blocking on network activity. + xbmc_notifier, + plex_notifier, + nmj_notifier, + nmjv2_notifier, + synoindex_notifier, + synology_notifier, + pytivo_notifier, + growl_notifier, + prowl_notifier, + notifo_notifier, + pushover_notifier, + boxcar_notifier, + nma_notifier, + pushalot_notifier, + twitter_notifier, + trakt_notifier, + mail_notifier, +] + + +def notify_download(ep_name): + for n in notifiers: + n.notify_download(ep_name) + +def notify_subtitle_download(ep_name, lang): + for n in notifiers: + n.notify_subtitle_download(ep_name, lang) + +def notify_snatch(ep_name): + for n in notifiers: + n.notify_snatch(ep_name) diff --git a/sickbeard/notifiers/pushalot.py b/sickbeard/notifiers/pushalot.py new file mode 100644 index 0000000000..3d3d634d16 --- /dev/null +++ b/sickbeard/notifiers/pushalot.py @@ -0,0 +1,83 @@ +# Author: Maciej Olesinski (https://github.com/molesinski/) +# Based on prowl.py by Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from httplib import HTTPSConnection, HTTPException +from urllib import urlencode +from ssl import SSLError + +import sickbeard +from sickbeard import logger, common + +class PushalotNotifier: + + def test_notify(self, pushalot_authorizationtoken): + return self._sendPushalot(pushalot_authorizationtoken, event="Test", message="Testing Pushalot settings from Sick Beard", force=True) + + def notify_snatch(self, ep_name): + if sickbeard.PUSHALOT_NOTIFY_ONSNATCH: + self._sendPushalot(pushalot_authorizationtoken=None, event=common.notifyStrings[common.NOTIFY_SNATCH], message=ep_name) + + def notify_download(self, ep_name): + if sickbeard.PUSHALOT_NOTIFY_ONDOWNLOAD: + self._sendPushalot(pushalot_authorizationtoken=None, event=common.notifyStrings[common.NOTIFY_DOWNLOAD], message=ep_name) + + def notify_subtitle_download(self, ep_name, lang): + if sickbeard.PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD: + self._sendPushalot(pushalot_authorizationtoken=None, event=common.notifyStrings[common.NOTIFY_SUBTITLE_DOWNLOAD], message=ep_name + ": " + lang) + + def _sendPushalot(self, pushalot_authorizationtoken=None, event=None, message=None, force=False): + + if not sickbeard.USE_PUSHALOT and not force: + return False + + if pushalot_authorizationtoken == None: + pushalot_authorizationtoken = sickbeard.PUSHALOT_AUTHORIZATIONTOKEN + + logger.log(u"Pushalot event: " + event, logger.DEBUG) + logger.log(u"Pushalot message: " + message, logger.DEBUG) + logger.log(u"Pushalot api: " + pushalot_authorizationtoken, logger.DEBUG) + + http_handler = HTTPSConnection("pushalot.com") + + data = {'AuthorizationToken': pushalot_authorizationtoken, + 'Title': event.encode('utf-8'), + 'Body': message.encode('utf-8') } + + try: + http_handler.request("POST", + "/api/sendmessage", + headers = {'Content-type': "application/x-www-form-urlencoded"}, + body = urlencode(data)) + except (SSLError, HTTPException): + logger.log(u"Pushalot notification failed.", logger.ERROR) + return False + response = http_handler.getresponse() + request_status = response.status + + if request_status == 200: + logger.log(u"Pushalot notifications sent.", logger.DEBUG) + return True + elif request_status == 410: + logger.log(u"Pushalot auth failed: %s" % response.reason, logger.ERROR) + return False + else: + logger.log(u"Pushalot notification failed.", logger.ERROR) + return False + +notifier = PushalotNotifier diff --git a/sickbeard/notifiers/synologynotifier.py b/sickbeard/notifiers/synologynotifier.py new file mode 100644 index 0000000000..a7f9b6799c --- /dev/null +++ b/sickbeard/notifiers/synologynotifier.py @@ -0,0 +1,55 @@ +# Author: Nyaran +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + + + +import os +import subprocess + +import sickbeard + +from sickbeard import logger +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex +from sickbeard import common + +class synologyNotifier: + + def notify_snatch(self, ep_name): + if sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH: + self._send_synologyNotifier(ep_name, common.notifyStrings[common.NOTIFY_SNATCH]) + + def notify_download(self, ep_name): + if sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD: + self._send_synologyNotifier(ep_name, common.notifyStrings[common.NOTIFY_DOWNLOAD]) + + def notify_subtitle_download(self, ep_name, lang): + if sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD: + self._send_synologyNotifier(ep_name + ": " + lang, common.notifyStrings[common.NOTIFY_SUBTITLE_DOWNLOAD]) + + def _send_synologyNotifier(self, message, title): + synodsmnotify_cmd = ["/usr/syno/bin/synodsmnotify", "@administrators", title, message] + logger.log(u"Executing command "+str(synodsmnotify_cmd)) + logger.log(u"Absolute path to command: "+ek.ek(os.path.abspath, synodsmnotify_cmd[0]), logger.DEBUG) + try: + p = subprocess.Popen(synodsmnotify_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=sickbeard.PROG_DIR) + out, err = p.communicate() #@UnusedVariable + logger.log(u"Script result: "+str(out), logger.DEBUG) + except OSError, e: + logger.log(u"Unable to run synodsmnotify: "+ex(e)) + +notifier = synologyNotifier diff --git a/sickbeard/show_queue.py b/sickbeard/show_queue.py index 0eef7bb960..146e36d191 100644 --- a/sickbeard/show_queue.py +++ b/sickbeard/show_queue.py @@ -1,492 +1,521 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from __future__ import with_statement - -import traceback - -import sickbeard - -from lib.tvdb_api import tvdb_exceptions, tvdb_api - -from sickbeard.common import SKIPPED, WANTED - -from sickbeard.tv import TVShow -from sickbeard import exceptions, logger, ui, db -from sickbeard import generic_queue -from sickbeard import name_cache -from sickbeard.exceptions import ex - - -class ShowQueue(generic_queue.GenericQueue): - - def __init__(self): - generic_queue.GenericQueue.__init__(self) - self.queue_name = "SHOWQUEUE" - - def _isInQueue(self, show, actions): - return show in [x.show for x in self.queue if x.action_id in actions] - - def _isBeingSomethinged(self, show, actions): - return self.currentItem != None and show == self.currentItem.show and \ - self.currentItem.action_id in actions - - def isInUpdateQueue(self, show): - return self._isInQueue(show, (ShowQueueActions.UPDATE, ShowQueueActions.FORCEUPDATE)) - - def isInRefreshQueue(self, show): - return self._isInQueue(show, (ShowQueueActions.REFRESH,)) - - def isInRenameQueue(self, show): - return self._isInQueue(show, (ShowQueueActions.RENAME,)) - - def isInSubtitleQueue(self, show): - return self._isInQueue(show, (ShowQueueActions.SUBTITLE,)) - - def isBeingAdded(self, show): - return self._isBeingSomethinged(show, (ShowQueueActions.ADD,)) - - def isBeingUpdated(self, show): - return self._isBeingSomethinged(show, (ShowQueueActions.UPDATE, ShowQueueActions.FORCEUPDATE)) - - def isBeingRefreshed(self, show): - return self._isBeingSomethinged(show, (ShowQueueActions.REFRESH,)) - - def isBeingRenamed(self, show): - return self._isBeingSomethinged(show, (ShowQueueActions.RENAME,)) - - def isBeingSubtitled(self, show): - return self._isBeingSomethinged(show, (ShowQueueActions.SUBTITLE,)) - - def _getLoadingShowList(self): - return [x for x in self.queue + [self.currentItem] if x != None and x.isLoading] - - loadingShowList = property(_getLoadingShowList) - - def updateShow(self, show, force=False): - - if self.isBeingAdded(show): - raise exceptions.CantUpdateException("Show is still being added, wait until it is finished before you update.") - - if self.isBeingUpdated(show): - raise exceptions.CantUpdateException("This show is already being updated, can't update again until it's done.") - - if self.isInUpdateQueue(show): - raise exceptions.CantUpdateException("This show is already being updated, can't update again until it's done.") - - if not force: - queueItemObj = QueueItemUpdate(show) - else: - queueItemObj = QueueItemForceUpdate(show) - - self.add_item(queueItemObj) - - return queueItemObj - - def refreshShow(self, show, force=False): - - if self.isBeingRefreshed(show) and not force: - raise exceptions.CantRefreshException("This show is already being refreshed, not refreshing again.") - - if (self.isBeingUpdated(show) or self.isInUpdateQueue(show)) and not force: - logger.log(u"A refresh was attempted but there is already an update queued or in progress. Since updates do a refres at the end anyway I'm skipping this request.", logger.DEBUG) - return - - queueItemObj = QueueItemRefresh(show) - - self.add_item(queueItemObj) - - return queueItemObj - - def renameShowEpisodes(self, show, force=False): - - queueItemObj = QueueItemRename(show) - - self.add_item(queueItemObj) - - return queueItemObj - - def downloadSubtitles(self, show, force=False): - - queueItemObj = QueueItemSubtitle(show) - - self.add_item(queueItemObj) - - return queueItemObj - - def addShow(self, tvdb_id, showDir, default_status=None, quality=None, flatten_folders=None, lang="fr", subtitles=None, audio_lang=None): - queueItemObj = QueueItemAdd(tvdb_id, showDir, default_status, quality, flatten_folders, lang, subtitles, audio_lang) - - self.add_item(queueItemObj) - - return queueItemObj - - -class ShowQueueActions: - REFRESH = 1 - ADD = 2 - UPDATE = 3 - FORCEUPDATE = 4 - RENAME = 5 - SUBTITLE=6 - - names = {REFRESH: 'Refresh', - ADD: 'Add', - UPDATE: 'Update', - FORCEUPDATE: 'Force Update', - RENAME: 'Rename', - SUBTITLE: 'Subtitle', - } - - -class ShowQueueItem(generic_queue.QueueItem): - """ - Represents an item in the queue waiting to be executed - - Can be either: - - show being added (may or may not be associated with a show object) - - show being refreshed - - show being updated - - show being force updated - - show being subtitled - """ - def __init__(self, action_id, show): - generic_queue.QueueItem.__init__(self, ShowQueueActions.names[action_id], action_id) - self.show = show - - def isInQueue(self): - return self in sickbeard.showQueueScheduler.action.queue + [sickbeard.showQueueScheduler.action.currentItem] #@UndefinedVariable - - def _getName(self): - return str(self.show.tvdbid) - - def _isLoading(self): - return False - - show_name = property(_getName) - - isLoading = property(_isLoading) - - -class QueueItemAdd(ShowQueueItem): - def __init__(self, tvdb_id, showDir, default_status, quality, flatten_folders, lang, subtitles, audio_lang): - - self.tvdb_id = tvdb_id - self.showDir = showDir - self.default_status = default_status - self.quality = quality - self.flatten_folders = flatten_folders - self.lang = lang - self.audio_lang = audio_lang - self.subtitles = subtitles - - self.show = None - - # this will initialize self.show to None - ShowQueueItem.__init__(self, ShowQueueActions.ADD, self.show) - - def _getName(self): - """ - Returns the show name if there is a show object created, if not returns - the dir that the show is being added to. - """ - if self.show == None: - return self.showDir - return self.show.name - - show_name = property(_getName) - - def _isLoading(self): - """ - Returns True if we've gotten far enough to have a show object, or False - if we still only know the folder name. - """ - if self.show == None: - return True - return False - - isLoading = property(_isLoading) - - def execute(self): - - ShowQueueItem.execute(self) - - logger.log(u"Starting to add show " + self.showDir) - - try: - # make sure the tvdb ids are valid - try: - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - if self.lang: - ltvdb_api_parms['language'] = self.lang - - logger.log(u"TVDB: " + repr(ltvdb_api_parms)) - - t = tvdb_api.Tvdb(**ltvdb_api_parms) - s = t[self.tvdb_id] - - # this usually only happens if they have an NFO in their show dir which gave us a TVDB ID that has no proper english version of the show - if not s['seriesname']: - logger.log(u"Show in " + self.showDir + " has no name on TVDB, probably the wrong language used to search with.", logger.ERROR) - ui.notifications.error("Unable to add show", "Show in " + self.showDir + " has no name on TVDB, probably the wrong language. Delete .nfo and add manually in the correct language.") - self._finishEarly() - return - # if the show has no episodes/seasons - if not s: - logger.log(u"Show " + str(s['seriesname']) + " is on TVDB but contains no season/episode data.", logger.ERROR) - ui.notifications.error("Unable to add show", "Show " + str(s['seriesname']) + " is on TVDB but contains no season/episode data.") - self._finishEarly() - return - except tvdb_exceptions.tvdb_exception, e: - logger.log(u"Error contacting TVDB: " + ex(e), logger.ERROR) - ui.notifications.error("Unable to add show", "Unable to look up the show in " + self.showDir + " on TVDB, not using the NFO. Delete .nfo and add manually in the correct language.") - self._finishEarly() - return - - # clear the name cache - name_cache.clearCache() - - newShow = TVShow(self.tvdb_id, self.lang, self.audio_lang) - newShow.loadFromTVDB() - - self.show = newShow - - # set up initial values - self.show.location = self.showDir - self.show.subtitles = self.subtitles if self.subtitles != None else sickbeard.SUBTITLES_DEFAULT - self.show.quality = self.quality if self.quality else sickbeard.QUALITY_DEFAULT - self.show.flatten_folders = self.flatten_folders if self.flatten_folders != None else sickbeard.FLATTEN_FOLDERS_DEFAULT - self.show.paused = 0 - - # be smartish about this - if self.show.genre and "talk show" in self.show.genre.lower(): - self.show.air_by_date = 1 - - except tvdb_exceptions.tvdb_exception, e: - logger.log(u"Unable to add show due to an error with TVDB: " + ex(e), logger.ERROR) - if self.show: - ui.notifications.error("Unable to add " + str(self.show.name) + " due to an error with TVDB") - else: - ui.notifications.error("Unable to add show due to an error with TVDB") - self._finishEarly() - return - - except exceptions.MultipleShowObjectsException: - logger.log(u"The show in " + self.showDir + " is already in your show list, skipping", logger.ERROR) - ui.notifications.error('Show skipped', "The show in " + self.showDir + " is already in your show list") - self._finishEarly() - return - - except Exception, e: - logger.log(u"Error trying to add show: " + ex(e), logger.ERROR) - logger.log(traceback.format_exc(), logger.DEBUG) - self._finishEarly() - raise - - # add it to the show list - sickbeard.showList.append(self.show) - - try: - self.show.loadEpisodesFromDir() - except Exception, e: - logger.log(u"Error searching dir for episodes: " + ex(e), logger.ERROR) - logger.log(traceback.format_exc(), logger.DEBUG) - - try: - self.show.loadEpisodesFromTVDB() - self.show.setTVRID() - - self.show.writeMetadata() - self.show.populateCache() - - except Exception, e: - logger.log(u"Error with TVDB, not creating episode list: " + ex(e), logger.ERROR) - logger.log(traceback.format_exc(), logger.DEBUG) - - try: - self.show.saveToDB() - except Exception, e: - logger.log(u"Error saving the episode to the database: " + ex(e), logger.ERROR) - logger.log(traceback.format_exc(), logger.DEBUG) - - # if they gave a custom status then change all the eps to it - if self.default_status != SKIPPED: - logger.log(u"Setting all episodes to the specified default status: " + str(self.default_status)) - myDB = db.DBConnection() - myDB.action("UPDATE tv_episodes SET status = ? WHERE status = ? AND showid = ? AND season != 0", [self.default_status, SKIPPED, self.show.tvdbid]) - - # if they started with WANTED eps then run the backlog - if self.default_status == WANTED: - logger.log(u"Launching backlog for this show since its episodes are WANTED") - sickbeard.backlogSearchScheduler.action.searchBacklog([self.show]) #@UndefinedVariable - - self.show.flushEpisodes() - - # if there are specific episodes that need to be added by trakt - sickbeard.traktWatchListCheckerSchedular.action.manageNewShow(self.show) - - self.finish() - - def _finishEarly(self): - if self.show != None: - self.show.deleteShow() - - self.finish() - - -class QueueItemRefresh(ShowQueueItem): - def __init__(self, show=None): - ShowQueueItem.__init__(self, ShowQueueActions.REFRESH, show) - - # do refreshes first because they're quick - self.priority = generic_queue.QueuePriorities.HIGH - - def execute(self): - - ShowQueueItem.execute(self) - - logger.log(u"Performing refresh on " + self.show.name) - - self.show.refreshDir() - self.show.writeMetadata() - self.show.populateCache() - - self.inProgress = False - - -class QueueItemRename(ShowQueueItem): - def __init__(self, show=None): - ShowQueueItem.__init__(self, ShowQueueActions.RENAME, show) - - def execute(self): - - ShowQueueItem.execute(self) - - logger.log(u"Performing rename on " + self.show.name) - - try: - show_loc = self.show.location - except exceptions.ShowDirNotFoundException: - logger.log(u"Can't perform rename on " + self.show.name + " when the show dir is missing.", logger.WARNING) - return - - ep_obj_rename_list = [] - - ep_obj_list = self.show.getAllEpisodes(has_location=True) - for cur_ep_obj in ep_obj_list: - # Only want to rename if we have a location - if cur_ep_obj.location: - if cur_ep_obj.relatedEps: - # do we have one of multi-episodes in the rename list already - have_already = False - for cur_related_ep in cur_ep_obj.relatedEps + [cur_ep_obj]: - if cur_related_ep in ep_obj_rename_list: - have_already = True - break - if not have_already: - ep_obj_rename_list.append(cur_ep_obj) - - else: - ep_obj_rename_list.append(cur_ep_obj) - - for cur_ep_obj in ep_obj_rename_list: - cur_ep_obj.rename() - - self.inProgress = False - -class QueueItemSubtitle(ShowQueueItem): - def __init__(self, show=None): - ShowQueueItem.__init__(self, ShowQueueActions.SUBTITLE, show) - - def execute(self): - - ShowQueueItem.execute(self) - - logger.log(u"Downloading subtitles for "+self.show.name) - - self.show.downloadSubtitles() - - self.inProgress = False - - -class QueueItemUpdate(ShowQueueItem): - def __init__(self, show=None): - ShowQueueItem.__init__(self, ShowQueueActions.UPDATE, show) - self.force = False - - def execute(self): - - ShowQueueItem.execute(self) - - logger.log(u"Beginning update of " + self.show.name) - - logger.log(u"Retrieving show info from TVDB", logger.DEBUG) - try: - self.show.loadFromTVDB(cache=not self.force) - except tvdb_exceptions.tvdb_error, e: - logger.log(u"Unable to contact TVDB, aborting: " + ex(e), logger.WARNING) - return - - # get episode list from DB - logger.log(u"Loading all episodes from the database", logger.DEBUG) - DBEpList = self.show.loadEpisodesFromDB() - - # get episode list from TVDB - logger.log(u"Loading all episodes from theTVDB", logger.DEBUG) - try: - TVDBEpList = self.show.loadEpisodesFromTVDB(cache=not self.force) - except tvdb_exceptions.tvdb_exception, e: - logger.log(u"Unable to get info from TVDB, the show info will not be refreshed: " + ex(e), logger.ERROR) - TVDBEpList = None - - if TVDBEpList == None: - logger.log(u"No data returned from TVDB, unable to update this show", logger.ERROR) - - else: - - # for each ep we found on TVDB delete it from the DB list - for curSeason in TVDBEpList: - for curEpisode in TVDBEpList[curSeason]: - logger.log(u"Removing " + str(curSeason) + "x" + str(curEpisode) + " from the DB list", logger.DEBUG) - if curSeason in DBEpList and curEpisode in DBEpList[curSeason]: - del DBEpList[curSeason][curEpisode] - - # for the remaining episodes in the DB list just delete them from the DB - for curSeason in DBEpList: - for curEpisode in DBEpList[curSeason]: - logger.log(u"Permanently deleting episode " + str(curSeason) + "x" + str(curEpisode) + " from the database", logger.MESSAGE) - curEp = self.show.getEpisode(curSeason, curEpisode) - try: - curEp.deleteEpisode() - except exceptions.EpisodeDeletedException: - pass - - # now that we've updated the DB from TVDB see if there's anything we can add from TVRage - with self.show.lock: - logger.log(u"Attempting to supplement show info with info from TVRage", logger.DEBUG) - self.show.loadLatestFromTVRage() - if self.show.tvrid == 0: - self.show.setTVRID() - - sickbeard.showQueueScheduler.action.refreshShow(self.show, True) #@UndefinedVariable - - -class QueueItemForceUpdate(QueueItemUpdate): - def __init__(self, show=None): - ShowQueueItem.__init__(self, ShowQueueActions.FORCEUPDATE, show) - self.force = True +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from __future__ import with_statement + +import traceback + +import sickbeard + +from lib.tvdb_api import tvdb_exceptions, tvdb_api +from lib.imdb import _exceptions as imdb_exceptions + +from sickbeard.common import SKIPPED, WANTED + +from sickbeard.tv import TVShow +from sickbeard import exceptions, logger, ui, db +from sickbeard import generic_queue +from sickbeard import name_cache +from sickbeard.exceptions import ex + +class ShowQueue(generic_queue.GenericQueue): + + def __init__(self): + generic_queue.GenericQueue.__init__(self) + self.queue_name = "SHOWQUEUE" + + + def _isInQueue(self, show, actions): + return show in [x.show for x in self.queue if x.action_id in actions] + + def _isBeingSomethinged(self, show, actions): + return self.currentItem != None and show == self.currentItem.show and \ + self.currentItem.action_id in actions + + def isInUpdateQueue(self, show): + return self._isInQueue(show, (ShowQueueActions.UPDATE, ShowQueueActions.FORCEUPDATE)) + + def isInRefreshQueue(self, show): + return self._isInQueue(show, (ShowQueueActions.REFRESH,)) + + def isInRenameQueue(self, show): + return self._isInQueue(show, (ShowQueueActions.RENAME,)) + + def isInSubtitleQueue(self, show): + return self._isInQueue(show, (ShowQueueActions.SUBTITLE,)) + + def isBeingAdded(self, show): + return self._isBeingSomethinged(show, (ShowQueueActions.ADD,)) + + def isBeingUpdated(self, show): + return self._isBeingSomethinged(show, (ShowQueueActions.UPDATE, ShowQueueActions.FORCEUPDATE)) + + def isBeingRefreshed(self, show): + return self._isBeingSomethinged(show, (ShowQueueActions.REFRESH,)) + + def isBeingRenamed(self, show): + return self._isBeingSomethinged(show, (ShowQueueActions.RENAME,)) + + def isBeingSubtitled(self, show): + return self._isBeingSomethinged(show, (ShowQueueActions.SUBTITLE,)) + + def _getLoadingShowList(self): + return [x for x in self.queue + [self.currentItem] if x != None and x.isLoading] + + loadingShowList = property(_getLoadingShowList) + + def updateShow(self, show, force=False): + + if self.isBeingAdded(show): + raise exceptions.CantUpdateException("Show is still being added, wait until it is finished before you update.") + + if self.isBeingUpdated(show): + raise exceptions.CantUpdateException("This show is already being updated, can't update again until it's done.") + + if self.isInUpdateQueue(show): + raise exceptions.CantUpdateException("This show is already being updated, can't update again until it's done.") + + if not force: + queueItemObj = QueueItemUpdate(show) + else: + queueItemObj = QueueItemForceUpdate(show) + + self.add_item(queueItemObj) + + return queueItemObj + + def refreshShow(self, show, force=False): + + if self.isBeingRefreshed(show) and not force: + raise exceptions.CantRefreshException("This show is already being refreshed, not refreshing again.") + + if (self.isBeingUpdated(show) or self.isInUpdateQueue(show)) and not force: + logger.log(u"A refresh was attempted but there is already an update queued or in progress. Since updates do a refres at the end anyway I'm skipping this request.", logger.DEBUG) + return + + queueItemObj = QueueItemRefresh(show) + + self.add_item(queueItemObj) + + return queueItemObj + + def renameShowEpisodes(self, show, force=False): + + queueItemObj = QueueItemRename(show) + + self.add_item(queueItemObj) + + return queueItemObj + + def downloadSubtitles(self, show, force=False): + + queueItemObj = QueueItemSubtitle(show) + + self.add_item(queueItemObj) + + return queueItemObj + + def addShow(self, tvdb_id, showDir, default_status=None, quality=None, flatten_folders=None, lang="fr", subtitles=None, audio_lang=None): + queueItemObj = QueueItemAdd(tvdb_id, showDir, default_status, quality, flatten_folders, lang, subtitles, audio_lang) + + self.add_item(queueItemObj) + + return queueItemObj + + +class ShowQueueActions: + REFRESH = 1 + ADD = 2 + UPDATE = 3 + FORCEUPDATE = 4 + RENAME = 5 + SUBTITLE=6 + + names = {REFRESH: 'Refresh', + ADD: 'Add', + UPDATE: 'Update', + FORCEUPDATE: 'Force Update', + RENAME: 'Rename', + SUBTITLE: 'Subtitle', + } + +class ShowQueueItem(generic_queue.QueueItem): + """ + Represents an item in the queue waiting to be executed + + Can be either: + - show being added (may or may not be associated with a show object) + - show being refreshed + - show being updated + - show being force updated + - show being subtitled + """ + def __init__(self, action_id, show): + generic_queue.QueueItem.__init__(self, ShowQueueActions.names[action_id], action_id) + self.show = show + + def isInQueue(self): + return self in sickbeard.showQueueScheduler.action.queue + [sickbeard.showQueueScheduler.action.currentItem] #@UndefinedVariable + + def _getName(self): + return str(self.show.tvdbid) + + def _isLoading(self): + return False + + show_name = property(_getName) + + isLoading = property(_isLoading) + + +class QueueItemAdd(ShowQueueItem): + def __init__(self, tvdb_id, showDir, default_status, quality, flatten_folders, lang, subtitles, audio_lang): + + self.tvdb_id = tvdb_id + self.showDir = showDir + self.default_status = default_status + self.quality = quality + self.flatten_folders = flatten_folders + self.lang = lang + self.audio_lang = audio_lang + self.subtitles = subtitles + + self.show = None + + # this will initialize self.show to None + ShowQueueItem.__init__(self, ShowQueueActions.ADD, self.show) + + def _getName(self): + """ + Returns the show name if there is a show object created, if not returns + the dir that the show is being added to. + """ + if self.show == None: + return self.showDir + return self.show.name + + show_name = property(_getName) + + def _isLoading(self): + """ + Returns True if we've gotten far enough to have a show object, or False + if we still only know the folder name. + """ + if self.show == None: + return True + return False + + isLoading = property(_isLoading) + + def execute(self): + + ShowQueueItem.execute(self) + + logger.log(u"Starting to add show " + self.showDir) + + try: + # make sure the tvdb ids are valid + try: + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + if self.lang: + ltvdb_api_parms['language'] = self.lang + + logger.log(u"TVDB: " + repr(ltvdb_api_parms)) + + t = tvdb_api.Tvdb(**ltvdb_api_parms) + s = t[self.tvdb_id] + + # this usually only happens if they have an NFO in their show dir which gave us a TVDB ID that has no proper english version of the show + if not s['seriesname']: + logger.log(u"Show in " + self.showDir + " has no name on TVDB, probably the wrong language used to search with.", logger.ERROR) + ui.notifications.error("Unable to add show", "Show in " + self.showDir + " has no name on TVDB, probably the wrong language. Delete .nfo and add manually in the correct language.") + self._finishEarly() + return + # if the show has no episodes/seasons + if not s: + logger.log(u"Show " + str(s['seriesname']) + " is on TVDB but contains no season/episode data.", logger.ERROR) + ui.notifications.error("Unable to add show", "Show " + str(s['seriesname']) + " is on TVDB but contains no season/episode data.") + self._finishEarly() + return + except tvdb_exceptions.tvdb_exception, e: + logger.log(u"Error contacting TVDB: " + ex(e), logger.ERROR) + ui.notifications.error("Unable to add show", "Unable to look up the show in " + self.showDir + " on TVDB, not using the NFO. Delete .nfo and add manually in the correct language.") + self._finishEarly() + return + + # clear the name cache + name_cache.clearCache() + + newShow = TVShow(self.tvdb_id, self.lang, self.audio_lang) + newShow.loadFromTVDB() + + self.show = newShow + + # set up initial values + self.show.location = self.showDir + self.show.subtitles = self.subtitles if self.subtitles != None else sickbeard.SUBTITLES_DEFAULT + self.show.quality = self.quality if self.quality else sickbeard.QUALITY_DEFAULT + self.show.flatten_folders = self.flatten_folders if self.flatten_folders != None else sickbeard.FLATTEN_FOLDERS_DEFAULT + self.show.paused = 0 + + # be smartish about this + if self.show.genre and "talk show" in self.show.genre.lower(): + self.show.air_by_date = 1 + + except tvdb_exceptions.tvdb_exception, e: + logger.log(u"Unable to add show due to an error with TVDB: " + ex(e), logger.ERROR) + if self.show: + ui.notifications.error("Unable to add " + str(self.show.name) + " due to an error with TVDB") + else: + ui.notifications.error("Unable to add show due to an error with TVDB") + self._finishEarly() + return + + except exceptions.MultipleShowObjectsException: + logger.log(u"The show in " + self.showDir + " is already in your show list, skipping", logger.ERROR) + ui.notifications.error('Show skipped', "The show in " + self.showDir + " is already in your show list") + self._finishEarly() + return + + except Exception, e: + logger.log(u"Error trying to add show: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + self._finishEarly() + raise + + logger.log(u"Retrieving show info from IMDb", logger.DEBUG) + try: + self.show.loadIMDbInfo() + except imdb_exceptions.IMDbError, e: + #todo Insert UI notification + logger.log(u" Something wrong on IMDb api: " + ex(e), logger.WARNING) + except imdb_exceptions.IMDbParserError, e: + logger.log(u" IMDb_api parser error: " + ex(e), logger.WARNING) + except Exception, e: + logger.log(u"Error loading IMDb info: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + + # add it to the show list + sickbeard.showList.append(self.show) + + try: + self.show.loadEpisodesFromDir() + except Exception, e: + logger.log(u"Error searching dir for episodes: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + + try: + self.show.loadEpisodesFromTVDB() + self.show.setTVRID() + + self.show.writeMetadata() + self.show.populateCache() + + except Exception, e: + logger.log(u"Error with TVDB, not creating episode list: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + + try: + self.show.saveToDB() + except Exception, e: + logger.log(u"Error saving the episode to the database: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + + # if they gave a custom status then change all the eps to it + if self.default_status != SKIPPED: + logger.log(u"Setting all episodes to the specified default status: " + str(self.default_status)) + myDB = db.DBConnection() + myDB.action("UPDATE tv_episodes SET status = ? WHERE status = ? AND showid = ? AND season != 0", [self.default_status, SKIPPED, self.show.tvdbid]) + + # if they started with WANTED eps then run the backlog + if self.default_status == WANTED: + logger.log(u"Launching backlog for this show since its episodes are WANTED") + sickbeard.backlogSearchScheduler.action.searchBacklog([self.show]) #@UndefinedVariable + + self.show.flushEpisodes() + + # if there are specific episodes that need to be added by trakt + sickbeard.traktWatchListCheckerSchedular.action.manageNewShow(self.show) + + self.finish() + + def _finishEarly(self): + if self.show != None: + self.show.deleteShow() + + self.finish() + + +class QueueItemRefresh(ShowQueueItem): + def __init__(self, show=None): + ShowQueueItem.__init__(self, ShowQueueActions.REFRESH, show) + + # do refreshes first because they're quick + self.priority = generic_queue.QueuePriorities.HIGH + + def execute(self): + + ShowQueueItem.execute(self) + + logger.log(u"Performing refresh on " + self.show.name) + + self.show.refreshDir() + self.show.writeMetadata() + self.show.populateCache() + + self.inProgress = False + + +class QueueItemRename(ShowQueueItem): + def __init__(self, show=None): + ShowQueueItem.__init__(self, ShowQueueActions.RENAME, show) + + def execute(self): + + ShowQueueItem.execute(self) + + logger.log(u"Performing rename on " + self.show.name) + + try: + show_loc = self.show.location + except exceptions.ShowDirNotFoundException: + logger.log(u"Can't perform rename on " + self.show.name + " when the show dir is missing.", logger.WARNING) + return + + ep_obj_rename_list = [] + + ep_obj_list = self.show.getAllEpisodes(has_location=True) + for cur_ep_obj in ep_obj_list: + # Only want to rename if we have a location + if cur_ep_obj.location: + if cur_ep_obj.relatedEps: + # do we have one of multi-episodes in the rename list already + have_already = False + for cur_related_ep in cur_ep_obj.relatedEps + [cur_ep_obj]: + if cur_related_ep in ep_obj_rename_list: + have_already = True + break + if not have_already: + ep_obj_rename_list.append(cur_ep_obj) + + else: + ep_obj_rename_list.append(cur_ep_obj) + + for cur_ep_obj in ep_obj_rename_list: + cur_ep_obj.rename() + + self.inProgress = False + +class QueueItemSubtitle(ShowQueueItem): + def __init__(self, show=None): + ShowQueueItem.__init__(self, ShowQueueActions.SUBTITLE, show) + + def execute(self): + + ShowQueueItem.execute(self) + + logger.log(u"Downloading subtitles for "+self.show.name) + + self.show.downloadSubtitles() + + self.inProgress = False + + +class QueueItemUpdate(ShowQueueItem): + def __init__(self, show=None): + ShowQueueItem.__init__(self, ShowQueueActions.UPDATE, show) + self.force = False + + def execute(self): + + ShowQueueItem.execute(self) + + logger.log(u"Beginning update of " + self.show.name) + + logger.log(u"Retrieving show info from TVDB", logger.DEBUG) + try: + self.show.loadFromTVDB(cache=not self.force) + except tvdb_exceptions.tvdb_error, e: + logger.log(u"Unable to contact TVDB, aborting: " + ex(e), logger.WARNING) + return + + logger.log(u"Retrieving show info from IMDb", logger.DEBUG) + try: + self.show.loadIMDbInfo() + except imdb_exceptions.IMDbError, e: + logger.log(u" Something wrong on IMDb api: " + ex(e), logger.WARNING) + except imdb_exceptions.IMDbParserError, e: + logger.log(u" IMDb api parser error: " + ex(e), logger.WARNING) + except Exception, e: + logger.log(u"Error loading IMDb info: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + + try: + self.show.saveToDB() + except Exception, e: + logger.log(u"Error saving the episode to the database: " + ex(e), logger.ERROR) + logger.log(traceback.format_exc(), logger.DEBUG) + + # get episode list from DB + logger.log(u"Loading all episodes from the database", logger.DEBUG) + DBEpList = self.show.loadEpisodesFromDB() + + # get episode list from TVDB + logger.log(u"Loading all episodes from theTVDB", logger.DEBUG) + try: + TVDBEpList = self.show.loadEpisodesFromTVDB(cache=not self.force) + except tvdb_exceptions.tvdb_exception, e: + logger.log(u"Unable to get info from TVDB, the show info will not be refreshed: " + ex(e), logger.ERROR) + TVDBEpList = None + + if TVDBEpList == None: + logger.log(u"No data returned from TVDB, unable to update this show", logger.ERROR) + + else: + + # for each ep we found on TVDB delete it from the DB list + for curSeason in TVDBEpList: + for curEpisode in TVDBEpList[curSeason]: + logger.log(u"Removing " + str(curSeason) + "x" + str(curEpisode) + " from the DB list", logger.DEBUG) + if curSeason in DBEpList and curEpisode in DBEpList[curSeason]: + del DBEpList[curSeason][curEpisode] + + # for the remaining episodes in the DB list just delete them from the DB + for curSeason in DBEpList: + for curEpisode in DBEpList[curSeason]: + logger.log(u"Permanently deleting episode " + str(curSeason) + "x" + str(curEpisode) + " from the database", logger.MESSAGE) + curEp = self.show.getEpisode(curSeason, curEpisode) + try: + curEp.deleteEpisode() + except exceptions.EpisodeDeletedException: + pass + + # now that we've updated the DB from TVDB see if there's anything we can add from TVRage + with self.show.lock: + logger.log(u"Attempting to supplement show info with info from TVRage", logger.DEBUG) + self.show.loadLatestFromTVRage() + if self.show.tvrid == 0: + self.show.setTVRID() + + sickbeard.showQueueScheduler.action.refreshShow(self.show, True) #@UndefinedVariable + + +class QueueItemForceUpdate(QueueItemUpdate): + def __init__(self, show=None): + ShowQueueItem.__init__(self, ShowQueueActions.FORCEUPDATE, show) + self.force = True diff --git a/sickbeard/tv.py b/sickbeard/tv.py index cac6be7e33..32debdd084 100644 --- a/sickbeard/tv.py +++ b/sickbeard/tv.py @@ -23,6 +23,7 @@ import threading import re import glob +import traceback import sickbeard @@ -34,6 +35,8 @@ from lib.tvdb_api import tvdb_api, tvdb_exceptions +from lib.imdb import imdb + from sickbeard import db from sickbeard import helpers, exceptions, logger from sickbeard.exceptions import ex @@ -50,6 +53,7 @@ from common import DOWNLOADED, SNATCHED, SNATCHED_PROPER, ARCHIVED, IGNORED, UNAIRED, WANTED, SKIPPED, UNKNOWN from common import NAMING_DUPLICATE, NAMING_EXTEND, NAMING_LIMITED_EXTEND, NAMING_SEPARATED_REPEAT, NAMING_LIMITED_EXTEND_E_PREFIXED + class TVShow(object): def __init__ (self, tvdbid, lang="", audio_lang=""): @@ -60,9 +64,11 @@ def __init__ (self, tvdbid, lang="", audio_lang=""): self.name = "" self.tvrid = 0 self.tvrname = "" + self.imdbid = "" self.network = "" self.genre = "" self.runtime = 0 + self.imdb_info = {} self.quality = int(sickbeard.QUALITY_DEFAULT) self.flatten_folders = int(sickbeard.FLATTEN_FOLDERS_DEFAULT) @@ -71,7 +77,7 @@ def __init__ (self, tvdbid, lang="", audio_lang=""): self.startyear = 0 self.paused = 0 self.air_by_date = 0 - self.subtitles = int(sickbeard.SUBTITLES_DEFAULT) + self.subtitles = int(sickbeard.SUBTITLES_DEFAULT if sickbeard.SUBTITLES_DEFAULT else 0) self.lang = lang self.audio_lang = audio_lang self.custom_search_names = "" @@ -283,7 +289,7 @@ def loadEpisodesFromDir (self): logger.log(str(self.tvdbid) + ": Could not refresh subtitles", logger.ERROR) logger.log(traceback.format_exc(), logger.DEBUG) curEpisode.saveToDB() - + def loadEpisodesFromDB(self): @@ -639,6 +645,18 @@ def loadFromDB(self, skipNFO=False): if self.custom_search_names == "": self.custom_search_names = sqlResults[0]["custom_search_names"] + + if self.imdbid == "": + self.imdbid = sqlResults[0]["imdb_id"] + + #Get IMDb_info from database + sqlResults = myDB.select("SELECT * FROM imdb_info WHERE tvdb_id = ?", [self.tvdbid]) + + if len(sqlResults) == 0: + logger.log(str(self.tvdbid) + ": Unable to find IMDb show info in the database") + return + else: + self.imdb_info = dict(zip(sqlResults[0].keys(), sqlResults[0])) def loadFromTVDB(self, cache=True, tvapi=None, cachedSeason=None): @@ -666,6 +684,8 @@ def loadFromTVDB(self, cache=True, tvapi=None, cachedSeason=None): self.genre = myEp['genre'] self.network = myEp['network'] + self.runtime = myEp['runtime'] + self.imdbid = myEp['imdb_id'] if myEp["airs_dayofweek"] != None and myEp["airs_time"] != None: self.airs = myEp["airs_dayofweek"] + " " + myEp["airs_time"] @@ -682,9 +702,76 @@ def loadFromTVDB(self, cache=True, tvapi=None, cachedSeason=None): if self.status == None: self.status = "" - self.saveToDB() - - +# self.saveToDB() + + def loadIMDbInfo(self, imdbapi=None): + + imdb_info = {'imdb_id' : self.imdbid, + 'title' : '', + 'year' : '', + 'akas' : [], + 'runtimes' : '', + 'genres' : [], + 'countries' : '', + 'country codes' : '', + 'certificates' : [], + 'rating' : '', + 'votes': '', + 'last_update': '' + } + + if self.imdbid: + + logger.log(str(self.tvdbid) + ": Loading show info from IMDb") + + i = imdb.IMDb() + imdbTv = i.get_movie(str(self.imdbid[2:])) + + for key in filter(lambda x: x in imdbTv.keys(), imdb_info.keys()): + # Store only the first value for string type + if type(imdb_info[key]) == type('') and type(imdbTv.get(key)) == type([]): + imdb_info[key] = imdbTv.get(key)[0] + else: + imdb_info[key] = imdbTv.get(key) + + #Filter only the value + if imdb_info['runtimes']: + imdb_info['runtimes'] = re.search('\d+',imdb_info['runtimes']).group(0) + else: + imdb_info['runtimes'] = self.runtime + + if imdb_info['akas']: + imdb_info['akas'] = '|'.join(imdb_info['akas']) + else: + imdb_info['akas'] = '' + + #Join all genres in a string + if imdb_info['genres']: + imdb_info['genres'] = '|'.join(imdb_info['genres']) + else: + imdb_info['genres'] = '' + + #Get only the production country certificate if any + if imdb_info['certificates'] and imdb_info['countries']: + dct = {} + try: + for item in imdb_info['certificates']: + dct[item.split(':')[0]] = item.split(':')[1] + + imdb_info['certificates'] = dct[imdb_info['countries']] + except: + imdb_info['certificates'] = '' + + else: + imdb_info['certificates'] = '' + + imdb_info['last_update'] = datetime.date.today().toordinal() + + #Rename dict keys without spaces for DB upsert + self.imdb_info = dict((k.replace(' ', '_'),f(v) if hasattr(v,'keys') else v) for k,v in imdb_info.items()) + + logger.log(str(self.tvdbid) + ": Obtained info from IMDb ->" + str(self.imdb_info), logger.DEBUG) + def loadNFO (self): if not os.path.isdir(self._location): @@ -772,7 +859,8 @@ def deleteShow(self): myDB = db.DBConnection() myDB.action("DELETE FROM tv_episodes WHERE showid = ?", [self.tvdbid]) myDB.action("DELETE FROM tv_shows WHERE tvdb_id = ?", [self.tvdbid]) - + myDB.action("DELETE FROM imdb_info WHERE tvdb_id = ?", [self.tvdbid]) + # remove self from show list sickbeard.showList = [x for x in sickbeard.showList if x.tvdbid != self.tvdbid] @@ -872,12 +960,18 @@ def saveToDB(self): "startyear": self.startyear, "tvr_name": self.tvrname, "lang": self.lang, + "imdb_id": self.imdbid, "audio_lang": self.audio_lang, "custom_search_names": self.custom_search_names } myDB.upsert("tv_shows", newValueDict, controlValueDict) - + + if self.imdbid: + controlValueDict = {"tvdb_id": self.tvdbid} + newValueDict = self.imdb_info + + myDB.upsert("imdb_info", newValueDict, controlValueDict) def __str__(self): toReturn = "" @@ -966,7 +1060,7 @@ def getOverview(self, epStatus): maxBestQuality = None epStatus, curQuality = Quality.splitCompositeStatus(epStatus) - + if epStatus in (SNATCHED, SNATCHED_PROPER): return Overview.SNATCHED # if they don't want re-downloads then we call it good if they have anything @@ -978,7 +1072,7 @@ def getOverview(self, epStatus): # if it's >= maxBestQuality then it's good else: return Overview.GOOD - + def dirty_setter(attr_name): def wrapper(self, val): if getattr(self, attr_name) != val: @@ -1072,7 +1166,7 @@ def downloadSubtitles(self): return self.refreshSubtitles() - self.subtitles_searchcount = self.subtitles_searchcount + 1 + self.subtitles_searchcount = self.subtitles_searchcount + 1 if self.subtitles_searchcount else 1 #added the if because sometime it raise an error self.subtitles_lastsearch = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") self.saveToDB() @@ -1686,7 +1780,7 @@ def _format_pattern(self, pattern=None, multi=None): result_name = result_name.replace('%RN', '%S.N.S%0SE%0E.%E.N-SiCKBEARD') result_name = result_name.replace('%rn', '%s.n.s%0se%0e.%e.n-sickbeard') - result_name = result_name.replace('%RG', 'SiCKBEARD') + result_name = result_name.replace('%RG', 'SICKBEARD') result_name = result_name.replace('%rg', 'sickbeard') logger.log(u"Episode has no release name, replacing it with a generic one: "+result_name, logger.DEBUG) @@ -1760,10 +1854,10 @@ def _format_pattern(self, pattern=None, multi=None): # add "E04" ep_string += ep_sep - + if multi == NAMING_LIMITED_EXTEND_E_PREFIXED: ep_string += 'E' - + ep_string += other_ep._format_string(ep_format.upper(), other_ep._replace_map()) if season_ep_match: @@ -1778,9 +1872,8 @@ def _format_pattern(self, pattern=None, multi=None): result_name = result_name.replace(cur_name_group, cur_name_group_result) result_name = self._format_string(result_name, replace_map) - - logger.log(u"formatting pattern: "+pattern+" -> "+result_name, logger.DEBUG) + logger.log(u"formatting pattern: "+pattern+" -> "+result_name, logger.DEBUG) return result_name diff --git a/sickbeard/versionChecker.py b/sickbeard/versionChecker.py index 73e249c67c..4549ae5381 100644 --- a/sickbeard/versionChecker.py +++ b/sickbeard/versionChecker.py @@ -21,6 +21,7 @@ from sickbeard import logger from sickbeard import scene_exceptions from sickbeard.exceptions import ex +from sickbeard import network_timezones import os, platform, shutil import subprocess, re @@ -52,6 +53,9 @@ def run(self): # refresh scene exceptions too scene_exceptions.retrieve_exceptions() + + # refresh network timezones + network_timezones.update_network_dict() def find_install_type(self): """ diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 31233ecdf7..522118f0d0 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -1,3526 +1,3734 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from __future__ import with_statement - -import os.path - -import time -import urllib -import re -import threading -import datetime -import random - -from Cheetah.Template import Template -import cherrypy.lib - -import sickbeard - -from sickbeard import config, sab -from sickbeard import clients -from sickbeard import history, notifiers, processTV -from sickbeard import ui -from sickbeard import logger, helpers, exceptions, classes, db -from sickbeard import encodingKludge as ek -from sickbeard import search_queue -from sickbeard import image_cache -from sickbeard import scene_exceptions -from sickbeard import naming -from sickbeard import subtitles - -from sickbeard.providers import newznab -from sickbeard.common import Quality, Overview, statusStrings -from sickbeard.common import SNATCHED, SKIPPED, UNAIRED, IGNORED, ARCHIVED, WANTED -from sickbeard.exceptions import ex -from sickbeard.webapi import Api - -from lib.tvdb_api import tvdb_api - -import subliminal - -try: - import json -except ImportError: - from lib import simplejson as json - -try: - import xml.etree.cElementTree as etree -except ImportError: - import xml.etree.ElementTree as etree - -from sickbeard import browser - - -class PageTemplate (Template): - def __init__(self, *args, **KWs): - KWs['file'] = os.path.join(sickbeard.PROG_DIR, "data/interfaces/default/", KWs['file']) - super(PageTemplate, self).__init__(*args, **KWs) - self.sbRoot = sickbeard.WEB_ROOT - self.sbHttpPort = sickbeard.WEB_PORT - self.sbHttpsPort = sickbeard.WEB_PORT - self.sbHttpsEnabled = sickbeard.ENABLE_HTTPS - if cherrypy.request.headers['Host'][0] == '[': - self.sbHost = re.match("^\[.*\]", cherrypy.request.headers['Host'], re.X|re.M|re.S).group(0) - else: - self.sbHost = re.match("^[^:]+", cherrypy.request.headers['Host'], re.X|re.M|re.S).group(0) - self.projectHomePage = "http://code.google.com/p/sickbeard/" - - if sickbeard.NZBS and sickbeard.NZBS_UID and sickbeard.NZBS_HASH: - logger.log(u"NZBs.org has been replaced, please check the config to configure the new provider!", logger.ERROR) - ui.notifications.error("NZBs.org Config Update", "NZBs.org has a new site. Please update your config with the api key from http://nzbs.org and then disable the old NZBs.org provider.") - - if "X-Forwarded-Host" in cherrypy.request.headers: - self.sbHost = cherrypy.request.headers['X-Forwarded-Host'] - if "X-Forwarded-Port" in cherrypy.request.headers: - self.sbHttpPort = cherrypy.request.headers['X-Forwarded-Port'] - self.sbHttpsPort = self.sbHttpPort - if "X-Forwarded-Proto" in cherrypy.request.headers: - self.sbHttpsEnabled = True if cherrypy.request.headers['X-Forwarded-Proto'] == 'https' else False - - logPageTitle = 'Logs & Errors' - if len(classes.ErrorViewer.errors): - logPageTitle += ' ('+str(len(classes.ErrorViewer.errors))+')' - self.logPageTitle = logPageTitle - self.sbPID = str(sickbeard.PID) - self.menu = [ - { 'title': 'Home', 'key': 'home' }, - { 'title': 'Coming Episodes', 'key': 'comingEpisodes' }, - { 'title': 'History', 'key': 'history' }, - { 'title': 'Manage', 'key': 'manage' }, - { 'title': 'Config', 'key': 'config' }, - { 'title': logPageTitle, 'key': 'errorlogs' }, - ] - -def redirect(abspath, *args, **KWs): - assert abspath[0] == '/' - raise cherrypy.HTTPRedirect(sickbeard.WEB_ROOT + abspath, *args, **KWs) - -class TVDBWebUI: - def __init__(self, config, log=None): - self.config = config - self.log = log - - def selectSeries(self, allSeries): - - searchList = ",".join([x['id'] for x in allSeries]) - showDirList = "" - for curShowDir in self.config['_showDir']: - showDirList += "showDir="+curShowDir+"&" - redirect("/home/addShows/addShow?" + showDirList + "seriesList=" + searchList) - -def _munge(string): - return unicode(string).encode('utf-8', 'xmlcharrefreplace') - -def _genericMessage(subject, message): - t = PageTemplate(file="genericMessage.tmpl") - t.submenu = HomeMenu() - t.subject = subject - t.message = message - return _munge(t) - -def _getEpisode(show, season, episode): - - if show == None or season == None or episode == None: - return "Invalid parameters" - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return "Show not in show list" - - epObj = showObj.getEpisode(int(season), int(episode)) - - if epObj == None: - return "Episode couldn't be retrieved" - - return epObj - -ManageMenu = [ - { 'title': 'Backlog Overview', 'path': 'manage/backlogOverview' }, - { 'title': 'Manage Searches', 'path': 'manage/manageSearches' }, - { 'title': 'Episode Status Management', 'path': 'manage/episodeStatuses' }, - { 'title': 'Manage Missed Subtitles', 'path': 'manage/subtitleMissed' }, -] -if sickbeard.USE_SUBTITLES: - ManageMenu.append({ 'title': 'Missed Subtitle Management', 'path': 'manage/subtitleMissed' }) - -class ManageSearches: - - @cherrypy.expose - def index(self): - t = PageTemplate(file="manage_manageSearches.tmpl") - #t.backlogPI = sickbeard.backlogSearchScheduler.action.getProgressIndicator() - t.backlogPaused = sickbeard.searchQueueScheduler.action.is_backlog_paused() #@UndefinedVariable - t.backlogRunning = sickbeard.searchQueueScheduler.action.is_backlog_in_progress() #@UndefinedVariable - t.searchStatus = sickbeard.currentSearchScheduler.action.amActive #@UndefinedVariable - t.submenu = ManageMenu - - return _munge(t) - - @cherrypy.expose - def forceSearch(self): - - # force it to run the next time it looks - result = sickbeard.currentSearchScheduler.forceRun() - if result: - logger.log(u"Search forced") - ui.notifications.message('Episode search started', - 'Note: RSS feeds may not be updated if retrieved recently') - - redirect("/manage/manageSearches") - - @cherrypy.expose - def pauseBacklog(self, paused=None): - if paused == "1": - sickbeard.searchQueueScheduler.action.pause_backlog() #@UndefinedVariable - else: - sickbeard.searchQueueScheduler.action.unpause_backlog() #@UndefinedVariable - - redirect("/manage/manageSearches") - - @cherrypy.expose - def forceVersionCheck(self): - - # force a check to see if there is a new version - result = sickbeard.versionCheckScheduler.action.check_for_new_version(force=True) #@UndefinedVariable - if result: - logger.log(u"Forcing version check") - - redirect("/manage/manageSearches") - - -class Manage: - - manageSearches = ManageSearches() - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="manage.tmpl") - t.submenu = ManageMenu - return _munge(t) - - @cherrypy.expose - def showEpisodeStatuses(self, tvdb_id, whichStatus): - myDB = db.DBConnection() - - status_list = [int(whichStatus)] - if status_list[0] == SNATCHED: - status_list = Quality.SNATCHED + Quality.SNATCHED_PROPER - - cur_show_results = myDB.select("SELECT season, episode, name FROM tv_episodes WHERE showid = ? AND season != 0 AND status IN ("+','.join(['?']*len(status_list))+")", [int(tvdb_id)] + status_list) - - result = {} - for cur_result in cur_show_results: - cur_season = int(cur_result["season"]) - cur_episode = int(cur_result["episode"]) - - if cur_season not in result: - result[cur_season] = {} - - result[cur_season][cur_episode] = cur_result["name"] - - return json.dumps(result) - - @cherrypy.expose - def episodeStatuses(self, whichStatus=None): - - if whichStatus: - whichStatus = int(whichStatus) - status_list = [whichStatus] - if status_list[0] == SNATCHED: - status_list = Quality.SNATCHED + Quality.SNATCHED_PROPER - else: - status_list = [] - - t = PageTemplate(file="manage_episodeStatuses.tmpl") - t.submenu = ManageMenu - t.whichStatus = whichStatus - - # if we have no status then this is as far as we need to go - if not status_list: - return _munge(t) - - myDB = db.DBConnection() - status_results = myDB.select("SELECT show_name, tv_shows.tvdb_id as tvdb_id FROM tv_episodes, tv_shows WHERE tv_episodes.status IN ("+','.join(['?']*len(status_list))+") AND season != 0 AND tv_episodes.showid = tv_shows.tvdb_id ORDER BY show_name", status_list) - - ep_counts = {} - show_names = {} - sorted_show_ids = [] - for cur_status_result in status_results: - cur_tvdb_id = int(cur_status_result["tvdb_id"]) - if cur_tvdb_id not in ep_counts: - ep_counts[cur_tvdb_id] = 1 - else: - ep_counts[cur_tvdb_id] += 1 - - show_names[cur_tvdb_id] = cur_status_result["show_name"] - if cur_tvdb_id not in sorted_show_ids: - sorted_show_ids.append(cur_tvdb_id) - - t.show_names = show_names - t.ep_counts = ep_counts - t.sorted_show_ids = sorted_show_ids - return _munge(t) - - @cherrypy.expose - def changeEpisodeStatuses(self, oldStatus, newStatus, *args, **kwargs): - - status_list = [int(oldStatus)] - if status_list[0] == SNATCHED: - status_list = Quality.SNATCHED + Quality.SNATCHED_PROPER - - to_change = {} - - # make a list of all shows and their associated args - for arg in kwargs: - tvdb_id, what = arg.split('-') - - # we don't care about unchecked checkboxes - if kwargs[arg] != 'on': - continue - - if tvdb_id not in to_change: - to_change[tvdb_id] = [] - - to_change[tvdb_id].append(what) - - myDB = db.DBConnection() - - for cur_tvdb_id in to_change: - - # get a list of all the eps we want to change if they just said "all" - if 'all' in to_change[cur_tvdb_id]: - all_eps_results = myDB.select("SELECT season, episode FROM tv_episodes WHERE status IN ("+','.join(['?']*len(status_list))+") AND season != 0 AND showid = ?", status_list + [cur_tvdb_id]) - all_eps = [str(x["season"])+'x'+str(x["episode"]) for x in all_eps_results] - to_change[cur_tvdb_id] = all_eps - - Home().setStatus(cur_tvdb_id, '|'.join(to_change[cur_tvdb_id]), newStatus, direct=True) - - redirect('/manage/episodeStatuses') - - @cherrypy.expose - def showSubtitleMissed(self, tvdb_id, whichSubs): - myDB = db.DBConnection() - - cur_show_results = myDB.select("SELECT season, episode, name, subtitles FROM tv_episodes WHERE showid = ? AND season != 0 AND status LIKE '%4'", [int(tvdb_id)]) - - result = {} - for cur_result in cur_show_results: - if whichSubs == 'all': - if len(set(cur_result["subtitles"].split(',')).intersection(set(subtitles.wantedLanguages()))) >= len(subtitles.wantedLanguages()): - continue - elif whichSubs in cur_result["subtitles"].split(','): - continue - - cur_season = int(cur_result["season"]) - cur_episode = int(cur_result["episode"]) - - if cur_season not in result: - result[cur_season] = {} - - if cur_episode not in result[cur_season]: - result[cur_season][cur_episode] = {} - - result[cur_season][cur_episode]["name"] = cur_result["name"] - - result[cur_season][cur_episode]["subtitles"] = ",".join(subliminal.language.Language(subtitle).alpha2 for subtitle in cur_result["subtitles"].split(',')) if not cur_result["subtitles"] == '' else '' - - return json.dumps(result) - - @cherrypy.expose - def subtitleMissed(self, whichSubs=None): - - t = PageTemplate(file="manage_subtitleMissed.tmpl") - t.submenu = ManageMenu - t.whichSubs = whichSubs - - if not whichSubs: - return _munge(t) - - myDB = db.DBConnection() - status_results = myDB.select("SELECT show_name, tv_shows.tvdb_id as tvdb_id, tv_episodes.subtitles subtitles FROM tv_episodes, tv_shows WHERE tv_shows.subtitles = 1 AND tv_episodes.status LIKE '%4' AND tv_episodes.season != 0 AND tv_episodes.showid = tv_shows.tvdb_id ORDER BY show_name") - - ep_counts = {} - show_names = {} - sorted_show_ids = [] - for cur_status_result in status_results: - if whichSubs == 'all': - if len(set(cur_status_result["subtitles"].split(',')).intersection(set(subtitles.wantedLanguages()))) >= len(subtitles.wantedLanguages()): - continue - elif whichSubs in cur_status_result["subtitles"].split(','): - continue - - cur_tvdb_id = int(cur_status_result["tvdb_id"]) - if cur_tvdb_id not in ep_counts: - ep_counts[cur_tvdb_id] = 1 - else: - ep_counts[cur_tvdb_id] += 1 - - show_names[cur_tvdb_id] = cur_status_result["show_name"] - if cur_tvdb_id not in sorted_show_ids: - sorted_show_ids.append(cur_tvdb_id) - - t.show_names = show_names - t.ep_counts = ep_counts - t.sorted_show_ids = sorted_show_ids - return _munge(t) - - @cherrypy.expose - def downloadSubtitleMissed(self, *args, **kwargs): - - to_download = {} - - # make a list of all shows and their associated args - for arg in kwargs: - tvdb_id, what = arg.split('-') - - # we don't care about unchecked checkboxes - if kwargs[arg] != 'on': - continue - - if tvdb_id not in to_download: - to_download[tvdb_id] = [] - - to_download[tvdb_id].append(what) - - for cur_tvdb_id in to_download: - # get a list of all the eps we want to download subtitles if they just said "all" - if 'all' in to_download[cur_tvdb_id]: - myDB = db.DBConnection() - all_eps_results = myDB.select("SELECT season, episode FROM tv_episodes WHERE status LIKE '%4' AND season != 0 AND showid = ?", [cur_tvdb_id]) - to_download[cur_tvdb_id] = [str(x["season"])+'x'+str(x["episode"]) for x in all_eps_results] - - for epResult in to_download[cur_tvdb_id]: - season, episode = epResult.split('x'); - - show = sickbeard.helpers.findCertainShow(sickbeard.showList, int(cur_tvdb_id)) - subtitles = show.getEpisode(int(season), int(episode)).downloadSubtitles() - - - - - redirect('/manage/subtitleMissed') - - @cherrypy.expose - def backlogShow(self, tvdb_id): - - show_obj = helpers.findCertainShow(sickbeard.showList, int(tvdb_id)) - - if show_obj: - sickbeard.backlogSearchScheduler.action.searchBacklog([show_obj]) #@UndefinedVariable - - redirect("/manage/backlogOverview") - - @cherrypy.expose - def backlogOverview(self): - - t = PageTemplate(file="manage_backlogOverview.tmpl") - t.submenu = ManageMenu - - myDB = db.DBConnection() - - showCounts = {} - showCats = {} - showSQLResults = {} - - for curShow in sickbeard.showList: - - epCounts = {} - epCats = {} - epCounts[Overview.SKIPPED] = 0 - epCounts[Overview.WANTED] = 0 - epCounts[Overview.QUAL] = 0 - epCounts[Overview.GOOD] = 0 - epCounts[Overview.UNAIRED] = 0 - epCounts[Overview.SNATCHED] = 0 - - sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? ORDER BY season DESC, episode DESC", [curShow.tvdbid]) - - for curResult in sqlResults: - - curEpCat = curShow.getOverview(int(curResult["status"])) - epCats[str(curResult["season"]) + "x" + str(curResult["episode"])] = curEpCat - epCounts[curEpCat] += 1 - - showCounts[curShow.tvdbid] = epCounts - showCats[curShow.tvdbid] = epCats - showSQLResults[curShow.tvdbid] = sqlResults - - t.showCounts = showCounts - t.showCats = showCats - t.showSQLResults = showSQLResults - - return _munge(t) - - @cherrypy.expose - def massEdit(self, toEdit=None): - - t = PageTemplate(file="manage_massEdit.tmpl") - t.submenu = ManageMenu - - if not toEdit: - redirect("/manage") - - showIDs = toEdit.split("|") - showList = [] - for curID in showIDs: - curID = int(curID) - showObj = helpers.findCertainShow(sickbeard.showList, curID) - if showObj: - showList.append(showObj) - - flatten_folders_all_same = True - last_flatten_folders = None - - paused_all_same = True - last_paused = None - - quality_all_same = True - last_quality = None - - subtitles_all_same = True - last_subtitles = None - - lang_all_same = True - last_lang_metadata= None - - lang_audio_all_same = True - last_lang_audio = None - - root_dir_list = [] - - for curShow in showList: - - cur_root_dir = ek.ek(os.path.dirname, curShow._location) - if cur_root_dir not in root_dir_list: - root_dir_list.append(cur_root_dir) - - # if we know they're not all the same then no point even bothering - if paused_all_same: - # if we had a value already and this value is different then they're not all the same - if last_paused not in (curShow.paused, None): - paused_all_same = False - else: - last_paused = curShow.paused - - if flatten_folders_all_same: - if last_flatten_folders not in (None, curShow.flatten_folders): - flatten_folders_all_same = False - else: - last_flatten_folders = curShow.flatten_folders - - if quality_all_same: - if last_quality not in (None, curShow.quality): - quality_all_same = False - else: - last_quality = curShow.quality - - if subtitles_all_same: - if last_subtitles not in (None, curShow.subtitles): - subtitles_all_same = False - else: - last_subtitles = curShow.subtitles - - if lang_all_same: - if last_lang_metadata not in (None, curShow.lang): - lang_all_same = False - else: - last_lang_metadata = curShow.lang - - if lang_audio_all_same: - if last_lang_audio not in (None, curShow.audio_lang): - lang_audio_all_same = False - else: - last_lang_audio = curShow.audio_lang - - t.showList = toEdit - t.paused_value = last_paused if paused_all_same else None - t.flatten_folders_value = last_flatten_folders if flatten_folders_all_same else None - t.quality_value = last_quality if quality_all_same else None - t.subtitles_value = last_subtitles if subtitles_all_same else None - t.root_dir_list = root_dir_list - t.lang_value = last_lang_metadata if lang_all_same else None - t.audio_value = last_lang_audio if lang_audio_all_same else None - return _munge(t) - - @cherrypy.expose - def massEditSubmit(self, paused=None, flatten_folders=None, quality_preset=False, subtitles=None, - anyQualities=[], bestQualities=[], tvdbLang=None, audioLang = None, toEdit=None, *args, **kwargs): - - dir_map = {} - for cur_arg in kwargs: - if not cur_arg.startswith('orig_root_dir_'): - continue - which_index = cur_arg.replace('orig_root_dir_', '') - end_dir = kwargs['new_root_dir_'+which_index] - dir_map[kwargs[cur_arg]] = end_dir - - showIDs = toEdit.split("|") - errors = [] - for curShow in showIDs: - curErrors = [] - showObj = helpers.findCertainShow(sickbeard.showList, int(curShow)) - if not showObj: - continue - - cur_root_dir = ek.ek(os.path.dirname, showObj._location) - cur_show_dir = ek.ek(os.path.basename, showObj._location) - if cur_root_dir in dir_map and cur_root_dir != dir_map[cur_root_dir]: - new_show_dir = ek.ek(os.path.join, dir_map[cur_root_dir], cur_show_dir) - logger.log(u"For show "+showObj.name+" changing dir from "+showObj._location+" to "+new_show_dir) - else: - new_show_dir = showObj._location - - if paused == 'keep': - new_paused = showObj.paused - else: - new_paused = True if paused == 'enable' else False - new_paused = 'on' if new_paused else 'off' - - if flatten_folders == 'keep': - new_flatten_folders = showObj.flatten_folders - else: - new_flatten_folders = True if flatten_folders == 'enable' else False - new_flatten_folders = 'on' if new_flatten_folders else 'off' - - if subtitles == 'keep': - new_subtitles = showObj.subtitles - else: - new_subtitles = True if subtitles == 'enable' else False - - new_subtitles = 'on' if new_subtitles else 'off' - - if quality_preset == 'keep': - anyQualities, bestQualities = Quality.splitQuality(showObj.quality) - - if tvdbLang == 'None': - new_lang = 'en' - else: - new_lang = tvdbLang - - if audioLang == 'None': - new_audio_lang = showObj.audio_lang; - else: - new_audio_lang = audioLang - - exceptions_list = [] - - curErrors += Home().editShow(curShow, new_show_dir, anyQualities, bestQualities, exceptions_list, new_flatten_folders, new_paused, subtitles=new_subtitles, tvdbLang=new_lang, audio_lang=new_audio_lang, custom_search_names=showObj.custom_search_names, directCall=True) - - if curErrors: - logger.log(u"Errors: "+str(curErrors), logger.ERROR) - errors.append('%s:\n
      ' % showObj.name + ' '.join(['
    • %s
    • ' % error for error in curErrors]) + "
    ") - - if len(errors) > 0: - ui.notifications.error('%d error%s while saving changes:' % (len(errors), "" if len(errors) == 1 else "s"), - " ".join(errors)) - - redirect("/manage") - - @cherrypy.expose - def massUpdate(self, toUpdate=None, toRefresh=None, toRename=None, toDelete=None, toMetadata=None, toSubtitle=None): - - if toUpdate != None: - toUpdate = toUpdate.split('|') - else: - toUpdate = [] - - if toRefresh != None: - toRefresh = toRefresh.split('|') - else: - toRefresh = [] - - if toRename != None: - toRename = toRename.split('|') - else: - toRename = [] - - if toSubtitle != None: - toSubtitle = toSubtitle.split('|') - else: - toSubtitle = [] - - if toDelete != None: - toDelete = toDelete.split('|') - else: - toDelete = [] - - if toMetadata != None: - toMetadata = toMetadata.split('|') - else: - toMetadata = [] - - errors = [] - refreshes = [] - updates = [] - renames = [] - subtitles = [] - - for curShowID in set(toUpdate+toRefresh+toRename+toSubtitle+toDelete+toMetadata): - - if curShowID == '': - continue - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(curShowID)) - - if showObj == None: - continue - - if curShowID in toDelete: - showObj.deleteShow() - # don't do anything else if it's being deleted - continue - - if curShowID in toUpdate: - try: - sickbeard.showQueueScheduler.action.updateShow(showObj, True) #@UndefinedVariable - updates.append(showObj.name) - except exceptions.CantUpdateException, e: - errors.append("Unable to update show "+showObj.name+": "+ex(e)) - - # don't bother refreshing shows that were updated anyway - if curShowID in toRefresh and curShowID not in toUpdate: - try: - sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable - refreshes.append(showObj.name) - except exceptions.CantRefreshException, e: - errors.append("Unable to refresh show "+showObj.name+": "+ex(e)) - - if curShowID in toRename: - sickbeard.showQueueScheduler.action.renameShowEpisodes(showObj) #@UndefinedVariable - renames.append(showObj.name) - - if curShowID in toSubtitle: - sickbeard.showQueueScheduler.action.downloadSubtitles(showObj) #@UndefinedVariable - subtitles.append(showObj.name) - - if len(errors) > 0: - ui.notifications.error("Errors encountered", - '
    \n'.join(errors)) - - messageDetail = "" - - if len(updates) > 0: - messageDetail += "
    Updates
    • " - messageDetail += "
    • ".join(updates) - messageDetail += "
    " - - if len(refreshes) > 0: - messageDetail += "
    Refreshes
    • " - messageDetail += "
    • ".join(refreshes) - messageDetail += "
    " - - if len(renames) > 0: - messageDetail += "
    Renames
    • " - messageDetail += "
    • ".join(renames) - messageDetail += "
    " - - if len(subtitles) > 0: - messageDetail += "
    Subtitles
    • " - messageDetail += "
    • ".join(subtitles) - messageDetail += "
    " - - if len(updates+refreshes+renames+subtitles) > 0: - ui.notifications.message("The following actions were queued:", - messageDetail) - - redirect("/manage") - - -class History: - - @cherrypy.expose - def index(self, limit=100): - - myDB = db.DBConnection() - -# sqlResults = myDB.select("SELECT h.*, show_name, name FROM history h, tv_shows s, tv_episodes e WHERE h.showid=s.tvdb_id AND h.showid=e.showid AND h.season=e.season AND h.episode=e.episode ORDER BY date DESC LIMIT "+str(numPerPage*(p-1))+", "+str(numPerPage)) - if limit == "0": - sqlResults = myDB.select("SELECT h.*, show_name FROM history h, tv_shows s WHERE h.showid=s.tvdb_id ORDER BY date DESC") - else: - sqlResults = myDB.select("SELECT h.*, show_name FROM history h, tv_shows s WHERE h.showid=s.tvdb_id ORDER BY date DESC LIMIT ?", [limit]) - - t = PageTemplate(file="history.tmpl") - t.historyResults = sqlResults - t.limit = limit - t.submenu = [ - { 'title': 'Clear History', 'path': 'history/clearHistory' }, - { 'title': 'Trim History', 'path': 'history/trimHistory' }, - { 'title': 'Trunc Episode History Links', 'path': 'history/truncEplinks' }, - ] - - return _munge(t) - - - @cherrypy.expose - def clearHistory(self): - - myDB = db.DBConnection() - myDB.action("DELETE FROM history WHERE 1=1") - ui.notifications.message('History cleared') - redirect("/history") - - - @cherrypy.expose - def trimHistory(self): - - myDB = db.DBConnection() - myDB.action("DELETE FROM history WHERE date < "+str((datetime.datetime.today()-datetime.timedelta(days=30)).strftime(history.dateFormat))) - ui.notifications.message('Removed history entries greater than 30 days old') - redirect("/history") - - - @cherrypy.expose - def truncEplinks(self): - - myDB = db.DBConnection() - nbep=myDB.select("SELECT count(*) from episode_links") - myDB.action("DELETE FROM episode_links WHERE 1=1") - messnum = str(nbep[0][0]) + ' history links deleted' - ui.notifications.message('All Episode Links Removed', messnum) - redirect("/history") - - -ConfigMenu = [ - { 'title': 'General', 'path': 'config/general/' }, - { 'title': 'Search Settings', 'path': 'config/search/' }, - { 'title': 'Search Providers', 'path': 'config/providers/' }, - { 'title': 'Subtitles Settings','path': 'config/subtitles/' }, - { 'title': 'Post Processing', 'path': 'config/postProcessing/' }, - { 'title': 'Notifications', 'path': 'config/notifications/' }, -] - -class ConfigGeneral: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="config_general.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - @cherrypy.expose - def saveRootDirs(self, rootDirString=None): - sickbeard.ROOT_DIRS = rootDirString - sickbeard.save_config() - @cherrypy.expose - def saveAddShowDefaults(self, defaultFlattenFolders, defaultStatus, anyQualities, bestQualities, audio_lang, subtitles): - - if anyQualities: - anyQualities = anyQualities.split(',') - else: - anyQualities = [] - - if bestQualities: - bestQualities = bestQualities.split(',') - else: - bestQualities = [] - - newQuality = Quality.combineQualities(map(int, anyQualities), map(int, bestQualities)) - - sickbeard.STATUS_DEFAULT = int(defaultStatus) - sickbeard.QUALITY_DEFAULT = int(newQuality) - sickbeard.AUDIO_SHOW_DEFAULT = str(audio_lang) - - if defaultFlattenFolders == "true": - defaultFlattenFolders = 1 - else: - defaultFlattenFolders = 0 - - sickbeard.FLATTEN_FOLDERS_DEFAULT = int(defaultFlattenFolders) - - if subtitles == "true": - subtitles = 1 - else: - subtitles = 0 - sickbeard.SUBTITLES_DEFAULT = int(subtitles) - - sickbeard.save_config() - - @cherrypy.expose - def generateKey(self): - """ Return a new randomized API_KEY - """ - - try: - from hashlib import md5 - except ImportError: - from md5 import md5 - - # Create some values to seed md5 - t = str(time.time()) - r = str(random.random()) - - # Create the md5 instance and give it the current time - m = md5(t) - - # Update the md5 instance with the random variable - m.update(r) - - # Return a hex digest of the md5, eg 49f68a5c8493ec2c0bf489821c21fc3b - logger.log(u"New API generated") - return m.hexdigest() - - @cherrypy.expose - def saveGeneral(self, log_dir=None, web_port=None, web_log=None, web_ipv6=None, - launch_browser=None, web_username=None, use_api=None, api_key=None, - web_password=None, version_notify=None, enable_https=None, https_cert=None, https_key=None, display_posters=None): - - results = [] - - if web_ipv6 == "on": - web_ipv6 = 1 - else: - web_ipv6 = 0 - - if web_log == "on": - web_log = 1 - else: - web_log = 0 - - if launch_browser == "on": - launch_browser = 1 - else: - launch_browser = 0 - - if display_posters == "on": - display_posters = 1 - else: - display_posters = 0 - - if version_notify == "on": - version_notify = 1 - else: - version_notify = 0 - - if not config.change_LOG_DIR(log_dir): - results += ["Unable to create directory " + os.path.normpath(log_dir) + ", log dir not changed."] - - sickbeard.LAUNCH_BROWSER = launch_browser - sickbeard.DISPLAY_POSTERS = display_posters - - sickbeard.WEB_PORT = int(web_port) - sickbeard.WEB_IPV6 = web_ipv6 - sickbeard.WEB_LOG = web_log - sickbeard.WEB_USERNAME = web_username - sickbeard.WEB_PASSWORD = web_password - - if use_api == "on": - use_api = 1 - else: - use_api = 0 - - sickbeard.USE_API = use_api - sickbeard.API_KEY = api_key - - if enable_https == "on": - enable_https = 1 - else: - enable_https = 0 - - sickbeard.ENABLE_HTTPS = enable_https - - if not config.change_HTTPS_CERT(https_cert): - results += ["Unable to create directory " + os.path.normpath(https_cert) + ", https cert dir not changed."] - - if not config.change_HTTPS_KEY(https_key): - results += ["Unable to create directory " + os.path.normpath(https_key) + ", https key dir not changed."] - - config.change_VERSION_NOTIFY(version_notify) - - sickbeard.save_config() - - if len(results) > 0: - for x in results: - logger.log(x, logger.ERROR) - ui.notifications.error('Error(s) Saving Configuration', - '
    \n'.join(results)) - else: - ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) - - redirect("/config/general/") - - -class ConfigSearch: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="config_search.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - @cherrypy.expose - def saveSearch(self, use_nzbs=None, use_torrents=None, nzb_dir=None, sab_username=None, sab_password=None, - sab_apikey=None, sab_category=None, sab_host=None, nzbget_password=None, nzbget_category=None, nzbget_host=None, - torrent_dir=None,torrent_method=None, nzb_method=None, usenet_retention=None, search_frequency=None, download_propers=None, torrent_username=None, torrent_password=None, torrent_host=None, torrent_label=None, torrent_path=None, - torrent_ratio=None, torrent_paused=None, ignore_words=None, prefered_method=None): - - results = [] - - if not config.change_NZB_DIR(nzb_dir): - results += ["Unable to create directory " + os.path.normpath(nzb_dir) + ", dir not changed."] - - if not config.change_TORRENT_DIR(torrent_dir): - results += ["Unable to create directory " + os.path.normpath(torrent_dir) + ", dir not changed."] - - config.change_SEARCH_FREQUENCY(search_frequency) - - if download_propers == "on": - download_propers = 1 - else: - download_propers = 0 - - if use_nzbs == "on": - use_nzbs = 1 - else: - use_nzbs = 0 - - if use_torrents == "on": - use_torrents = 1 - else: - use_torrents = 0 - - if usenet_retention == None: - usenet_retention = 200 - - if ignore_words == None: - ignore_words = "" - - sickbeard.USE_NZBS = use_nzbs - sickbeard.USE_TORRENTS = use_torrents - - sickbeard.NZB_METHOD = nzb_method - sickbeard.PREFERED_METHOD = prefered_method - sickbeard.TORRENT_METHOD = torrent_method - sickbeard.USENET_RETENTION = int(usenet_retention) - - sickbeard.IGNORE_WORDS = ignore_words - - sickbeard.DOWNLOAD_PROPERS = download_propers - - sickbeard.SAB_USERNAME = sab_username - sickbeard.SAB_PASSWORD = sab_password - sickbeard.SAB_APIKEY = sab_apikey.strip() - sickbeard.SAB_CATEGORY = sab_category - - if sab_host and not re.match('https?://.*', sab_host): - sab_host = 'http://' + sab_host - - if not sab_host.endswith('/'): - sab_host = sab_host + '/' - - sickbeard.SAB_HOST = sab_host - - sickbeard.NZBGET_PASSWORD = nzbget_password - sickbeard.NZBGET_CATEGORY = nzbget_category - sickbeard.NZBGET_HOST = nzbget_host - - sickbeard.TORRENT_USERNAME = torrent_username - sickbeard.TORRENT_PASSWORD = torrent_password - sickbeard.TORRENT_LABEL = torrent_label - sickbeard.TORRENT_PATH = torrent_path - sickbeard.TORRENT_RATIO = torrent_ratio - if torrent_paused == "on": - torrent_paused = 1 - else: - torrent_paused = 0 - sickbeard.TORRENT_PAUSED = torrent_paused - - if torrent_host and not re.match('https?://.*', torrent_host): - torrent_host = 'http://' + torrent_host - - if not torrent_host.endswith('/'): - torrent_host = torrent_host + '/' - - sickbeard.TORRENT_HOST = torrent_host - - sickbeard.save_config() - - if len(results) > 0: - for x in results: - logger.log(x, logger.ERROR) - ui.notifications.error('Error(s) Saving Configuration', - '
    \n'.join(results)) - else: - ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) - - redirect("/config/search/") - -class ConfigPostProcessing: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="config_postProcessing.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - @cherrypy.expose - def savePostProcessing(self, naming_pattern=None, naming_multi_ep=None, - xbmc_data=None, xbmc__frodo__data=None, mediabrowser_data=None, synology_data=None, sony_ps3_data=None, wdtv_data=None, tivo_data=None, - use_banner=None, keep_processed_dir=None, process_automatically=None, process_automatically_torrent=None, rename_episodes=None, - move_associated_files=None, tv_download_dir=None, torrent_download_dir=None, naming_custom_abd=None, naming_abd_pattern=None): - - results = [] - - if not config.change_TV_DOWNLOAD_DIR(tv_download_dir): - results += ["Unable to create directory " + os.path.normpath(tv_download_dir) + ", dir not changed."] - - if not config.change_TORRENT_DOWNLOAD_DIR(torrent_download_dir): - results += ["Unable to create directory " + os.path.normpath(torrent_download_dir) + ", dir not changed."] - - if use_banner == "on": - use_banner = 1 - else: - use_banner = 0 - - if process_automatically == "on": - process_automatically = 1 - else: - process_automatically = 0 - - if process_automatically_torrent == "on": - process_automatically_torrent = 1 - else: - process_automatically_torrent = 0 - - if rename_episodes == "on": - rename_episodes = 1 - else: - rename_episodes = 0 - - if keep_processed_dir == "on": - keep_processed_dir = 1 - else: - keep_processed_dir = 0 - - if move_associated_files == "on": - move_associated_files = 1 - else: - move_associated_files = 0 - - if naming_custom_abd == "on": - naming_custom_abd = 1 - else: - naming_custom_abd = 0 - - sickbeard.PROCESS_AUTOMATICALLY = process_automatically - sickbeard.PROCESS_AUTOMATICALLY_TORRENT = process_automatically_torrent - sickbeard.KEEP_PROCESSED_DIR = keep_processed_dir - sickbeard.RENAME_EPISODES = rename_episodes - sickbeard.MOVE_ASSOCIATED_FILES = move_associated_files - sickbeard.NAMING_CUSTOM_ABD = naming_custom_abd - - sickbeard.metadata_provider_dict['XBMC'].set_config(xbmc_data) - sickbeard.metadata_provider_dict['XBMC (Frodo)'].set_config(xbmc__frodo__data) - sickbeard.metadata_provider_dict['MediaBrowser'].set_config(mediabrowser_data) - sickbeard.metadata_provider_dict['Synology'].set_config(synology_data) - sickbeard.metadata_provider_dict['Sony PS3'].set_config(sony_ps3_data) - sickbeard.metadata_provider_dict['WDTV'].set_config(wdtv_data) - sickbeard.metadata_provider_dict['TIVO'].set_config(tivo_data) - - if self.isNamingValid(naming_pattern, naming_multi_ep) != "invalid": - sickbeard.NAMING_PATTERN = naming_pattern - sickbeard.NAMING_MULTI_EP = int(naming_multi_ep) - sickbeard.NAMING_FORCE_FOLDERS = naming.check_force_season_folders() - else: - results.append("You tried saving an invalid naming config, not saving your naming settings") - - if self.isNamingValid(naming_abd_pattern, None, True) != "invalid": - sickbeard.NAMING_ABD_PATTERN = naming_abd_pattern - elif naming_custom_abd: - results.append("You tried saving an invalid air-by-date naming config, not saving your air-by-date settings") - - sickbeard.USE_BANNER = use_banner - - sickbeard.save_config() - - if len(results) > 0: - for x in results: - logger.log(x, logger.ERROR) - ui.notifications.error('Error(s) Saving Configuration', - '
    \n'.join(results)) - else: - ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) - - redirect("/config/postProcessing/") - - @cherrypy.expose - def testNaming(self, pattern=None, multi=None, abd=False): - - if multi != None: - multi = int(multi) - - result = naming.test_name(pattern, multi, abd) - - result = ek.ek(os.path.join, result['dir'], result['name']) - - return result - - @cherrypy.expose - def isNamingValid(self, pattern=None, multi=None, abd=False): - if pattern == None: - return "invalid" - - # air by date shows just need one check, we don't need to worry about season folders - if abd: - is_valid = naming.check_valid_abd_naming(pattern) - require_season_folders = False - - else: - # check validity of single and multi ep cases for the whole path - is_valid = naming.check_valid_naming(pattern, multi) - - # check validity of single and multi ep cases for only the file name - require_season_folders = naming.check_force_season_folders(pattern, multi) - - if is_valid and not require_season_folders: - return "valid" - elif is_valid and require_season_folders: - return "seasonfolders" - else: - return "invalid" - - -class ConfigProviders: - - @cherrypy.expose - def index(self): - t = PageTemplate(file="config_providers.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - @cherrypy.expose - def canAddNewznabProvider(self, name): - - if not name: - return json.dumps({'error': 'Invalid name specified'}) - - providerDict = dict(zip([x.getID() for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) - - tempProvider = newznab.NewznabProvider(name, '') - - if tempProvider.getID() in providerDict: - return json.dumps({'error': 'Exists as '+providerDict[tempProvider.getID()].name}) - else: - return json.dumps({'success': tempProvider.getID()}) - - @cherrypy.expose - def saveNewznabProvider(self, name, url, key=''): - - if not name or not url: - return '0' - - if not url.endswith('/'): - url = url + '/' - - providerDict = dict(zip([x.name for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) - - if name in providerDict: - if not providerDict[name].default: - providerDict[name].name = name - providerDict[name].url = url - providerDict[name].key = key - - return providerDict[name].getID() + '|' + providerDict[name].configStr() - - else: - - newProvider = newznab.NewznabProvider(name, url, key) - sickbeard.newznabProviderList.append(newProvider) - return newProvider.getID() + '|' + newProvider.configStr() - - - - @cherrypy.expose - def deleteNewznabProvider(self, id): - - providerDict = dict(zip([x.getID() for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) - - if id not in providerDict or providerDict[id].default: - return '0' - - # delete it from the list - sickbeard.newznabProviderList.remove(providerDict[id]) - - if id in sickbeard.PROVIDER_ORDER: - sickbeard.PROVIDER_ORDER.remove(id) - - return '1' - - - @cherrypy.expose - def saveProviders(self, nzbmatrix_username=None, nzbmatrix_apikey=None, - nzbs_r_us_uid=None, nzbs_r_us_hash=None, newznab_string='', - omgwtfnzbs_uid=None, omgwtfnzbs_key=None, - tvtorrents_digest=None, tvtorrents_hash=None, - torrentleech_key=None, - btn_api_key=None, - newzbin_username=None, newzbin_password=None,t411_username=None,t411_password=None, - gks_key=None, - provider_order=None): - - results = [] - - provider_str_list = provider_order.split() - provider_list = [] - - newznabProviderDict = dict(zip([x.getID() for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) - - finishedNames = [] - - # add all the newznab info we got into our list - for curNewznabProviderStr in newznab_string.split('!!!'): - - if not curNewznabProviderStr: - continue - - curName, curURL, curKey = curNewznabProviderStr.split('|') - - newProvider = newznab.NewznabProvider(curName, curURL, curKey) - - curID = newProvider.getID() - - # if it already exists then update it - if curID in newznabProviderDict: - newznabProviderDict[curID].name = curName - newznabProviderDict[curID].url = curURL - newznabProviderDict[curID].key = curKey - else: - sickbeard.newznabProviderList.append(newProvider) - - finishedNames.append(curID) - - # delete anything that is missing - for curProvider in sickbeard.newznabProviderList: - if curProvider.getID() not in finishedNames: - sickbeard.newznabProviderList.remove(curProvider) - - # do the enable/disable - for curProviderStr in provider_str_list: - curProvider, curEnabled = curProviderStr.split(':') - curEnabled = int(curEnabled) - - provider_list.append(curProvider) - - if curProvider == 'nzbs_r_us': - sickbeard.NZBSRUS = curEnabled - elif curProvider == 'nzbs_org_old': - sickbeard.NZBS = curEnabled - elif curProvider == 'nzbmatrix': - sickbeard.NZBMATRIX = curEnabled - elif curProvider == 'newzbin': - sickbeard.NEWZBIN = curEnabled - elif curProvider == 'bin_req': - sickbeard.BINREQ = curEnabled - elif curProvider == 'womble_s_index': - sickbeard.WOMBLE = curEnabled - elif curProvider == 'nzbx': - sickbeard.NZBX = curEnabled - elif curProvider == 'omgwtfnzbs': - sickbeard.OMGWTFNZBS = curEnabled - elif curProvider == 'ezrss': - sickbeard.EZRSS = curEnabled - elif curProvider == 'tvtorrents': - sickbeard.TVTORRENTS = curEnabled - elif curProvider == 'torrentleech': - sickbeard.TORRENTLEECH = curEnabled - elif curProvider == 'btn': - sickbeard.BTN = curEnabled - elif curProvider == 'binnewz': - sickbeard.BINNEWZ = curEnabled - elif curProvider == 't411': - sickbeard.T411 = curEnabled - elif curProvider == 'cpasbien': - sickbeard.Cpasbien = curEnabled - elif curProvider == 'kat': - sickbeard.kat = curEnabled - elif curProvider == 'piratebay': - sickbeard.THEPIRATEBAY = curEnabled - elif curProvider == 'gks': - sickbeard.GKS = curEnabled - elif curProvider in newznabProviderDict: - newznabProviderDict[curProvider].enabled = bool(curEnabled) - else: - logger.log(u"don't know what " + curProvider + " is, skipping") - - sickbeard.TVTORRENTS_DIGEST = tvtorrents_digest.strip() - sickbeard.TVTORRENTS_HASH = tvtorrents_hash.strip() - - sickbeard.TORRENTLEECH_KEY = torrentleech_key.strip() - - sickbeard.BTN_API_KEY = btn_api_key.strip() - - sickbeard.T411_USERNAME = t411_username - sickbeard.T411_PASSWORD = t411_password - - sickbeard.NZBSRUS_UID = nzbs_r_us_uid.strip() - sickbeard.NZBSRUS_HASH = nzbs_r_us_hash.strip() - - sickbeard.OMGWTFNZBS_UID = omgwtfnzbs_uid.strip() - sickbeard.OMGWTFNZBS_KEY = omgwtfnzbs_key.strip() - - sickbeard.GKS_KEY = gks_key.strip() - - sickbeard.PROVIDER_ORDER = provider_list - - sickbeard.save_config() - - if len(results) > 0: - for x in results: - logger.log(x, logger.ERROR) - ui.notifications.error('Error(s) Saving Configuration', - '
    \n'.join(results)) - else: - ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) - - redirect("/config/providers/") - - -class ConfigNotifications: - - @cherrypy.expose - def index(self): - t = PageTemplate(file="config_notifications.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - @cherrypy.expose - def saveNotifications(self, use_xbmc=None, xbmc_notify_onsnatch=None, xbmc_notify_ondownload=None, xbmc_update_onlyfirst=None, xbmc_notify_onsubtitledownload=None, - xbmc_update_library=None, xbmc_update_full=None, xbmc_host=None, xbmc_username=None, xbmc_password=None, - use_plex=None, plex_notify_onsnatch=None, plex_notify_ondownload=None, plex_notify_onsubtitledownload=None, plex_update_library=None, - plex_server_host=None, plex_host=None, plex_username=None, plex_password=None, - use_growl=None, growl_notify_onsnatch=None, growl_notify_ondownload=None, growl_notify_onsubtitledownload=None, growl_host=None, growl_password=None, - use_prowl=None, prowl_notify_onsnatch=None, prowl_notify_ondownload=None, prowl_notify_onsubtitledownload=None, prowl_api=None, prowl_priority=0, - use_twitter=None, twitter_notify_onsnatch=None, twitter_notify_ondownload=None, twitter_notify_onsubtitledownload=None, - use_notifo=None, notifo_notify_onsnatch=None, notifo_notify_ondownload=None, notifo_notify_onsubtitledownload=None, notifo_username=None, notifo_apisecret=None, - use_boxcar=None, boxcar_notify_onsnatch=None, boxcar_notify_ondownload=None, boxcar_notify_onsubtitledownload=None, boxcar_username=None, - use_pushover=None, pushover_notify_onsnatch=None, pushover_notify_ondownload=None, pushover_notify_onsubtitledownload=None, pushover_userkey=None, - use_libnotify=None, libnotify_notify_onsnatch=None, libnotify_notify_ondownload=None, libnotify_notify_onsubtitledownload=None, - use_nmj=None, nmj_host=None, nmj_database=None, nmj_mount=None, use_synoindex=None, - use_nmjv2=None, nmjv2_host=None, nmjv2_dbloc=None, nmjv2_database=None, - use_trakt=None, trakt_username=None, trakt_password=None, trakt_api=None,trakt_remove_watchlist=None,trakt_use_watchlist=None,trakt_start_paused=None,trakt_method_add=None, - use_pytivo=None, pytivo_notify_onsnatch=None, pytivo_notify_ondownload=None, pytivo_notify_onsubtitledownload=None, pytivo_update_library=None, - pytivo_host=None, pytivo_share_name=None, pytivo_tivo_name=None, - use_nma=None, nma_notify_onsnatch=None, nma_notify_ondownload=None, nma_notify_onsubtitledownload=None, nma_api=None, nma_priority=0, - use_mail=None, mail_username=None, mail_password=None, mail_server=None, mail_ssl=None, mail_from=None, mail_to=None, mail_notify_onsnatch=None ): - - - - results = [] - - if xbmc_notify_onsnatch == "on": - xbmc_notify_onsnatch = 1 - else: - xbmc_notify_onsnatch = 0 - - if xbmc_notify_ondownload == "on": - xbmc_notify_ondownload = 1 - else: - xbmc_notify_ondownload = 0 - - if xbmc_notify_onsubtitledownload == "on": - xbmc_notify_onsubtitledownload = 1 - else: - xbmc_notify_onsubtitledownload = 0 - - if xbmc_update_library == "on": - xbmc_update_library = 1 - else: - xbmc_update_library = 0 - - if xbmc_update_full == "on": - xbmc_update_full = 1 - else: - xbmc_update_full = 0 - - if xbmc_update_onlyfirst == "on": - xbmc_update_onlyfirst = 1 - else: - xbmc_update_onlyfirst = 0 - - if use_xbmc == "on": - use_xbmc = 1 - else: - use_xbmc = 0 - - if plex_update_library == "on": - plex_update_library = 1 - else: - plex_update_library = 0 - - if plex_notify_onsnatch == "on": - plex_notify_onsnatch = 1 - else: - plex_notify_onsnatch = 0 - - if plex_notify_ondownload == "on": - plex_notify_ondownload = 1 - else: - plex_notify_ondownload = 0 - - if plex_notify_onsubtitledownload == "on": - plex_notify_onsubtitledownload = 1 - else: - plex_notify_onsubtitledownload = 0 - - if use_plex == "on": - use_plex = 1 - else: - use_plex = 0 - - if growl_notify_onsnatch == "on": - growl_notify_onsnatch = 1 - else: - growl_notify_onsnatch = 0 - - if growl_notify_ondownload == "on": - growl_notify_ondownload = 1 - else: - growl_notify_ondownload = 0 - - if growl_notify_onsubtitledownload == "on": - growl_notify_onsubtitledownload = 1 - else: - growl_notify_onsubtitledownload = 0 - - if use_growl == "on": - use_growl = 1 - else: - use_growl = 0 - - if prowl_notify_onsnatch == "on": - prowl_notify_onsnatch = 1 - else: - prowl_notify_onsnatch = 0 - - if prowl_notify_ondownload == "on": - prowl_notify_ondownload = 1 - else: - prowl_notify_ondownload = 0 - - if prowl_notify_onsubtitledownload == "on": - prowl_notify_onsubtitledownload = 1 - else: - prowl_notify_onsubtitledownload = 0 - - if use_prowl == "on": - use_prowl = 1 - else: - use_prowl = 0 - - if twitter_notify_onsnatch == "on": - twitter_notify_onsnatch = 1 - else: - twitter_notify_onsnatch = 0 - - if twitter_notify_ondownload == "on": - twitter_notify_ondownload = 1 - else: - twitter_notify_ondownload = 0 - - if twitter_notify_onsubtitledownload == "on": - twitter_notify_onsubtitledownload = 1 - else: - twitter_notify_onsubtitledownload = 0 - - if use_twitter == "on": - use_twitter = 1 - else: - use_twitter = 0 - - if notifo_notify_onsnatch == "on": - notifo_notify_onsnatch = 1 - else: - notifo_notify_onsnatch = 0 - - if notifo_notify_ondownload == "on": - notifo_notify_ondownload = 1 - else: - notifo_notify_ondownload = 0 - - if notifo_notify_onsubtitledownload == "on": - notifo_notify_onsubtitledownload = 1 - else: - notifo_notify_onsubtitledownload = 0 - - if use_notifo == "on": - use_notifo = 1 - else: - use_notifo = 0 - - if boxcar_notify_onsnatch == "on": - boxcar_notify_onsnatch = 1 - else: - boxcar_notify_onsnatch = 0 - - if boxcar_notify_ondownload == "on": - boxcar_notify_ondownload = 1 - else: - boxcar_notify_ondownload = 0 - - if boxcar_notify_onsubtitledownload == "on": - boxcar_notify_onsubtitledownload = 1 - else: - boxcar_notify_onsubtitledownload = 0 - - if use_boxcar == "on": - use_boxcar = 1 - else: - use_boxcar = 0 - - if pushover_notify_onsnatch == "on": - pushover_notify_onsnatch = 1 - else: - pushover_notify_onsnatch = 0 - - if pushover_notify_ondownload == "on": - pushover_notify_ondownload = 1 - else: - pushover_notify_ondownload = 0 - - if pushover_notify_onsubtitledownload == "on": - pushover_notify_onsubtitledownload = 1 - else: - pushover_notify_onsubtitledownload = 0 - - if use_pushover == "on": - use_pushover = 1 - else: - use_pushover = 0 - - if use_nmj == "on": - use_nmj = 1 - else: - use_nmj = 0 - - if use_synoindex == "on": - use_synoindex = 1 - else: - use_synoindex = 0 - - if use_nmjv2 == "on": - use_nmjv2 = 1 - else: - use_nmjv2 = 0 - - if use_trakt == "on": - use_trakt = 1 - else: - use_trakt = 0 - if trakt_remove_watchlist == "on": - trakt_remove_watchlist = 1 - else: - trakt_remove_watchlist = 0 - - if trakt_use_watchlist == "on": - trakt_use_watchlist = 1 - else: - trakt_use_watchlist = 0 - - if trakt_start_paused == "on": - trakt_start_paused = 1 - else: - trakt_start_paused = 0 - - if use_pytivo == "on": - use_pytivo = 1 - else: - use_pytivo = 0 - - if pytivo_notify_onsnatch == "on": - pytivo_notify_onsnatch = 1 - else: - pytivo_notify_onsnatch = 0 - - if pytivo_notify_ondownload == "on": - pytivo_notify_ondownload = 1 - else: - pytivo_notify_ondownload = 0 - - if pytivo_notify_onsubtitledownload == "on": - pytivo_notify_onsubtitledownload = 1 - else: - pytivo_notify_onsubtitledownload = 0 - - if pytivo_update_library == "on": - pytivo_update_library = 1 - else: - pytivo_update_library = 0 - - if use_nma == "on": - use_nma = 1 - else: - use_nma = 0 - - if nma_notify_onsnatch == "on": - nma_notify_onsnatch = 1 - else: - nma_notify_onsnatch = 0 - - if nma_notify_ondownload == "on": - nma_notify_ondownload = 1 - else: - nma_notify_ondownload = 0 - - if nma_notify_onsubtitledownload == "on": - nma_notify_onsubtitledownload = 1 - else: - nma_notify_onsubtitledownload = 0 - - if use_mail == "on": - use_mail = 1 - else: - use_mail = 0 - - if mail_ssl == "on": - mail_ssl = 1 - else: - mail_ssl = 0 - - if mail_notify_onsnatch == "on": - mail_notify_onsnatch = 1 - else: - mail_notify_onsnatch = 0 - - - sickbeard.USE_XBMC = use_xbmc - sickbeard.XBMC_NOTIFY_ONSNATCH = xbmc_notify_onsnatch - sickbeard.XBMC_NOTIFY_ONDOWNLOAD = xbmc_notify_ondownload - sickbeard.XBMC_NOTIFY_ONSUBTITLEDOWNLOAD = xbmc_notify_onsubtitledownload - sickbeard.XBMC_UPDATE_LIBRARY = xbmc_update_library - sickbeard.XBMC_UPDATE_FULL = xbmc_update_full - sickbeard.XBMC_UPDATE_ONLYFIRST = xbmc_update_onlyfirst - sickbeard.XBMC_HOST = xbmc_host - sickbeard.XBMC_USERNAME = xbmc_username - sickbeard.XBMC_PASSWORD = xbmc_password - - sickbeard.USE_PLEX = use_plex - sickbeard.PLEX_NOTIFY_ONSNATCH = plex_notify_onsnatch - sickbeard.PLEX_NOTIFY_ONDOWNLOAD = plex_notify_ondownload - sickbeard.PLEX_NOTIFY_ONSUBTITLEDOWNLOAD = plex_notify_onsubtitledownload - sickbeard.PLEX_UPDATE_LIBRARY = plex_update_library - sickbeard.PLEX_HOST = plex_host - sickbeard.PLEX_SERVER_HOST = plex_server_host - sickbeard.PLEX_USERNAME = plex_username - sickbeard.PLEX_PASSWORD = plex_password - - sickbeard.USE_GROWL = use_growl - sickbeard.GROWL_NOTIFY_ONSNATCH = growl_notify_onsnatch - sickbeard.GROWL_NOTIFY_ONDOWNLOAD = growl_notify_ondownload - sickbeard.GROWL_NOTIFY_ONSUBTITLEDOWNLOAD = growl_notify_onsubtitledownload - sickbeard.GROWL_HOST = growl_host - sickbeard.GROWL_PASSWORD = growl_password - - sickbeard.USE_PROWL = use_prowl - sickbeard.PROWL_NOTIFY_ONSNATCH = prowl_notify_onsnatch - sickbeard.PROWL_NOTIFY_ONDOWNLOAD = prowl_notify_ondownload - sickbeard.PROWL_NOTIFY_ONSUBTITLEDOWNLOAD = prowl_notify_onsubtitledownload - sickbeard.PROWL_API = prowl_api - sickbeard.PROWL_PRIORITY = prowl_priority - - sickbeard.USE_TWITTER = use_twitter - sickbeard.TWITTER_NOTIFY_ONSNATCH = twitter_notify_onsnatch - sickbeard.TWITTER_NOTIFY_ONDOWNLOAD = twitter_notify_ondownload - sickbeard.TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD = twitter_notify_onsubtitledownload - - sickbeard.USE_NOTIFO = use_notifo - sickbeard.NOTIFO_NOTIFY_ONSNATCH = notifo_notify_onsnatch - sickbeard.NOTIFO_NOTIFY_ONDOWNLOAD = notifo_notify_ondownload - sickbeard.NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD = notifo_notify_onsubtitledownload - sickbeard.NOTIFO_USERNAME = notifo_username - sickbeard.NOTIFO_APISECRET = notifo_apisecret - - sickbeard.USE_BOXCAR = use_boxcar - sickbeard.BOXCAR_NOTIFY_ONSNATCH = boxcar_notify_onsnatch - sickbeard.BOXCAR_NOTIFY_ONDOWNLOAD = boxcar_notify_ondownload - sickbeard.BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD = boxcar_notify_onsubtitledownload - sickbeard.BOXCAR_USERNAME = boxcar_username - - sickbeard.USE_PUSHOVER = use_pushover - sickbeard.PUSHOVER_NOTIFY_ONSNATCH = pushover_notify_onsnatch - sickbeard.PUSHOVER_NOTIFY_ONDOWNLOAD = pushover_notify_ondownload - sickbeard.PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD = pushover_notify_onsubtitledownload - sickbeard.PUSHOVER_USERKEY = pushover_userkey - - sickbeard.USE_LIBNOTIFY = use_libnotify == "on" - sickbeard.LIBNOTIFY_NOTIFY_ONSNATCH = libnotify_notify_onsnatch == "on" - sickbeard.LIBNOTIFY_NOTIFY_ONDOWNLOAD = libnotify_notify_ondownload == "on" - sickbeard.LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD = libnotify_notify_onsubtitledownload == "on" - - sickbeard.USE_NMJ = use_nmj - sickbeard.NMJ_HOST = nmj_host - sickbeard.NMJ_DATABASE = nmj_database - sickbeard.NMJ_MOUNT = nmj_mount - - sickbeard.USE_SYNOINDEX = use_synoindex - - sickbeard.USE_NMJv2 = use_nmjv2 - sickbeard.NMJv2_HOST = nmjv2_host - sickbeard.NMJv2_DATABASE = nmjv2_database - sickbeard.NMJv2_DBLOC = nmjv2_dbloc - - sickbeard.USE_TRAKT = use_trakt - sickbeard.TRAKT_USERNAME = trakt_username - sickbeard.TRAKT_PASSWORD = trakt_password - sickbeard.TRAKT_API = trakt_api - sickbeard.TRAKT_REMOVE_WATCHLIST = trakt_remove_watchlist - sickbeard.TRAKT_USE_WATCHLIST = trakt_use_watchlist - sickbeard.TRAKT_METHOD_ADD = trakt_method_add - sickbeard.TRAKT_START_PAUSED = trakt_start_paused - - sickbeard.USE_PYTIVO = use_pytivo - sickbeard.PYTIVO_NOTIFY_ONSNATCH = pytivo_notify_onsnatch == "off" - sickbeard.PYTIVO_NOTIFY_ONDOWNLOAD = pytivo_notify_ondownload == "off" - sickbeard.PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD = pytivo_notify_onsubtitledownload == "off" - sickbeard.PYTIVO_UPDATE_LIBRARY = pytivo_update_library - sickbeard.PYTIVO_HOST = pytivo_host - sickbeard.PYTIVO_SHARE_NAME = pytivo_share_name - sickbeard.PYTIVO_TIVO_NAME = pytivo_tivo_name - - sickbeard.USE_NMA = use_nma - sickbeard.NMA_NOTIFY_ONSNATCH = nma_notify_onsnatch - sickbeard.NMA_NOTIFY_ONDOWNLOAD = nma_notify_ondownload - sickbeard.NMA_NOTIFY_ONSUBTITLEDOWNLOAD = nma_notify_onsubtitledownload - sickbeard.NMA_API = nma_api - sickbeard.NMA_PRIORITY = nma_priority - - sickbeard.USE_MAIL = use_mail - sickbeard.MAIL_USERNAME = mail_username - sickbeard.MAIL_PASSWORD = mail_password - sickbeard.MAIL_SERVER = mail_server - sickbeard.MAIL_SSL = mail_ssl - sickbeard.MAIL_FROM = mail_from - sickbeard.MAIL_TO = mail_to - sickbeard.MAIL_NOTIFY_ONSNATCH = mail_notify_onsnatch - - sickbeard.save_config() - - if len(results) > 0: - for x in results: - logger.log(x, logger.ERROR) - ui.notifications.error('Error(s) Saving Configuration', - '
    \n'.join(results)) - else: - ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) - - redirect("/config/notifications/") - -class ConfigSubtitles: - - @cherrypy.expose - def index(self): - t = PageTemplate(file="config_subtitles.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - @cherrypy.expose - def saveSubtitles(self, use_subtitles=None, subtitles_plugins=None, subtitles_languages=None, subtitles_dir=None, subtitles_dir_sub=None, subsnolang = None, service_order=None, subtitles_history=None): - results = [] - - if use_subtitles == "on": - use_subtitles = 1 - if sickbeard.subtitlesFinderScheduler.thread == None or not sickbeard.subtitlesFinderScheduler.thread.isAlive(): - sickbeard.subtitlesFinderScheduler.initThread() - else: - use_subtitles = 0 - sickbeard.subtitlesFinderScheduler.abort = True - logger.log(u"Waiting for the SUBTITLESFINDER thread to exit") - try: - sickbeard.subtitlesFinderScheduler.thread.join(5) - except: - pass - - if subtitles_history == "on": - subtitles_history = 1 - else: - subtitles_history = 0 - - if subtitles_dir_sub == "on": - subtitles_dir_sub = 1 - else: - subtitles_dir_sub = 0 - - if subsnolang == "on": - subsnolang = 1 - else: - subsnolang = 0 - - sickbeard.USE_SUBTITLES = use_subtitles - sickbeard.SUBTITLES_LANGUAGES = [lang.alpha2 for lang in subtitles.isValidLanguage(subtitles_languages.replace(' ', '').split(','))] if subtitles_languages != '' else '' - sickbeard.SUBTITLES_DIR = subtitles_dir - sickbeard.SUBTITLES_DIR_SUB = subtitles_dir_sub - sickbeard.SUBSNOLANG = subsnolang - sickbeard.SUBTITLES_HISTORY = subtitles_history - - # Subtitles services - services_str_list = service_order.split() - subtitles_services_list = [] - subtitles_services_enabled = [] - for curServiceStr in services_str_list: - curService, curEnabled = curServiceStr.split(':') - subtitles_services_list.append(curService) - subtitles_services_enabled.append(int(curEnabled)) - - sickbeard.SUBTITLES_SERVICES_LIST = subtitles_services_list - sickbeard.SUBTITLES_SERVICES_ENABLED = subtitles_services_enabled - - sickbeard.save_config() - - if len(results) > 0: - for x in results: - logger.log(x, logger.ERROR) - ui.notifications.error('Error(s) Saving Configuration', - '
    \n'.join(results)) - else: - ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) - - redirect("/config/subtitles/") - -class Config: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="config.tmpl") - t.submenu = ConfigMenu - return _munge(t) - - general = ConfigGeneral() - - search = ConfigSearch() - - postProcessing = ConfigPostProcessing() - - providers = ConfigProviders() - - notifications = ConfigNotifications() - - subtitles = ConfigSubtitles() - -def haveXBMC(): - return sickbeard.USE_XBMC and sickbeard.XBMC_UPDATE_LIBRARY - -def havePLEX(): - return sickbeard.USE_PLEX and sickbeard.PLEX_UPDATE_LIBRARY - -def HomeMenu(): - return [ - { 'title': 'Add Shows', 'path': 'home/addShows/', }, - { 'title': 'Manual Post-Processing', 'path': 'home/postprocess/' }, - { 'title': 'Update XBMC', 'path': 'home/updateXBMC/', 'requires': haveXBMC }, - { 'title': 'Update Plex', 'path': 'home/updatePLEX/', 'requires': havePLEX }, - { 'title': 'Restart', 'path': 'home/restart/?pid='+str(sickbeard.PID), 'confirm': True }, - { 'title': 'Shutdown', 'path': 'home/shutdown/?pid='+str(sickbeard.PID), 'confirm': True }, - ] - -class HomePostProcess: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="home_postprocess.tmpl") - t.submenu = HomeMenu() - return _munge(t) - - @cherrypy.expose - def processEpisode(self, dir=None, nzbName=None, jobName=None, quiet=None): - - if not dir: - redirect("/home/postprocess") - else: - result = processTV.processDir(dir, nzbName) - if quiet != None and int(quiet) == 1: - return result - - result = result.replace("\n","
    \n") - return _genericMessage("Postprocessing results", result) - - -class NewHomeAddShows: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="home_addShows.tmpl") - t.submenu = HomeMenu() - return _munge(t) - - @cherrypy.expose - def getTVDBLanguages(self): - result = tvdb_api.Tvdb().config['valid_languages'] - - # Make sure list is sorted alphabetically but 'fr' is in front - if 'fr' in result: - del result[result.index('fr')] - result.sort() - result.insert(0, 'fr') - - return json.dumps({'results': result}) - - @cherrypy.expose - def sanitizeFileName(self, name): - return helpers.sanitizeFileName(name) - - @cherrypy.expose - def searchTVDBForShowName(self, name, lang="fr"): - if not lang or lang == 'null': - lang = "fr" - - baseURL = "http://thetvdb.com/api/GetSeries.php?" - nameUTF8 = name.encode('utf-8') - - logger.log(u"Trying to find Show on thetvdb.com with: " + nameUTF8.decode('utf-8'), logger.DEBUG) - - # Use each word in the show's name as a possible search term - keywords = nameUTF8.split(' ') - - # Insert the whole show's name as the first search term so best results are first - # ex: keywords = ['Some Show Name', 'Some', 'Show', 'Name'] - if len(keywords) > 1: - keywords.insert(0, nameUTF8) - - # Query the TVDB for each search term and build the list of results - results = [] - - for searchTerm in keywords: - params = {'seriesname': searchTerm, - 'language': lang} - - finalURL = baseURL + urllib.urlencode(params) - - logger.log(u"Searching for Show with searchterm: \'" + searchTerm.decode('utf-8') + u"\' on URL " + finalURL, logger.DEBUG) - urlData = helpers.getURL(finalURL) - - if urlData is None: - # When urlData is None, trouble connecting to TVDB, don't try the rest of the keywords - logger.log(u"Unable to get URL: " + finalURL, logger.ERROR) - break - else: - try: - seriesXML = etree.ElementTree(etree.XML(urlData)) - series = seriesXML.getiterator('Series') - - except Exception, e: - # use finalURL in log, because urlData can be too much information - logger.log(u"Unable to parse XML for some reason: " + ex(e) + " from XML: " + finalURL, logger.ERROR) - series = '' - - # add each result to our list - for curSeries in series: - tvdb_id = int(curSeries.findtext('seriesid')) - - # don't add duplicates - if tvdb_id in [x[0] for x in results]: - continue - - results.append((tvdb_id, curSeries.findtext('SeriesName'), curSeries.findtext('FirstAired'))) - - lang_id = tvdb_api.Tvdb().config['langabbv_to_id'][lang] - - return json.dumps({'results': results, 'langid': lang_id}) - - @cherrypy.expose - def massAddTable(self, rootDir=None): - t = PageTemplate(file="home_massAddTable.tmpl") - t.submenu = HomeMenu() - - myDB = db.DBConnection() - - if not rootDir: - return "No folders selected." - elif type(rootDir) != list: - root_dirs = [rootDir] - else: - root_dirs = rootDir - - root_dirs = [urllib.unquote_plus(x) for x in root_dirs] - - default_index = int(sickbeard.ROOT_DIRS.split('|')[0]) - if len(root_dirs) > default_index: - tmp = root_dirs[default_index] - if tmp in root_dirs: - root_dirs.remove(tmp) - root_dirs = [tmp]+root_dirs - - dir_list = [] - - for root_dir in root_dirs: - try: - file_list = ek.ek(os.listdir, root_dir) - except: - continue - - for cur_file in file_list: - - cur_path = ek.ek(os.path.normpath, ek.ek(os.path.join, root_dir, cur_file)) - if not ek.ek(os.path.isdir, cur_path): - continue - - cur_dir = { - 'dir': cur_path, - 'display_dir': ''+ek.ek(os.path.dirname, cur_path)+os.sep+''+ek.ek(os.path.basename, cur_path), - } - - # see if the folder is in XBMC already - dirResults = myDB.select("SELECT * FROM tv_shows WHERE location = ?", [cur_path]) - - if dirResults: - cur_dir['added_already'] = True - else: - cur_dir['added_already'] = False - - dir_list.append(cur_dir) - - tvdb_id = '' - show_name = '' - for cur_provider in sickbeard.metadata_provider_dict.values(): - (tvdb_id, show_name) = cur_provider.retrieveShowMetadata(cur_path) - if tvdb_id and show_name: - break - - cur_dir['existing_info'] = (tvdb_id, show_name) - - if tvdb_id and helpers.findCertainShow(sickbeard.showList, tvdb_id): - cur_dir['added_already'] = True - - t.dirList = dir_list - - return _munge(t) - - @cherrypy.expose - def newShow(self, show_to_add=None, other_shows=None): - """ - Display the new show page which collects a tvdb id, folder, and extra options and - posts them to addNewShow - """ - t = PageTemplate(file="home_newShow.tmpl") - t.submenu = HomeMenu() - - show_dir, tvdb_id, show_name = self.split_extra_show(show_to_add) - - if tvdb_id and show_name: - use_provided_info = True - else: - use_provided_info = False - - # tell the template whether we're giving it show name & TVDB ID - t.use_provided_info = use_provided_info - - # use the given show_dir for the tvdb search if available - if not show_dir: - t.default_show_name = '' - elif not show_name: - t.default_show_name = ek.ek(os.path.basename, ek.ek(os.path.normpath, show_dir)).replace('.',' ') - else: - t.default_show_name = show_name - - # carry a list of other dirs if given - if not other_shows: - other_shows = [] - elif type(other_shows) != list: - other_shows = [other_shows] - - if use_provided_info: - t.provided_tvdb_id = tvdb_id - t.provided_tvdb_name = show_name - - t.provided_show_dir = show_dir - t.other_shows = other_shows - - return _munge(t) - - @cherrypy.expose - def addNewShow(self, whichSeries=None, tvdbLang="fr", rootDir=None, defaultStatus=None, - anyQualities=None, bestQualities=None, flatten_folders=None, subtitles=None, fullShowPath=None, - other_shows=None, skipShow=None, audio_lang=None): - """ - Receive tvdb id, dir, and other options and create a show from them. If extra show dirs are - provided then it forwards back to newShow, if not it goes to /home. - """ - - # grab our list of other dirs if given - if not other_shows: - other_shows = [] - elif type(other_shows) != list: - other_shows = [other_shows] - - def finishAddShow(): - # if there are no extra shows then go home - if not other_shows: - redirect('/home') - - # peel off the next one - next_show_dir = other_shows[0] - rest_of_show_dirs = other_shows[1:] - - # go to add the next show - return self.newShow(next_show_dir, rest_of_show_dirs) - - # if we're skipping then behave accordingly - if skipShow: - return finishAddShow() - - # sanity check on our inputs - if (not rootDir and not fullShowPath) or not whichSeries: - return "Missing params, no tvdb id or folder:"+repr(whichSeries)+" and "+repr(rootDir)+"/"+repr(fullShowPath) - - # figure out what show we're adding and where - series_pieces = whichSeries.partition('|') - if len(series_pieces) < 3: - return "Error with show selection." - - tvdb_id = int(series_pieces[0]) - show_name = series_pieces[2] - - # use the whole path if it's given, or else append the show name to the root dir to get the full show path - if fullShowPath: - show_dir = ek.ek(os.path.normpath, fullShowPath) - else: - show_dir = ek.ek(os.path.join, rootDir, helpers.sanitizeFileName(show_name)) - - # blanket policy - if the dir exists you should have used "add existing show" numbnuts - if ek.ek(os.path.isdir, show_dir) and not fullShowPath: - ui.notifications.error("Unable to add show", "Folder "+show_dir+" exists already") - redirect('/home/addShows/existingShows') - - # don't create show dir if config says not to - if sickbeard.ADD_SHOWS_WO_DIR: - logger.log(u"Skipping initial creation of "+show_dir+" due to config.ini setting") - else: - dir_exists = helpers.makeDir(show_dir) - if not dir_exists: - logger.log(u"Unable to create the folder "+show_dir+", can't add the show", logger.ERROR) - ui.notifications.error("Unable to add show", "Unable to create the folder "+show_dir+", can't add the show") - redirect("/home") - else: - helpers.chmodAsParent(show_dir) - - # prepare the inputs for passing along - if flatten_folders == "on": - flatten_folders = 1 - else: - flatten_folders = 0 - - if subtitles == "on": - subtitles = 1 - else: - subtitles = 0 - - if not anyQualities: - anyQualities = [] - if not bestQualities: - bestQualities = [] - if type(anyQualities) != list: - anyQualities = [anyQualities] - if type(bestQualities) != list: - bestQualities = [bestQualities] - newQuality = Quality.combineQualities(map(int, anyQualities), map(int, bestQualities)) - - # add the show - sickbeard.showQueueScheduler.action.addShow(tvdb_id, show_dir, int(defaultStatus), newQuality, flatten_folders, tvdbLang, subtitles, audio_lang) #@UndefinedVariable - ui.notifications.message('Show added', 'Adding the specified show into '+show_dir) - - return finishAddShow() - - - @cherrypy.expose - def existingShows(self): - """ - Prints out the page to add existing shows from a root dir - """ - t = PageTemplate(file="home_addExistingShow.tmpl") - t.submenu = HomeMenu() - - return _munge(t) - - def split_extra_show(self, extra_show): - if not extra_show: - return (None, None, None) - split_vals = extra_show.split('|') - if len(split_vals) < 3: - return (extra_show, None, None) - show_dir = split_vals[0] - tvdb_id = split_vals[1] - show_name = '|'.join(split_vals[2:]) - - return (show_dir, tvdb_id, show_name) - - @cherrypy.expose - def addExistingShows(self, shows_to_add=None, promptForSettings=None): - """ - Receives a dir list and add them. Adds the ones with given TVDB IDs first, then forwards - along to the newShow page. - """ - - # grab a list of other shows to add, if provided - if not shows_to_add: - shows_to_add = [] - elif type(shows_to_add) != list: - shows_to_add = [shows_to_add] - - shows_to_add = [urllib.unquote_plus(x) for x in shows_to_add] - - if promptForSettings == "on": - promptForSettings = 1 - else: - promptForSettings = 0 - - tvdb_id_given = [] - dirs_only = [] - # separate all the ones with TVDB IDs - for cur_dir in shows_to_add: - if not '|' in cur_dir: - dirs_only.append(cur_dir) - else: - show_dir, tvdb_id, show_name = self.split_extra_show(cur_dir) - if not show_dir or not tvdb_id or not show_name: - continue - tvdb_id_given.append((show_dir, int(tvdb_id), show_name)) - - - # if they want me to prompt for settings then I will just carry on to the newShow page - if promptForSettings and shows_to_add: - return self.newShow(shows_to_add[0], shows_to_add[1:]) - - # if they don't want me to prompt for settings then I can just add all the nfo shows now - num_added = 0 - for cur_show in tvdb_id_given: - show_dir, tvdb_id, show_name = cur_show - - # add the show - sickbeard.showQueueScheduler.action.addShow(tvdb_id, show_dir, SKIPPED, sickbeard.QUALITY_DEFAULT, sickbeard.FLATTEN_FOLDERS_DEFAULT, sickbeard.SUBTITLES_DEFAULT) #@UndefinedVariable - num_added += 1 - - if num_added: - ui.notifications.message("Shows Added", "Automatically added "+str(num_added)+" from their existing metadata files") - - # if we're done then go home - if not dirs_only: - redirect('/home') - - # for the remaining shows we need to prompt for each one, so forward this on to the newShow page - return self.newShow(dirs_only[0], dirs_only[1:]) - - - - -ErrorLogsMenu = [ - { 'title': 'Clear Errors', 'path': 'errorlogs/clearerrors' }, - #{ 'title': 'View Log', 'path': 'errorlogs/viewlog' }, -] - - -class ErrorLogs: - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="errorlogs.tmpl") - t.submenu = ErrorLogsMenu - - return _munge(t) - - - @cherrypy.expose - def clearerrors(self): - classes.ErrorViewer.clear() - redirect("/errorlogs") - - @cherrypy.expose - def viewlog(self, minLevel=logger.MESSAGE, maxLines=500): - - t = PageTemplate(file="viewlogs.tmpl") - t.submenu = ErrorLogsMenu - - minLevel = int(minLevel) - - data = [] - if os.path.isfile(logger.sb_log_instance.log_file): - f = open(logger.sb_log_instance.log_file) - data = f.readlines() - f.close() - - regex = "^(\w+).?\-(\d\d)\s+(\d\d)\:(\d\d):(\d\d)\s+([A-Z]+)\s+(.*)$" - - finalData = [] - - numLines = 0 - lastLine = False - numToShow = min(maxLines, len(data)) - - for x in reversed(data): - - x = x.decode('utf-8') - match = re.match(regex, x) - - if match: - level = match.group(6) - if level not in logger.reverseNames: - lastLine = False - continue - - if logger.reverseNames[level] >= minLevel: - lastLine = True - finalData.append(x) - else: - lastLine = False - continue - - elif lastLine: - finalData.append("AA"+x) - - numLines += 1 - - if numLines >= numToShow: - break - - result = "".join(finalData) - - t.logLines = result - t.minLevel = minLevel - - return _munge(t) - - -class Home: - - @cherrypy.expose - def is_alive(self, *args, **kwargs): - if 'callback' in kwargs and '_' in kwargs: - callback, _ = kwargs['callback'], kwargs['_'] - else: - return "Error: Unsupported Request. Send jsonp request with 'callback' variable in the query stiring." - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - cherrypy.response.headers['Content-Type'] = 'text/javascript' - cherrypy.response.headers['Access-Control-Allow-Origin'] = '*' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'x-requested-with' - - if sickbeard.started: - return callback+'('+json.dumps({"msg": str(sickbeard.PID)})+');' - else: - return callback+'('+json.dumps({"msg": "nope"})+');' - - @cherrypy.expose - def index(self): - - t = PageTemplate(file="home.tmpl") - t.submenu = HomeMenu() - return _munge(t) - - addShows = NewHomeAddShows() - - postprocess = HomePostProcess() - - @cherrypy.expose - def testSABnzbd(self, host=None, username=None, password=None, apikey=None): - if not host.endswith("/"): - host = host + "/" - connection, accesMsg = sab.getSabAccesMethod(host, username, password, apikey) - if connection: - authed, authMsg = sab.testAuthentication(host, username, password, apikey) #@UnusedVariable - if authed: - return "Success. Connected and authenticated" - else: - return "Authentication failed. SABnzbd expects '"+accesMsg+"' as authentication method" - else: - return "Unable to connect to host" - - @cherrypy.expose - def testTorrent(self, torrent_method=None, host=None, username=None, password=None): - if not host.endswith("/"): - host = host + "/" - - client = clients.getClientIstance(torrent_method) - - connection, accesMsg = client(host, username, password).testAuthentication() - - return accesMsg - - @cherrypy.expose - def testGrowl(self, host=None, password=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.growl_notifier.test_notify(host, password) - if password==None or password=='': - pw_append = '' - else: - pw_append = " with password: " + password - - if result: - return "Registered and Tested growl successfully "+urllib.unquote_plus(host)+pw_append - else: - return "Registration and Testing of growl failed "+urllib.unquote_plus(host)+pw_append - - @cherrypy.expose - def testProwl(self, prowl_api=None, prowl_priority=0): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.prowl_notifier.test_notify(prowl_api, prowl_priority) - if result: - return "Test prowl notice sent successfully" - else: - return "Test prowl notice failed" - - @cherrypy.expose - def testNotifo(self, username=None, apisecret=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.notifo_notifier.test_notify(username, apisecret) - if result: - return "Notifo notification succeeded. Check your Notifo clients to make sure it worked" - else: - return "Error sending Notifo notification" - - @cherrypy.expose - def testBoxcar(self, username=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.boxcar_notifier.test_notify(username) - if result: - return "Boxcar notification succeeded. Check your Boxcar clients to make sure it worked" - else: - return "Error sending Boxcar notification" - - @cherrypy.expose - def testPushover(self, userKey=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.pushover_notifier.test_notify(userKey) - if result: - return "Pushover notification succeeded. Check your Pushover clients to make sure it worked" - else: - return "Error sending Pushover notification" - - @cherrypy.expose - def twitterStep1(self): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - return notifiers.twitter_notifier._get_authorization() - - @cherrypy.expose - def twitterStep2(self, key): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.twitter_notifier._get_credentials(key) - logger.log(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" - - result = notifiers.twitter_notifier.test_notify() - if result: - return "Tweet successful, check your twitter to make sure it worked" - else: - return "Error sending tweet" - - @cherrypy.expose - def testXBMC(self, host=None, username=None, password=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - finalResult = '' - for curHost in [x.strip() for x in host.split(",")]: - curResult = notifiers.xbmc_notifier.test_notify(urllib.unquote_plus(curHost), username, password) - if len(curResult.split(":")) > 2 and 'OK' in curResult.split(":")[2]: - finalResult += "Test XBMC notice sent successfully to " + urllib.unquote_plus(curHost) - else: - finalResult += "Test XBMC notice failed to " + urllib.unquote_plus(curHost) - finalResult += "
    \n" - - return finalResult - - @cherrypy.expose - def testPLEX(self, host=None, username=None, password=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - finalResult = '' - for curHost in [x.strip() for x in host.split(",")]: - curResult = notifiers.plex_notifier.test_notify(urllib.unquote_plus(curHost), username, password) - if len(curResult.split(":")) > 2 and 'OK' in curResult.split(":")[2]: - finalResult += "Test Plex notice sent successfully to " + urllib.unquote_plus(curHost) - else: - finalResult += "Test Plex notice failed to " + urllib.unquote_plus(curHost) - finalResult += "
    \n" - - return finalResult - - @cherrypy.expose - def testLibnotify(self): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - if notifiers.libnotify_notifier.test_notify(): - return "Tried sending desktop notification via libnotify" - else: - return notifiers.libnotify.diagnose() - - @cherrypy.expose - def testNMJ(self, host=None, database=None, mount=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.nmj_notifier.test_notify(urllib.unquote_plus(host), database, mount) - if result: - return "Successfull started the scan update" - else: - return "Test failed to start the scan update" - - @cherrypy.expose - def settingsNMJ(self, host=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.nmj_notifier.notify_settings(urllib.unquote_plus(host)) - if result: - return '{"message": "Got settings from %(host)s", "database": "%(database)s", "mount": "%(mount)s"}' % {"host": host, "database": sickbeard.NMJ_DATABASE, "mount": sickbeard.NMJ_MOUNT} - else: - return '{"message": "Failed! Make sure your Popcorn is on and NMJ is running. (see Log & Errors -> Debug for detailed info)", "database": "", "mount": ""}' - - @cherrypy.expose - def testNMJv2(self, host=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.nmjv2_notifier.test_notify(urllib.unquote_plus(host)) - if result: - return "Test notice sent successfully to " + urllib.unquote_plus(host) - else: - return "Test notice failed to " + urllib.unquote_plus(host) - - @cherrypy.expose - def settingsNMJv2(self, host=None, dbloc=None, instance=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - result = notifiers.nmjv2_notifier.notify_settings(urllib.unquote_plus(host), dbloc, instance) - if result: - return '{"message": "NMJ Database found at: %(host)s", "database": "%(database)s"}' % {"host": host, "database": sickbeard.NMJv2_DATABASE} - else: - return '{"message": "Unable to find NMJ Database at location: %(dbloc)s. Is the right location selected and PCH running?", "database": ""}' % {"dbloc": dbloc} - - @cherrypy.expose - def testTrakt(self, api=None, username=None, password=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.trakt_notifier.test_notify(api, username, password) - if result: - return "Test notice sent successfully to Trakt" - else: - return "Test notice failed to Trakt" - - @cherrypy.expose - def testMail(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_user=None, mail_password=None): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.mail_notifier.test_notify(mail_from, mail_to, mail_server, mail_ssl, mail_user, mail_password) - if result: - return "Mail sent" - else: - return "Can't sent mail." - - @cherrypy.expose - def testNMA(self, nma_api=None, nma_priority=0): - cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" - - result = notifiers.nma_notifier.test_notify(nma_api, nma_priority) - if result: - return "Test NMA notice sent successfully" - else: - return "Test NMA notice failed" - - @cherrypy.expose - def shutdown(self, pid=None): - - if str(pid) != str(sickbeard.PID): - redirect("/home") - - threading.Timer(2, sickbeard.invoke_shutdown).start() - - title = "Shutting down" - message = "Sick Beard is shutting down..." - - return _genericMessage(title, message) - - @cherrypy.expose - def restart(self, pid=None): - - if str(pid) != str(sickbeard.PID): - redirect("/home") - - t = PageTemplate(file="restart.tmpl") - t.submenu = HomeMenu() - - # do a soft restart - threading.Timer(2, sickbeard.invoke_restart, [False]).start() - - return _munge(t) - - @cherrypy.expose - def update(self, pid=None): - - if str(pid) != str(sickbeard.PID): - redirect("/home") - - updated = sickbeard.versionCheckScheduler.action.update() #@UndefinedVariable - - if updated: - # do a hard restart - threading.Timer(2, sickbeard.invoke_restart, [False]).start() - t = PageTemplate(file="restart_bare.tmpl") - return _munge(t) - else: - return _genericMessage("Update Failed","Update wasn't successful, not restarting. Check your log for more information.") - - @cherrypy.expose - def displayShow(self, show=None): - - if show == None: - return _genericMessage("Error", "Invalid show ID") - else: - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Show not in show list") - - myDB = db.DBConnection() - - seasonResults = myDB.select( - "SELECT DISTINCT season FROM tv_episodes WHERE showid = ? ORDER BY season desc", - [showObj.tvdbid] - ) - - sqlResults = myDB.select( - "SELECT * FROM tv_episodes WHERE showid = ? ORDER BY season DESC, episode DESC", - [showObj.tvdbid] - ) - - t = PageTemplate(file="displayShow.tmpl") - t.submenu = [ { 'title': 'Edit', 'path': 'home/editShow?show=%d'%showObj.tvdbid } ] - - try: - t.showLoc = (showObj.location, True) - except sickbeard.exceptions.ShowDirNotFoundException: - t.showLoc = (showObj._location, False) - - show_message = '' - - if sickbeard.showQueueScheduler.action.isBeingAdded(showObj): #@UndefinedVariable - show_message = 'This show is in the process of being downloaded from theTVDB.com - the info below is incomplete.' - - elif sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): #@UndefinedVariable - show_message = 'The information below is in the process of being updated.' - - elif sickbeard.showQueueScheduler.action.isBeingRefreshed(showObj): #@UndefinedVariable - show_message = 'The episodes below are currently being refreshed from disk' - - elif sickbeard.showQueueScheduler.action.isBeingSubtitled(showObj): #@UndefinedVariable - show_message = 'Currently downloading subtitles for this show' - - elif sickbeard.showQueueScheduler.action.isInRefreshQueue(showObj): #@UndefinedVariable - show_message = 'This show is queued to be refreshed.' - - elif sickbeard.showQueueScheduler.action.isInUpdateQueue(showObj): #@UndefinedVariable - show_message = 'This show is queued and awaiting an update.' - - elif sickbeard.showQueueScheduler.action.isInSubtitleQueue(showObj): #@UndefinedVariable - show_message = 'This show is queued and awaiting subtitles download.' - - if not sickbeard.showQueueScheduler.action.isBeingAdded(showObj): #@UndefinedVariable - if not sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): #@UndefinedVariable - t.submenu.append({ 'title': 'Delete', 'path': 'home/deleteShow?show=%d'%showObj.tvdbid, 'confirm': True }) - t.submenu.append({ 'title': 'Re-scan files', 'path': 'home/refreshShow?show=%d'%showObj.tvdbid }) - t.submenu.append({ 'title': 'Force Full Update', 'path': 'home/updateShow?show=%d&force=1'%showObj.tvdbid }) - t.submenu.append({ 'title': 'Update show in XBMC', 'path': 'home/updateXBMC?showName=%s'%urllib.quote_plus(showObj.name.encode('utf-8')), 'requires': haveXBMC }) - t.submenu.append({ 'title': 'Preview Rename', 'path': 'home/testRename?show=%d'%showObj.tvdbid }) - if sickbeard.USE_SUBTITLES and not sickbeard.showQueueScheduler.action.isBeingSubtitled(showObj) and showObj.subtitles: - t.submenu.append({ 'title': 'Download Subtitles', 'path': 'home/subtitleShow?show=%d'%showObj.tvdbid }) - - t.show = showObj - t.sqlResults = sqlResults - t.seasonResults = seasonResults - t.show_message = show_message - - epCounts = {} - epCats = {} - epCounts[Overview.SKIPPED] = 0 - epCounts[Overview.WANTED] = 0 - epCounts[Overview.QUAL] = 0 - epCounts[Overview.GOOD] = 0 - epCounts[Overview.UNAIRED] = 0 - epCounts[Overview.SNATCHED] = 0 - - for curResult in sqlResults: - - curEpCat = showObj.getOverview(int(curResult["status"])) - epCats[str(curResult["season"])+"x"+str(curResult["episode"])] = curEpCat - epCounts[curEpCat] += 1 - - def titler(x): - if not x: - return x - if x.lower().startswith('a '): - x = x[2:] - elif x.lower().startswith('the '): - x = x[4:] - return x - t.sortedShowList = sorted(sickbeard.showList, lambda x, y: cmp(titler(x.name), titler(y.name))) - - t.epCounts = epCounts - t.epCats = epCats - - return _munge(t) - - @cherrypy.expose - def plotDetails(self, show, season, episode): - result = db.DBConnection().action("SELECT description FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ?", (show, season, episode)).fetchone() - return result['description'] if result else 'Episode not found.' - - @cherrypy.expose - def editShow(self, show=None, location=None, anyQualities=[], bestQualities=[], exceptions_list=[], flatten_folders=None, paused=None, directCall=False, air_by_date=None, tvdbLang=None, audio_lang=None, custom_search_names=None, subtitles=None): - - if show == None: - errString = "Invalid show ID: "+str(show) - if directCall: - return [errString] - else: - return _genericMessage("Error", errString) - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - errString = "Unable to find the specified show: "+str(show) - if directCall: - return [errString] - else: - return _genericMessage("Error", errString) - - showObj.exceptions = scene_exceptions.get_scene_exceptions(showObj.tvdbid) - - if not location and not anyQualities and not bestQualities and not flatten_folders: - - t = PageTemplate(file="editShow.tmpl") - t.submenu = HomeMenu() - with showObj.lock: - t.show = showObj - - return _munge(t) - - if flatten_folders == "on": - flatten_folders = 1 - else: - flatten_folders = 0 - - logger.log(u"flatten folders: "+str(flatten_folders)) - - if paused == "on": - paused = 1 - else: - paused = 0 - - if air_by_date == "on": - air_by_date = 1 - else: - air_by_date = 0 - - if subtitles == "on": - subtitles = 1 - else: - subtitles = 0 - - - if tvdbLang and tvdbLang in tvdb_api.Tvdb().config['valid_languages']: - tvdb_lang = tvdbLang - else: - tvdb_lang = showObj.lang - - # if we changed the language then kick off an update - if tvdb_lang == showObj.lang: - do_update = False - else: - do_update = True - - if type(anyQualities) != list: - anyQualities = [anyQualities] - - if type(bestQualities) != list: - bestQualities = [bestQualities] - - if type(exceptions_list) != list: - exceptions_list = [exceptions_list] - - errors = [] - with showObj.lock: - newQuality = Quality.combineQualities(map(int, anyQualities), map(int, bestQualities)) - showObj.quality = newQuality - - # reversed for now - if bool(showObj.flatten_folders) != bool(flatten_folders): - showObj.flatten_folders = flatten_folders - try: - sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable - except exceptions.CantRefreshException, e: - errors.append("Unable to refresh this show: "+ex(e)) - - showObj.paused = paused - showObj.air_by_date = air_by_date - showObj.subtitles = subtitles - showObj.lang = tvdb_lang - showObj.audio_lang = audio_lang - showObj.custom_search_names = custom_search_names - - # if we change location clear the db of episodes, change it, write to db, and rescan - if os.path.normpath(showObj._location) != os.path.normpath(location): - logger.log(os.path.normpath(showObj._location)+" != "+os.path.normpath(location), logger.DEBUG) - if not ek.ek(os.path.isdir, location): - errors.append("New location %s does not exist" % location) - - # don't bother if we're going to update anyway - elif not do_update: - # change it - try: - showObj.location = location - try: - sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable - except exceptions.CantRefreshException, e: - errors.append("Unable to refresh this show:"+ex(e)) - # grab updated info from TVDB - #showObj.loadEpisodesFromTVDB() - # rescan the episodes in the new folder - except exceptions.NoNFOException: - errors.append("The folder at %s doesn't contain a tvshow.nfo - copy your files to that folder before you change the directory in Sick Beard." % location) - - # save it to the DB - showObj.saveToDB() - - # force the update - if do_update: - try: - sickbeard.showQueueScheduler.action.updateShow(showObj, True) #@UndefinedVariable - time.sleep(1) - except exceptions.CantUpdateException, e: - errors.append("Unable to force an update on the show.") - - if directCall: - return errors - - if len(errors) > 0: - ui.notifications.error('%d error%s while saving changes:' % (len(errors), "" if len(errors) == 1 else "s"), - '
      ' + '\n'.join(['
    • %s
    • ' % error for error in errors]) + "
    ") - - redirect("/home/displayShow?show=" + show) - - @cherrypy.expose - def deleteShow(self, show=None): - - if show == None: - return _genericMessage("Error", "Invalid show ID") - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Unable to find the specified show") - - if sickbeard.showQueueScheduler.action.isBeingAdded(showObj) or sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): #@UndefinedVariable - return _genericMessage("Error", "Shows can't be deleted while they're being added or updated.") - - showObj.deleteShow() - - ui.notifications.message('%s has been deleted' % showObj.name) - redirect("/home") - - @cherrypy.expose - def refreshShow(self, show=None): - - if show == None: - return _genericMessage("Error", "Invalid show ID") - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Unable to find the specified show") - - # force the update from the DB - try: - sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable - except exceptions.CantRefreshException, e: - ui.notifications.error("Unable to refresh this show.", - ex(e)) - - time.sleep(3) - - redirect("/home/displayShow?show="+str(showObj.tvdbid)) - - @cherrypy.expose - def updateShow(self, show=None, force=0): - - if show == None: - return _genericMessage("Error", "Invalid show ID") - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Unable to find the specified show") - - # force the update - try: - sickbeard.showQueueScheduler.action.updateShow(showObj, bool(force)) #@UndefinedVariable - except exceptions.CantUpdateException, e: - ui.notifications.error("Unable to update this show.", - ex(e)) - - # just give it some time - time.sleep(3) - - redirect("/home/displayShow?show=" + str(showObj.tvdbid)) - - @cherrypy.expose - def subtitleShow(self, show=None, force=0): - - if show == None: - return _genericMessage("Error", "Invalid show ID") - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Unable to find the specified show") - - # search and download subtitles - sickbeard.showQueueScheduler.action.downloadSubtitles(showObj, bool(force)) #@UndefinedVariable - - time.sleep(3) - - redirect("/home/displayShow?show="+str(showObj.tvdbid)) - - - @cherrypy.expose - def updateXBMC(self, showName=None): - if sickbeard.XBMC_UPDATE_ONLYFIRST: - # only send update to first host in the list -- workaround for xbmc sql backend users - host = sickbeard.XBMC_HOST.split(",")[0].strip() - else: - host = sickbeard.XBMC_HOST - - if notifiers.xbmc_notifier.update_library(showName=showName): - ui.notifications.message("Library update command sent to XBMC host(s): " + host) - else: - ui.notifications.error("Unable to contact one or more XBMC host(s): " + host) - redirect('/home') - - @cherrypy.expose - def updatePLEX(self): - if notifiers.plex_notifier.update_library(): - ui.notifications.message("Library update command sent to Plex Media Server host: " + sickbeard.PLEX_SERVER_HOST) - else: - ui.notifications.error("Unable to contact Plex Media Server host: " + sickbeard.PLEX_SERVER_HOST) - redirect('/home') - - @cherrypy.expose - def setStatus(self, show=None, eps=None, status=None, direct=False): - - if show == None or eps == None or status == None: - errMsg = "You must specify a show and at least one episode" - if direct: - ui.notifications.error('Error', errMsg) - return json.dumps({'result': 'error'}) - else: - return _genericMessage("Error", errMsg) - - if not statusStrings.has_key(int(status)): - errMsg = "Invalid status" - if direct: - ui.notifications.error('Error', errMsg) - return json.dumps({'result': 'error'}) - else: - return _genericMessage("Error", errMsg) - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - errMsg = "Error", "Show not in show list" - if direct: - ui.notifications.error('Error', errMsg) - return json.dumps({'result': 'error'}) - else: - return _genericMessage("Error", errMsg) - - segment_list = [] - - if eps != None: - - for curEp in eps.split('|'): - - logger.log(u"Attempting to set status on episode "+curEp+" to "+status, logger.DEBUG) - - epInfo = curEp.split('x') - - epObj = showObj.getEpisode(int(epInfo[0]), int(epInfo[1])) - - if int(status) == WANTED: - # figure out what segment the episode is in and remember it so we can backlog it - if epObj.show.air_by_date: - ep_segment = str(epObj.airdate)[:7] - else: - ep_segment = epObj.season - - if ep_segment not in segment_list: - segment_list.append(ep_segment) - - if epObj == None: - return _genericMessage("Error", "Episode couldn't be retrieved") - - with epObj.lock: - # don't let them mess up UNAIRED episodes - if epObj.status == UNAIRED: - logger.log(u"Refusing to change status of "+curEp+" because it is UNAIRED", logger.ERROR) - continue - - if int(status) in Quality.DOWNLOADED and epObj.status not in Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.DOWNLOADED + [IGNORED] and not ek.ek(os.path.isfile, epObj.location): - logger.log(u"Refusing to change status of "+curEp+" to DOWNLOADED because it's not SNATCHED/DOWNLOADED", logger.ERROR) - continue - - epObj.status = int(status) - epObj.saveToDB() - - msg = "Backlog was automatically started for the following seasons of "+showObj.name+":
    " - for cur_segment in segment_list: - msg += "
  • Season "+str(cur_segment)+"
  • " - logger.log(u"Sending backlog for "+showObj.name+" season "+str(cur_segment)+" because some eps were set to wanted") - cur_backlog_queue_item = search_queue.BacklogQueueItem(showObj, cur_segment) - sickbeard.searchQueueScheduler.action.add_item(cur_backlog_queue_item) #@UndefinedVariable - msg += "" - - if segment_list: - ui.notifications.message("Backlog started", msg) - - if direct: - return json.dumps({'result': 'success'}) - else: - redirect("/home/displayShow?show=" + show) - - @cherrypy.expose - def setAudio(self, show=None, eps=None, audio_langs=None, direct=False): - - if show == None or eps == None or audio_langs == None: - errMsg = "You must specify a show and at least one episode" - if direct: - ui.notifications.error('Error', errMsg) - return json.dumps({'result': 'error'}) - else: - return _genericMessage("Error", errMsg) - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Show not in show list") - - try: - show_loc = showObj.location #@UnusedVariable - except exceptions.ShowDirNotFoundException: - return _genericMessage("Error", "Can't rename episodes when the show dir is missing.") - - ep_obj_rename_list = [] - - for curEp in eps.split('|'): - - logger.log(u"Attempting to set audio on episode "+curEp+" to "+audio_langs, logger.DEBUG) - - epInfo = curEp.split('x') - - epObj = showObj.getEpisode(int(epInfo[0]), int(epInfo[1])) - - epObj.audio_langs = str(audio_langs) - epObj.saveToDB() - - if direct: - return json.dumps({'result': 'success'}) - else: - redirect("/home/displayShow?show=" + show) - - @cherrypy.expose - def testRename(self, show=None): - - if show == None: - return _genericMessage("Error", "You must specify a show") - - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj == None: - return _genericMessage("Error", "Show not in show list") - - try: - show_loc = showObj.location #@UnusedVariable - except exceptions.ShowDirNotFoundException: - return _genericMessage("Error", "Can't rename episodes when the show dir is missing.") - - ep_obj_rename_list = [] - - ep_obj_list = showObj.getAllEpisodes(has_location=True) - - for cur_ep_obj in ep_obj_list: - # Only want to rename if we have a location - if cur_ep_obj.location: - if cur_ep_obj.relatedEps: - # do we have one of multi-episodes in the rename list already - have_already = False - for cur_related_ep in cur_ep_obj.relatedEps + [cur_ep_obj]: - if cur_related_ep in ep_obj_rename_list: - have_already = True - break - if not have_already: - ep_obj_rename_list.append(cur_ep_obj) - - else: - ep_obj_rename_list.append(cur_ep_obj) - - if ep_obj_rename_list: - # present season DESC episode DESC on screen - ep_obj_rename_list.reverse() - - t = PageTemplate(file="testRename.tmpl") - t.submenu = [{'title': 'Edit', 'path': 'home/editShow?show=%d' % showObj.tvdbid}] - t.ep_obj_list = ep_obj_rename_list - t.show = showObj - - return _munge(t) - - @cherrypy.expose - def doRename(self, show=None, eps=None): - - if show == None or eps == None: - errMsg = "You must specify a show and at least one episode" - return _genericMessage("Error", errMsg) - - show_obj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if show_obj == None: - errMsg = "Error", "Show not in show list" - return _genericMessage("Error", errMsg) - - try: - show_loc = show_obj.location #@UnusedVariable - except exceptions.ShowDirNotFoundException: - return _genericMessage("Error", "Can't rename episodes when the show dir is missing.") - - myDB = db.DBConnection() - - if eps == None: - redirect("/home/displayShow?show=" + show) - - for curEp in eps.split('|'): - - epInfo = curEp.split('x') - - # this is probably the worst possible way to deal with double eps but I've kinda painted myself into a corner here with this stupid database - ep_result = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ? AND 5=5", [show, epInfo[0], epInfo[1]]) - if not ep_result: - logger.log(u"Unable to find an episode for "+curEp+", skipping", logger.WARNING) - continue - related_eps_result = myDB.select("SELECT * FROM tv_episodes WHERE location = ? AND episode != ?", [ep_result[0]["location"], epInfo[1]]) - - root_ep_obj = show_obj.getEpisode(int(epInfo[0]), int(epInfo[1])) - for cur_related_ep in related_eps_result: - related_ep_obj = show_obj.getEpisode(int(cur_related_ep["season"]), int(cur_related_ep["episode"])) - if related_ep_obj not in root_ep_obj.relatedEps: - root_ep_obj.relatedEps.append(related_ep_obj) - - root_ep_obj.rename() - - redirect("/home/displayShow?show=" + show) - - @cherrypy.expose - def trunchistory(self, epid): - - myDB = db.DBConnection() - nbep = myDB.select("Select count(*) from episode_links where episode_id=?",[epid]) - myDB.action("DELETE from episode_links where episode_id=?",[epid]) - messnum = str(nbep[0][0]) + ' history links deleted' - ui.notifications.message('Episode History Truncated' , messnum) - return json.dumps({'result': 'ok'}) - - @cherrypy.expose - def searchEpisode(self, show=None, season=None, episode=None): - - # retrieve the episode object and fail if we can't get one - ep_obj = _getEpisode(show, season, episode) - if isinstance(ep_obj, str): - return json.dumps({'result': 'failure'}) - - # make a queue item for it and put it on the queue - ep_queue_item = search_queue.ManualSearchQueueItem(ep_obj) - sickbeard.searchQueueScheduler.action.add_item(ep_queue_item) #@UndefinedVariable - - # wait until the queue item tells us whether it worked or not - while ep_queue_item.success == None: #@UndefinedVariable - time.sleep(1) - - # return the correct json value - if ep_queue_item.success: - return json.dumps({'result': statusStrings[ep_obj.status]}) - - return json.dumps({'result': 'failure'}) - - @cherrypy.expose - def searchEpisodeSubtitles(self, show=None, season=None, episode=None): - - # retrieve the episode object and fail if we can't get one - ep_obj = _getEpisode(show, season, episode) - if isinstance(ep_obj, str): - return json.dumps({'result': 'failure'}) - - # try do download subtitles for that episode - previous_subtitles = ep_obj.subtitles - try: - subtitles = ep_obj.downloadSubtitles() - - if sickbeard.SUBTITLES_DIR: - for video in subtitles: - subs_new_path = ek.ek(os.path.join, os.path.dirname(video.path), sickbeard.SUBTITLES_DIR) - dir_exists = helpers.makeDir(subs_new_path) - if not dir_exists: - logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) - else: - helpers.chmodAsParent(subs_new_path) - - for subtitle in subtitles.get(video): - new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) - helpers.moveFile(subtitle.path, new_file_path) - if sickbeard.SUBSNOLANG: - helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") - helpers.chmodAsParent(new_file_path[:-6]+"srt") - helpers.chmodAsParent(new_file_path) - else: - if sickbeard.SUBTITLES_DIR_SUB: - for video in subtitles: - subs_new_path = os.path.join(os.path.dirname(video.path),"Subs") - dir_exists = helpers.makeDir(subs_new_path) - if not dir_exists: - logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) - else: - helpers.chmodAsParent(subs_new_path) - - for subtitle in subtitles.get(video): - new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) - helpers.moveFile(subtitle.path, new_file_path) - if sickbeard.SUBSNOLANG: - helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") - helpers.chmodAsParent(new_file_path[:-6]+"srt") - helpers.chmodAsParent(new_file_path) - else: - for video in subtitles: - for subtitle in subtitles.get(video): - if sickbeard.SUBSNOLANG: - helpers.copyFile(subtitle.path,subtitle.path[:-6]+"srt") - helpers.chmodAsParent(subtitle.path[:-6]+"srt") - helpers.chmodAsParent(subtitle.path) - except: - return json.dumps({'result': 'failure'}) - - # return the correct json value - if previous_subtitles != ep_obj.subtitles: - status = 'New subtitles downloaded: %s' % ' '.join([""+subliminal.language.Language(x).name+"" for x in sorted(list(set(ep_obj.subtitles).difference(previous_subtitles)))]) - else: - status = 'No subtitles downloaded' - ui.notifications.message('Subtitles Search', status) - return json.dumps({'result': status, 'subtitles': ','.join([x for x in ep_obj.subtitles])}) - - @cherrypy.expose - def mergeEpisodeSubtitles(self, show=None, season=None, episode=None): - - # retrieve the episode object and fail if we can't get one - ep_obj = _getEpisode(show, season, episode) - if isinstance(ep_obj, str): - return json.dumps({'result': 'failure'}) - - # try do merge subtitles for that episode - try: - ep_obj.mergeSubtitles() - except Exception as e: - return json.dumps({'result': 'failure', 'exception': str(e)}) - - # return the correct json value - status = 'Subtitles merged successfully ' - ui.notifications.message('Merge Subtitles', status) - return json.dumps({'result': 'ok'}) - -class UI: - - @cherrypy.expose - def add_message(self): - - ui.notifications.message('Test 1', 'This is test number 1') - ui.notifications.error('Test 2', 'This is test number 2') - - return "ok" - - @cherrypy.expose - def get_messages(self): - messages = {} - cur_notification_num = 1 - for cur_notification in ui.notifications.get_notifications(): - messages['notification-'+str(cur_notification_num)] = {'title': cur_notification.title, - 'message': cur_notification.message, - 'type': cur_notification.type} - cur_notification_num += 1 - - return json.dumps(messages) - - -class WebInterface: - - @cherrypy.expose - def index(self): - - redirect("/home") - - @cherrypy.expose - def showPoster(self, show=None, which=None): - - #Redirect initial poster/banner thumb to default images - if which[0:6] == 'poster': - default_image_name = 'poster.png' - else: - default_image_name = 'banner.png' - - default_image_path = ek.ek(os.path.join, sickbeard.PROG_DIR, 'data', 'images', default_image_name) - if show is None: - return cherrypy.lib.static.serve_file(default_image_path, content_type="image/png") - else: - showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) - - if showObj is None: - return cherrypy.lib.static.serve_file(default_image_path, content_type="image/png") - - cache_obj = image_cache.ImageCache() - - if which == 'poster': - image_file_name = cache_obj.poster_path(showObj.tvdbid) - if which == 'poster_thumb': - image_file_name = cache_obj.poster_thumb_path(showObj.tvdbid) - if which == 'banner': - image_file_name = cache_obj.banner_path(showObj.tvdbid) - if which == 'banner_thumb': - image_file_name = cache_obj.banner_thumb_path(showObj.tvdbid) - - if ek.ek(os.path.isfile, image_file_name): - return cherrypy.lib.static.serve_file(image_file_name, content_type="image/jpeg") - else: - return cherrypy.lib.static.serve_file(default_image_path, content_type="image/png") - @cherrypy.expose - def setComingEpsLayout(self, layout): - if layout not in ('poster', 'banner', 'list'): - layout = 'banner' - - sickbeard.COMING_EPS_LAYOUT = layout - - redirect("/comingEpisodes") - - @cherrypy.expose - def toggleComingEpsDisplayPaused(self): - - sickbeard.COMING_EPS_DISPLAY_PAUSED = not sickbeard.COMING_EPS_DISPLAY_PAUSED - - redirect("/comingEpisodes") - - @cherrypy.expose - def setComingEpsSort(self, sort): - if sort not in ('date', 'network', 'show'): - sort = 'date' - - sickbeard.COMING_EPS_SORT = sort - - redirect("/comingEpisodes") - - @cherrypy.expose - def comingEpisodes(self, layout="None"): - - myDB = db.DBConnection() - - today = datetime.date.today().toordinal() - next_week = (datetime.date.today() + datetime.timedelta(days=7)).toordinal() - recently = (datetime.date.today() - datetime.timedelta(days=3)).toordinal() - - done_show_list = [] - qualList = Quality.DOWNLOADED + Quality.SNATCHED + [ARCHIVED, IGNORED] - sql_results = myDB.select("SELECT *, tv_shows.status as show_status FROM tv_episodes, tv_shows WHERE season != 0 AND airdate >= ? AND airdate < ? AND tv_shows.tvdb_id = tv_episodes.showid AND tv_episodes.status NOT IN ("+','.join(['?']*len(qualList))+")", [today, next_week] + qualList) - for cur_result in sql_results: - done_show_list.append(int(cur_result["showid"])) - - more_sql_results = myDB.select("SELECT *, tv_shows.status as show_status FROM tv_episodes outer_eps, tv_shows WHERE season != 0 AND showid NOT IN ("+','.join(['?']*len(done_show_list))+") AND tv_shows.tvdb_id = outer_eps.showid AND airdate = (SELECT airdate FROM tv_episodes inner_eps WHERE inner_eps.showid = outer_eps.showid AND inner_eps.airdate >= ? ORDER BY inner_eps.airdate ASC LIMIT 1) AND outer_eps.status NOT IN ("+','.join(['?']*len(Quality.DOWNLOADED+Quality.SNATCHED))+")", done_show_list + [next_week] + Quality.DOWNLOADED + Quality.SNATCHED) - sql_results += more_sql_results - - more_sql_results = myDB.select("SELECT *, tv_shows.status as show_status FROM tv_episodes, tv_shows WHERE season != 0 AND tv_shows.tvdb_id = tv_episodes.showid AND airdate < ? AND airdate >= ? AND tv_episodes.status = ? AND tv_episodes.status NOT IN ("+','.join(['?']*len(qualList))+")", [today, recently, WANTED] + qualList) - sql_results += more_sql_results - - #epList = sickbeard.comingList - - # sort by air date - sorts = { - 'date': (lambda x, y: cmp(int(x["airdate"]), int(y["airdate"]))), - 'show': (lambda a, b: cmp(a["show_name"], b["show_name"])), - 'network': (lambda a, b: cmp(a["network"], b["network"])), - } - - #epList.sort(sorts[sort]) - sql_results.sort(sorts[sickbeard.COMING_EPS_SORT]) - - t = PageTemplate(file="comingEpisodes.tmpl") - paused_item = { 'title': '', 'path': 'toggleComingEpsDisplayPaused' } - paused_item['title'] = 'Hide Paused' if sickbeard.COMING_EPS_DISPLAY_PAUSED else 'Show Paused' - t.submenu = [ - { 'title': 'Sort by:', 'path': {'Date': 'setComingEpsSort/?sort=date', - 'Show': 'setComingEpsSort/?sort=show', - 'Network': 'setComingEpsSort/?sort=network', - }}, - - { 'title': 'Layout:', 'path': {'Banner': 'setComingEpsLayout/?layout=banner', - 'Poster': 'setComingEpsLayout/?layout=poster', - 'List': 'setComingEpsLayout/?layout=list', - }}, - paused_item, - ] - - t.next_week = next_week - t.today = today - t.sql_results = sql_results - - # Allow local overriding of layout parameter - if layout and layout in ('poster', 'banner', 'list'): - t.layout = layout - else: - t.layout = sickbeard.COMING_EPS_LAYOUT - - - return _munge(t) - - manage = Manage() - - history = History() - - config = Config() - - home = Home() - - api = Api() - - browser = browser.WebFileBrowser() - - errorlogs = ErrorLogs() - - ui = UI() +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from __future__ import with_statement + +import os.path + +import time +import urllib +import re +import threading +import datetime +import random + +import locale + +from Cheetah.Template import Template +import cherrypy.lib + +import sickbeard + +from sickbeard import config, sab +from sickbeard import clients +from sickbeard import history, notifiers, processTV +from sickbeard import ui +from sickbeard import logger, helpers, exceptions, classes, db +from sickbeard import encodingKludge as ek +from sickbeard import search_queue +from sickbeard import image_cache +from sickbeard import scene_exceptions +from sickbeard import naming +from sickbeard import subtitles + +from sickbeard.providers import newznab +from sickbeard.common import Quality, Overview, statusStrings +from sickbeard.common import SNATCHED, SKIPPED, UNAIRED, IGNORED, ARCHIVED, WANTED +from sickbeard.exceptions import ex +from sickbeard.webapi import Api + +from lib.tvdb_api import tvdb_api +from lib.dateutil import tz +import network_timezones + +import subliminal + +try: + import json +except ImportError: + from lib import simplejson as json + +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +from sickbeard import browser + + +class PageTemplate (Template): + def __init__(self, *args, **KWs): + KWs['file'] = os.path.join(sickbeard.PROG_DIR, "data/interfaces/default/", KWs['file']) + super(PageTemplate, self).__init__(*args, **KWs) + self.sbRoot = sickbeard.WEB_ROOT + self.sbHttpPort = sickbeard.WEB_PORT + self.sbHttpsPort = sickbeard.WEB_PORT + self.sbHttpsEnabled = sickbeard.ENABLE_HTTPS + if cherrypy.request.headers['Host'][0] == '[': + self.sbHost = re.match("^\[.*\]", cherrypy.request.headers['Host'], re.X|re.M|re.S).group(0) + else: + self.sbHost = re.match("^[^:]+", cherrypy.request.headers['Host'], re.X|re.M|re.S).group(0) + self.projectHomePage = "http://code.google.com/p/sickbeard/" + + if sickbeard.NZBS and sickbeard.NZBS_UID and sickbeard.NZBS_HASH: + logger.log(u"NZBs.org has been replaced, please check the config to configure the new provider!", logger.ERROR) + ui.notifications.error("NZBs.org Config Update", "NZBs.org has a new site. Please update your config with the api key from http://nzbs.org and then disable the old NZBs.org provider.") + + if "X-Forwarded-Host" in cherrypy.request.headers: + self.sbHost = cherrypy.request.headers['X-Forwarded-Host'] + if "X-Forwarded-Port" in cherrypy.request.headers: + self.sbHttpPort = cherrypy.request.headers['X-Forwarded-Port'] + self.sbHttpsPort = self.sbHttpPort + if "X-Forwarded-Proto" in cherrypy.request.headers: + self.sbHttpsEnabled = True if cherrypy.request.headers['X-Forwarded-Proto'] == 'https' else False + + logPageTitle = 'Logs & Errors' + if len(classes.ErrorViewer.errors): + logPageTitle += ' ('+str(len(classes.ErrorViewer.errors))+')' + self.logPageTitle = logPageTitle + self.sbPID = str(sickbeard.PID) + self.menu = [ + { 'title': 'Home', 'key': 'home' }, + { 'title': 'Coming Episodes', 'key': 'comingEpisodes' }, + { 'title': 'History', 'key': 'history' }, + { 'title': 'Manage', 'key': 'manage' }, + { 'title': 'Config', 'key': 'config' }, + { 'title': logPageTitle, 'key': 'errorlogs' }, + ] + +def redirect(abspath, *args, **KWs): + assert abspath[0] == '/' + raise cherrypy.HTTPRedirect(sickbeard.WEB_ROOT + abspath, *args, **KWs) + +class TVDBWebUI: + def __init__(self, config, log=None): + self.config = config + self.log = log + + def selectSeries(self, allSeries): + + searchList = ",".join([x['id'] for x in allSeries]) + showDirList = "" + for curShowDir in self.config['_showDir']: + showDirList += "showDir="+curShowDir+"&" + redirect("/home/addShows/addShow?" + showDirList + "seriesList=" + searchList) + +def _munge(string): + return unicode(string).encode('utf-8', 'xmlcharrefreplace') + +def _genericMessage(subject, message): + t = PageTemplate(file="genericMessage.tmpl") + t.submenu = HomeMenu() + t.subject = subject + t.message = message + return _munge(t) + +def _getEpisode(show, season, episode): + + if show == None or season == None or episode == None: + return "Invalid parameters" + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return "Show not in show list" + + epObj = showObj.getEpisode(int(season), int(episode)) + + if epObj == None: + return "Episode couldn't be retrieved" + + return epObj + +ManageMenu = [ + { 'title': 'Backlog Overview', 'path': 'manage/backlogOverview' }, + { 'title': 'Manage Searches', 'path': 'manage/manageSearches' }, + { 'title': 'Episode Status Management', 'path': 'manage/episodeStatuses' }, + { 'title': 'Manage Missed Subtitles', 'path': 'manage/subtitleMissed' }, +] +if sickbeard.USE_SUBTITLES: + ManageMenu.append({ 'title': 'Missed Subtitle Management', 'path': 'manage/subtitleMissed' }) + +class ManageSearches: + + @cherrypy.expose + def index(self): + t = PageTemplate(file="manage_manageSearches.tmpl") + #t.backlogPI = sickbeard.backlogSearchScheduler.action.getProgressIndicator() + t.backlogPaused = sickbeard.searchQueueScheduler.action.is_backlog_paused() #@UndefinedVariable + t.backlogRunning = sickbeard.searchQueueScheduler.action.is_backlog_in_progress() #@UndefinedVariable + t.searchStatus = sickbeard.currentSearchScheduler.action.amActive #@UndefinedVariable + t.submenu = ManageMenu + + return _munge(t) + + @cherrypy.expose + def forceSearch(self): + + # force it to run the next time it looks + result = sickbeard.currentSearchScheduler.forceRun() + if result: + logger.log(u"Search forced") + ui.notifications.message('Episode search started', + 'Note: RSS feeds may not be updated if retrieved recently') + + redirect("/manage/manageSearches") + + @cherrypy.expose + def pauseBacklog(self, paused=None): + if paused == "1": + sickbeard.searchQueueScheduler.action.pause_backlog() #@UndefinedVariable + else: + sickbeard.searchQueueScheduler.action.unpause_backlog() #@UndefinedVariable + + redirect("/manage/manageSearches") + + @cherrypy.expose + def forceVersionCheck(self): + + # force a check to see if there is a new version + result = sickbeard.versionCheckScheduler.action.check_for_new_version(force=True) #@UndefinedVariable + if result: + logger.log(u"Forcing version check") + + redirect("/manage/manageSearches") + + +class Manage: + + manageSearches = ManageSearches() + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="manage.tmpl") + t.submenu = ManageMenu + return _munge(t) + + @cherrypy.expose + def showEpisodeStatuses(self, tvdb_id, whichStatus): + myDB = db.DBConnection() + + status_list = [int(whichStatus)] + if status_list[0] == SNATCHED: + status_list = Quality.SNATCHED + Quality.SNATCHED_PROPER + + cur_show_results = myDB.select("SELECT season, episode, name FROM tv_episodes WHERE showid = ? AND season != 0 AND status IN ("+','.join(['?']*len(status_list))+")", [int(tvdb_id)] + status_list) + + result = {} + for cur_result in cur_show_results: + cur_season = int(cur_result["season"]) + cur_episode = int(cur_result["episode"]) + + if cur_season not in result: + result[cur_season] = {} + + result[cur_season][cur_episode] = cur_result["name"] + + return json.dumps(result) + + @cherrypy.expose + def episodeStatuses(self, whichStatus=None): + + if whichStatus: + whichStatus = int(whichStatus) + status_list = [whichStatus] + if status_list[0] == SNATCHED: + status_list = Quality.SNATCHED + Quality.SNATCHED_PROPER + else: + status_list = [] + + t = PageTemplate(file="manage_episodeStatuses.tmpl") + t.submenu = ManageMenu + t.whichStatus = whichStatus + + # if we have no status then this is as far as we need to go + if not status_list: + return _munge(t) + + myDB = db.DBConnection() + status_results = myDB.select("SELECT show_name, tv_shows.tvdb_id as tvdb_id FROM tv_episodes, tv_shows WHERE tv_episodes.status IN ("+','.join(['?']*len(status_list))+") AND season != 0 AND tv_episodes.showid = tv_shows.tvdb_id ORDER BY show_name", status_list) + + ep_counts = {} + show_names = {} + sorted_show_ids = [] + for cur_status_result in status_results: + cur_tvdb_id = int(cur_status_result["tvdb_id"]) + if cur_tvdb_id not in ep_counts: + ep_counts[cur_tvdb_id] = 1 + else: + ep_counts[cur_tvdb_id] += 1 + + show_names[cur_tvdb_id] = cur_status_result["show_name"] + if cur_tvdb_id not in sorted_show_ids: + sorted_show_ids.append(cur_tvdb_id) + + t.show_names = show_names + t.ep_counts = ep_counts + t.sorted_show_ids = sorted_show_ids + return _munge(t) + + @cherrypy.expose + def changeEpisodeStatuses(self, oldStatus, newStatus, *args, **kwargs): + + status_list = [int(oldStatus)] + if status_list[0] == SNATCHED: + status_list = Quality.SNATCHED + Quality.SNATCHED_PROPER + + to_change = {} + + # make a list of all shows and their associated args + for arg in kwargs: + tvdb_id, what = arg.split('-') + + # we don't care about unchecked checkboxes + if kwargs[arg] != 'on': + continue + + if tvdb_id not in to_change: + to_change[tvdb_id] = [] + + to_change[tvdb_id].append(what) + + myDB = db.DBConnection() + + for cur_tvdb_id in to_change: + + # get a list of all the eps we want to change if they just said "all" + if 'all' in to_change[cur_tvdb_id]: + all_eps_results = myDB.select("SELECT season, episode FROM tv_episodes WHERE status IN ("+','.join(['?']*len(status_list))+") AND season != 0 AND showid = ?", status_list + [cur_tvdb_id]) + all_eps = [str(x["season"])+'x'+str(x["episode"]) for x in all_eps_results] + to_change[cur_tvdb_id] = all_eps + + Home().setStatus(cur_tvdb_id, '|'.join(to_change[cur_tvdb_id]), newStatus, direct=True) + + redirect('/manage/episodeStatuses') + + @cherrypy.expose + def showSubtitleMissed(self, tvdb_id, whichSubs): + myDB = db.DBConnection() + + cur_show_results = myDB.select("SELECT season, episode, name, subtitles FROM tv_episodes WHERE showid = ? AND season != 0 AND status LIKE '%4'", [int(tvdb_id)]) + + result = {} + for cur_result in cur_show_results: + if whichSubs == 'all': + if len(set(cur_result["subtitles"].split(',')).intersection(set(subtitles.wantedLanguages()))) >= len(subtitles.wantedLanguages()): + continue + elif whichSubs in cur_result["subtitles"].split(','): + continue + + cur_season = int(cur_result["season"]) + cur_episode = int(cur_result["episode"]) + + if cur_season not in result: + result[cur_season] = {} + + if cur_episode not in result[cur_season]: + result[cur_season][cur_episode] = {} + + result[cur_season][cur_episode]["name"] = cur_result["name"] + + result[cur_season][cur_episode]["subtitles"] = ",".join(subliminal.language.Language(subtitle).alpha2 for subtitle in cur_result["subtitles"].split(',')) if not cur_result["subtitles"] == '' else '' + + return json.dumps(result) + + @cherrypy.expose + def subtitleMissed(self, whichSubs=None): + + t = PageTemplate(file="manage_subtitleMissed.tmpl") + t.submenu = ManageMenu + t.whichSubs = whichSubs + + if not whichSubs: + return _munge(t) + + myDB = db.DBConnection() + status_results = myDB.select("SELECT show_name, tv_shows.tvdb_id as tvdb_id, tv_episodes.subtitles subtitles FROM tv_episodes, tv_shows WHERE tv_shows.subtitles = 1 AND tv_episodes.status LIKE '%4' AND tv_episodes.season != 0 AND tv_episodes.showid = tv_shows.tvdb_id ORDER BY show_name") + + ep_counts = {} + show_names = {} + sorted_show_ids = [] + for cur_status_result in status_results: + if whichSubs == 'all': + if len(set(cur_status_result["subtitles"].split(',')).intersection(set(subtitles.wantedLanguages()))) >= len(subtitles.wantedLanguages()): + continue + elif whichSubs in cur_status_result["subtitles"].split(','): + continue + + cur_tvdb_id = int(cur_status_result["tvdb_id"]) + if cur_tvdb_id not in ep_counts: + ep_counts[cur_tvdb_id] = 1 + else: + ep_counts[cur_tvdb_id] += 1 + + show_names[cur_tvdb_id] = cur_status_result["show_name"] + if cur_tvdb_id not in sorted_show_ids: + sorted_show_ids.append(cur_tvdb_id) + + t.show_names = show_names + t.ep_counts = ep_counts + t.sorted_show_ids = sorted_show_ids + return _munge(t) + + @cherrypy.expose + def downloadSubtitleMissed(self, *args, **kwargs): + + to_download = {} + + # make a list of all shows and their associated args + for arg in kwargs: + tvdb_id, what = arg.split('-') + + # we don't care about unchecked checkboxes + if kwargs[arg] != 'on': + continue + + if tvdb_id not in to_download: + to_download[tvdb_id] = [] + + to_download[tvdb_id].append(what) + + for cur_tvdb_id in to_download: + # get a list of all the eps we want to download subtitles if they just said "all" + if 'all' in to_download[cur_tvdb_id]: + myDB = db.DBConnection() + all_eps_results = myDB.select("SELECT season, episode FROM tv_episodes WHERE status LIKE '%4' AND season != 0 AND showid = ?", [cur_tvdb_id]) + to_download[cur_tvdb_id] = [str(x["season"])+'x'+str(x["episode"]) for x in all_eps_results] + + for epResult in to_download[cur_tvdb_id]: + season, episode = epResult.split('x'); + + show = sickbeard.helpers.findCertainShow(sickbeard.showList, int(cur_tvdb_id)) + subtitles = show.getEpisode(int(season), int(episode)).downloadSubtitles() + + + + + redirect('/manage/subtitleMissed') + + @cherrypy.expose + def backlogShow(self, tvdb_id): + + show_obj = helpers.findCertainShow(sickbeard.showList, int(tvdb_id)) + + if show_obj: + sickbeard.backlogSearchScheduler.action.searchBacklog([show_obj]) #@UndefinedVariable + + redirect("/manage/backlogOverview") + + @cherrypy.expose + def backlogOverview(self): + + t = PageTemplate(file="manage_backlogOverview.tmpl") + t.submenu = ManageMenu + + myDB = db.DBConnection() + + showCounts = {} + showCats = {} + showSQLResults = {} + + for curShow in sickbeard.showList: + + epCounts = {} + epCats = {} + epCounts[Overview.SKIPPED] = 0 + epCounts[Overview.WANTED] = 0 + epCounts[Overview.QUAL] = 0 + epCounts[Overview.GOOD] = 0 + epCounts[Overview.UNAIRED] = 0 + epCounts[Overview.SNATCHED] = 0 + + sqlResults = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? ORDER BY season DESC, episode DESC", [curShow.tvdbid]) + + for curResult in sqlResults: + + curEpCat = curShow.getOverview(int(curResult["status"])) + epCats[str(curResult["season"]) + "x" + str(curResult["episode"])] = curEpCat + epCounts[curEpCat] += 1 + + showCounts[curShow.tvdbid] = epCounts + showCats[curShow.tvdbid] = epCats + showSQLResults[curShow.tvdbid] = sqlResults + + t.showCounts = showCounts + t.showCats = showCats + t.showSQLResults = showSQLResults + + return _munge(t) + + @cherrypy.expose + def massEdit(self, toEdit=None): + + t = PageTemplate(file="manage_massEdit.tmpl") + t.submenu = ManageMenu + + if not toEdit: + redirect("/manage") + + showIDs = toEdit.split("|") + showList = [] + for curID in showIDs: + curID = int(curID) + showObj = helpers.findCertainShow(sickbeard.showList, curID) + if showObj: + showList.append(showObj) + + flatten_folders_all_same = True + last_flatten_folders = None + + paused_all_same = True + last_paused = None + + quality_all_same = True + last_quality = None + + subtitles_all_same = True + last_subtitles = None + + lang_all_same = True + last_lang_metadata= None + + lang_audio_all_same = True + last_lang_audio = None + + root_dir_list = [] + + for curShow in showList: + + cur_root_dir = ek.ek(os.path.dirname, curShow._location) + if cur_root_dir not in root_dir_list: + root_dir_list.append(cur_root_dir) + + # if we know they're not all the same then no point even bothering + if paused_all_same: + # if we had a value already and this value is different then they're not all the same + if last_paused not in (curShow.paused, None): + paused_all_same = False + else: + last_paused = curShow.paused + + if flatten_folders_all_same: + if last_flatten_folders not in (None, curShow.flatten_folders): + flatten_folders_all_same = False + else: + last_flatten_folders = curShow.flatten_folders + + if quality_all_same: + if last_quality not in (None, curShow.quality): + quality_all_same = False + else: + last_quality = curShow.quality + + if subtitles_all_same: + if last_subtitles not in (None, curShow.subtitles): + subtitles_all_same = False + else: + last_subtitles = curShow.subtitles + + if lang_all_same: + if last_lang_metadata not in (None, curShow.lang): + lang_all_same = False + else: + last_lang_metadata = curShow.lang + + if lang_audio_all_same: + if last_lang_audio not in (None, curShow.audio_lang): + lang_audio_all_same = False + else: + last_lang_audio = curShow.audio_lang + + t.showList = toEdit + t.paused_value = last_paused if paused_all_same else None + t.flatten_folders_value = last_flatten_folders if flatten_folders_all_same else None + t.quality_value = last_quality if quality_all_same else None + t.subtitles_value = last_subtitles if subtitles_all_same else None + t.root_dir_list = root_dir_list + t.lang_value = last_lang_metadata if lang_all_same else None + t.audio_value = last_lang_audio if lang_audio_all_same else None + return _munge(t) + + @cherrypy.expose + def massEditSubmit(self, paused=None, flatten_folders=None, quality_preset=False, subtitles=None, + anyQualities=[], bestQualities=[], tvdbLang=None, audioLang = None, toEdit=None, *args, **kwargs): + + dir_map = {} + for cur_arg in kwargs: + if not cur_arg.startswith('orig_root_dir_'): + continue + which_index = cur_arg.replace('orig_root_dir_', '') + end_dir = kwargs['new_root_dir_'+which_index] + dir_map[kwargs[cur_arg]] = end_dir + + showIDs = toEdit.split("|") + errors = [] + for curShow in showIDs: + curErrors = [] + showObj = helpers.findCertainShow(sickbeard.showList, int(curShow)) + if not showObj: + continue + + cur_root_dir = ek.ek(os.path.dirname, showObj._location) + cur_show_dir = ek.ek(os.path.basename, showObj._location) + if cur_root_dir in dir_map and cur_root_dir != dir_map[cur_root_dir]: + new_show_dir = ek.ek(os.path.join, dir_map[cur_root_dir], cur_show_dir) + logger.log(u"For show "+showObj.name+" changing dir from "+showObj._location+" to "+new_show_dir) + else: + new_show_dir = showObj._location + + if paused == 'keep': + new_paused = showObj.paused + else: + new_paused = True if paused == 'enable' else False + new_paused = 'on' if new_paused else 'off' + + if flatten_folders == 'keep': + new_flatten_folders = showObj.flatten_folders + else: + new_flatten_folders = True if flatten_folders == 'enable' else False + new_flatten_folders = 'on' if new_flatten_folders else 'off' + + if subtitles == 'keep': + new_subtitles = showObj.subtitles + else: + new_subtitles = True if subtitles == 'enable' else False + + new_subtitles = 'on' if new_subtitles else 'off' + + if quality_preset == 'keep': + anyQualities, bestQualities = Quality.splitQuality(showObj.quality) + + if tvdbLang == 'None': + new_lang = 'en' + else: + new_lang = tvdbLang + + if audioLang == 'None': + new_audio_lang = showObj.audio_lang; + else: + new_audio_lang = audioLang + + exceptions_list = [] + + curErrors += Home().editShow(curShow, new_show_dir, anyQualities, bestQualities, exceptions_list, new_flatten_folders, new_paused, subtitles=new_subtitles, tvdbLang=new_lang, audio_lang=new_audio_lang, custom_search_names=showObj.custom_search_names, directCall=True) + + if curErrors: + logger.log(u"Errors: "+str(curErrors), logger.ERROR) + errors.append('%s:\n
      ' % showObj.name + ' '.join(['
    • %s
    • ' % error for error in curErrors]) + "
    ") + + if len(errors) > 0: + ui.notifications.error('%d error%s while saving changes:' % (len(errors), "" if len(errors) == 1 else "s"), + " ".join(errors)) + + redirect("/manage") + + @cherrypy.expose + def massUpdate(self, toUpdate=None, toRefresh=None, toRename=None, toDelete=None, toMetadata=None, toSubtitle=None): + + if toUpdate != None: + toUpdate = toUpdate.split('|') + else: + toUpdate = [] + + if toRefresh != None: + toRefresh = toRefresh.split('|') + else: + toRefresh = [] + + if toRename != None: + toRename = toRename.split('|') + else: + toRename = [] + + if toSubtitle != None: + toSubtitle = toSubtitle.split('|') + else: + toSubtitle = [] + + if toDelete != None: + toDelete = toDelete.split('|') + else: + toDelete = [] + + if toMetadata != None: + toMetadata = toMetadata.split('|') + else: + toMetadata = [] + + errors = [] + refreshes = [] + updates = [] + renames = [] + subtitles = [] + + for curShowID in set(toUpdate+toRefresh+toRename+toSubtitle+toDelete+toMetadata): + + if curShowID == '': + continue + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(curShowID)) + + if showObj == None: + continue + + if curShowID in toDelete: + showObj.deleteShow() + # don't do anything else if it's being deleted + continue + + if curShowID in toUpdate: + try: + sickbeard.showQueueScheduler.action.updateShow(showObj, True) #@UndefinedVariable + updates.append(showObj.name) + except exceptions.CantUpdateException, e: + errors.append("Unable to update show "+showObj.name+": "+ex(e)) + + # don't bother refreshing shows that were updated anyway + if curShowID in toRefresh and curShowID not in toUpdate: + try: + sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable + refreshes.append(showObj.name) + except exceptions.CantRefreshException, e: + errors.append("Unable to refresh show "+showObj.name+": "+ex(e)) + + if curShowID in toRename: + sickbeard.showQueueScheduler.action.renameShowEpisodes(showObj) #@UndefinedVariable + renames.append(showObj.name) + + if curShowID in toSubtitle: + sickbeard.showQueueScheduler.action.downloadSubtitles(showObj) #@UndefinedVariable + subtitles.append(showObj.name) + + if len(errors) > 0: + ui.notifications.error("Errors encountered", + '
    \n'.join(errors)) + + messageDetail = "" + + if len(updates) > 0: + messageDetail += "
    Updates
    • " + messageDetail += "
    • ".join(updates) + messageDetail += "
    " + + if len(refreshes) > 0: + messageDetail += "
    Refreshes
    • " + messageDetail += "
    • ".join(refreshes) + messageDetail += "
    " + + if len(renames) > 0: + messageDetail += "
    Renames
    • " + messageDetail += "
    • ".join(renames) + messageDetail += "
    " + + if len(subtitles) > 0: + messageDetail += "
    Subtitles
    • " + messageDetail += "
    • ".join(subtitles) + messageDetail += "
    " + + if len(updates+refreshes+renames+subtitles) > 0: + ui.notifications.message("The following actions were queued:", + messageDetail) + + redirect("/manage") + + +class History: + + @cherrypy.expose + def index(self, limit=100): + + myDB = db.DBConnection() + +# sqlResults = myDB.select("SELECT h.*, show_name, name FROM history h, tv_shows s, tv_episodes e WHERE h.showid=s.tvdb_id AND h.showid=e.showid AND h.season=e.season AND h.episode=e.episode ORDER BY date DESC LIMIT "+str(numPerPage*(p-1))+", "+str(numPerPage)) + if limit == "0": + sqlResults = myDB.select("SELECT h.*, show_name FROM history h, tv_shows s WHERE h.showid=s.tvdb_id ORDER BY date DESC") + else: + sqlResults = myDB.select("SELECT h.*, show_name FROM history h, tv_shows s WHERE h.showid=s.tvdb_id ORDER BY date DESC LIMIT ?", [limit]) + + t = PageTemplate(file="history.tmpl") + t.historyResults = sqlResults + t.limit = limit + t.submenu = [ + { 'title': 'Clear History', 'path': 'history/clearHistory' }, + { 'title': 'Trim History', 'path': 'history/trimHistory' }, + { 'title': 'Trunc Episode Links', 'path': 'history/truncEplinks' }, + ] + + return _munge(t) + + + @cherrypy.expose + def clearHistory(self): + + myDB = db.DBConnection() + myDB.action("DELETE FROM history WHERE 1=1") + ui.notifications.message('History cleared') + redirect("/history") + + + @cherrypy.expose + def trimHistory(self): + + myDB = db.DBConnection() + myDB.action("DELETE FROM history WHERE date < "+str((datetime.datetime.today()-datetime.timedelta(days=30)).strftime(history.dateFormat))) + ui.notifications.message('Removed history entries greater than 30 days old') + redirect("/history") + + + @cherrypy.expose + def truncEplinks(self): + + myDB = db.DBConnection() + nbep=myDB.select("SELECT count(*) from episode_links") + myDB.action("DELETE FROM episode_links WHERE 1=1") + messnum = str(nbep[0][0]) + ' history links deleted' + ui.notifications.message('All Episode Links Removed', messnum) + redirect("/history") + + +ConfigMenu = [ + { 'title': 'General', 'path': 'config/general/' }, + { 'title': 'Search Settings', 'path': 'config/search/' }, + { 'title': 'Search Providers', 'path': 'config/providers/' }, + { 'title': 'Subtitles Settings','path': 'config/subtitles/' }, + { 'title': 'Post Processing', 'path': 'config/postProcessing/' }, + { 'title': 'Notifications', 'path': 'config/notifications/' }, +] + +class ConfigGeneral: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="config_general.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + @cherrypy.expose + def saveRootDirs(self, rootDirString=None): + sickbeard.ROOT_DIRS = rootDirString + sickbeard.save_config() + @cherrypy.expose + def saveAddShowDefaults(self, defaultFlattenFolders, defaultStatus, anyQualities, bestQualities, audio_lang, subtitles): + + if anyQualities: + anyQualities = anyQualities.split(',') + else: + anyQualities = [] + + if bestQualities: + bestQualities = bestQualities.split(',') + else: + bestQualities = [] + + newQuality = Quality.combineQualities(map(int, anyQualities), map(int, bestQualities)) + + sickbeard.STATUS_DEFAULT = int(defaultStatus) + sickbeard.QUALITY_DEFAULT = int(newQuality) + sickbeard.AUDIO_SHOW_DEFAULT = str(audio_lang) + + if defaultFlattenFolders == "true": + defaultFlattenFolders = 1 + else: + defaultFlattenFolders = 0 + + sickbeard.FLATTEN_FOLDERS_DEFAULT = int(defaultFlattenFolders) + + if subtitles == "true": + subtitles = 1 + else: + subtitles = 0 + sickbeard.SUBTITLES_DEFAULT = int(subtitles) + + sickbeard.save_config() + + @cherrypy.expose + def generateKey(self): + """ Return a new randomized API_KEY + """ + + try: + from hashlib import md5 + except ImportError: + from md5 import md5 + + # Create some values to seed md5 + t = str(time.time()) + r = str(random.random()) + + # Create the md5 instance and give it the current time + m = md5(t) + + # Update the md5 instance with the random variable + m.update(r) + + # Return a hex digest of the md5, eg 49f68a5c8493ec2c0bf489821c21fc3b + logger.log(u"New API generated") + return m.hexdigest() + + @cherrypy.expose + def saveGeneral(self, log_dir=None, web_port=None, web_log=None, web_ipv6=None, + update_shows_on_start=None,launch_browser=None, web_username=None, use_api=None, api_key=None, + web_password=None, version_notify=None, enable_https=None, https_cert=None, https_key=None, sort_article=None): + + results = [] + + if web_ipv6 == "on": + web_ipv6 = 1 + else: + web_ipv6 = 0 + + if web_log == "on": + web_log = 1 + else: + web_log = 0 + + if launch_browser == "on": + launch_browser = 1 + else: + launch_browser = 0 + + if update_shows_on_start == "on": + update_shows_on_start = 1 + else: + update_shows_on_start = 0 + + if sort_article == "on": + sort_article = 1 + else: + sort_article = 0 + + if version_notify == "on": + version_notify = 1 + else: + version_notify = 0 + + if not config.change_LOG_DIR(log_dir): + results += ["Unable to create directory " + os.path.normpath(log_dir) + ", log dir not changed."] + + sickbeard.UPDATE_SHOWS_ON_START = update_shows_on_start + sickbeard.LAUNCH_BROWSER = launch_browser + sickbeard.SORT_ARTICLE = sort_article + + sickbeard.WEB_PORT = int(web_port) + sickbeard.WEB_IPV6 = web_ipv6 + sickbeard.WEB_LOG = web_log + sickbeard.WEB_USERNAME = web_username + sickbeard.WEB_PASSWORD = web_password + + if use_api == "on": + use_api = 1 + else: + use_api = 0 + + sickbeard.USE_API = use_api + sickbeard.API_KEY = api_key + + if enable_https == "on": + enable_https = 1 + else: + enable_https = 0 + + sickbeard.ENABLE_HTTPS = enable_https + + if not config.change_HTTPS_CERT(https_cert): + results += ["Unable to create directory " + os.path.normpath(https_cert) + ", https cert dir not changed."] + + if not config.change_HTTPS_KEY(https_key): + results += ["Unable to create directory " + os.path.normpath(https_key) + ", https key dir not changed."] + + config.change_VERSION_NOTIFY(version_notify) + + sickbeard.save_config() + + if len(results) > 0: + for x in results: + logger.log(x, logger.ERROR) + ui.notifications.error('Error(s) Saving Configuration', + '
    \n'.join(results)) + else: + ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) + + redirect("/config/general/") + + +class ConfigSearch: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="config_search.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + @cherrypy.expose + def saveSearch(self, use_nzbs=None, use_torrents=None, nzb_dir=None, sab_username=None, sab_password=None, + sab_apikey=None, sab_category=None, sab_host=None, nzbget_password=None, nzbget_category=None, nzbget_host=None, + torrent_dir=None,torrent_method=None, nzb_method=None, usenet_retention=None, search_frequency=None, download_propers=None, torrent_username=None, torrent_password=None, torrent_host=None, torrent_label=None, torrent_path=None, + torrent_ratio=None, torrent_paused=None, ignore_words=None, prefered_method=None): + + results = [] + + if not config.change_NZB_DIR(nzb_dir): + results += ["Unable to create directory " + os.path.normpath(nzb_dir) + ", dir not changed."] + + if not config.change_TORRENT_DIR(torrent_dir): + results += ["Unable to create directory " + os.path.normpath(torrent_dir) + ", dir not changed."] + + config.change_SEARCH_FREQUENCY(search_frequency) + + if download_propers == "on": + download_propers = 1 + else: + download_propers = 0 + + if use_nzbs == "on": + use_nzbs = 1 + else: + use_nzbs = 0 + + if use_torrents == "on": + use_torrents = 1 + else: + use_torrents = 0 + + if usenet_retention == None: + usenet_retention = 200 + + if ignore_words == None: + ignore_words = "" + + sickbeard.USE_NZBS = use_nzbs + sickbeard.USE_TORRENTS = use_torrents + + sickbeard.NZB_METHOD = nzb_method + sickbeard.PREFERED_METHOD = prefered_method + sickbeard.TORRENT_METHOD = torrent_method + sickbeard.USENET_RETENTION = int(usenet_retention) + + sickbeard.IGNORE_WORDS = ignore_words + + sickbeard.DOWNLOAD_PROPERS = download_propers + + sickbeard.SAB_USERNAME = sab_username + sickbeard.SAB_PASSWORD = sab_password + sickbeard.SAB_APIKEY = sab_apikey.strip() + sickbeard.SAB_CATEGORY = sab_category + + if sab_host and not re.match('https?://.*', sab_host): + sab_host = 'http://' + sab_host + + if not sab_host.endswith('/'): + sab_host = sab_host + '/' + + sickbeard.SAB_HOST = sab_host + + sickbeard.NZBGET_PASSWORD = nzbget_password + sickbeard.NZBGET_CATEGORY = nzbget_category + sickbeard.NZBGET_HOST = nzbget_host + + sickbeard.TORRENT_USERNAME = torrent_username + sickbeard.TORRENT_PASSWORD = torrent_password + sickbeard.TORRENT_LABEL = torrent_label + sickbeard.TORRENT_PATH = torrent_path + sickbeard.TORRENT_RATIO = torrent_ratio + if torrent_paused == "on": + torrent_paused = 1 + else: + torrent_paused = 0 + sickbeard.TORRENT_PAUSED = torrent_paused + + if torrent_host and not re.match('https?://.*', torrent_host): + torrent_host = 'http://' + torrent_host + + if not torrent_host.endswith('/'): + torrent_host = torrent_host + '/' + + sickbeard.TORRENT_HOST = torrent_host + + sickbeard.save_config() + + if len(results) > 0: + for x in results: + logger.log(x, logger.ERROR) + ui.notifications.error('Error(s) Saving Configuration', + '
    \n'.join(results)) + else: + ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) + + redirect("/config/search/") + +class ConfigPostProcessing: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="config_postProcessing.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + @cherrypy.expose + def savePostProcessing(self, naming_pattern=None, naming_multi_ep=None, + xbmc_data=None, xbmc__frodo__data=None, mediabrowser_data=None, synology_data=None, sony_ps3_data=None, wdtv_data=None, tivo_data=None, + use_banner=None, keep_processed_dir=None, process_automatically=None, process_automatically_torrent=None, rename_episodes=None, + move_associated_files=None, tv_download_dir=None, torrent_download_dir=None, naming_custom_abd=None, naming_abd_pattern=None): + + results = [] + + if not config.change_TV_DOWNLOAD_DIR(tv_download_dir): + results += ["Unable to create directory " + os.path.normpath(tv_download_dir) + ", dir not changed."] + + if not config.change_TORRENT_DOWNLOAD_DIR(torrent_download_dir): + results += ["Unable to create directory " + os.path.normpath(torrent_download_dir) + ", dir not changed."] + + if use_banner == "on": + use_banner = 1 + else: + use_banner = 0 + + if process_automatically == "on": + process_automatically = 1 + else: + process_automatically = 0 + + if process_automatically_torrent == "on": + process_automatically_torrent = 1 + else: + process_automatically_torrent = 0 + + if rename_episodes == "on": + rename_episodes = 1 + else: + rename_episodes = 0 + + if keep_processed_dir == "on": + keep_processed_dir = 1 + else: + keep_processed_dir = 0 + + if move_associated_files == "on": + move_associated_files = 1 + else: + move_associated_files = 0 + + if naming_custom_abd == "on": + naming_custom_abd = 1 + else: + naming_custom_abd = 0 + + sickbeard.PROCESS_AUTOMATICALLY = process_automatically + sickbeard.PROCESS_AUTOMATICALLY_TORRENT = process_automatically_torrent + sickbeard.KEEP_PROCESSED_DIR = keep_processed_dir + sickbeard.RENAME_EPISODES = rename_episodes + sickbeard.MOVE_ASSOCIATED_FILES = move_associated_files + sickbeard.NAMING_CUSTOM_ABD = naming_custom_abd + + sickbeard.metadata_provider_dict['XBMC'].set_config(xbmc_data) + sickbeard.metadata_provider_dict['XBMC (Frodo)'].set_config(xbmc__frodo__data) + sickbeard.metadata_provider_dict['MediaBrowser'].set_config(mediabrowser_data) + sickbeard.metadata_provider_dict['Synology'].set_config(synology_data) + sickbeard.metadata_provider_dict['Sony PS3'].set_config(sony_ps3_data) + sickbeard.metadata_provider_dict['WDTV'].set_config(wdtv_data) + sickbeard.metadata_provider_dict['TIVO'].set_config(tivo_data) + + if self.isNamingValid(naming_pattern, naming_multi_ep) != "invalid": + sickbeard.NAMING_PATTERN = naming_pattern + sickbeard.NAMING_MULTI_EP = int(naming_multi_ep) + sickbeard.NAMING_FORCE_FOLDERS = naming.check_force_season_folders() + else: + results.append("You tried saving an invalid naming config, not saving your naming settings") + + if self.isNamingValid(naming_abd_pattern, None, True) != "invalid": + sickbeard.NAMING_ABD_PATTERN = naming_abd_pattern + elif naming_custom_abd: + results.append("You tried saving an invalid air-by-date naming config, not saving your air-by-date settings") + + sickbeard.USE_BANNER = use_banner + + sickbeard.save_config() + + if len(results) > 0: + for x in results: + logger.log(x, logger.ERROR) + ui.notifications.error('Error(s) Saving Configuration', + '
    \n'.join(results)) + else: + ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) + + redirect("/config/postProcessing/") + + @cherrypy.expose + def testNaming(self, pattern=None, multi=None, abd=False): + + if multi != None: + multi = int(multi) + + result = naming.test_name(pattern, multi, abd) + + result = ek.ek(os.path.join, result['dir'], result['name']) + + return result + + @cherrypy.expose + def isNamingValid(self, pattern=None, multi=None, abd=False): + if pattern == None: + return "invalid" + + # air by date shows just need one check, we don't need to worry about season folders + if abd: + is_valid = naming.check_valid_abd_naming(pattern) + require_season_folders = False + + else: + # check validity of single and multi ep cases for the whole path + is_valid = naming.check_valid_naming(pattern, multi) + + # check validity of single and multi ep cases for only the file name + require_season_folders = naming.check_force_season_folders(pattern, multi) + + if is_valid and not require_season_folders: + return "valid" + elif is_valid and require_season_folders: + return "seasonfolders" + else: + return "invalid" + + +class ConfigProviders: + + @cherrypy.expose + def index(self): + t = PageTemplate(file="config_providers.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + @cherrypy.expose + def canAddNewznabProvider(self, name): + + if not name: + return json.dumps({'error': 'Invalid name specified'}) + + providerDict = dict(zip([x.getID() for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) + + tempProvider = newznab.NewznabProvider(name, '') + + if tempProvider.getID() in providerDict: + return json.dumps({'error': 'Exists as '+providerDict[tempProvider.getID()].name}) + else: + return json.dumps({'success': tempProvider.getID()}) + + @cherrypy.expose + def saveNewznabProvider(self, name, url, key=''): + + if not name or not url: + return '0' + + if not url.endswith('/'): + url = url + '/' + + providerDict = dict(zip([x.name for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) + + if name in providerDict: + if not providerDict[name].default: + providerDict[name].name = name + providerDict[name].url = url + providerDict[name].key = key + + return providerDict[name].getID() + '|' + providerDict[name].configStr() + + else: + + newProvider = newznab.NewznabProvider(name, url, key) + sickbeard.newznabProviderList.append(newProvider) + return newProvider.getID() + '|' + newProvider.configStr() + + + + @cherrypy.expose + def deleteNewznabProvider(self, id): + + providerDict = dict(zip([x.getID() for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) + + if id not in providerDict or providerDict[id].default: + return '0' + + # delete it from the list + sickbeard.newznabProviderList.remove(providerDict[id]) + + if id in sickbeard.PROVIDER_ORDER: + sickbeard.PROVIDER_ORDER.remove(id) + + return '1' + + + @cherrypy.expose + def saveProviders(self, nzbmatrix_username=None, nzbmatrix_apikey=None, + nzbs_r_us_uid=None, nzbs_r_us_hash=None, newznab_string='', + omgwtfnzbs_uid=None, omgwtfnzbs_key=None, + tvtorrents_digest=None, tvtorrents_hash=None, + torrentleech_key=None, + btn_api_key=None, + newzbin_username=None, newzbin_password=None,t411_username=None,t411_password=None, + gks_key=None, + provider_order=None): + + results = [] + + provider_str_list = provider_order.split() + provider_list = [] + + newznabProviderDict = dict(zip([x.getID() for x in sickbeard.newznabProviderList], sickbeard.newznabProviderList)) + + finishedNames = [] + + # add all the newznab info we got into our list + for curNewznabProviderStr in newznab_string.split('!!!'): + + if not curNewznabProviderStr: + continue + + curName, curURL, curKey = curNewznabProviderStr.split('|') + + newProvider = newznab.NewznabProvider(curName, curURL, curKey) + + curID = newProvider.getID() + + # if it already exists then update it + if curID in newznabProviderDict: + newznabProviderDict[curID].name = curName + newznabProviderDict[curID].url = curURL + newznabProviderDict[curID].key = curKey + else: + sickbeard.newznabProviderList.append(newProvider) + + finishedNames.append(curID) + + # delete anything that is missing + for curProvider in sickbeard.newznabProviderList: + if curProvider.getID() not in finishedNames: + sickbeard.newznabProviderList.remove(curProvider) + + # do the enable/disable + for curProviderStr in provider_str_list: + curProvider, curEnabled = curProviderStr.split(':') + curEnabled = int(curEnabled) + + provider_list.append(curProvider) + + if curProvider == 'nzbs_r_us': + sickbeard.NZBSRUS = curEnabled + elif curProvider == 'nzbs_org_old': + sickbeard.NZBS = curEnabled + elif curProvider == 'nzbmatrix': + sickbeard.NZBMATRIX = curEnabled + elif curProvider == 'newzbin': + sickbeard.NEWZBIN = curEnabled + elif curProvider == 'bin_req': + sickbeard.BINREQ = curEnabled + elif curProvider == 'womble_s_index': + sickbeard.WOMBLE = curEnabled + elif curProvider == 'nzbx': + sickbeard.NZBX = curEnabled + elif curProvider == 'omgwtfnzbs': + sickbeard.OMGWTFNZBS = curEnabled + elif curProvider == 'ezrss': + sickbeard.EZRSS = curEnabled + elif curProvider == 'tvtorrents': + sickbeard.TVTORRENTS = curEnabled + elif curProvider == 'torrentleech': + sickbeard.TORRENTLEECH = curEnabled + elif curProvider == 'btn': + sickbeard.BTN = curEnabled + elif curProvider == 'binnewz': + sickbeard.BINNEWZ = curEnabled + elif curProvider == 't411': + sickbeard.T411 = curEnabled + elif curProvider == 'cpasbien': + sickbeard.Cpasbien = curEnabled + elif curProvider == 'kat': + sickbeard.kat = curEnabled + elif curProvider == 'piratebay': + sickbeard.THEPIRATEBAY = curEnabled + elif curProvider == 'gks': + sickbeard.GKS = curEnabled + elif curProvider in newznabProviderDict: + newznabProviderDict[curProvider].enabled = bool(curEnabled) + else: + logger.log(u"don't know what " + curProvider + " is, skipping") + + sickbeard.TVTORRENTS_DIGEST = tvtorrents_digest.strip() + sickbeard.TVTORRENTS_HASH = tvtorrents_hash.strip() + + sickbeard.TORRENTLEECH_KEY = torrentleech_key.strip() + + sickbeard.BTN_API_KEY = btn_api_key.strip() + + sickbeard.T411_USERNAME = t411_username + sickbeard.T411_PASSWORD = t411_password + + sickbeard.NZBSRUS_UID = nzbs_r_us_uid.strip() + sickbeard.NZBSRUS_HASH = nzbs_r_us_hash.strip() + + sickbeard.OMGWTFNZBS_UID = omgwtfnzbs_uid.strip() + sickbeard.OMGWTFNZBS_KEY = omgwtfnzbs_key.strip() + + sickbeard.GKS_KEY = gks_key.strip() + + sickbeard.PROVIDER_ORDER = provider_list + + sickbeard.save_config() + + if len(results) > 0: + for x in results: + logger.log(x, logger.ERROR) + ui.notifications.error('Error(s) Saving Configuration', + '
    \n'.join(results)) + else: + ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) + + redirect("/config/providers/") + + +class ConfigNotifications: + + @cherrypy.expose + def index(self): + t = PageTemplate(file="config_notifications.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + @cherrypy.expose + def saveNotifications(self, use_xbmc=None, xbmc_notify_onsnatch=None, xbmc_notify_ondownload=None, xbmc_update_onlyfirst=None, xbmc_notify_onsubtitledownload=None, + xbmc_update_library=None, xbmc_update_full=None, xbmc_host=None, xbmc_username=None, xbmc_password=None, + use_plex=None, plex_notify_onsnatch=None, plex_notify_ondownload=None, plex_notify_onsubtitledownload=None, plex_update_library=None, + plex_server_host=None, plex_host=None, plex_username=None, plex_password=None, + use_growl=None, growl_notify_onsnatch=None, growl_notify_ondownload=None, growl_notify_onsubtitledownload=None, growl_host=None, growl_password=None, + use_prowl=None, prowl_notify_onsnatch=None, prowl_notify_ondownload=None, prowl_notify_onsubtitledownload=None, prowl_api=None, prowl_priority=0, + use_twitter=None, twitter_notify_onsnatch=None, twitter_notify_ondownload=None, twitter_notify_onsubtitledownload=None, + use_notifo=None, notifo_notify_onsnatch=None, notifo_notify_ondownload=None, notifo_notify_onsubtitledownload=None, notifo_username=None, notifo_apisecret=None, + use_boxcar=None, boxcar_notify_onsnatch=None, boxcar_notify_ondownload=None, boxcar_notify_onsubtitledownload=None, boxcar_username=None, + use_pushover=None, pushover_notify_onsnatch=None, pushover_notify_ondownload=None, pushover_notify_onsubtitledownload=None, pushover_userkey=None, + use_libnotify=None, libnotify_notify_onsnatch=None, libnotify_notify_ondownload=None, libnotify_notify_onsubtitledownload=None, + use_nmj=None, nmj_host=None, nmj_database=None, nmj_mount=None, use_synoindex=None, + use_nmjv2=None, nmjv2_host=None, nmjv2_dbloc=None, nmjv2_database=None, + use_trakt=None, trakt_username=None, trakt_password=None, trakt_api=None,trakt_remove_watchlist=None,trakt_use_watchlist=None,trakt_start_paused=None,trakt_method_add=None, + use_synologynotifier=None, synologynotifier_notify_onsnatch=None, synologynotifier_notify_ondownload=None, synologynotifier_notify_onsubtitledownload=None, + use_pytivo=None, pytivo_notify_onsnatch=None, pytivo_notify_ondownload=None, pytivo_notify_onsubtitledownload=None, pytivo_update_library=None, + pytivo_host=None, pytivo_share_name=None, pytivo_tivo_name=None, + use_nma=None, nma_notify_onsnatch=None, nma_notify_ondownload=None, nma_notify_onsubtitledownload=None, nma_api=None, nma_priority=0, + use_pushalot=None, pushalot_notify_onsnatch=None, pushalot_notify_ondownload=None, pushalot_notify_onsubtitledownload=None, pushalot_authorizationtoken=None, + use_mail=None, mail_username=None, mail_password=None, mail_server=None, mail_ssl=None, mail_from=None, mail_to=None, mail_notify_onsnatch=None ): + + + + results = [] + + if xbmc_notify_onsnatch == "on": + xbmc_notify_onsnatch = 1 + else: + xbmc_notify_onsnatch = 0 + + if xbmc_notify_ondownload == "on": + xbmc_notify_ondownload = 1 + else: + xbmc_notify_ondownload = 0 + + if xbmc_notify_onsubtitledownload == "on": + xbmc_notify_onsubtitledownload = 1 + else: + xbmc_notify_onsubtitledownload = 0 + + if xbmc_update_library == "on": + xbmc_update_library = 1 + else: + xbmc_update_library = 0 + + if xbmc_update_full == "on": + xbmc_update_full = 1 + else: + xbmc_update_full = 0 + + if xbmc_update_onlyfirst == "on": + xbmc_update_onlyfirst = 1 + else: + xbmc_update_onlyfirst = 0 + + if use_xbmc == "on": + use_xbmc = 1 + else: + use_xbmc = 0 + + if plex_update_library == "on": + plex_update_library = 1 + else: + plex_update_library = 0 + + if plex_notify_onsnatch == "on": + plex_notify_onsnatch = 1 + else: + plex_notify_onsnatch = 0 + + if plex_notify_ondownload == "on": + plex_notify_ondownload = 1 + else: + plex_notify_ondownload = 0 + + if plex_notify_onsubtitledownload == "on": + plex_notify_onsubtitledownload = 1 + else: + plex_notify_onsubtitledownload = 0 + + if use_plex == "on": + use_plex = 1 + else: + use_plex = 0 + + if growl_notify_onsnatch == "on": + growl_notify_onsnatch = 1 + else: + growl_notify_onsnatch = 0 + + if growl_notify_ondownload == "on": + growl_notify_ondownload = 1 + else: + growl_notify_ondownload = 0 + + if growl_notify_onsubtitledownload == "on": + growl_notify_onsubtitledownload = 1 + else: + growl_notify_onsubtitledownload = 0 + + if use_growl == "on": + use_growl = 1 + else: + use_growl = 0 + + if prowl_notify_onsnatch == "on": + prowl_notify_onsnatch = 1 + else: + prowl_notify_onsnatch = 0 + + if prowl_notify_ondownload == "on": + prowl_notify_ondownload = 1 + else: + prowl_notify_ondownload = 0 + + if prowl_notify_onsubtitledownload == "on": + prowl_notify_onsubtitledownload = 1 + else: + prowl_notify_onsubtitledownload = 0 + + if use_prowl == "on": + use_prowl = 1 + else: + use_prowl = 0 + + if twitter_notify_onsnatch == "on": + twitter_notify_onsnatch = 1 + else: + twitter_notify_onsnatch = 0 + + if twitter_notify_ondownload == "on": + twitter_notify_ondownload = 1 + else: + twitter_notify_ondownload = 0 + + if twitter_notify_onsubtitledownload == "on": + twitter_notify_onsubtitledownload = 1 + else: + twitter_notify_onsubtitledownload = 0 + + if use_twitter == "on": + use_twitter = 1 + else: + use_twitter = 0 + + if notifo_notify_onsnatch == "on": + notifo_notify_onsnatch = 1 + else: + notifo_notify_onsnatch = 0 + + if notifo_notify_ondownload == "on": + notifo_notify_ondownload = 1 + else: + notifo_notify_ondownload = 0 + + if notifo_notify_onsubtitledownload == "on": + notifo_notify_onsubtitledownload = 1 + else: + notifo_notify_onsubtitledownload = 0 + + if use_notifo == "on": + use_notifo = 1 + else: + use_notifo = 0 + + if boxcar_notify_onsnatch == "on": + boxcar_notify_onsnatch = 1 + else: + boxcar_notify_onsnatch = 0 + + if boxcar_notify_ondownload == "on": + boxcar_notify_ondownload = 1 + else: + boxcar_notify_ondownload = 0 + + if boxcar_notify_onsubtitledownload == "on": + boxcar_notify_onsubtitledownload = 1 + else: + boxcar_notify_onsubtitledownload = 0 + + if use_boxcar == "on": + use_boxcar = 1 + else: + use_boxcar = 0 + + if pushover_notify_onsnatch == "on": + pushover_notify_onsnatch = 1 + else: + pushover_notify_onsnatch = 0 + + if pushover_notify_ondownload == "on": + pushover_notify_ondownload = 1 + else: + pushover_notify_ondownload = 0 + + if pushover_notify_onsubtitledownload == "on": + pushover_notify_onsubtitledownload = 1 + else: + pushover_notify_onsubtitledownload = 0 + + if use_pushover == "on": + use_pushover = 1 + else: + use_pushover = 0 + + if use_nmj == "on": + use_nmj = 1 + else: + use_nmj = 0 + + if use_synoindex == "on": + use_synoindex = 1 + else: + use_synoindex = 0 + + if use_synologynotifier == "on": + use_synologynotifier = 1 + else: + use_synologynotifier = 0 + + if synologynotifier_notify_onsnatch == "on": + synologynotifier_notify_onsnatch = 1 + else: + synologynotifier_notify_onsnatch = 0 + + if synologynotifier_notify_ondownload == "on": + synologynotifier_notify_ondownload = 1 + else: + synologynotifier_notify_ondownload = 0 + + if synologynotifier_notify_onsubtitledownload == "on": + synologynotifier_notify_onsubtitledownload = 1 + else: + synologynotifier_notify_onsubtitledownload = 0 + + if use_nmjv2 == "on": + use_nmjv2 = 1 + else: + use_nmjv2 = 0 + + if use_trakt == "on": + use_trakt = 1 + else: + use_trakt = 0 + if trakt_remove_watchlist == "on": + trakt_remove_watchlist = 1 + else: + trakt_remove_watchlist = 0 + + if trakt_use_watchlist == "on": + trakt_use_watchlist = 1 + else: + trakt_use_watchlist = 0 + + if trakt_start_paused == "on": + trakt_start_paused = 1 + else: + trakt_start_paused = 0 + + if use_pytivo == "on": + use_pytivo = 1 + else: + use_pytivo = 0 + + if pytivo_notify_onsnatch == "on": + pytivo_notify_onsnatch = 1 + else: + pytivo_notify_onsnatch = 0 + + if pytivo_notify_ondownload == "on": + pytivo_notify_ondownload = 1 + else: + pytivo_notify_ondownload = 0 + + if pytivo_notify_onsubtitledownload == "on": + pytivo_notify_onsubtitledownload = 1 + else: + pytivo_notify_onsubtitledownload = 0 + + if pytivo_update_library == "on": + pytivo_update_library = 1 + else: + pytivo_update_library = 0 + + if use_nma == "on": + use_nma = 1 + else: + use_nma = 0 + + if nma_notify_onsnatch == "on": + nma_notify_onsnatch = 1 + else: + nma_notify_onsnatch = 0 + + if nma_notify_ondownload == "on": + nma_notify_ondownload = 1 + else: + nma_notify_ondownload = 0 + + if nma_notify_onsubtitledownload == "on": + nma_notify_onsubtitledownload = 1 + else: + nma_notify_onsubtitledownload = 0 + + if use_mail == "on": + use_mail = 1 + else: + use_mail = 0 + + if mail_ssl == "on": + mail_ssl = 1 + else: + mail_ssl = 0 + + if mail_notify_onsnatch == "on": + mail_notify_onsnatch = 1 + else: + mail_notify_onsnatch = 0 + + if use_pushalot == "on": + use_pushalot = 1 + else: + use_pushalot = 0 + + if pushalot_notify_onsnatch == "on": + pushalot_notify_onsnatch = 1 + else: + pushalot_notify_onsnatch = 0 + + if pushalot_notify_ondownload == "on": + pushalot_notify_ondownload = 1 + else: + pushalot_notify_ondownload = 0 + + if pushalot_notify_onsubtitledownload == "on": + pushalot_notify_onsubtitledownload = 1 + else: + pushalot_notify_onsubtitledownload = 0 + + + sickbeard.USE_XBMC = use_xbmc + sickbeard.XBMC_NOTIFY_ONSNATCH = xbmc_notify_onsnatch + sickbeard.XBMC_NOTIFY_ONDOWNLOAD = xbmc_notify_ondownload + sickbeard.XBMC_NOTIFY_ONSUBTITLEDOWNLOAD = xbmc_notify_onsubtitledownload + sickbeard.XBMC_UPDATE_LIBRARY = xbmc_update_library + sickbeard.XBMC_UPDATE_FULL = xbmc_update_full + sickbeard.XBMC_UPDATE_ONLYFIRST = xbmc_update_onlyfirst + sickbeard.XBMC_HOST = xbmc_host + sickbeard.XBMC_USERNAME = xbmc_username + sickbeard.XBMC_PASSWORD = xbmc_password + + sickbeard.USE_PLEX = use_plex + sickbeard.PLEX_NOTIFY_ONSNATCH = plex_notify_onsnatch + sickbeard.PLEX_NOTIFY_ONDOWNLOAD = plex_notify_ondownload + sickbeard.PLEX_NOTIFY_ONSUBTITLEDOWNLOAD = plex_notify_onsubtitledownload + sickbeard.PLEX_UPDATE_LIBRARY = plex_update_library + sickbeard.PLEX_HOST = plex_host + sickbeard.PLEX_SERVER_HOST = plex_server_host + sickbeard.PLEX_USERNAME = plex_username + sickbeard.PLEX_PASSWORD = plex_password + + sickbeard.USE_GROWL = use_growl + sickbeard.GROWL_NOTIFY_ONSNATCH = growl_notify_onsnatch + sickbeard.GROWL_NOTIFY_ONDOWNLOAD = growl_notify_ondownload + sickbeard.GROWL_NOTIFY_ONSUBTITLEDOWNLOAD = growl_notify_onsubtitledownload + sickbeard.GROWL_HOST = growl_host + sickbeard.GROWL_PASSWORD = growl_password + + sickbeard.USE_PROWL = use_prowl + sickbeard.PROWL_NOTIFY_ONSNATCH = prowl_notify_onsnatch + sickbeard.PROWL_NOTIFY_ONDOWNLOAD = prowl_notify_ondownload + sickbeard.PROWL_NOTIFY_ONSUBTITLEDOWNLOAD = prowl_notify_onsubtitledownload + sickbeard.PROWL_API = prowl_api + sickbeard.PROWL_PRIORITY = prowl_priority + + sickbeard.USE_TWITTER = use_twitter + sickbeard.TWITTER_NOTIFY_ONSNATCH = twitter_notify_onsnatch + sickbeard.TWITTER_NOTIFY_ONDOWNLOAD = twitter_notify_ondownload + sickbeard.TWITTER_NOTIFY_ONSUBTITLEDOWNLOAD = twitter_notify_onsubtitledownload + + sickbeard.USE_NOTIFO = use_notifo + sickbeard.NOTIFO_NOTIFY_ONSNATCH = notifo_notify_onsnatch + sickbeard.NOTIFO_NOTIFY_ONDOWNLOAD = notifo_notify_ondownload + sickbeard.NOTIFO_NOTIFY_ONSUBTITLEDOWNLOAD = notifo_notify_onsubtitledownload + sickbeard.NOTIFO_USERNAME = notifo_username + sickbeard.NOTIFO_APISECRET = notifo_apisecret + + sickbeard.USE_BOXCAR = use_boxcar + sickbeard.BOXCAR_NOTIFY_ONSNATCH = boxcar_notify_onsnatch + sickbeard.BOXCAR_NOTIFY_ONDOWNLOAD = boxcar_notify_ondownload + sickbeard.BOXCAR_NOTIFY_ONSUBTITLEDOWNLOAD = boxcar_notify_onsubtitledownload + sickbeard.BOXCAR_USERNAME = boxcar_username + + sickbeard.USE_PUSHOVER = use_pushover + sickbeard.PUSHOVER_NOTIFY_ONSNATCH = pushover_notify_onsnatch + sickbeard.PUSHOVER_NOTIFY_ONDOWNLOAD = pushover_notify_ondownload + sickbeard.PUSHOVER_NOTIFY_ONSUBTITLEDOWNLOAD = pushover_notify_onsubtitledownload + sickbeard.PUSHOVER_USERKEY = pushover_userkey + + sickbeard.USE_LIBNOTIFY = use_libnotify == "on" + sickbeard.LIBNOTIFY_NOTIFY_ONSNATCH = libnotify_notify_onsnatch == "on" + sickbeard.LIBNOTIFY_NOTIFY_ONDOWNLOAD = libnotify_notify_ondownload == "on" + sickbeard.LIBNOTIFY_NOTIFY_ONSUBTITLEDOWNLOAD = libnotify_notify_onsubtitledownload == "on" + + sickbeard.USE_NMJ = use_nmj + sickbeard.NMJ_HOST = nmj_host + sickbeard.NMJ_DATABASE = nmj_database + sickbeard.NMJ_MOUNT = nmj_mount + + sickbeard.USE_SYNOINDEX = use_synoindex + + sickbeard.USE_SYNOLOGYNOTIFIER = use_synologynotifier + sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH = synologynotifier_notify_onsnatch + sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD = synologynotifier_notify_ondownload + sickbeard.SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD = synologynotifier_notify_onsubtitledownload + + sickbeard.USE_NMJv2 = use_nmjv2 + sickbeard.NMJv2_HOST = nmjv2_host + sickbeard.NMJv2_DATABASE = nmjv2_database + sickbeard.NMJv2_DBLOC = nmjv2_dbloc + + sickbeard.USE_TRAKT = use_trakt + sickbeard.TRAKT_USERNAME = trakt_username + sickbeard.TRAKT_PASSWORD = trakt_password + sickbeard.TRAKT_API = trakt_api + sickbeard.TRAKT_REMOVE_WATCHLIST = trakt_remove_watchlist + sickbeard.TRAKT_USE_WATCHLIST = trakt_use_watchlist + sickbeard.TRAKT_METHOD_ADD = trakt_method_add + sickbeard.TRAKT_START_PAUSED = trakt_start_paused + + sickbeard.USE_PYTIVO = use_pytivo + sickbeard.PYTIVO_NOTIFY_ONSNATCH = pytivo_notify_onsnatch == "off" + sickbeard.PYTIVO_NOTIFY_ONDOWNLOAD = pytivo_notify_ondownload == "off" + sickbeard.PYTIVO_NOTIFY_ONSUBTITLEDOWNLOAD = pytivo_notify_onsubtitledownload == "off" + sickbeard.PYTIVO_UPDATE_LIBRARY = pytivo_update_library + sickbeard.PYTIVO_HOST = pytivo_host + sickbeard.PYTIVO_SHARE_NAME = pytivo_share_name + sickbeard.PYTIVO_TIVO_NAME = pytivo_tivo_name + + sickbeard.USE_NMA = use_nma + sickbeard.NMA_NOTIFY_ONSNATCH = nma_notify_onsnatch + sickbeard.NMA_NOTIFY_ONDOWNLOAD = nma_notify_ondownload + sickbeard.NMA_NOTIFY_ONSUBTITLEDOWNLOAD = nma_notify_onsubtitledownload + sickbeard.NMA_API = nma_api + sickbeard.NMA_PRIORITY = nma_priority + + sickbeard.USE_MAIL = use_mail + sickbeard.MAIL_USERNAME = mail_username + sickbeard.MAIL_PASSWORD = mail_password + sickbeard.MAIL_SERVER = mail_server + sickbeard.MAIL_SSL = mail_ssl + sickbeard.MAIL_FROM = mail_from + sickbeard.MAIL_TO = mail_to + sickbeard.MAIL_NOTIFY_ONSNATCH = mail_notify_onsnatch + + sickbeard.USE_PUSHALOT = use_pushalot + sickbeard.PUSHALOT_NOTIFY_ONSNATCH = pushalot_notify_onsnatch + sickbeard.PUSHALOT_NOTIFY_ONDOWNLOAD = pushalot_notify_ondownload + sickbeard.PUSHALOT_NOTIFY_ONSUBTITLEDOWNLOAD = pushalot_notify_onsubtitledownload + sickbeard.PUSHALOT_AUTHORIZATIONTOKEN = pushalot_authorizationtoken + + sickbeard.save_config() + + if len(results) > 0: + for x in results: + logger.log(x, logger.ERROR) + ui.notifications.error('Error(s) Saving Configuration', + '
    \n'.join(results)) + else: + ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) + + redirect("/config/notifications/") + +class ConfigSubtitles: + + @cherrypy.expose + def index(self): + t = PageTemplate(file="config_subtitles.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + @cherrypy.expose + def saveSubtitles(self, use_subtitles=None, subtitles_plugins=None, subtitles_languages=None, subtitles_dir=None, subtitles_dir_sub=None, subsnolang = None, service_order=None, subtitles_history=None): + results = [] + + if use_subtitles == "on": + use_subtitles = 1 + if sickbeard.subtitlesFinderScheduler.thread == None or not sickbeard.subtitlesFinderScheduler.thread.isAlive(): + sickbeard.subtitlesFinderScheduler.initThread() + else: + use_subtitles = 0 + sickbeard.subtitlesFinderScheduler.abort = True + logger.log(u"Waiting for the SUBTITLESFINDER thread to exit") + try: + sickbeard.subtitlesFinderScheduler.thread.join(5) + except: + pass + + if subtitles_history == "on": + subtitles_history = 1 + else: + subtitles_history = 0 + + if subtitles_dir_sub == "on": + subtitles_dir_sub = 1 + else: + subtitles_dir_sub = 0 + + if subsnolang == "on": + subsnolang = 1 + else: + subsnolang = 0 + + sickbeard.USE_SUBTITLES = use_subtitles + sickbeard.SUBTITLES_LANGUAGES = [lang.alpha2 for lang in subtitles.isValidLanguage(subtitles_languages.replace(' ', '').split(','))] if subtitles_languages != '' else '' + sickbeard.SUBTITLES_DIR = subtitles_dir + sickbeard.SUBTITLES_DIR_SUB = subtitles_dir_sub + sickbeard.SUBSNOLANG = subsnolang + sickbeard.SUBTITLES_HISTORY = subtitles_history + + # Subtitles services + services_str_list = service_order.split() + subtitles_services_list = [] + subtitles_services_enabled = [] + for curServiceStr in services_str_list: + curService, curEnabled = curServiceStr.split(':') + subtitles_services_list.append(curService) + subtitles_services_enabled.append(int(curEnabled)) + + sickbeard.SUBTITLES_SERVICES_LIST = subtitles_services_list + sickbeard.SUBTITLES_SERVICES_ENABLED = subtitles_services_enabled + + sickbeard.save_config() + + if len(results) > 0: + for x in results: + logger.log(x, logger.ERROR) + ui.notifications.error('Error(s) Saving Configuration', + '
    \n'.join(results)) + else: + ui.notifications.message('Configuration Saved', ek.ek(os.path.join, sickbeard.CONFIG_FILE) ) + + redirect("/config/subtitles/") + +class Config: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="config.tmpl") + t.submenu = ConfigMenu + return _munge(t) + + general = ConfigGeneral() + + search = ConfigSearch() + + postProcessing = ConfigPostProcessing() + + providers = ConfigProviders() + + notifications = ConfigNotifications() + + subtitles = ConfigSubtitles() + +def haveXBMC(): + return sickbeard.USE_XBMC and sickbeard.XBMC_UPDATE_LIBRARY + +def havePLEX(): + return sickbeard.USE_PLEX and sickbeard.PLEX_UPDATE_LIBRARY + +def HomeMenu(): + return [ + { 'title': 'Add Shows', 'path': 'home/addShows/', }, + { 'title': 'Manual Post-Processing', 'path': 'home/postprocess/' }, + { 'title': 'Update XBMC', 'path': 'home/updateXBMC/', 'requires': haveXBMC }, + { 'title': 'Update Plex', 'path': 'home/updatePLEX/', 'requires': havePLEX }, + { 'title': 'Restart', 'path': 'home/restart/?pid='+str(sickbeard.PID), 'confirm': True }, + { 'title': 'Shutdown', 'path': 'home/shutdown/?pid='+str(sickbeard.PID), 'confirm': True }, + ] + +class HomePostProcess: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="home_postprocess.tmpl") + t.submenu = HomeMenu() + return _munge(t) + + @cherrypy.expose + def processEpisode(self, dir=None, nzbName=None, jobName=None, quiet=None): + + if not dir: + redirect("/home/postprocess") + else: + result = processTV.processDir(dir, nzbName) + if quiet != None and int(quiet) == 1: + return result + + result = result.replace("\n","
    \n") + return _genericMessage("Postprocessing results", result) + + +class NewHomeAddShows: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="home_addShows.tmpl") + t.submenu = HomeMenu() + return _munge(t) + + @cherrypy.expose + def getTVDBLanguages(self): + result = tvdb_api.Tvdb().config['valid_languages'] + + # Make sure list is sorted alphabetically but 'fr' is in front + if 'fr' in result: + del result[result.index('fr')] + result.sort() + result.insert(0, 'fr') + + return json.dumps({'results': result}) + + @cherrypy.expose + def sanitizeFileName(self, name): + return helpers.sanitizeFileName(name) + + @cherrypy.expose + def searchTVDBForShowName(self, name, lang="fr"): + if not lang or lang == 'null': + lang = "fr" + + baseURL = "http://thetvdb.com/api/GetSeries.php?" + nameUTF8 = name.encode('utf-8') + + logger.log(u"Trying to find Show on thetvdb.com with: " + nameUTF8.decode('utf-8'), logger.DEBUG) + + # Use each word in the show's name as a possible search term + keywords = nameUTF8.split(' ') + + # Insert the whole show's name as the first search term so best results are first + # ex: keywords = ['Some Show Name', 'Some', 'Show', 'Name'] + if len(keywords) > 1: + keywords.insert(0, nameUTF8) + + # Query the TVDB for each search term and build the list of results + results = [] + + for searchTerm in keywords: + params = {'seriesname': searchTerm, + 'language': lang} + + finalURL = baseURL + urllib.urlencode(params) + + logger.log(u"Searching for Show with searchterm: \'" + searchTerm.decode('utf-8') + u"\' on URL " + finalURL, logger.DEBUG) + urlData = helpers.getURL(finalURL) + + if urlData is None: + # When urlData is None, trouble connecting to TVDB, don't try the rest of the keywords + logger.log(u"Unable to get URL: " + finalURL, logger.ERROR) + break + else: + try: + seriesXML = etree.ElementTree(etree.XML(urlData)) + series = seriesXML.getiterator('Series') + + except Exception, e: + # use finalURL in log, because urlData can be too much information + logger.log(u"Unable to parse XML for some reason: " + ex(e) + " from XML: " + finalURL, logger.ERROR) + series = '' + + # add each result to our list + for curSeries in series: + tvdb_id = int(curSeries.findtext('seriesid')) + + # don't add duplicates + if tvdb_id in [x[0] for x in results]: + continue + + results.append((tvdb_id, curSeries.findtext('SeriesName'), curSeries.findtext('FirstAired'))) + + lang_id = tvdb_api.Tvdb().config['langabbv_to_id'][lang] + + return json.dumps({'results': results, 'langid': lang_id}) + + @cherrypy.expose + def massAddTable(self, rootDir=None): + t = PageTemplate(file="home_massAddTable.tmpl") + t.submenu = HomeMenu() + + myDB = db.DBConnection() + + if not rootDir: + return "No folders selected." + elif type(rootDir) != list: + root_dirs = [rootDir] + else: + root_dirs = rootDir + + root_dirs = [urllib.unquote_plus(x) for x in root_dirs] + + default_index = int(sickbeard.ROOT_DIRS.split('|')[0]) + if len(root_dirs) > default_index: + tmp = root_dirs[default_index] + if tmp in root_dirs: + root_dirs.remove(tmp) + root_dirs = [tmp]+root_dirs + + dir_list = [] + + for root_dir in root_dirs: + try: + file_list = ek.ek(os.listdir, root_dir) + except: + continue + + for cur_file in file_list: + + cur_path = ek.ek(os.path.normpath, ek.ek(os.path.join, root_dir, cur_file)) + if not ek.ek(os.path.isdir, cur_path): + continue + + cur_dir = { + 'dir': cur_path, + 'display_dir': ''+ek.ek(os.path.dirname, cur_path)+os.sep+''+ek.ek(os.path.basename, cur_path), + } + + # see if the folder is in XBMC already + dirResults = myDB.select("SELECT * FROM tv_shows WHERE location = ?", [cur_path]) + + if dirResults: + cur_dir['added_already'] = True + else: + cur_dir['added_already'] = False + + dir_list.append(cur_dir) + + tvdb_id = '' + show_name = '' + for cur_provider in sickbeard.metadata_provider_dict.values(): + (tvdb_id, show_name) = cur_provider.retrieveShowMetadata(cur_path) + if tvdb_id and show_name: + break + + cur_dir['existing_info'] = (tvdb_id, show_name) + + if tvdb_id and helpers.findCertainShow(sickbeard.showList, tvdb_id): + cur_dir['added_already'] = True + + t.dirList = dir_list + + return _munge(t) + + @cherrypy.expose + def newShow(self, show_to_add=None, other_shows=None): + """ + Display the new show page which collects a tvdb id, folder, and extra options and + posts them to addNewShow + """ + t = PageTemplate(file="home_newShow.tmpl") + t.submenu = HomeMenu() + + show_dir, tvdb_id, show_name = self.split_extra_show(show_to_add) + + if tvdb_id and show_name: + use_provided_info = True + else: + use_provided_info = False + + # tell the template whether we're giving it show name & TVDB ID + t.use_provided_info = use_provided_info + + # use the given show_dir for the tvdb search if available + if not show_dir: + t.default_show_name = '' + elif not show_name: + t.default_show_name = ek.ek(os.path.basename, ek.ek(os.path.normpath, show_dir)).replace('.',' ') + else: + t.default_show_name = show_name + + # carry a list of other dirs if given + if not other_shows: + other_shows = [] + elif type(other_shows) != list: + other_shows = [other_shows] + + if use_provided_info: + t.provided_tvdb_id = tvdb_id + t.provided_tvdb_name = show_name + + t.provided_show_dir = show_dir + t.other_shows = other_shows + + return _munge(t) + + @cherrypy.expose + def addNewShow(self, whichSeries=None, tvdbLang="fr", rootDir=None, defaultStatus=None, + anyQualities=None, bestQualities=None, flatten_folders=None, subtitles=None, fullShowPath=None, + other_shows=None, skipShow=None, audio_lang=None): + """ + Receive tvdb id, dir, and other options and create a show from them. If extra show dirs are + provided then it forwards back to newShow, if not it goes to /home. + """ + + # grab our list of other dirs if given + if not other_shows: + other_shows = [] + elif type(other_shows) != list: + other_shows = [other_shows] + + def finishAddShow(): + # if there are no extra shows then go home + if not other_shows: + redirect('/home') + + # peel off the next one + next_show_dir = other_shows[0] + rest_of_show_dirs = other_shows[1:] + + # go to add the next show + return self.newShow(next_show_dir, rest_of_show_dirs) + + # if we're skipping then behave accordingly + if skipShow: + return finishAddShow() + + # sanity check on our inputs + if (not rootDir and not fullShowPath) or not whichSeries: + return "Missing params, no tvdb id or folder:"+repr(whichSeries)+" and "+repr(rootDir)+"/"+repr(fullShowPath) + + # figure out what show we're adding and where + series_pieces = whichSeries.partition('|') + if len(series_pieces) < 3: + return "Error with show selection." + + tvdb_id = int(series_pieces[0]) + show_name = series_pieces[2] + + # use the whole path if it's given, or else append the show name to the root dir to get the full show path + if fullShowPath: + show_dir = ek.ek(os.path.normpath, fullShowPath) + else: + show_dir = ek.ek(os.path.join, rootDir, helpers.sanitizeFileName(show_name)) + + # blanket policy - if the dir exists you should have used "add existing show" numbnuts + if ek.ek(os.path.isdir, show_dir) and not fullShowPath: + ui.notifications.error("Unable to add show", "Folder "+show_dir+" exists already") + redirect('/home/addShows/existingShows') + + # don't create show dir if config says not to + if sickbeard.ADD_SHOWS_WO_DIR: + logger.log(u"Skipping initial creation of "+show_dir+" due to config.ini setting") + else: + dir_exists = helpers.makeDir(show_dir) + if not dir_exists: + logger.log(u"Unable to create the folder "+show_dir+", can't add the show", logger.ERROR) + ui.notifications.error("Unable to add show", "Unable to create the folder "+show_dir+", can't add the show") + redirect("/home") + else: + helpers.chmodAsParent(show_dir) + + # prepare the inputs for passing along + if flatten_folders == "on": + flatten_folders = 1 + else: + flatten_folders = 0 + + if subtitles == "on": + subtitles = 1 + else: + subtitles = 0 + + if not anyQualities: + anyQualities = [] + if not bestQualities: + bestQualities = [] + if type(anyQualities) != list: + anyQualities = [anyQualities] + if type(bestQualities) != list: + bestQualities = [bestQualities] + newQuality = Quality.combineQualities(map(int, anyQualities), map(int, bestQualities)) + + # add the show + sickbeard.showQueueScheduler.action.addShow(tvdb_id, show_dir, int(defaultStatus), newQuality, flatten_folders, tvdbLang, subtitles, audio_lang) #@UndefinedVariable + ui.notifications.message('Show added', 'Adding the specified show into '+show_dir) + + return finishAddShow() + + + @cherrypy.expose + def existingShows(self): + """ + Prints out the page to add existing shows from a root dir + """ + t = PageTemplate(file="home_addExistingShow.tmpl") + t.submenu = HomeMenu() + + return _munge(t) + + def split_extra_show(self, extra_show): + if not extra_show: + return (None, None, None) + split_vals = extra_show.split('|') + if len(split_vals) < 3: + return (extra_show, None, None) + show_dir = split_vals[0] + tvdb_id = split_vals[1] + show_name = '|'.join(split_vals[2:]) + + return (show_dir, tvdb_id, show_name) + + @cherrypy.expose + def addExistingShows(self, shows_to_add=None, promptForSettings=None): + """ + Receives a dir list and add them. Adds the ones with given TVDB IDs first, then forwards + along to the newShow page. + """ + + # grab a list of other shows to add, if provided + if not shows_to_add: + shows_to_add = [] + elif type(shows_to_add) != list: + shows_to_add = [shows_to_add] + + shows_to_add = [urllib.unquote_plus(x) for x in shows_to_add] + + if promptForSettings == "on": + promptForSettings = 1 + else: + promptForSettings = 0 + + tvdb_id_given = [] + dirs_only = [] + # separate all the ones with TVDB IDs + for cur_dir in shows_to_add: + if not '|' in cur_dir: + dirs_only.append(cur_dir) + else: + show_dir, tvdb_id, show_name = self.split_extra_show(cur_dir) + if not show_dir or not tvdb_id or not show_name: + continue + tvdb_id_given.append((show_dir, int(tvdb_id), show_name)) + + + # if they want me to prompt for settings then I will just carry on to the newShow page + if promptForSettings and shows_to_add: + return self.newShow(shows_to_add[0], shows_to_add[1:]) + + # if they don't want me to prompt for settings then I can just add all the nfo shows now + num_added = 0 + for cur_show in tvdb_id_given: + show_dir, tvdb_id, show_name = cur_show + + # add the show + sickbeard.showQueueScheduler.action.addShow(tvdb_id, show_dir, SKIPPED, sickbeard.QUALITY_DEFAULT, sickbeard.FLATTEN_FOLDERS_DEFAULT, sickbeard.SUBTITLES_DEFAULT) #@UndefinedVariable + num_added += 1 + + if num_added: + ui.notifications.message("Shows Added", "Automatically added "+str(num_added)+" from their existing metadata files") + + # if we're done then go home + if not dirs_only: + redirect('/home') + + # for the remaining shows we need to prompt for each one, so forward this on to the newShow page + return self.newShow(dirs_only[0], dirs_only[1:]) + + + + +ErrorLogsMenu = [ + { 'title': 'Clear Errors', 'path': 'errorlogs/clearerrors' }, + #{ 'title': 'View Log', 'path': 'errorlogs/viewlog' }, +] + + +class ErrorLogs: + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="errorlogs.tmpl") + t.submenu = ErrorLogsMenu + + return _munge(t) + + + @cherrypy.expose + def clearerrors(self): + classes.ErrorViewer.clear() + redirect("/errorlogs") + + @cherrypy.expose + def viewlog(self, minLevel=logger.MESSAGE, maxLines=500): + + t = PageTemplate(file="viewlogs.tmpl") + t.submenu = ErrorLogsMenu + + minLevel = int(minLevel) + + data = [] + if os.path.isfile(logger.sb_log_instance.log_file): + f = open(logger.sb_log_instance.log_file) + data = f.readlines() + f.close() + + regex = "^(\w+).?\-(\d\d)\s+(\d\d)\:(\d\d):(\d\d)\s+([A-Z]+)\s+(.*)$" + + finalData = [] + + numLines = 0 + lastLine = False + numToShow = min(maxLines, len(data)) + + for x in reversed(data): + + x = x.decode('utf-8') + match = re.match(regex, x) + + if match: + level = match.group(6) + if level not in logger.reverseNames: + lastLine = False + continue + + if logger.reverseNames[level] >= minLevel: + lastLine = True + finalData.append(x) + else: + lastLine = False + continue + + elif lastLine: + finalData.append("AA"+x) + + numLines += 1 + + if numLines >= numToShow: + break + + result = "".join(finalData) + + t.logLines = result + t.minLevel = minLevel + + return _munge(t) + + +class Home: + + @cherrypy.expose + def is_alive(self, *args, **kwargs): + if 'callback' in kwargs and '_' in kwargs: + callback, _ = kwargs['callback'], kwargs['_'] + else: + return "Error: Unsupported Request. Send jsonp request with 'callback' variable in the query stiring." + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + cherrypy.response.headers['Content-Type'] = 'text/javascript' + cherrypy.response.headers['Access-Control-Allow-Origin'] = '*' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'x-requested-with' + + if sickbeard.started: + return callback+'('+json.dumps({"msg": str(sickbeard.PID)})+');' + else: + return callback+'('+json.dumps({"msg": "nope"})+');' + + @cherrypy.expose + def index(self): + + t = PageTemplate(file="home.tmpl") + t.submenu = HomeMenu() + return _munge(t) + + addShows = NewHomeAddShows() + + postprocess = HomePostProcess() + + @cherrypy.expose + def testSABnzbd(self, host=None, username=None, password=None, apikey=None): + if not host.endswith("/"): + host = host + "/" + connection, accesMsg = sab.getSabAccesMethod(host, username, password, apikey) + if connection: + authed, authMsg = sab.testAuthentication(host, username, password, apikey) #@UnusedVariable + if authed: + return "Success. Connected and authenticated" + else: + return "Authentication failed. SABnzbd expects '"+accesMsg+"' as authentication method" + else: + return "Unable to connect to host" + + @cherrypy.expose + def testTorrent(self, torrent_method=None, host=None, username=None, password=None): + if not host.endswith("/"): + host = host + "/" + + client = clients.getClientIstance(torrent_method) + + connection, accesMsg = client(host, username, password).testAuthentication() + + return accesMsg + + @cherrypy.expose + def testGrowl(self, host=None, password=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.growl_notifier.test_notify(host, password) + if password==None or password=='': + pw_append = '' + else: + pw_append = " with password: " + password + + if result: + return "Registered and Tested growl successfully "+urllib.unquote_plus(host)+pw_append + else: + return "Registration and Testing of growl failed "+urllib.unquote_plus(host)+pw_append + + @cherrypy.expose + def testProwl(self, prowl_api=None, prowl_priority=0): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.prowl_notifier.test_notify(prowl_api, prowl_priority) + if result: + return "Test prowl notice sent successfully" + else: + return "Test prowl notice failed" + + @cherrypy.expose + def testNotifo(self, username=None, apisecret=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.notifo_notifier.test_notify(username, apisecret) + if result: + return "Notifo notification succeeded. Check your Notifo clients to make sure it worked" + else: + return "Error sending Notifo notification" + + @cherrypy.expose + def testBoxcar(self, username=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.boxcar_notifier.test_notify(username) + if result: + return "Boxcar notification succeeded. Check your Boxcar clients to make sure it worked" + else: + return "Error sending Boxcar notification" + + @cherrypy.expose + def testPushover(self, userKey=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.pushover_notifier.test_notify(userKey) + if result: + return "Pushover notification succeeded. Check your Pushover clients to make sure it worked" + else: + return "Error sending Pushover notification" + + @cherrypy.expose + def twitterStep1(self): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + return notifiers.twitter_notifier._get_authorization() + + @cherrypy.expose + def twitterStep2(self, key): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.twitter_notifier._get_credentials(key) + logger.log(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" + + result = notifiers.twitter_notifier.test_notify() + if result: + return "Tweet successful, check your twitter to make sure it worked" + else: + return "Error sending tweet" + + @cherrypy.expose + def testXBMC(self, host=None, username=None, password=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + finalResult = '' + for curHost in [x.strip() for x in host.split(",")]: + curResult = notifiers.xbmc_notifier.test_notify(urllib.unquote_plus(curHost), username, password) + if len(curResult.split(":")) > 2 and 'OK' in curResult.split(":")[2]: + finalResult += "Test XBMC notice sent successfully to " + urllib.unquote_plus(curHost) + else: + finalResult += "Test XBMC notice failed to " + urllib.unquote_plus(curHost) + finalResult += "
    \n" + + return finalResult + + @cherrypy.expose + def testPLEX(self, host=None, username=None, password=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + finalResult = '' + for curHost in [x.strip() for x in host.split(",")]: + curResult = notifiers.plex_notifier.test_notify(urllib.unquote_plus(curHost), username, password) + if len(curResult.split(":")) > 2 and 'OK' in curResult.split(":")[2]: + finalResult += "Test Plex notice sent successfully to " + urllib.unquote_plus(curHost) + else: + finalResult += "Test Plex notice failed to " + urllib.unquote_plus(curHost) + finalResult += "
    \n" + + return finalResult + + @cherrypy.expose + def testLibnotify(self): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + if notifiers.libnotify_notifier.test_notify(): + return "Tried sending desktop notification via libnotify" + else: + return notifiers.libnotify.diagnose() + + @cherrypy.expose + def testNMJ(self, host=None, database=None, mount=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.nmj_notifier.test_notify(urllib.unquote_plus(host), database, mount) + if result: + return "Successfull started the scan update" + else: + return "Test failed to start the scan update" + + @cherrypy.expose + def settingsNMJ(self, host=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.nmj_notifier.notify_settings(urllib.unquote_plus(host)) + if result: + return '{"message": "Got settings from %(host)s", "database": "%(database)s", "mount": "%(mount)s"}' % {"host": host, "database": sickbeard.NMJ_DATABASE, "mount": sickbeard.NMJ_MOUNT} + else: + return '{"message": "Failed! Make sure your Popcorn is on and NMJ is running. (see Log & Errors -> Debug for detailed info)", "database": "", "mount": ""}' + + @cherrypy.expose + def testNMJv2(self, host=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.nmjv2_notifier.test_notify(urllib.unquote_plus(host)) + if result: + return "Test notice sent successfully to " + urllib.unquote_plus(host) + else: + return "Test notice failed to " + urllib.unquote_plus(host) + + @cherrypy.expose + def settingsNMJv2(self, host=None, dbloc=None, instance=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + result = notifiers.nmjv2_notifier.notify_settings(urllib.unquote_plus(host), dbloc, instance) + if result: + return '{"message": "NMJ Database found at: %(host)s", "database": "%(database)s"}' % {"host": host, "database": sickbeard.NMJv2_DATABASE} + else: + return '{"message": "Unable to find NMJ Database at location: %(dbloc)s. Is the right location selected and PCH running?", "database": ""}' % {"dbloc": dbloc} + + @cherrypy.expose + def testTrakt(self, api=None, username=None, password=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.trakt_notifier.test_notify(api, username, password) + if result: + return "Test notice sent successfully to Trakt" + else: + return "Test notice failed to Trakt" + + @cherrypy.expose + def testMail(self, mail_from=None, mail_to=None, mail_server=None, mail_ssl=None, mail_user=None, mail_password=None): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.mail_notifier.test_notify(mail_from, mail_to, mail_server, mail_ssl, mail_user, mail_password) + if result: + return "Mail sent" + else: + return "Can't sent mail." + + @cherrypy.expose + def testNMA(self, nma_api=None, nma_priority=0): + cherrypy.response.headers['Cache-Control'] = "max-age=0,no-cache,no-store" + + result = notifiers.nma_notifier.test_notify(nma_api, nma_priority) + if result: + return "Test NMA notice sent successfully" + else: + return "Test NMA notice failed" + + @cherrypy.expose + def shutdown(self, pid=None): + + if str(pid) != str(sickbeard.PID): + redirect("/home") + + threading.Timer(2, sickbeard.invoke_shutdown).start() + + title = "Shutting down" + message = "Sick Beard is shutting down..." + + return _genericMessage(title, message) + + @cherrypy.expose + def restart(self, pid=None): + + if str(pid) != str(sickbeard.PID): + redirect("/home") + + t = PageTemplate(file="restart.tmpl") + t.submenu = HomeMenu() + + # do a soft restart + threading.Timer(2, sickbeard.invoke_restart, [False]).start() + + return _munge(t) + + @cherrypy.expose + def update(self, pid=None): + + if str(pid) != str(sickbeard.PID): + redirect("/home") + + updated = sickbeard.versionCheckScheduler.action.update() #@UndefinedVariable + + if updated: + # do a hard restart + threading.Timer(2, sickbeard.invoke_restart, [False]).start() + t = PageTemplate(file="restart_bare.tmpl") + return _munge(t) + else: + return _genericMessage("Update Failed","Update wasn't successful, not restarting. Check your log for more information.") + + @cherrypy.expose + def displayShow(self, show=None): + + if show == None: + return _genericMessage("Error", "Invalid show ID") + else: + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Show not in show list") + + myDB = db.DBConnection() + + seasonResults = myDB.select( + "SELECT DISTINCT season FROM tv_episodes WHERE showid = ? ORDER BY season desc", + [showObj.tvdbid] + ) + + sqlResults = myDB.select( + "SELECT * FROM tv_episodes WHERE showid = ? ORDER BY season DESC, episode DESC", + [showObj.tvdbid] + ) + + t = PageTemplate(file="displayShow.tmpl") + t.submenu = [ { 'title': 'Edit', 'path': 'home/editShow?show=%d'%showObj.tvdbid } ] + + try: + t.showLoc = (showObj.location, True) + except sickbeard.exceptions.ShowDirNotFoundException: + t.showLoc = (showObj._location, False) + + show_message = '' + + if sickbeard.showQueueScheduler.action.isBeingAdded(showObj): #@UndefinedVariable + show_message = 'This show is in the process of being downloaded from theTVDB.com - the info below is incomplete.' + + elif sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): #@UndefinedVariable + show_message = 'The information below is in the process of being updated.' + + elif sickbeard.showQueueScheduler.action.isBeingRefreshed(showObj): #@UndefinedVariable + show_message = 'The episodes below are currently being refreshed from disk' + + elif sickbeard.showQueueScheduler.action.isBeingSubtitled(showObj): #@UndefinedVariable + show_message = 'Currently downloading subtitles for this show' + + elif sickbeard.showQueueScheduler.action.isInRefreshQueue(showObj): #@UndefinedVariable + show_message = 'This show is queued to be refreshed.' + + elif sickbeard.showQueueScheduler.action.isInUpdateQueue(showObj): #@UndefinedVariable + show_message = 'This show is queued and awaiting an update.' + + elif sickbeard.showQueueScheduler.action.isInSubtitleQueue(showObj): #@UndefinedVariable + show_message = 'This show is queued and awaiting subtitles download.' + + if not sickbeard.showQueueScheduler.action.isBeingAdded(showObj): #@UndefinedVariable + if not sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): #@UndefinedVariable + t.submenu.append({ 'title': 'Delete', 'path': 'home/deleteShow?show=%d'%showObj.tvdbid, 'confirm': True }) + t.submenu.append({ 'title': 'Re-scan files', 'path': 'home/refreshShow?show=%d'%showObj.tvdbid }) + t.submenu.append({ 'title': 'Force Full Update', 'path': 'home/updateShow?show=%d&force=1'%showObj.tvdbid }) + t.submenu.append({ 'title': 'Update show in XBMC', 'path': 'home/updateXBMC?showName=%s'%urllib.quote_plus(showObj.name.encode('utf-8')), 'requires': haveXBMC }) + t.submenu.append({ 'title': 'Preview Rename', 'path': 'home/testRename?show=%d'%showObj.tvdbid }) + if sickbeard.USE_SUBTITLES and not sickbeard.showQueueScheduler.action.isBeingSubtitled(showObj) and showObj.subtitles: + t.submenu.append({ 'title': 'Download Subtitles', 'path': 'home/subtitleShow?show=%d'%showObj.tvdbid }) + + t.show = showObj + t.sqlResults = sqlResults + t.seasonResults = seasonResults + t.show_message = show_message + + epCounts = {} + epCats = {} + epCounts[Overview.SKIPPED] = 0 + epCounts[Overview.WANTED] = 0 + epCounts[Overview.QUAL] = 0 + epCounts[Overview.GOOD] = 0 + epCounts[Overview.UNAIRED] = 0 + epCounts[Overview.SNATCHED] = 0 + + for curResult in sqlResults: + + curEpCat = showObj.getOverview(int(curResult["status"])) + epCats[str(curResult["season"])+"x"+str(curResult["episode"])] = curEpCat + epCounts[curEpCat] += 1 + + def titler(x): + if not x: + return x + if x.lower().startswith('a '): + x = x[2:] + elif x.lower().startswith('the '): + x = x[4:] + return x + t.sortedShowList = sorted(sickbeard.showList, lambda x, y: cmp(titler(x.name), titler(y.name))) + + t.epCounts = epCounts + t.epCats = epCats + + return _munge(t) + + @cherrypy.expose + def plotDetails(self, show, season, episode): + result = db.DBConnection().action("SELECT description FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ?", (show, season, episode)).fetchone() + return result['description'] if result else 'Episode not found.' + + @cherrypy.expose + def editShow(self, show=None, location=None, anyQualities=[], bestQualities=[], exceptions_list=[], flatten_folders=None, paused=None, directCall=False, air_by_date=None, tvdbLang=None, audio_lang=None, custom_search_names=None, subtitles=None): + + if show == None: + errString = "Invalid show ID: "+str(show) + if directCall: + return [errString] + else: + return _genericMessage("Error", errString) + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + errString = "Unable to find the specified show: "+str(show) + if directCall: + return [errString] + else: + return _genericMessage("Error", errString) + + showObj.exceptions = scene_exceptions.get_scene_exceptions(showObj.tvdbid) + + if not location and not anyQualities and not bestQualities and not flatten_folders: + + t = PageTemplate(file="editShow.tmpl") + t.submenu = HomeMenu() + with showObj.lock: + t.show = showObj + + return _munge(t) + + if flatten_folders == "on": + flatten_folders = 1 + else: + flatten_folders = 0 + + logger.log(u"flatten folders: "+str(flatten_folders)) + + if paused == "on": + paused = 1 + else: + paused = 0 + + if air_by_date == "on": + air_by_date = 1 + else: + air_by_date = 0 + + if subtitles == "on": + subtitles = 1 + else: + subtitles = 0 + + + if tvdbLang and tvdbLang in tvdb_api.Tvdb().config['valid_languages']: + tvdb_lang = tvdbLang + else: + tvdb_lang = showObj.lang + + # if we changed the language then kick off an update + if tvdb_lang == showObj.lang: + do_update = False + else: + do_update = True + + if type(anyQualities) != list: + anyQualities = [anyQualities] + + if type(bestQualities) != list: + bestQualities = [bestQualities] + + if type(exceptions_list) != list: + exceptions_list = [exceptions_list] + + errors = [] + with showObj.lock: + newQuality = Quality.combineQualities(map(int, anyQualities), map(int, bestQualities)) + showObj.quality = newQuality + + # reversed for now + if bool(showObj.flatten_folders) != bool(flatten_folders): + showObj.flatten_folders = flatten_folders + try: + sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable + except exceptions.CantRefreshException, e: + errors.append("Unable to refresh this show: "+ex(e)) + + showObj.paused = paused + showObj.air_by_date = air_by_date + showObj.subtitles = subtitles + showObj.lang = tvdb_lang + showObj.audio_lang = audio_lang + showObj.custom_search_names = custom_search_names + + # if we change location clear the db of episodes, change it, write to db, and rescan + if os.path.normpath(showObj._location) != os.path.normpath(location): + logger.log(os.path.normpath(showObj._location)+" != "+os.path.normpath(location), logger.DEBUG) + if not ek.ek(os.path.isdir, location): + errors.append("New location %s does not exist" % location) + + # don't bother if we're going to update anyway + elif not do_update: + # change it + try: + showObj.location = location + try: + sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable + except exceptions.CantRefreshException, e: + errors.append("Unable to refresh this show:"+ex(e)) + # grab updated info from TVDB + #showObj.loadEpisodesFromTVDB() + # rescan the episodes in the new folder + except exceptions.NoNFOException: + errors.append("The folder at %s doesn't contain a tvshow.nfo - copy your files to that folder before you change the directory in Sick Beard." % location) + + # save it to the DB + showObj.saveToDB() + + # force the update + if do_update: + try: + sickbeard.showQueueScheduler.action.updateShow(showObj, True) #@UndefinedVariable + time.sleep(1) + except exceptions.CantUpdateException, e: + errors.append("Unable to force an update on the show.") + + if directCall: + return errors + + if len(errors) > 0: + ui.notifications.error('%d error%s while saving changes:' % (len(errors), "" if len(errors) == 1 else "s"), + '
      ' + '\n'.join(['
    • %s
    • ' % error for error in errors]) + "
    ") + + redirect("/home/displayShow?show=" + show) + + @cherrypy.expose + def deleteShow(self, show=None): + + if show == None: + return _genericMessage("Error", "Invalid show ID") + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Unable to find the specified show") + + if sickbeard.showQueueScheduler.action.isBeingAdded(showObj) or sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): #@UndefinedVariable + return _genericMessage("Error", "Shows can't be deleted while they're being added or updated.") + + showObj.deleteShow() + + ui.notifications.message('%s has been deleted' % showObj.name) + redirect("/home") + + @cherrypy.expose + def refreshShow(self, show=None): + + if show == None: + return _genericMessage("Error", "Invalid show ID") + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Unable to find the specified show") + + # force the update from the DB + try: + sickbeard.showQueueScheduler.action.refreshShow(showObj) #@UndefinedVariable + except exceptions.CantRefreshException, e: + ui.notifications.error("Unable to refresh this show.", + ex(e)) + + time.sleep(3) + + redirect("/home/displayShow?show="+str(showObj.tvdbid)) + + @cherrypy.expose + def updateShow(self, show=None, force=0): + + if show == None: + return _genericMessage("Error", "Invalid show ID") + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Unable to find the specified show") + + # force the update + try: + sickbeard.showQueueScheduler.action.updateShow(showObj, bool(force)) #@UndefinedVariable + except exceptions.CantUpdateException, e: + ui.notifications.error("Unable to update this show.", + ex(e)) + + # just give it some time + time.sleep(3) + + redirect("/home/displayShow?show=" + str(showObj.tvdbid)) + + @cherrypy.expose + def subtitleShow(self, show=None, force=0): + + if show == None: + return _genericMessage("Error", "Invalid show ID") + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Unable to find the specified show") + + # search and download subtitles + sickbeard.showQueueScheduler.action.downloadSubtitles(showObj, bool(force)) #@UndefinedVariable + + time.sleep(3) + + redirect("/home/displayShow?show="+str(showObj.tvdbid)) + + + @cherrypy.expose + def updateXBMC(self, showName=None): + if sickbeard.XBMC_UPDATE_ONLYFIRST: + # only send update to first host in the list -- workaround for xbmc sql backend users + host = sickbeard.XBMC_HOST.split(",")[0].strip() + else: + host = sickbeard.XBMC_HOST + + if notifiers.xbmc_notifier.update_library(showName=showName): + ui.notifications.message("Library update command sent to XBMC host(s): " + host) + else: + ui.notifications.error("Unable to contact one or more XBMC host(s): " + host) + redirect('/home') + + @cherrypy.expose + def updatePLEX(self): + if notifiers.plex_notifier.update_library(): + ui.notifications.message("Library update command sent to Plex Media Server host: " + sickbeard.PLEX_SERVER_HOST) + else: + ui.notifications.error("Unable to contact Plex Media Server host: " + sickbeard.PLEX_SERVER_HOST) + redirect('/home') + + @cherrypy.expose + def setStatus(self, show=None, eps=None, status=None, direct=False): + + if show == None or eps == None or status == None: + errMsg = "You must specify a show and at least one episode" + if direct: + ui.notifications.error('Error', errMsg) + return json.dumps({'result': 'error'}) + else: + return _genericMessage("Error", errMsg) + + if not statusStrings.has_key(int(status)): + errMsg = "Invalid status" + if direct: + ui.notifications.error('Error', errMsg) + return json.dumps({'result': 'error'}) + else: + return _genericMessage("Error", errMsg) + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + errMsg = "Error", "Show not in show list" + if direct: + ui.notifications.error('Error', errMsg) + return json.dumps({'result': 'error'}) + else: + return _genericMessage("Error", errMsg) + + segment_list = [] + + if eps != None: + + for curEp in eps.split('|'): + + logger.log(u"Attempting to set status on episode "+curEp+" to "+status, logger.DEBUG) + + epInfo = curEp.split('x') + + epObj = showObj.getEpisode(int(epInfo[0]), int(epInfo[1])) + + if int(status) == WANTED: + # figure out what segment the episode is in and remember it so we can backlog it + if epObj.show.air_by_date: + ep_segment = str(epObj.airdate)[:7] + else: + ep_segment = epObj.season + + if ep_segment not in segment_list: + segment_list.append(ep_segment) + + if epObj == None: + return _genericMessage("Error", "Episode couldn't be retrieved") + + with epObj.lock: + # don't let them mess up UNAIRED episodes + if epObj.status == UNAIRED: + logger.log(u"Refusing to change status of "+curEp+" because it is UNAIRED", logger.ERROR) + continue + + if int(status) in Quality.DOWNLOADED and epObj.status not in Quality.SNATCHED + Quality.SNATCHED_PROPER + Quality.DOWNLOADED + [IGNORED] and not ek.ek(os.path.isfile, epObj.location): + logger.log(u"Refusing to change status of "+curEp+" to DOWNLOADED because it's not SNATCHED/DOWNLOADED", logger.ERROR) + continue + + epObj.status = int(status) + epObj.saveToDB() + + msg = "Backlog was automatically started for the following seasons of "+showObj.name+":
    " + for cur_segment in segment_list: + msg += "
  • Season "+str(cur_segment)+"
  • " + logger.log(u"Sending backlog for "+showObj.name+" season "+str(cur_segment)+" because some eps were set to wanted") + cur_backlog_queue_item = search_queue.BacklogQueueItem(showObj, cur_segment) + sickbeard.searchQueueScheduler.action.add_item(cur_backlog_queue_item) #@UndefinedVariable + msg += "" + + if segment_list: + ui.notifications.message("Backlog started", msg) + + if direct: + return json.dumps({'result': 'success'}) + else: + redirect("/home/displayShow?show=" + show) + + @cherrypy.expose + def setAudio(self, show=None, eps=None, audio_langs=None, direct=False): + + if show == None or eps == None or audio_langs == None: + errMsg = "You must specify a show and at least one episode" + if direct: + ui.notifications.error('Error', errMsg) + return json.dumps({'result': 'error'}) + else: + return _genericMessage("Error", errMsg) + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Show not in show list") + + try: + show_loc = showObj.location #@UnusedVariable + except exceptions.ShowDirNotFoundException: + return _genericMessage("Error", "Can't rename episodes when the show dir is missing.") + + ep_obj_rename_list = [] + + for curEp in eps.split('|'): + + logger.log(u"Attempting to set audio on episode "+curEp+" to "+audio_langs, logger.DEBUG) + + epInfo = curEp.split('x') + + epObj = showObj.getEpisode(int(epInfo[0]), int(epInfo[1])) + + epObj.audio_langs = str(audio_langs) + epObj.saveToDB() + + if direct: + return json.dumps({'result': 'success'}) + else: + redirect("/home/displayShow?show=" + show) + + @cherrypy.expose + def testRename(self, show=None): + + if show == None: + return _genericMessage("Error", "You must specify a show") + + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj == None: + return _genericMessage("Error", "Show not in show list") + + try: + show_loc = showObj.location #@UnusedVariable + except exceptions.ShowDirNotFoundException: + return _genericMessage("Error", "Can't rename episodes when the show dir is missing.") + + ep_obj_rename_list = [] + + ep_obj_list = showObj.getAllEpisodes(has_location=True) + + for cur_ep_obj in ep_obj_list: + # Only want to rename if we have a location + if cur_ep_obj.location: + if cur_ep_obj.relatedEps: + # do we have one of multi-episodes in the rename list already + have_already = False + for cur_related_ep in cur_ep_obj.relatedEps + [cur_ep_obj]: + if cur_related_ep in ep_obj_rename_list: + have_already = True + break + if not have_already: + ep_obj_rename_list.append(cur_ep_obj) + + else: + ep_obj_rename_list.append(cur_ep_obj) + + if ep_obj_rename_list: + # present season DESC episode DESC on screen + ep_obj_rename_list.reverse() + + t = PageTemplate(file="testRename.tmpl") + t.submenu = [{'title': 'Edit', 'path': 'home/editShow?show=%d' % showObj.tvdbid}] + t.ep_obj_list = ep_obj_rename_list + t.show = showObj + + return _munge(t) + + @cherrypy.expose + def doRename(self, show=None, eps=None): + + if show == None or eps == None: + errMsg = "You must specify a show and at least one episode" + return _genericMessage("Error", errMsg) + + show_obj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if show_obj == None: + errMsg = "Error", "Show not in show list" + return _genericMessage("Error", errMsg) + + try: + show_loc = show_obj.location #@UnusedVariable + except exceptions.ShowDirNotFoundException: + return _genericMessage("Error", "Can't rename episodes when the show dir is missing.") + + myDB = db.DBConnection() + + if eps == None: + redirect("/home/displayShow?show=" + show) + + for curEp in eps.split('|'): + + epInfo = curEp.split('x') + + # this is probably the worst possible way to deal with double eps but I've kinda painted myself into a corner here with this stupid database + ep_result = myDB.select("SELECT * FROM tv_episodes WHERE showid = ? AND season = ? AND episode = ? AND 5=5", [show, epInfo[0], epInfo[1]]) + if not ep_result: + logger.log(u"Unable to find an episode for "+curEp+", skipping", logger.WARNING) + continue + related_eps_result = myDB.select("SELECT * FROM tv_episodes WHERE location = ? AND episode != ?", [ep_result[0]["location"], epInfo[1]]) + + root_ep_obj = show_obj.getEpisode(int(epInfo[0]), int(epInfo[1])) + for cur_related_ep in related_eps_result: + related_ep_obj = show_obj.getEpisode(int(cur_related_ep["season"]), int(cur_related_ep["episode"])) + if related_ep_obj not in root_ep_obj.relatedEps: + root_ep_obj.relatedEps.append(related_ep_obj) + + root_ep_obj.rename() + + redirect("/home/displayShow?show=" + show) + + @cherrypy.expose + def trunchistory(self, epid): + + myDB = db.DBConnection() + nbep = myDB.select("Select count(*) from episode_links where episode_id=?",[epid]) + myDB.action("DELETE from episode_links where episode_id=?",[epid]) + messnum = str(nbep[0][0]) + ' history links deleted' + ui.notifications.message('Episode History Truncated' , messnum) + return json.dumps({'result': 'ok'}) + + @cherrypy.expose + def searchEpisode(self, show=None, season=None, episode=None): + + # retrieve the episode object and fail if we can't get one + ep_obj = _getEpisode(show, season, episode) + if isinstance(ep_obj, str): + return json.dumps({'result': 'failure'}) + + # make a queue item for it and put it on the queue + ep_queue_item = search_queue.ManualSearchQueueItem(ep_obj) + sickbeard.searchQueueScheduler.action.add_item(ep_queue_item) #@UndefinedVariable + + # wait until the queue item tells us whether it worked or not + while ep_queue_item.success == None: #@UndefinedVariable + time.sleep(1) + + # return the correct json value + if ep_queue_item.success: + return json.dumps({'result': statusStrings[ep_obj.status]}) + + return json.dumps({'result': 'failure'}) + + @cherrypy.expose + def searchEpisodeSubtitles(self, show=None, season=None, episode=None): + + # retrieve the episode object and fail if we can't get one + ep_obj = _getEpisode(show, season, episode) + if isinstance(ep_obj, str): + return json.dumps({'result': 'failure'}) + + # try do download subtitles for that episode + previous_subtitles = ep_obj.subtitles + try: + subtitles = ep_obj.downloadSubtitles() + + if sickbeard.SUBTITLES_DIR: + for video in subtitles: + subs_new_path = ek.ek(os.path.join, os.path.dirname(video.path), sickbeard.SUBTITLES_DIR) + dir_exists = helpers.makeDir(subs_new_path) + if not dir_exists: + logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) + else: + helpers.chmodAsParent(subs_new_path) + + for subtitle in subtitles.get(video): + new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) + helpers.moveFile(subtitle.path, new_file_path) + if sickbeard.SUBSNOLANG: + helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") + helpers.chmodAsParent(new_file_path[:-6]+"srt") + helpers.chmodAsParent(new_file_path) + else: + if sickbeard.SUBTITLES_DIR_SUB: + for video in subtitles: + subs_new_path = os.path.join(os.path.dirname(video.path),"Subs") + dir_exists = helpers.makeDir(subs_new_path) + if not dir_exists: + logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) + else: + helpers.chmodAsParent(subs_new_path) + + for subtitle in subtitles.get(video): + new_file_path = ek.ek(os.path.join, subs_new_path, os.path.basename(subtitle.path)) + helpers.moveFile(subtitle.path, new_file_path) + if sickbeard.SUBSNOLANG: + helpers.copyFile(new_file_path,new_file_path[:-6]+"srt") + helpers.chmodAsParent(new_file_path[:-6]+"srt") + helpers.chmodAsParent(new_file_path) + else: + for video in subtitles: + for subtitle in subtitles.get(video): + if sickbeard.SUBSNOLANG: + helpers.copyFile(subtitle.path,subtitle.path[:-6]+"srt") + helpers.chmodAsParent(subtitle.path[:-6]+"srt") + helpers.chmodAsParent(subtitle.path) + except: + return json.dumps({'result': 'failure'}) + + # return the correct json value + if previous_subtitles != ep_obj.subtitles: + status = 'New subtitles downloaded: %s' % ' '.join([""+subliminal.language.Language(x).name+"" for x in sorted(list(set(ep_obj.subtitles).difference(previous_subtitles)))]) + else: + status = 'No subtitles downloaded' + ui.notifications.message('Subtitles Search', status) + return json.dumps({'result': status, 'subtitles': ','.join([x for x in ep_obj.subtitles])}) + + @cherrypy.expose + def mergeEpisodeSubtitles(self, show=None, season=None, episode=None): + + # retrieve the episode object and fail if we can't get one + ep_obj = _getEpisode(show, season, episode) + if isinstance(ep_obj, str): + return json.dumps({'result': 'failure'}) + + # try do merge subtitles for that episode + try: + ep_obj.mergeSubtitles() + except Exception as e: + return json.dumps({'result': 'failure', 'exception': str(e)}) + + # return the correct json value + status = 'Subtitles merged successfully ' + ui.notifications.message('Merge Subtitles', status) + return json.dumps({'result': 'ok'}) + +class UI: + + @cherrypy.expose + def add_message(self): + + ui.notifications.message('Test 1', 'This is test number 1') + ui.notifications.error('Test 2', 'This is test number 2') + + return "ok" + + @cherrypy.expose + def get_messages(self): + messages = {} + cur_notification_num = 1 + for cur_notification in ui.notifications.get_notifications(): + messages['notification-'+str(cur_notification_num)] = {'title': cur_notification.title, + 'message': cur_notification.message, + 'type': cur_notification.type} + cur_notification_num += 1 + + return json.dumps(messages) + + +class WebInterface: + + @cherrypy.expose + def index(self): + + redirect("/home") + + @cherrypy.expose + def showPoster(self, show=None, which=None): + + #Redirect initial poster/banner thumb to default images + if which[0:6] == 'poster': + default_image_name = 'poster.png' + else: + default_image_name = 'banner.png' + + default_image_path = ek.ek(os.path.join, sickbeard.PROG_DIR, 'data', 'images', default_image_name) + if show is None: + return cherrypy.lib.static.serve_file(default_image_path, content_type="image/png") + else: + showObj = sickbeard.helpers.findCertainShow(sickbeard.showList, int(show)) + + if showObj is None: + return cherrypy.lib.static.serve_file(default_image_path, content_type="image/png") + + cache_obj = image_cache.ImageCache() + + if which == 'poster': + image_file_name = cache_obj.poster_path(showObj.tvdbid) + if which == 'poster_thumb': + image_file_name = cache_obj.poster_thumb_path(showObj.tvdbid) + if which == 'banner': + image_file_name = cache_obj.banner_path(showObj.tvdbid) + if which == 'banner_thumb': + image_file_name = cache_obj.banner_thumb_path(showObj.tvdbid) + + if ek.ek(os.path.isfile, image_file_name): + return cherrypy.lib.static.serve_file(image_file_name, content_type="image/jpeg") + else: + return cherrypy.lib.static.serve_file(default_image_path, content_type="image/png") + @cherrypy.expose + def setHomeLayout(self, layout): + + if layout not in ('poster', 'banner', 'simple'): + layout = 'poster' + + sickbeard.HOME_LAYOUT = layout + + redirect("/home") + @cherrypy.expose + def toggleDisplayShowSpecials(self, show): + + sickbeard.DISPLAY_SHOW_SPECIALS = not sickbeard.DISPLAY_SHOW_SPECIALS + + redirect("/home/displayShow?show=" + show) + + @cherrypy.expose + def setComingEpsLayout(self, layout): + if layout not in ('poster', 'banner', 'list'): + layout = 'banner' + + sickbeard.COMING_EPS_LAYOUT = layout + + redirect("/comingEpisodes") + + @cherrypy.expose + def toggleComingEpsDisplayPaused(self): + + sickbeard.COMING_EPS_DISPLAY_PAUSED = not sickbeard.COMING_EPS_DISPLAY_PAUSED + + redirect("/comingEpisodes") + + @cherrypy.expose + def setComingEpsSort(self, sort): + if sort not in ('date', 'network', 'show'): + sort = 'date' + + sickbeard.COMING_EPS_SORT = sort + + redirect("/comingEpisodes") + + @cherrypy.expose + def comingEpisodes(self, layout="None"): + + # get local timezone and load network timezones + sb_timezone = tz.tzlocal() + network_dict = network_timezones.load_network_dict() + + myDB = db.DBConnection() + + today1 = datetime.date.today() + today = today1.toordinal() + next_week1 = (datetime.date.today() + datetime.timedelta(days=7)) + next_week = next_week1.toordinal() + recently = (datetime.date.today() - datetime.timedelta(days=sickbeard.COMING_EPS_MISSED_RANGE)).toordinal() + + done_show_list = [] + qualList = Quality.DOWNLOADED + Quality.SNATCHED + [ARCHIVED, IGNORED] + sql_results1 = myDB.select("SELECT *, 0 as localtime, tv_shows.status as show_status FROM tv_episodes, tv_shows WHERE season != 0 AND airdate >= ? AND airdate < ? AND tv_shows.tvdb_id = tv_episodes.showid AND tv_episodes.status NOT IN ("+','.join(['?']*len(qualList))+")", [today, next_week] + qualList) + for cur_result in sql_results1: + done_show_list.append(helpers.tryInt(cur_result["showid"])) + + more_sql_results = myDB.select("SELECT *, tv_shows.status as show_status FROM tv_episodes outer_eps, tv_shows WHERE season != 0 AND showid NOT IN ("+','.join(['?']*len(done_show_list))+") AND tv_shows.tvdb_id = outer_eps.showid AND airdate IN (SELECT airdate FROM tv_episodes inner_eps WHERE inner_eps.showid = outer_eps.showid AND inner_eps.airdate >= ? AND inner_eps.status NOT IN ("+','.join(['?']*len(Quality.DOWNLOADED+Quality.SNATCHED))+") ORDER BY inner_eps.airdate ASC LIMIT 1)", done_show_list + [next_week] + Quality.DOWNLOADED + Quality.SNATCHED) + sql_results1 += more_sql_results + + more_sql_results = myDB.select("SELECT *, 0 as localtime, tv_shows.status as show_status FROM tv_episodes, tv_shows WHERE season != 0 AND tv_shows.tvdb_id = tv_episodes.showid AND airdate < ? AND airdate >= ? AND tv_episodes.status = ? AND tv_episodes.status NOT IN ("+','.join(['?']*len(qualList))+")", [today, recently, WANTED] + qualList) + sql_results1 += more_sql_results + + # sort by localtime + sorts = { + 'date': (lambda x, y: cmp(x["localtime"], y["localtime"])), + 'show': (lambda a, b: cmp((a["show_name"], a["localtime"]), (b["show_name"], b["localtime"]))), + 'network': (lambda a, b: cmp((a["network"], a["localtime"]), (b["network"], b["localtime"]))), + } + + # make a dict out of the sql results + sql_results = [dict(row) for row in sql_results1] + + # regex to parse time (12/24 hour format) + time_regex = re.compile(r"(\d{1,2}):(\d{2,2})( [PA]M)?\b", flags=re.IGNORECASE) + + # add localtime to the dict + for index, item in enumerate(sql_results1): + mo = time_regex.search(item['airs']) + if mo != None and len(mo.groups()) >= 2: + try: + hr = helpers.tryInt(mo.group(1)) + m = helpers.tryInt(mo.group(2)) + ap = mo.group(3) + # convert am/pm to 24 hour clock + if ap != None: + if ap.lower() == u" pm" and hr != 12: + hr += 12 + elif ap.lower() == u" am" and hr == 12: + hr -= 12 + except: + hr = 0 + m = 0 + else: + hr = 0 + m = 0 + if hr < 0 or hr > 23 or m < 0 or m > 59: + hr = 0 + m = 0 + + te = datetime.datetime.fromordinal(helpers.tryInt(item['airdate'])) + foreign_timezone = network_timezones.get_network_timezone(item['network'], network_dict, sb_timezone) + foreign_naive = datetime.datetime(te.year, te.month, te.day, hr, m,tzinfo=foreign_timezone) + sql_results[index]['localtime'] = foreign_naive.astimezone(sb_timezone) + + #Normalize/Format the Airing Time + try: + locale.setlocale(locale.LC_TIME, 'us_US') + sql_results[index]['localtime_string'] = sql_results[index]['localtime'].strftime("%A %H:%M %p") + locale.setlocale(locale.LC_ALL, '') #Reseting to default locale + except: + sql_results[index]['localtime_string'] = sql_results[index]['localtime'].strftime("%A %H:%M %p") + + sql_results.sort(sorts[sickbeard.COMING_EPS_SORT]) + + t = PageTemplate(file="comingEpisodes.tmpl") +# paused_item = { 'title': '', 'path': 'toggleComingEpsDisplayPaused' } +# paused_item['title'] = 'Hide Paused' if sickbeard.COMING_EPS_DISPLAY_PAUSED else 'Show Paused' + paused_item = { 'title': 'View Paused:', 'path': {'': ''} } + paused_item['path'] = {'Hide': 'toggleComingEpsDisplayPaused'} if sickbeard.COMING_EPS_DISPLAY_PAUSED else {'Show': 'toggleComingEpsDisplayPaused'} + t.submenu = [ + { 'title': 'Sort by:', 'path': {'Date': 'setComingEpsSort/?sort=date', + 'Show': 'setComingEpsSort/?sort=show', + 'Network': 'setComingEpsSort/?sort=network', + }}, + + { 'title': 'Layout:', 'path': {'Banner': 'setComingEpsLayout/?layout=banner', + 'Poster': 'setComingEpsLayout/?layout=poster', + 'List': 'setComingEpsLayout/?layout=list', + }}, + paused_item, + ] + + t.next_week = datetime.datetime.combine(next_week1, datetime.time(tzinfo=sb_timezone)) + t.today = datetime.datetime.now().replace(tzinfo=sb_timezone) + t.sql_results = sql_results + + # Allow local overriding of layout parameter + if layout and layout in ('poster', 'banner', 'list'): + t.layout = layout + else: + t.layout = sickbeard.COMING_EPS_LAYOUT + + + return _munge(t) + + # Raw iCalendar implementation by Pedro Jose Pereira Vieito (@pvieito). + # + # iCalendar (iCal) - Standard RFC 5545 + # Works with iCloud, Google Calendar and Outlook. + @cherrypy.expose + def calendar(self): + """ Provides a subscribeable URL for iCal subscriptions + """ + + logger.log(u"Receiving iCal request from %s" % cherrypy.request.remote.ip) + + poster_url = cherrypy.url().replace('ical', '') + + time_re = re.compile('([0-9]{1,2})\:([0-9]{2})(\ |)([AM|am|PM|pm]{2})') + + # Create a iCal string + ical = 'BEGIN:VCALENDAR\n' + ical += 'VERSION:2.0\n' + ical += 'PRODID://Sick-Beard Upcoming Episodes//\n' + + # Get shows info + myDB = db.DBConnection() + + # Limit dates + past_date = (datetime.date.today() + datetime.timedelta(weeks=-52)).toordinal() + future_date = (datetime.date.today() + datetime.timedelta(weeks=52)).toordinal() + + # Get all the shows that are not paused and are currently on air (from kjoconnor Fork) + calendar_shows = myDB.select("SELECT show_name, tvdb_id, network, airs, runtime FROM tv_shows WHERE status = 'Continuing' AND paused != '1'") + for show in calendar_shows: + # Get all episodes of this show airing between today and next month + episode_list = myDB.select("SELECT tvdbid, name, season, episode, description, airdate FROM tv_episodes WHERE airdate >= ? AND airdate < ? AND showid = ?", (past_date, future_date, int(show["tvdb_id"]))) + + for episode in episode_list: + + # Get local timezone and load network timezones + local_zone = tz.tzlocal() + try: + network_zone = network_timezones.get_network_timezone(show['network'], network_timezones.load_network_dict(), local_zone) + except: + # Dummy network_zone for exceptions + network_zone = None + + # Get the air date and time + air_date = datetime.datetime.fromordinal(int(episode['airdate'])) + air_time = re.compile('([0-9]{1,2})\:([0-9]{2})(\ |)([AM|am|PM|pm]{2})').search(show["airs"]) + + # Parse out the air time + try: + if (air_time.group(4).lower() == 'pm' and int(air_time.group(1)) == 12): + t = datetime.time(12, int(air_time.group(2)), 0, tzinfo=network_zone) + elif (air_time.group(4).lower() == 'pm'): + t = datetime.time((int(air_time.group(1)) + 12), int(air_time.group(2)), 0, tzinfo=network_zone) + elif (air_time.group(4).lower() == 'am' and int(air_time.group(1)) == 12): + t = datetime.time(0, int(air_time.group(2)), 0, tzinfo=network_zone) + else: + t = datetime.time(int(air_time.group(1)), int(air_time.group(2)), 0, tzinfo=network_zone) + except: + # Dummy time for exceptions + t = datetime.time(22, 0, 0, tzinfo=network_zone) + + # Combine air time and air date into one datetime object + air_date_time = datetime.datetime.combine(air_date, t).astimezone(local_zone) + + # Create event for episode + ical = ical + 'BEGIN:VEVENT\n' + ical = ical + 'DTSTART:' + str(air_date_time.date()).replace("-", "") + '\n' + ical = ical + 'SUMMARY:' + show['show_name'] + ': ' + episode['name'] + '\n' + ical = ical + 'UID:' + str(datetime.date.today().isoformat()) + '-' + str(random.randint(10000,99999)) + '@Sick-Beard\n' + if (episode['description'] != ''): + ical = ical + 'DESCRIPTION:' + show['airs'] + ' on ' + show['network'] + '\\n\\n' + episode['description'] + '\n' + else: + ical = ical + 'DESCRIPTION:' + show['airs'] + ' on ' + show['network'] + '\n' + ical = ical + 'LOCATION:' + 'Episode ' + str(episode['episode']) + ' - Season ' + str(episode['season']) + '\n' + ical = ical + 'END:VEVENT\n' + + # Ending the iCal + ical += 'END:VCALENDAR\n' + + return ical + + manage = Manage() + + history = History() + + config = Config() + + home = Home() + + api = Api() + + browser = browser.WebFileBrowser() + + errorlogs = ErrorLogs() + + ui = UI() From 56d98fdb37627270def86e754899aa97432caee8 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 26 May 2013 01:45:55 +0200 Subject: [PATCH 060/492] handles poster position if subs are on or not --- data/css/default.css | 23 ++++++++++++++++++++- data/interfaces/default/comingEpisodes.tmpl | 2 +- data/interfaces/default/displayShow.tmpl | 5 ++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/data/css/default.css b/data/css/default.css index 2a5ba73b72..0e13e07edb 100644 --- a/data/css/default.css +++ b/data/css/default.css @@ -251,7 +251,7 @@ a{ float: right; height: auto; margin-bottom: 0px; - margin-top: 73px; + margin-top: 45px; margin-right: 0px; overflow: hidden; text-indent: -3000px; @@ -262,6 +262,27 @@ a{ min-width: 100%; position: relative; } +.tvshowImgsubs { + background: url("../images/loading.gif") no-repeat scroll center center #ffffff; + border: 5px solid #FFFFFF; + -moz-box-shadow: 1px 1px 2px 0 #555555; + -webkit-box-shadow: 1px 1px 2px 0 #555555; + -o-box-shadow: 1px 1px 2px 0 #555555; + box-shadow: 1px 1px 2px 0 #555555; + float: right; + height: auto; + margin-bottom: 0px; + margin-top: 73px; + margin-right: 0px; + overflow: hidden; + text-indent: -3000px; + width: 200px; +} +.tvshowImgsubs img { + float: right; + min-width: 100%; + position: relative; +} /* --------------------------------------------- */ table { margin: 0; diff --git a/data/interfaces/default/comingEpisodes.tmpl b/data/interfaces/default/comingEpisodes.tmpl index 05b64d7c1f..5ddff47af9 100644 --- a/data/interfaces/default/comingEpisodes.tmpl +++ b/data/interfaces/default/comingEpisodes.tmpl @@ -284,7 +284,7 @@
    #if $layout == 'banner': diff --git a/data/interfaces/default/displayShow.tmpl b/data/interfaces/default/displayShow.tmpl index 1c87c6dd64..1186faed76 100644 --- a/data/interfaces/default/displayShow.tmpl +++ b/data/interfaces/default/displayShow.tmpl @@ -215,9 +215,12 @@ +#if $sickbeard.USE_SUBTITLES +
    +#else
    +#end if
    - #set $curSeason = -1 #set $odd = 0 From 0dd375fb8c3fa65c929ff2978329fa3c072e99c5 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 26 May 2013 01:56:23 +0200 Subject: [PATCH 061/492] trakt thread only starts if use trakt is enabled --- sickbeard/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 6896846437..39dae4e1f0 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -1042,7 +1042,8 @@ def start(): subtitlesFinderScheduler.thread.start() # start the trakt watchlist - traktWatchListCheckerSchedular.thread.start() + if USE_TRAKT: + traktWatchListCheckerSchedular.thread.start() started = True From 9ce1a7cdc4f92574f8e1c3541a7662a135405533 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 26 May 2013 02:44:36 +0200 Subject: [PATCH 062/492] auto show update at start --- sickbeard/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 39dae4e1f0..7d2c90bc82 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -928,7 +928,7 @@ def initialize(consoleLogging=True): # the interval for this is stored inside the ShowUpdater class showUpdaterInstance = showUpdater.ShowUpdater() - if UPDATE_SHOWS_ON_START == True: + if UPDATE_SHOWS_ON_START: showUpdateScheduler = scheduler.Scheduler(showUpdaterInstance, cycleTime=showUpdaterInstance.updateInterval, threadName="SHOWUPDATER", @@ -1044,6 +1044,13 @@ def start(): # start the trakt watchlist if USE_TRAKT: traktWatchListCheckerSchedular.thread.start() + if UPDATE_SHOWS_ON_START: + myDB = db.DBConnection() + listshow=myDB.select("SELECT tvdb_id from tv_shows") + for show in listshow: + showObj = helpers.findCertainShow(showList, show[0]) + if showObj: + showQueueScheduler.action.updateShow(showObj, True) started = True From 3badbffb820ce971076c50a3fc918c8215c0ae00 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 26 May 2013 03:32:49 +0200 Subject: [PATCH 063/492] added ui message when being refreshed --- data/interfaces/default/home.tmpl | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index 49b1d20c64..f83af3abf4 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -1,7 +1,7 @@ #import sickbeard #import datetime #from sickbeard.common import * -#from sickbeard import db +#from sickbeard import db,helpers #set global $title="Home" #set global $header="Show List" @@ -19,6 +19,29 @@ #set $fr = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'fr' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") #set $layout = $sickbeard.HOME_LAYOUT #set $sort = $sickbeard.SORT_ARTICLE +#set $show_message='' +#set $listshow=myDB.select("SELECT tvdb_id from tv_shows") +#for show in $listshow: + #set $showObj = helpers.findCertainShow(sickbeard.showList, show[0]) + #if $showObj: + #if sickbeard.showQueueScheduler.action.isInUpdateQueue($showObj): + #set $show_message = 'Objects are being refreshed. When finished, all images and imdb infos will be available. ' + #break + #end if + #end if +#end for +#for show in $listshow: + #set $showObj = helpers.findCertainShow(sickbeard.showList, show[0]) + #if $showObj: + #if sickbeard.showQueueScheduler.action.isBeingUpdated(showObj): + #set $show_message += ' Current show being processed : '+ showObj.name + #break + #end if + #end if +#end for +#if $show_message: +
    $show_message
    +#end if #include $os.path.join($sickbeard.PROG_DIR,"data/interfaces/default/inc_bottom.tmpl") From 62f5c9f9c2ecfb1f85934e85c993c694e7fcc700 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 26 May 2013 14:01:12 +0200 Subject: [PATCH 069/492] added update on top menu and corrected french ep ui bar --- data/interfaces/default/home.tmpl | 2 +- data/interfaces/default/inc_top.tmpl | 5 +++-- sickbeard/webserve.py | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index 3aff230cfd..c09b732b6f 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -293,7 +293,7 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name)) value: parseInt($nomfr) * 100 / parseInt($denfr)/1000 }); \$("\#progressbar2$curShow.tvdbid").append( "
    $frStat
    " ) - var progressBar2Width = parseInt($nom) * 100 / parseInt($den) + "%" + var progressBar2Width = parseInt($nomfr) * 100 / parseInt($denfr) + "%" \$("\#progressbar2$curShow.tvdbid > .ui-progressbar-value").animate({ width: progressBar2Width }, 1000); }); //--> diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index 2842e68db2..f2f59eaa23 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -72,8 +72,9 @@ #if $varExists('header') -

    $header

    +

    $header

    #else -

    $title

    +

    $title

    #end if - -
    +
    +#if sickbeard.TOGGLE_SEARCH=='True': + + Toggle search + #else: + + Toggle search + #end if +
    +
    Layout: Poster · Banner · - Simple + Simple
    - +
    - #if $layout=="poster" then "" else ""# + #if $layout=="poster" then "" else ""# + #if ($layout == 'poster'): + #else: + + #end if @@ -185,7 +223,9 @@ + #if ($layout == 'poster'): + #end if #end for @@ -242,7 +282,6 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name)) #else if $layout == 'banner': - #else if $layout == 'simple': - #end if diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 7d2c90bc82..84882d2b81 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -390,6 +390,7 @@ SUBTITLES_HISTORY = False DISPLAY_POSTERS = None +TOGGLE_SEARCH = False EXTRA_SCRIPTS = [] @@ -450,7 +451,7 @@ def initialize(consoleLogging=True): NEWZBIN, NEWZBIN_USERNAME, NEWZBIN_PASSWORD, GIT_PATH, MOVE_ASSOCIATED_FILES, \ GKS, GKS_KEY, \ HOME_LAYOUT, DISPLAY_SHOW_SPECIALS, COMING_EPS_LAYOUT, COMING_EPS_SORT, COMING_EPS_DISPLAY_PAUSED, COMING_EPS_MISSED_RANGE, METADATA_WDTV, METADATA_TIVO, IGNORE_WORDS, CREATE_MISSING_SHOW_DIRS, \ - ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler + ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler, TOGGLE_SEARCH if __INITIALIZED__: @@ -524,7 +525,8 @@ def initialize(consoleLogging=True): TVDB_API_PARMS['cache'] = os.path.join(CACHE_DIR, 'tvdb') TVDB_BASE_URL = 'http://thetvdb.com/api/' + TVDB_API_KEY - + + TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') QUALITY_DEFAULT = check_setting_int(CFG, 'General', 'quality_default', SD) STATUS_DEFAULT = check_setting_int(CFG, 'General', 'status_default', SKIPPED) AUDIO_SHOW_DEFAULT = check_setting_str(CFG, 'General', 'audio_show_default', 'fr' ) @@ -1245,6 +1247,7 @@ def save_config(): new_config.filename = CONFIG_FILE new_config['General'] = {} + new_config['General']['toggle_search'] = TOGGLE_SEARCH new_config['General']['log_dir'] = LOG_DIR new_config['General']['web_port'] = WEB_PORT new_config['General']['web_host'] = WEB_HOST diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 4905ba5e1d..906db3aadc 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -3496,6 +3496,15 @@ def setHomeLayout(self, layout): redirect("/home") @cherrypy.expose + def setHomeSearch(self, search): + + if search not in ('True', 'False'): + search = 'False' + + sickbeard.TOGGLE_SEARCH= search + + redirect("/home") + @cherrypy.expose def toggleDisplayShowSpecials(self, show): sickbeard.DISPLAY_SHOW_SPECIALS = not sickbeard.DISPLAY_SHOW_SPECIALS From d8d9bb212a965570800849139895d2c65ec4e50c Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Tue, 28 May 2013 00:18:33 +0200 Subject: [PATCH 077/492] added new binnews string search waiting to rewrit the all scrpaer --- sickbeard/__init__.py | 2 +- sickbeard/providers/binnewz/__init__.py | 635 ++++++++++++------------ 2 files changed, 319 insertions(+), 318 deletions(-) diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 84882d2b81..fffb1ee36f 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -580,7 +580,7 @@ def initialize(consoleLogging=True): EZRSS = bool(check_setting_int(CFG, 'EZRSS', 'ezrss', 0)) GIT_PATH = check_setting_str(CFG, 'General', 'git_path', '') - IGNORE_WORDS = check_setting_str(CFG, 'General', 'ignore_words', '') + IGNORE_WORDS = check_setting_str(CFG, 'General', 'ignore_words', 'german,spanish,core2hd,dutch,swedish') EXTRA_SCRIPTS = [x for x in check_setting_str(CFG, 'General', 'extra_scripts', '').split('|') if x] USE_BANNER = bool(check_setting_int(CFG, 'General', 'use_banner', 0)) diff --git a/sickbeard/providers/binnewz/__init__.py b/sickbeard/providers/binnewz/__init__.py index 810e8a88b6..acdc4aea5d 100644 --- a/sickbeard/providers/binnewz/__init__.py +++ b/sickbeard/providers/binnewz/__init__.py @@ -1,317 +1,318 @@ -# Author: Guillaume Serre -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from binsearch import BinSearch -from nzbclub import NZBClub -from nzbindex import NZBIndex - -from bs4 import BeautifulSoup -from sickbeard import logger, classes, show_name_helpers,db -from sickbeard.providers import generic -from sickbeard.common import Quality -from sickbeard.exceptions import ex - -import sickbeard -import re -import urllib -import urllib2 - -class BinNewzProvider(generic.NZBProvider): - - def __init__(self): - - generic.NZBProvider.__init__(self, "BinnewZ") - - self.supportsBacklog = True - - self.nzbDownloaders = [BinSearch(),NZBIndex(), NZBClub() ] - - self.url = "http://www.binnews.in/" - - def isEnabled(self): - return sickbeard.BINNEWZ - - def _get_season_search_strings(self, show, season): - - showNames = show_name_helpers.allPossibleShowNames(show) - result = [] - global globepid - globepid=show.tvdbid - for showName in showNames: - result.append( showName + ".saison %2d" % season ) - return result - - def _get_episode_search_strings(self, ep_obj): - strings = [] - - showNames = show_name_helpers.allPossibleShowNames(ep_obj.show) - global globepid - myDB = db.DBConnection() - epidr=myDB.select("SELECT episode_id from tv_episodes where tvdbid=?",[ep_obj.tvdbid]) - globepid = epidr[0][0] - for showName in showNames: - strings.append("%s S%02dE%02d" % ( showName, ep_obj.season, ep_obj.episode) ) - strings.append("%s %dx%d" % ( showName, ep_obj.season, ep_obj.episode ) ) - - return strings - - def _get_title_and_url(self, item): - return (item.title, item.refererURL) - - def getQuality(self, item): - return item.quality - - def _doSearch(self, searchString, show=None, season=None): - - logger.log("BinNewz : Searching for " + searchString) - if show.quality==3: - cat1='24' - cat2='7' - cat3='56' - data = 'chkInit=1&edTitre='+searchString+'&chkTitre=on&chkFichier=on&chkCat=on&cats%5B%5D='+cat1+'&cats%5B%5D='+cat2+'&cats%5B%5D='+cat3+'&edAge=&edYear=' - - elif show.quality==500: - cat1='44' - cat2='53' - data = 'chkInit=1&edTitre='+searchString+'&chkTitre=on&chkFichier=on&chkCat=on&cats%5B%5D='+cat1+'&cats%5B%5D='+cat2+'&edAge=&edYear=' - - else: - data = urllib.urlencode({'b_submit': 'BinnewZ', 'cats[]' : 'all', 'edSearchAll' : searchString, 'sections[]': 'all'}) - - - try: - soup = BeautifulSoup( urllib2.urlopen("http://www.binnews.in/_bin/search2.php", data) ) - except Exception, e: - logger.log(u"Error trying to load BinNewz response: "+ex(e), logger.ERROR) - return [] - - results = [] - - tables = soup.findAll("table", id="tabliste") - for table in tables: - - rows = table.findAll("tr") - for row in rows: - - cells = row.select("> td") - if (len(cells) < 11): - continue - - name = cells[2].text.strip() - language = cells[3].find("img").get("src") - - if show: - if show.audio_lang == "fr": - if not "_fr" in language: - continue - elif show.audio_lang == "en": - if "_fr" in language: - continue - - # blacklist_groups = [ "alt.binaries.multimedia" ] - blacklist_groups = [] - - newgroupLink = cells[4].find("a") - newsgroup = None - if newgroupLink.contents: - newsgroup = newgroupLink.contents[0] - if newsgroup == "abmulti": - newsgroup = "alt.binaries.multimedia" - elif newsgroup == "abtvseries": - newsgroup = "alt.binaries.tvseries" - elif newsgroup == "abtv": - newsgroup = "alt.binaries.tv" - elif newsgroup == "a.b.teevee": - newsgroup = "alt.binaries.teevee" - elif newsgroup == "abstvdivxf": - newsgroup = "alt.binaries.series.tv.divx.french" - elif newsgroup == "abhdtvx264fr": - newsgroup = "alt.binaries.hdtv.x264.french" - elif newsgroup == "abmom": - newsgroup = "alt.binaries.mom" - elif newsgroup == "abhdtv": - newsgroup = "alt.binaries.hdtv" - elif newsgroup == "abboneless": - newsgroup = "alt.binaries.boneless" - elif newsgroup == "abhdtvf": - newsgroup = "alt.binaries.hdtv.french" - elif newsgroup == "abhdtvx264": - newsgroup = "alt.binaries.hdtv.x264" - elif newsgroup == "absuperman": - newsgroup = "alt.binaries.superman" - elif newsgroup == "abechangeweb": - newsgroup = "alt.binaries.echange-web" - elif newsgroup == "abmdfvost": - newsgroup = "alt.binaries.movies.divx.french.vost" - elif newsgroup == "abdvdr": - newsgroup = "alt.binaries.dvdr" - elif newsgroup == "abmzeromov": - newsgroup = "alt.binaries.movies.zeromovies" - elif newsgroup == "abcfaf": - newsgroup = "alt.binaries.cartoons.french.animes-fansub" - elif newsgroup == "abcfrench": - newsgroup = "alt.binaries.cartoons.french" - elif newsgroup == "abgougouland": - newsgroup = "alt.binaries.gougouland" - elif newsgroup == "abroger": - newsgroup = "alt.binaries.roger" - elif newsgroup == "abtatu": - newsgroup = "alt.binaries.tatu" - elif newsgroup =="abstvf": - newsgroup = "alt.binaries.series.tv.french" - elif newsgroup =="abmdfreposts": - newsgroup="alt.binaries.movies.divx.french.reposts" - elif newsgroup =="abmdf": - newsgroup="alt.binaries.movies.french" - elif newsgroup =="ab.aa": - newsgroup="alt.binaries.aa" - elif newsgroup =="abspectdf": - newsgroup="alt.binaries.spectacles.divx.french" - else: - logger.log(u"Unknown binnewz newsgroup: " + newsgroup, logger.ERROR) - continue - - if newsgroup in blacklist_groups: - logger.log(u"Ignoring result, newsgroup is blacklisted: " + newsgroup, logger.WARNING) - continue - - filename = cells[5].contents[0] - - m = re.search("^(.+)\s+{(.*)}$", name) - qualityStr = "" - if m: - name = m.group(1) - qualityStr = m.group(2) - - m = re.search("^(.+)\s+\[(.*)\]$", name) - source = None - if m: - name = m.group(1) - source = m.group(2) - - m = re.search("(.+)\(([0-9]{4})\)", name) - year = "" - if m: - name = m.group(1) - year = m.group(2) - - m = re.search("(.+)\((\d{2}/\d{2}/\d{4})\)", name) - dateStr = "" - if m: - name = m.group(1) - dateStr = m.group(2) - - m = re.search("(.+)\s+S(\d{2})\s+E(\d{2})(.*)", name) - if m: - name = m.group(1) + " S" + m.group(2) + "E" + m.group(3) + m.group(4) - - m = re.search("(.+)\s+S(\d{2})\s+Ep(\d{2})(.*)", name) - if m: - name = m.group(1) + " S" + m.group(2) + "E" + m.group(3) + m.group(4) - - - if filename: - filenameLower = filename.lower() - else: - filenameLower="" - if source: - sourcelower = source.lower() - else: - sourcelower="" - if "720p" in qualityStr: - if "web-dl" in name or "web-dl" in filenameLower: - quality = Quality.HDWEBDL - elif "bluray" in filenameLower or "blu-ray" in filenameLower: - quality = Quality.HDBLURAY - else: - quality=Quality.HDWEBDL - minSize = 600 - elif "1080p" in qualityStr: - if "web-dl" in name or "web-dl" in filenameLower: - quality = Quality.FULLHDWEBDL - elif "bluray" in filenameLower or "blu-ray" in filenameLower: - quality = Quality.FULLHDBLURAY - else: - quality = Quality.FULLHDTV - minSize = 600 - elif "dvdrip" in qualityStr or "dvdrip" in filenameLower or "dvdrip" in sourcelower: - quality= Quality.SDDVD - minSize = 130 - else: - quality = Quality.SDTV - minSize = 130 - - # FIXME - if show and show.quality == 28 and quality == Quality.SDTV: - continue - - searchItems = [] - multiEpisodes = False - - rangeMatcher = re.search(".*S\d{2}\s*E(\d{2})\s+[.|Et]\s+E(\d{2}).*", name) - if not rangeMatcher: - rangeMatcher = re.search(".*S\d{2}\s*E(\d{2}),(\d{2}).*", name) - if rangeMatcher: - rangeStart = int( rangeMatcher.group(1)) - rangeEnd = int( rangeMatcher.group(2)) - if ( filename.find("*") != -1 ): - for i in range(rangeStart, rangeEnd + 1): - searchItem = filename.replace("**", str(i) ) - searchItem = searchItem.replace("*", str(i) ) - searchItems.append( searchItem ) - else: - multiEpisodes = True - - if len(searchItems) == 0: - searchItems.append( filename ) - - for searchItem in searchItems: - for downloader in self.nzbDownloaders: - logger.log("Searching for download : " + name + ", search string = "+ searchItem + " on " + downloader.__class__.__name__) - try: - binsearch_result = downloader.search(searchItem, minSize, newsgroup ) - if binsearch_result: - links=[] - binsearch_result.audio_langs = show.audio_lang - binsearch_result.title = name - binsearch_result.quality = quality - myDB = db.DBConnection() - listlink=myDB.select("SELECT link from episode_links where episode_id =?",[globepid]) - for dlink in listlink: - links.append(dlink[0]) - if binsearch_result.nzburl in links: - continue - else: - results.append( binsearch_result ) - logger.log("Found : " + searchItem + " on " + downloader.__class__.__name__) - break - except Exception, e: - logger.log("Searching from " + downloader.__class__.__name__ + " failed : " + ex(e), logger.ERROR) - - return results - - def getResult(self, episodes): - """ - Returns a result of the correct type for this provider - """ - result = classes.NZBDataSearchResult(episodes) - result.provider = self - - return result - -provider = BinNewzProvider() +# Author: Guillaume Serre +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from binsearch import BinSearch +from nzbclub import NZBClub +from nzbindex import NZBIndex + +from bs4 import BeautifulSoup +from sickbeard import logger, classes, show_name_helpers,db +from sickbeard.providers import generic +from sickbeard.common import Quality +from sickbeard.exceptions import ex + +import sickbeard +import re +import urllib +import urllib2 + +class BinNewzProvider(generic.NZBProvider): + + def __init__(self): + + generic.NZBProvider.__init__(self, "BinnewZ") + + self.supportsBacklog = True + + self.nzbDownloaders = [BinSearch(),NZBIndex(), NZBClub() ] + + self.url = "http://www.binnews.in/" + + def isEnabled(self): + return sickbeard.BINNEWZ + + def _get_season_search_strings(self, show, season): + + showNames = show_name_helpers.allPossibleShowNames(show) + result = [] + global globepid + globepid=show.tvdbid + for showName in showNames: + result.append( showName + ".saison %2d" % season ) + return result + + def _get_episode_search_strings(self, ep_obj): + strings = [] + + showNames = show_name_helpers.allPossibleShowNames(ep_obj.show) + global globepid + myDB = db.DBConnection() + epidr=myDB.select("SELECT episode_id from tv_episodes where tvdbid=?",[ep_obj.tvdbid]) + globepid = epidr[0][0] + for showName in showNames: + strings.append("%s S%02dE%02d" % ( showName, ep_obj.season, ep_obj.episode) ) + strings.append("%s %dx%d" % ( showName, ep_obj.season, ep_obj.episode ) ) + strings.append("%s S%02d E%02d" % ( showName, ep_obj.season, ep_obj.episode) ) + + return strings + + def _get_title_and_url(self, item): + return (item.title, item.refererURL) + + def getQuality(self, item): + return item.quality + + def _doSearch(self, searchString, show=None, season=None): + + logger.log("BinNewz : Searching for " + searchString) + if show.quality==3: + cat1='24' + cat2='7' + cat3='56' + data = 'chkInit=1&edTitre='+searchString+'&chkTitre=on&chkFichier=on&chkCat=on&cats%5B%5D='+cat1+'&cats%5B%5D='+cat2+'&cats%5B%5D='+cat3+'&edAge=&edYear=' + + elif show.quality==500: + cat1='44' + cat2='53' + data = 'chkInit=1&edTitre='+searchString+'&chkTitre=on&chkFichier=on&chkCat=on&cats%5B%5D='+cat1+'&cats%5B%5D='+cat2+'&edAge=&edYear=' + + else: + data = urllib.urlencode({'b_submit': 'BinnewZ', 'cats[]' : 'all', 'edSearchAll' : searchString, 'sections[]': 'all'}) + + + try: + soup = BeautifulSoup( urllib2.urlopen("http://www.binnews.in/_bin/search2.php", data) ) + except Exception, e: + logger.log(u"Error trying to load BinNewz response: "+ex(e), logger.ERROR) + return [] + + results = [] + + tables = soup.findAll("table", id="tabliste") + for table in tables: + + rows = table.findAll("tr") + for row in rows: + + cells = row.select("> td") + if (len(cells) < 11): + continue + + name = cells[2].text.strip() + language = cells[3].find("img").get("src") + + if show: + if show.audio_lang == "fr": + if not "_fr" in language: + continue + elif show.audio_lang == "en": + if "_fr" in language: + continue + + # blacklist_groups = [ "alt.binaries.multimedia" ] + blacklist_groups = [] + + newgroupLink = cells[4].find("a") + newsgroup = None + if newgroupLink.contents: + newsgroup = newgroupLink.contents[0] + if newsgroup == "abmulti": + newsgroup = "alt.binaries.multimedia" + elif newsgroup == "abtvseries": + newsgroup = "alt.binaries.tvseries" + elif newsgroup == "abtv": + newsgroup = "alt.binaries.tv" + elif newsgroup == "a.b.teevee": + newsgroup = "alt.binaries.teevee" + elif newsgroup == "abstvdivxf": + newsgroup = "alt.binaries.series.tv.divx.french" + elif newsgroup == "abhdtvx264fr": + newsgroup = "alt.binaries.hdtv.x264.french" + elif newsgroup == "abmom": + newsgroup = "alt.binaries.mom" + elif newsgroup == "abhdtv": + newsgroup = "alt.binaries.hdtv" + elif newsgroup == "abboneless": + newsgroup = "alt.binaries.boneless" + elif newsgroup == "abhdtvf": + newsgroup = "alt.binaries.hdtv.french" + elif newsgroup == "abhdtvx264": + newsgroup = "alt.binaries.hdtv.x264" + elif newsgroup == "absuperman": + newsgroup = "alt.binaries.superman" + elif newsgroup == "abechangeweb": + newsgroup = "alt.binaries.echange-web" + elif newsgroup == "abmdfvost": + newsgroup = "alt.binaries.movies.divx.french.vost" + elif newsgroup == "abdvdr": + newsgroup = "alt.binaries.dvdr" + elif newsgroup == "abmzeromov": + newsgroup = "alt.binaries.movies.zeromovies" + elif newsgroup == "abcfaf": + newsgroup = "alt.binaries.cartoons.french.animes-fansub" + elif newsgroup == "abcfrench": + newsgroup = "alt.binaries.cartoons.french" + elif newsgroup == "abgougouland": + newsgroup = "alt.binaries.gougouland" + elif newsgroup == "abroger": + newsgroup = "alt.binaries.roger" + elif newsgroup == "abtatu": + newsgroup = "alt.binaries.tatu" + elif newsgroup =="abstvf": + newsgroup = "alt.binaries.series.tv.french" + elif newsgroup =="abmdfreposts": + newsgroup="alt.binaries.movies.divx.french.reposts" + elif newsgroup =="abmdf": + newsgroup="alt.binaries.movies.french" + elif newsgroup =="ab.aa": + newsgroup="alt.binaries.aa" + elif newsgroup =="abspectdf": + newsgroup="alt.binaries.spectacles.divx.french" + else: + logger.log(u"Unknown binnewz newsgroup: " + newsgroup, logger.ERROR) + continue + + if newsgroup in blacklist_groups: + logger.log(u"Ignoring result, newsgroup is blacklisted: " + newsgroup, logger.WARNING) + continue + + filename = cells[5].contents[0] + + m = re.search("^(.+)\s+{(.*)}$", name) + qualityStr = "" + if m: + name = m.group(1) + qualityStr = m.group(2) + + m = re.search("^(.+)\s+\[(.*)\]$", name) + source = None + if m: + name = m.group(1) + source = m.group(2) + + m = re.search("(.+)\(([0-9]{4})\)", name) + year = "" + if m: + name = m.group(1) + year = m.group(2) + + m = re.search("(.+)\((\d{2}/\d{2}/\d{4})\)", name) + dateStr = "" + if m: + name = m.group(1) + dateStr = m.group(2) + + m = re.search("(.+)\s+S(\d{2})\s+E(\d{2})(.*)", name) + if m: + name = m.group(1) + " S" + m.group(2) + "E" + m.group(3) + m.group(4) + + m = re.search("(.+)\s+S(\d{2})\s+Ep(\d{2})(.*)", name) + if m: + name = m.group(1) + " S" + m.group(2) + "E" + m.group(3) + m.group(4) + + + if filename: + filenameLower = filename.lower() + else: + filenameLower="" + if source: + sourcelower = source.lower() + else: + sourcelower="" + if "720p" in qualityStr: + if "web-dl" in name or "web-dl" in filenameLower: + quality = Quality.HDWEBDL + elif "bluray" in filenameLower or "blu-ray" in filenameLower: + quality = Quality.HDBLURAY + else: + quality=Quality.HDWEBDL + minSize = 600 + elif "1080p" in qualityStr: + if "web-dl" in name or "web-dl" in filenameLower: + quality = Quality.FULLHDWEBDL + elif "bluray" in filenameLower or "blu-ray" in filenameLower: + quality = Quality.FULLHDBLURAY + else: + quality = Quality.FULLHDTV + minSize = 600 + elif "dvdrip" in qualityStr or "dvdrip" in filenameLower or "dvdrip" in sourcelower: + quality= Quality.SDDVD + minSize = 130 + else: + quality = Quality.SDTV + minSize = 130 + + # FIXME + if show and show.quality == 28 and quality == Quality.SDTV: + continue + + searchItems = [] + multiEpisodes = False + + rangeMatcher = re.search(".*S\d{2}\s*E(\d{2})\s+[.|Et]\s+E(\d{2}).*", name) + if not rangeMatcher: + rangeMatcher = re.search(".*S\d{2}\s*E(\d{2}),(\d{2}).*", name) + if rangeMatcher: + rangeStart = int( rangeMatcher.group(1)) + rangeEnd = int( rangeMatcher.group(2)) + if ( filename.find("*") != -1 ): + for i in range(rangeStart, rangeEnd + 1): + searchItem = filename.replace("**", str(i) ) + searchItem = searchItem.replace("*", str(i) ) + searchItems.append( searchItem ) + else: + multiEpisodes = True + + if len(searchItems) == 0: + searchItems.append( filename ) + + for searchItem in searchItems: + for downloader in self.nzbDownloaders: + logger.log("Searching for download : " + name + ", search string = "+ searchItem + " on " + downloader.__class__.__name__) + try: + binsearch_result = downloader.search(searchItem, minSize, newsgroup ) + if binsearch_result: + links=[] + binsearch_result.audio_langs = show.audio_lang + binsearch_result.title = name + binsearch_result.quality = quality + myDB = db.DBConnection() + listlink=myDB.select("SELECT link from episode_links where episode_id =?",[globepid]) + for dlink in listlink: + links.append(dlink[0]) + if binsearch_result.nzburl in links: + continue + else: + results.append( binsearch_result ) + logger.log("Found : " + searchItem + " on " + downloader.__class__.__name__) + break + except Exception, e: + logger.log("Searching from " + downloader.__class__.__name__ + " failed : " + ex(e), logger.ERROR) + + return results + + def getResult(self, episodes): + """ + Returns a result of the correct type for this provider + """ + result = classes.NZBDataSearchResult(episodes) + result.provider = self + + return result + +provider = BinNewzProvider() From 72ae389d4deeb4aef351bd2a542932fad12ab487 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Tue, 28 May 2013 00:53:39 +0200 Subject: [PATCH 078/492] corrected quality parser --- sickbeard/common.py | 598 ++++++++++++++++++++++---------------------- 1 file changed, 301 insertions(+), 297 deletions(-) diff --git a/sickbeard/common.py b/sickbeard/common.py index 38b202ab2d..70ac584264 100644 --- a/sickbeard/common.py +++ b/sickbeard/common.py @@ -1,298 +1,302 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import os.path -import operator -import platform -import re - -from sickbeard import version - -USER_AGENT = 'Sick Beard/alpha2-' + version.SICKBEARD_VERSION.replace(' ', '-') + ' (' + platform.system() + ' ' + platform.release() + ')' - -mediaExtensions = ['avi', 'mkv', 'mpg', 'mpeg', 'wmv', - 'ogm', 'mp4', 'iso', 'img', 'divx', - 'm2ts', 'm4v', 'ts', 'flv', 'f4v', - 'mov', 'rmvb', 'vob', 'dvr-ms', 'wtv', - 'ogv', '3gp'] - -subtitleExtensions = ['srt', 'sub', 'ass', 'idx', 'ssa'] - -### Other constants -MULTI_EP_RESULT = -1 -SEASON_RESULT = -2 - -### Notification Types -NOTIFY_SNATCH = 1 -NOTIFY_DOWNLOAD = 2 -NOTIFY_SUBTITLE_DOWNLOAD = 3 - -notifyStrings = {} -notifyStrings[NOTIFY_SNATCH] = "Started Download" -notifyStrings[NOTIFY_DOWNLOAD] = "Download Finished" -notifyStrings[NOTIFY_SUBTITLE_DOWNLOAD] = "Subtitle Download Finished" - -### Episode statuses -UNKNOWN = -1 # should never happen -UNAIRED = 1 # episodes that haven't aired yet -SNATCHED = 2 # qualified with quality -WANTED = 3 # episodes we don't have but want to get -DOWNLOADED = 4 # qualified with quality -SKIPPED = 5 # episodes we don't want -ARCHIVED = 6 # episodes that you don't have locally (counts toward download completion stats) -IGNORED = 7 # episodes that you don't want included in your download stats -SNATCHED_PROPER = 9 # qualified with quality -SUBTITLED = 10 # qualified with quality - -NAMING_REPEAT = 1 -NAMING_EXTEND = 2 -NAMING_DUPLICATE = 4 -NAMING_LIMITED_EXTEND = 8 -NAMING_SEPARATED_REPEAT = 16 -NAMING_LIMITED_EXTEND_E_PREFIXED = 32 - -multiEpStrings = {} -multiEpStrings[NAMING_REPEAT] = "Repeat" -multiEpStrings[NAMING_SEPARATED_REPEAT] = "Repeat (Separated)" -multiEpStrings[NAMING_DUPLICATE] = "Duplicate" -multiEpStrings[NAMING_EXTEND] = "Extend" -multiEpStrings[NAMING_LIMITED_EXTEND] = "Extend (Limited)" -multiEpStrings[NAMING_LIMITED_EXTEND_E_PREFIXED] = "Extend (Limited, E-prefixed)" - - -class Quality: - NONE = 0 # 0 - SDTV = 1 # 1 - SDDVD = 1 << 1 # 2 - HDTV = 1 << 2 # 4 - RAWHDTV = 1 << 3 # 8 -- 720p/1080i mpeg2 (trollhd releases) - FULLHDTV = 1 << 4 # 16 -- 1080p HDTV (QCF releases) - HDWEBDL = 1 << 5 # 32 - FULLHDWEBDL = 1 << 6 # 64 -- 1080p web-dl - HDBLURAY = 1 << 7 # 128 - FULLHDBLURAY = 1 << 8 # 256 - - # put these bits at the other end of the spectrum, far enough out that they shouldn't interfere - UNKNOWN = 1 << 15 # 32768 - - qualityStrings = {NONE: "N/A", - UNKNOWN: "Unknown", - SDTV: "SD TV", - SDDVD: "SD DVD", - HDTV: "HD TV", - RAWHDTV: "RawHD TV", - FULLHDTV: "1080p HD TV", - HDWEBDL: "720p WEB-DL", - FULLHDWEBDL: "1080p WEB-DL", - HDBLURAY: "720p BluRay", - FULLHDBLURAY: "1080p BluRay"} - - statusPrefixes = {DOWNLOADED: "Downloaded", - SNATCHED: "Snatched"} - - @staticmethod - def _getStatusStrings(status): - toReturn = {} - for x in Quality.qualityStrings.keys(): - toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + Quality.qualityStrings[x] + ")" - return toReturn - - @staticmethod - def combineQualities(anyQualities, bestQualities): - anyQuality = 0 - bestQuality = 0 - if anyQualities: - anyQuality = reduce(operator.or_, anyQualities) - if bestQualities: - bestQuality = reduce(operator.or_, bestQualities) - return anyQuality | (bestQuality << 16) - - @staticmethod - def splitQuality(quality): - anyQualities = [] - bestQualities = [] - for curQual in Quality.qualityStrings.keys(): - if curQual & quality: - anyQualities.append(curQual) - if curQual << 16 & quality: - bestQualities.append(curQual) - - return (sorted(anyQualities), sorted(bestQualities)) - - @staticmethod - def nameQuality(name): - name = os.path.basename(name) - - # if we have our exact text then assume we put it there - for x in sorted(Quality.qualityStrings, reverse=True): - if x == Quality.UNKNOWN: - continue - - regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W' - regex_match = re.search(regex, name, re.I) - if regex_match: - return x - - checkName = lambda list, func: func([re.search(x, name, re.I) for x in list]) - - if checkName(["(pdtv|hdtv|dsr|tvrip|webrip).(xvid|x264)"], all) and not checkName(["(720|1080)[pi]"], all): - return Quality.SDTV - elif checkName(["(dvdrip|bdrip)(.ws)?.(xvid|divx|x264)"], any) and not checkName(["(720|1080)[pi]"], all): - return Quality.SDDVD - elif checkName(["720p", "hdtv", "x264"], all) or checkName(["hr.ws.pdtv.x264"], any) and not checkName(["(1080)[pi]"], all): - return Quality.HDTV - elif checkName(["720p|1080i", "hdtv", "mpeg2"], all): - return Quality.RAWHDTV - elif checkName(["1080p", "hdtv", "x264"], all): - return Quality.FULLHDTV - elif checkName(["720p", "web.dl|webrip"], all) or checkName(["720p", "itunes", "h.?264"], all): - return Quality.HDWEBDL - elif checkName(["1080p", "web.dl|webrip"], all) or checkName(["1080p", "itunes", "h.?264"], all): - return Quality.FULLHDWEBDL - elif checkName(["720p", "bluray|hddvd", "x264"], all): - return Quality.HDBLURAY - elif checkName(["1080p", "bluray|hddvd", "x264"], all): - return Quality.FULLHDBLURAY - else: - return Quality.SDTV - - @staticmethod - def assumeQuality(name): - if name.lower().endswith((".avi", ".mp4")): - return Quality.SDTV - elif name.lower().endswith(".mkv"): - return Quality.HDTV - elif name.lower().endswith(".ts"): - return Quality.RAWHDTV - else: - return Quality.UNKNOWN - - @staticmethod - def compositeStatus(status, quality): - return status + 100 * quality - - @staticmethod - def qualityDownloaded(status): - return (status - DOWNLOADED) / 100 - - @staticmethod - def splitCompositeStatus(status): - """Returns a tuple containing (status, quality)""" - if status == UNKNOWN: - return (UNKNOWN, Quality.UNKNOWN) - - for x in sorted(Quality.qualityStrings.keys(), reverse=True): - if status > x * 100: - return (status - x * 100, x) - - return (status, Quality.NONE) - - @staticmethod - def statusFromName(name, assume=True): - quality = Quality.nameQuality(name) - if assume and quality == Quality.UNKNOWN: - quality = Quality.assumeQuality(name) - return Quality.compositeStatus(DOWNLOADED, quality) - - DOWNLOADED = None - SNATCHED = None - SNATCHED_PROPER = None - -Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()] -Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()] -Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in Quality.qualityStrings.keys()] - -SD = Quality.combineQualities([Quality.SDTV, Quality.SDDVD], []) -HD = Quality.combineQualities([Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY], []) # HD720p + HD1080p -HD720p = Quality.combineQualities([Quality.HDTV, Quality.HDWEBDL, Quality.HDBLURAY], []) -HD1080p = Quality.combineQualities([Quality.FULLHDTV, Quality.FULLHDWEBDL, Quality.FULLHDBLURAY], []) -ANY = Quality.combineQualities([Quality.SDTV, Quality.SDDVD, Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY, Quality.UNKNOWN], []) # SD + HD - -# legacy template, cant remove due to reference in mainDB upgrade? -BEST = Quality.combineQualities([Quality.SDTV, Quality.HDTV, Quality.HDWEBDL], [Quality.HDTV]) - -qualityPresets = (SD, HD, HD720p, HD1080p, ANY) -qualityPresetStrings = {SD: "SD", - HD: "HD", - HD720p: "HD720p", - HD1080p: "HD1080p", - ANY: "Any"} - - -class StatusStrings: - def __init__(self): - self.statusStrings = {UNKNOWN: "Unknown", - UNAIRED: "Unaired", - SNATCHED: "Snatched", - DOWNLOADED: "Downloaded", - SKIPPED: "Skipped", - SNATCHED_PROPER: "Snatched (Proper)", - WANTED: "Wanted", - ARCHIVED: "Archived", - IGNORED: "Ignored", - SUBTITLED: "Subtitled"} - - def __getitem__(self, name): - if name in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER: - status, quality = Quality.splitCompositeStatus(name) - if quality == Quality.NONE: - return self.statusStrings[status] - else: - return self.statusStrings[status] + " (" + Quality.qualityStrings[quality] + ")" - else: - return self.statusStrings[name] - - def has_key(self, name): - return name in self.statusStrings or name in Quality.DOWNLOADED or name in Quality.SNATCHED or name in Quality.SNATCHED_PROPER - -statusStrings = StatusStrings() - -class Overview: - UNAIRED = UNAIRED # 1 - QUAL = 2 - WANTED = WANTED # 3 - GOOD = 4 - SKIPPED = SKIPPED # 5 - - # For both snatched statuses. Note: SNATCHED/QUAL have same value and break dict. - SNATCHED = SNATCHED_PROPER # 9 - - overviewStrings = {SKIPPED: "skipped", - WANTED: "wanted", - QUAL: "qual", - GOOD: "good", - UNAIRED: "unaired", - SNATCHED: "snatched"} - -# Get our xml namespaces correct for lxml -XML_NSMAP = {'xsi': 'http://www.w3.org/2001/XMLSchema-instance', - 'xsd': 'http://www.w3.org/2001/XMLSchema'} - - -countryList = {'Australia': 'AU', - 'Canada': 'CA', - 'USA': 'US' - } -showLanguages = {'en':'english', - 'fr':'french', - '':'unknown' - } - -languageShortCode = {'english':'en', - 'french':'fr' +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import os.path +import operator +import platform +import re + +from sickbeard import version + +USER_AGENT = 'Sick Beard/alpha2-' + version.SICKBEARD_VERSION.replace(' ', '-') + ' (' + platform.system() + ' ' + platform.release() + ')' + +mediaExtensions = ['avi', 'mkv', 'mpg', 'mpeg', 'wmv', + 'ogm', 'mp4', 'iso', 'img', 'divx', + 'm2ts', 'm4v', 'ts', 'flv', 'f4v', + 'mov', 'rmvb', 'vob', 'dvr-ms', 'wtv', + 'ogv', '3gp'] + +subtitleExtensions = ['srt', 'sub', 'ass', 'idx', 'ssa'] + +### Other constants +MULTI_EP_RESULT = -1 +SEASON_RESULT = -2 + +### Notification Types +NOTIFY_SNATCH = 1 +NOTIFY_DOWNLOAD = 2 +NOTIFY_SUBTITLE_DOWNLOAD = 3 + +notifyStrings = {} +notifyStrings[NOTIFY_SNATCH] = "Started Download" +notifyStrings[NOTIFY_DOWNLOAD] = "Download Finished" +notifyStrings[NOTIFY_SUBTITLE_DOWNLOAD] = "Subtitle Download Finished" + +### Episode statuses +UNKNOWN = -1 # should never happen +UNAIRED = 1 # episodes that haven't aired yet +SNATCHED = 2 # qualified with quality +WANTED = 3 # episodes we don't have but want to get +DOWNLOADED = 4 # qualified with quality +SKIPPED = 5 # episodes we don't want +ARCHIVED = 6 # episodes that you don't have locally (counts toward download completion stats) +IGNORED = 7 # episodes that you don't want included in your download stats +SNATCHED_PROPER = 9 # qualified with quality +SUBTITLED = 10 # qualified with quality + +NAMING_REPEAT = 1 +NAMING_EXTEND = 2 +NAMING_DUPLICATE = 4 +NAMING_LIMITED_EXTEND = 8 +NAMING_SEPARATED_REPEAT = 16 +NAMING_LIMITED_EXTEND_E_PREFIXED = 32 + +multiEpStrings = {} +multiEpStrings[NAMING_REPEAT] = "Repeat" +multiEpStrings[NAMING_SEPARATED_REPEAT] = "Repeat (Separated)" +multiEpStrings[NAMING_DUPLICATE] = "Duplicate" +multiEpStrings[NAMING_EXTEND] = "Extend" +multiEpStrings[NAMING_LIMITED_EXTEND] = "Extend (Limited)" +multiEpStrings[NAMING_LIMITED_EXTEND_E_PREFIXED] = "Extend (Limited, E-prefixed)" + + +class Quality: + NONE = 0 # 0 + SDTV = 1 # 1 + SDDVD = 1 << 1 # 2 + HDTV = 1 << 2 # 4 + RAWHDTV = 1 << 3 # 8 -- 720p/1080i mpeg2 (trollhd releases) + FULLHDTV = 1 << 4 # 16 -- 1080p HDTV (QCF releases) + HDWEBDL = 1 << 5 # 32 + FULLHDWEBDL = 1 << 6 # 64 -- 1080p web-dl + HDBLURAY = 1 << 7 # 128 + FULLHDBLURAY = 1 << 8 # 256 + + # put these bits at the other end of the spectrum, far enough out that they shouldn't interfere + UNKNOWN = 1 << 15 # 32768 + + qualityStrings = {NONE: "N/A", + UNKNOWN: "Unknown", + SDTV: "SD TV", + SDDVD: "SD DVD", + HDTV: "HD TV", + RAWHDTV: "RawHD TV", + FULLHDTV: "1080p HD TV", + HDWEBDL: "720p WEB-DL", + FULLHDWEBDL: "1080p WEB-DL", + HDBLURAY: "720p BluRay", + FULLHDBLURAY: "1080p BluRay"} + + statusPrefixes = {DOWNLOADED: "Downloaded", + SNATCHED: "Snatched"} + + @staticmethod + def _getStatusStrings(status): + toReturn = {} + for x in Quality.qualityStrings.keys(): + toReturn[Quality.compositeStatus(status, x)] = Quality.statusPrefixes[status] + " (" + Quality.qualityStrings[x] + ")" + return toReturn + + @staticmethod + def combineQualities(anyQualities, bestQualities): + anyQuality = 0 + bestQuality = 0 + if anyQualities: + anyQuality = reduce(operator.or_, anyQualities) + if bestQualities: + bestQuality = reduce(operator.or_, bestQualities) + return anyQuality | (bestQuality << 16) + + @staticmethod + def splitQuality(quality): + anyQualities = [] + bestQualities = [] + for curQual in Quality.qualityStrings.keys(): + if curQual & quality: + anyQualities.append(curQual) + if curQual << 16 & quality: + bestQualities.append(curQual) + + return (sorted(anyQualities), sorted(bestQualities)) + + @staticmethod + def nameQuality(name): + name = os.path.basename(name) + + # if we have our exact text then assume we put it there + for x in sorted(Quality.qualityStrings, reverse=True): + if x == Quality.UNKNOWN: + continue + + regex = '\W' + Quality.qualityStrings[x].replace(' ', '\W') + '\W' + regex_match = re.search(regex, name, re.I) + if regex_match: + return x + + checkName = lambda list, func: func([re.search(x, name, re.I) for x in list]) + + if checkName(["(pdtv|hdtv|dsr|tvrip|web.dl|webrip|web-dl).(xvid|x264|h.?264)"], all) and not checkName(["(720|1080)[pi]"], all): + return Quality.SDTV + elif checkName(["(dvdrip|b[r|d]rip)(.ws)?.(xvid|divx|x264)"], any) and not checkName(["(720|1080)[pi]"], all): + return Quality.SDDVD + elif checkName(["720p", "hdtv", "x264"], all) or checkName(["hr.ws.pdtv.x264"], any) and not checkName(["(1080)[pi]"], all): + return Quality.HDTV + elif checkName(["720p|1080i", "hdtv", "mpeg-?2"], all): + return Quality.RAWHDTV + elif checkName(["1080p", "hdtv", "x264"], all): + return Quality.FULLHDTV + elif checkName(["720p", "web.dl", "h.?264"], all) or checkName(["720p", "itunes", "h.?264"], all) or checkName(["720p", "WEB-DL", "H264"], all): + return Quality.HDWEBDL + elif checkName(["1080p", "web.dl", "h.?264"], all) or checkName(["1080p", "itunes", "h.?264"], all) or checkName(["1080p", "web-dl", "h.?264"], all): + return Quality.FULLHDWEBDL + elif checkName(["720p", "webrip", "x264"], all): + return Quality.HDWEBDL + elif checkName(["1080p", "webrip", "x264"], all): + return Quality.FULLHDWEBDL + elif checkName(["720p", "bluray|hddvd|b[r|d]rip", "x264"], all): + return Quality.HDBLURAY + elif checkName(["1080p", "bluray|hddvd|b[r|d]rip", "x264"], all): + return Quality.FULLHDBLURAY + else: + return Quality.UNKNOWN + + @staticmethod + def assumeQuality(name): + if name.lower().endswith((".avi", ".mp4")): + return Quality.SDTV + elif name.lower().endswith(".mkv"): + return Quality.HDTV + elif name.lower().endswith(".ts"): + return Quality.RAWHDTV + else: + return Quality.UNKNOWN + + @staticmethod + def compositeStatus(status, quality): + return status + 100 * quality + + @staticmethod + def qualityDownloaded(status): + return (status - DOWNLOADED) / 100 + + @staticmethod + def splitCompositeStatus(status): + """Returns a tuple containing (status, quality)""" + if status == UNKNOWN: + return (UNKNOWN, Quality.UNKNOWN) + + for x in sorted(Quality.qualityStrings.keys(), reverse=True): + if status > x * 100: + return (status - x * 100, x) + + return (status, Quality.NONE) + + @staticmethod + def statusFromName(name, assume=True): + quality = Quality.nameQuality(name) + if assume and quality == Quality.UNKNOWN: + quality = Quality.assumeQuality(name) + return Quality.compositeStatus(DOWNLOADED, quality) + + DOWNLOADED = None + SNATCHED = None + SNATCHED_PROPER = None + +Quality.DOWNLOADED = [Quality.compositeStatus(DOWNLOADED, x) for x in Quality.qualityStrings.keys()] +Quality.SNATCHED = [Quality.compositeStatus(SNATCHED, x) for x in Quality.qualityStrings.keys()] +Quality.SNATCHED_PROPER = [Quality.compositeStatus(SNATCHED_PROPER, x) for x in Quality.qualityStrings.keys()] + +SD = Quality.combineQualities([Quality.SDTV, Quality.SDDVD], []) +HD = Quality.combineQualities([Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY], []) # HD720p + HD1080p +HD720p = Quality.combineQualities([Quality.HDTV, Quality.HDWEBDL, Quality.HDBLURAY], []) +HD1080p = Quality.combineQualities([Quality.FULLHDTV, Quality.FULLHDWEBDL, Quality.FULLHDBLURAY], []) +ANY = Quality.combineQualities([Quality.SDTV, Quality.SDDVD, Quality.HDTV, Quality.FULLHDTV, Quality.HDWEBDL, Quality.FULLHDWEBDL, Quality.HDBLURAY, Quality.FULLHDBLURAY, Quality.UNKNOWN], []) # SD + HD + +# legacy template, cant remove due to reference in mainDB upgrade? +BEST = Quality.combineQualities([Quality.SDTV, Quality.HDTV, Quality.HDWEBDL], [Quality.HDTV]) + +qualityPresets = (SD, HD, HD720p, HD1080p, ANY) +qualityPresetStrings = {SD: "SD", + HD: "HD", + HD720p: "HD720p", + HD1080p: "HD1080p", + ANY: "Any"} + + +class StatusStrings: + def __init__(self): + self.statusStrings = {UNKNOWN: "Unknown", + UNAIRED: "Unaired", + SNATCHED: "Snatched", + DOWNLOADED: "Downloaded", + SKIPPED: "Skipped", + SNATCHED_PROPER: "Snatched (Proper)", + WANTED: "Wanted", + ARCHIVED: "Archived", + IGNORED: "Ignored", + SUBTITLED: "Subtitled"} + + def __getitem__(self, name): + if name in Quality.DOWNLOADED + Quality.SNATCHED + Quality.SNATCHED_PROPER: + status, quality = Quality.splitCompositeStatus(name) + if quality == Quality.NONE: + return self.statusStrings[status] + else: + return self.statusStrings[status] + " (" + Quality.qualityStrings[quality] + ")" + else: + return self.statusStrings[name] + + def has_key(self, name): + return name in self.statusStrings or name in Quality.DOWNLOADED or name in Quality.SNATCHED or name in Quality.SNATCHED_PROPER + +statusStrings = StatusStrings() + +class Overview: + UNAIRED = UNAIRED # 1 + QUAL = 2 + WANTED = WANTED # 3 + GOOD = 4 + SKIPPED = SKIPPED # 5 + + # For both snatched statuses. Note: SNATCHED/QUAL have same value and break dict. + SNATCHED = SNATCHED_PROPER # 9 + + overviewStrings = {SKIPPED: "skipped", + WANTED: "wanted", + QUAL: "qual", + GOOD: "good", + UNAIRED: "unaired", + SNATCHED: "snatched"} + +# Get our xml namespaces correct for lxml +XML_NSMAP = {'xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsd': 'http://www.w3.org/2001/XMLSchema'} + + +countryList = {'Australia': 'AU', + 'Canada': 'CA', + 'USA': 'US' + } +showLanguages = {'en':'english', + 'fr':'french', + '':'unknown' + } + +languageShortCode = {'english':'en', + 'french':'fr' } \ No newline at end of file From 73abdb8f8b10f7520b2bf10d14db6af91ba7a824 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Tue, 28 May 2013 13:16:15 +0200 Subject: [PATCH 079/492] addicted correction --- lib/subliminal/api.py | 5 +- lib/subliminal/core.py | 2 +- lib/subliminal/language.py | 1 + lib/subliminal/services/__init__.py | 10 +- lib/subliminal/services/addic7ed.py | 6 +- lib/subliminal/services/itasa.py | 216 +++++++++++++++++++++++ lib/subliminal/services/opensubtitles.py | 6 +- lib/subliminal/services/podnapisi.py | 4 +- lib/subliminal/services/subswiki.py | 4 +- lib/subliminal/services/subtitulos.py | 6 +- lib/subliminal/services/thesubdb.py | 8 +- lib/subliminal/services/tvsubtitles.py | 4 +- 12 files changed, 244 insertions(+), 28 deletions(-) create mode 100644 lib/subliminal/services/itasa.py diff --git a/lib/subliminal/api.py b/lib/subliminal/api.py index 3b6f9139d2..f95fda3b48 100644 --- a/lib/subliminal/api.py +++ b/lib/subliminal/api.py @@ -94,7 +94,10 @@ def download_subtitles(paths, languages=None, services=None, force=True, multi=F order = order or [LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE] subtitles_by_video = list_subtitles(paths, languages, services, force, multi, cache_dir, max_depth, scan_filter) for video, subtitles in subtitles_by_video.iteritems(): - subtitles.sort(key=lambda s: key_subtitles(s, video, languages, services, order), reverse=True) + try: + subtitles.sort(key=lambda s: key_subtitles(s, video, languages, services, order), reverse=True) + except StopIteration: + break results = [] service_instances = {} tasks = create_download_tasks(subtitles_by_video, languages, multi) diff --git a/lib/subliminal/core.py b/lib/subliminal/core.py index 1b8c840d12..80c7f024f8 100644 --- a/lib/subliminal/core.py +++ b/lib/subliminal/core.py @@ -32,7 +32,7 @@ 'create_list_tasks', 'create_download_tasks', 'consume_task', 'matching_confidence', 'key_subtitles', 'group_by_video'] logger = logging.getLogger("subliminal") -SERVICES = ['opensubtitles', 'bierdopje', 'subswiki', 'subtitulos', 'thesubdb', 'addic7ed', 'tvsubtitles'] +SERVICES = ['opensubtitles', 'bierdopje', 'subswiki', 'subtitulos', 'thesubdb', 'addic7ed', 'tvsubtitles', 'itasa'] LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE = range(4) diff --git a/lib/subliminal/language.py b/lib/subliminal/language.py index c89e7abc0b..6403bcc0a5 100644 --- a/lib/subliminal/language.py +++ b/lib/subliminal/language.py @@ -619,6 +619,7 @@ ('pli', '', 'pi', u'Pali', u'pali'), ('pol', '', 'pl', u'Polish', u'polonais'), ('pon', '', '', u'Pohnpeian', u'pohnpei'), + ('pob', '', 'pb', u'Brazilian Portuguese', u'brazilian portuguese'), ('por', '', 'pt', u'Portuguese', u'portugais'), ('pra', '', '', u'Prakrit languages', u'prâkrit, langues'), ('pro', '', '', u'Provençal, Old (to 1500)', u'provençal ancien (jusqu\'à 1500)'), diff --git a/lib/subliminal/services/__init__.py b/lib/subliminal/services/__init__.py index 7cad1cd6a1..9a21666c00 100644 --- a/lib/subliminal/services/__init__.py +++ b/lib/subliminal/services/__init__.py @@ -219,18 +219,10 @@ def download_zip_file(self, url, filepath): # TODO: could check if maybe we already have a text file and # download it directly raise DownloadFailedError('Downloaded file is not a zip file') -# with zipfile.ZipFile(zippath) as zipsub: -# for subfile in zipsub.namelist(): -# if os.path.splitext(subfile)[1] in EXTENSIONS: -# with open(filepath, 'w') as f: -# f.write(zipsub.open(subfile).read()) -# break -# else: -# raise DownloadFailedError('No subtitles found in zip file') zipsub = zipfile.ZipFile(zippath) for subfile in zipsub.namelist(): if os.path.splitext(subfile)[1] in EXTENSIONS: - with open(filepath, 'w') as f: + with open(filepath, 'wb') as f: f.write(zipsub.open(subfile).read()) break else: diff --git a/lib/subliminal/services/addic7ed.py b/lib/subliminal/services/addic7ed.py index 1080cb4798..6f7f0f8790 100644 --- a/lib/subliminal/services/addic7ed.py +++ b/lib/subliminal/services/addic7ed.py @@ -38,13 +38,12 @@ class Addic7ed(ServiceBase): api_based = False #TODO: Complete this languages = language_set(['ar', 'ca', 'de', 'el', 'en', 'es', 'eu', 'fr', 'ga', 'gl', 'he', 'hr', 'hu', - 'it', 'pl', 'pt', 'ro', 'ru', 'se', 'pt-br']) - language_map = {'Portuguese (Brazilian)': Language('por-BR'), 'Greek': Language('gre'), + 'it', 'pl', 'pt', 'ro', 'ru', 'se', 'pb']) + language_map = {'Portuguese (Brazilian)': Language('pob'), 'Greek': Language('gre'), 'Spanish (Latin America)': Language('spa'), 'Galego': Language('glg'), u'Català': Language('cat')} videos = [Episode] require_video = False - required_features = ['permissive'] @cachedmethod def get_series_id(self, name): @@ -64,6 +63,7 @@ def list_checked(self, video, languages): return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) def query(self, filepath, languages, keywords, series, season, episode): + logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) self.init_cache() try: diff --git a/lib/subliminal/services/itasa.py b/lib/subliminal/services/itasa.py new file mode 100644 index 0000000000..f726a156e6 --- /dev/null +++ b/lib/subliminal/services/itasa.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# Copyright 2012 Mr_Orange +# +# This file is part of subliminal. +# +# subliminal is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# subliminal 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with subliminal. If not, see . +from . import ServiceBase +from ..exceptions import DownloadFailedError, ServiceError +from ..cache import cachedmethod +from ..language import language_set, Language +from ..subtitles import get_subtitle_path, ResultSubtitle, EXTENSIONS +from ..utils import get_keywords +from ..videos import Episode +from bs4 import BeautifulSoup +import logging +import re +import os +import requests +import zipfile +import StringIO +import guessit + +from sickbeard.common import Quality + +logger = logging.getLogger("subliminal") + + +class Itasa(ServiceBase): + server_url = 'http://www.italiansubs.net/' + site_url = 'http://www.italiansubs.net/' + api_based = False + languages = language_set(['it']) + videos = [Episode] + require_video = False + required_features = ['permissive'] + quality_dict = {Quality.SDTV : '', + Quality.SDDVD : 'dvdrip', + Quality.RAWHDTV : '1080i', + Quality.HDTV : '720p', + Quality.FULLHDTV : ('1080p','720p'), + Quality.HDWEBDL : 'web-dl', + Quality.FULLHDWEBDL : 'web-dl', + Quality.HDBLURAY : ('bdrip', 'bluray'), + Quality.FULLHDBLURAY : ('bdrip', 'bluray'), + Quality.UNKNOWN : 'unknown' #Any subtitle will be downloaded + } + + def init(self): + + super(Itasa, self).init() + login_pattern = '' + + response = requests.get(self.server_url + 'index.php') + if response.status_code != 200: + raise ServiceError('Initiate failed') + + match = re.search(login_pattern, response.content, re.IGNORECASE | re.DOTALL) + if not match: + raise ServiceError('Can not find unique id parameter on page') + + login_parameter = {'username': 'sickbeard', + 'passwd': 'subliminal', + 'remember': 'yes', + 'Submit': 'Login', + 'remember': 'yes', + 'option': 'com_user', + 'task': 'login', + 'silent': 'true', + 'return': match.group(1), + match.group(2): match.group(3) + } + + self.session = requests.session() + r = self.session.post(self.server_url + 'index.php', data=login_parameter) + if not re.search('logouticon.png', r.content, re.IGNORECASE | re.DOTALL): + raise ServiceError('Itasa Login Failed') + + @cachedmethod + def get_series_id(self, name): + """Get the show page and cache every show found in it""" + r = self.session.get(self.server_url + 'index.php?option=com_remository&Itemid=9') + soup = BeautifulSoup(r.content, self.required_features) + all_series = soup.find('div', attrs = {'id' : 'remositorycontainerlist'}) + for tv_series in all_series.find_all(href=re.compile('func=select')): + series_name = tv_series.text.lower().strip().replace(':','') + match = re.search('&id=([0-9]+)', tv_series['href']) + if match is None: + continue + series_id = int(match.group(1)) + self.cache_for(self.get_series_id, args=(series_name,), result=series_id) + return self.cached_value(self.get_series_id, args=(name,)) + + def get_episode_id(self, series, series_id, season, episode, quality): + """Get the id subtitle for episode with the given quality""" + + season_link = None + quality_link = None + episode_id = None + + r = self.session.get(self.server_url + 'index.php?option=com_remository&Itemid=6&func=select&id=' + str(series_id)) + soup = BeautifulSoup(r.content, self.required_features) + all_seasons = soup.find('div', attrs = {'id' : 'remositorycontainerlist'}) + for seasons in all_seasons.find_all(href=re.compile('func=select')): + if seasons.text.lower().strip() == 'stagione %s' % str(season): + season_link = seasons['href'] + break + + if not season_link: + logger.debug(u'Could not find season %s for series %s' % (series, str(season))) + return None + + r = self.session.get(season_link) + soup = BeautifulSoup(r.content, self.required_features) + + all_qualities = soup.find('div', attrs = {'id' : 'remositorycontainerlist'}) + for qualities in all_qualities.find_all(href=re.compile('func=select')): + if qualities.text.lower().strip() in self.quality_dict[quality]: + quality_link = qualities['href'] + r = self.session.get(qualities['href']) + soup = BeautifulSoup(r.content, self.required_features) + break + + #If we want SDTV we are just on the right page so quality link will be None + if not quality == Quality.SDTV and not quality_link: + logger.debug(u'Could not find a subtitle with required quality for series %s season %s' % (series, str(season))) + return None + + all_episodes = soup.find('div', attrs = {'id' : 'remositoryfilelisting'}) + for episodes in all_episodes.find_all(href=re.compile('func=fileinfo')): + ep_string = "%(seasonnumber)dx%(episodenumber)02d" % {'seasonnumber': season, 'episodenumber': episode} + if re.search(ep_string, episodes.text, re.I) or re.search('completa$', episodes.text, re.I): + match = re.search('&id=([0-9]+)', episodes['href']) + if match: + episode_id = match.group(1) + return episode_id + + return episode_id + + def list_checked(self, video, languages): + return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) + + def query(self, filepath, languages, keywords, series, season, episode): + + logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) + self.init_cache() + try: + series = series.lower().replace('(','').replace(')','') + series_id = self.get_series_id(series) + except KeyError: + logger.debug(u'Could not find series id for %s' % series) + return [] + + episode_id = self.get_episode_id(series, series_id, season, episode, Quality.nameQuality(filepath)) + if not episode_id: + logger.debug(u'Could not find subtitle for series %s' % series) + return [] + + r = self.session.get(self.server_url + 'index.php?option=com_remository&Itemid=6&func=fileinfo&id=' + episode_id) + soup = BeautifulSoup(r.content) + + sub_link = soup.find('div', attrs = {'id' : 'remositoryfileinfo'}).find(href=re.compile('func=download'))['href'] + sub_language = self.get_language('it') + path = get_subtitle_path(filepath, sub_language, self.config.multi) + subtitle = ResultSubtitle(path, sub_language, self.__class__.__name__.lower(), sub_link) + + return [subtitle] + + def download(self, subtitle): + + logger.info(u'Downloading %s in %s' % (subtitle.link, subtitle.path)) + try: + r = self.session.get(subtitle.link, headers={'Referer': self.server_url, 'User-Agent': self.user_agent}) + zipcontent = StringIO.StringIO(r.content) + zipsub = zipfile.ZipFile(zipcontent) + +# if not zipsub.is_zipfile(zipcontent): +# raise DownloadFailedError('Downloaded file is not a zip file') + + subfile = '' + if len(zipsub.namelist()) == 1: + subfile = zipsub.namelist()[0] + else: + #Season Zip Retrive Season and episode Numbers from path + guess = guessit.guess_file_info(subtitle.path, 'episode') + ep_string = "s%(seasonnumber)02de%(episodenumber)02d" % {'seasonnumber': guess['season'], 'episodenumber': guess['episodeNumber']} + for file in zipsub.namelist(): + if re.search(ep_string, file, re.I): + subfile = file + break + if os.path.splitext(subfile)[1] in EXTENSIONS: + with open(subtitle.path, 'wb') as f: + f.write(zipsub.open(subfile).read()) + else: + zipsub.close() + raise DownloadFailedError('No subtitles found in zip file') + + zipsub.close() + except Exception as e: + if os.path.exists(subtitle.path): + os.remove(subtitle.path) + raise DownloadFailedError(str(e)) + + logger.debug(u'Download finished') + +Service = Itasa \ No newline at end of file diff --git a/lib/subliminal/services/opensubtitles.py b/lib/subliminal/services/opensubtitles.py index fba8e4091d..65599d2450 100644 --- a/lib/subliminal/services/opensubtitles.py +++ b/lib/subliminal/services/opensubtitles.py @@ -74,9 +74,9 @@ class OpenSubtitles(ServiceBase): 'twi', 'tyv', 'udm', 'uga', 'uig', 'ukr', 'umb', 'urd', 'uzb', 'vai', 'ven', 'vie', 'vol', 'vot', 'wak', 'wal', 'war', 'was', 'wel', 'wen', 'wln', 'wol', 'xal', 'xho', 'yao', 'yap', 'yid', 'yor', 'ypk', 'zap', 'zen', 'zha', 'znd', 'zul', 'zun', - 'por-BR', 'rum-MD']) - language_map = {'mol': Language('rum-MD'), 'scc': Language('srp'), 'pob': Language('por-BR'), - Language('rum-MD'): 'mol', Language('srp'): 'scc', Language('por-BR'): 'pob'} + 'pob', 'rum-MD']) + language_map = {'mol': Language('rum-MD'), 'scc': Language('srp'), + Language('rum-MD'): 'mol', Language('srp'): 'scc'} language_code = 'alpha3' videos = [Episode, Movie] require_video = False diff --git a/lib/subliminal/services/podnapisi.py b/lib/subliminal/services/podnapisi.py index 108de211ba..be02dd51d5 100644 --- a/lib/subliminal/services/podnapisi.py +++ b/lib/subliminal/services/podnapisi.py @@ -37,10 +37,10 @@ class Podnapisi(ServiceBase): 'es', 'et', 'fa', 'fi', 'fr', 'ga', 'he', 'hi', 'hr', 'hu', 'id', 'is', 'it', 'ja', 'ko', 'lt', 'lv', 'mk', 'ms', 'nl', 'nn', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sq', 'sr', 'sv', 'th', 'tr', 'uk', - 'vi', 'zh', 'es-ar', 'pt-br']) + 'vi', 'zh', 'es-ar', 'pb']) language_map = {'jp': Language('jpn'), Language('jpn'): 'jp', 'gr': Language('gre'), Language('gre'): 'gr', - 'pb': Language('por-BR'), Language('por-BR'): 'pb', +# 'pb': Language('por-BR'), Language('por-BR'): 'pb', 'ag': Language('spa-AR'), Language('spa-AR'): 'ag', 'cyr': Language('srp')} videos = [Episode, Movie] diff --git a/lib/subliminal/services/subswiki.py b/lib/subliminal/services/subswiki.py index 2a3d57f8a5..9f9a341413 100644 --- a/lib/subliminal/services/subswiki.py +++ b/lib/subliminal/services/subswiki.py @@ -33,9 +33,9 @@ class SubsWiki(ServiceBase): server_url = 'http://www.subswiki.com' site_url = 'http://www.subswiki.com' api_based = False - languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'por-BR', 'por', 'spa-ES', u'spa', u'ita', u'cat']) + languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'pob', 'por', 'spa-ES', u'spa', u'ita', u'cat']) language_map = {u'Español': Language('spa'), u'Español (España)': Language('spa'), u'Español (Latinoamérica)': Language('spa'), - u'Català': Language('cat'), u'Brazilian': Language('por-BR'), u'English (US)': Language('eng-US'), + u'Català': Language('cat'), u'Brazilian': Language('pob'), u'English (US)': Language('eng-US'), u'English (UK)': Language('eng-GB')} language_code = 'name' videos = [Episode, Movie] diff --git a/lib/subliminal/services/subtitulos.py b/lib/subliminal/services/subtitulos.py index 103b241c97..6dd085a3b4 100644 --- a/lib/subliminal/services/subtitulos.py +++ b/lib/subliminal/services/subtitulos.py @@ -34,9 +34,9 @@ class Subtitulos(ServiceBase): server_url = 'http://www.subtitulos.es' site_url = 'http://www.subtitulos.es' api_based = False - languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'por-BR', 'por', 'spa-ES', u'spa', u'ita', u'cat']) + languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'pob', 'por', 'spa-ES', u'spa', u'ita', u'cat']) language_map = {u'Español': Language('spa'), u'Español (España)': Language('spa'), #u'Español (Latinoamérica)': Language('spa'), - u'Català': Language('cat'), u'Brazilian': Language('por-BR'), u'English (US)': Language('eng-US'), + u'Català': Language('cat'), u'Brazilian': Language('pob'), u'English (US)': Language('eng-US'), u'English (UK)': Language('eng-GB'), 'Galego': Language('glg')} language_code = 'name' videos = [Episode] @@ -52,7 +52,7 @@ def list_checked(self, video, languages): return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) def query(self, filepath, languages, keywords, series, season, episode): - request_series = series.lower().replace(' ', '_').replace('&', '@').replace('(','').replace(')','') + request_series = series.lower().replace(' ', '-').replace('&', '@').replace('(','').replace(')','') if isinstance(request_series, unicode): request_series = unicodedata.normalize('NFKD', request_series).encode('ascii', 'ignore') logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) diff --git a/lib/subliminal/services/thesubdb.py b/lib/subliminal/services/thesubdb.py index 9d2ced82bf..93787ad62e 100644 --- a/lib/subliminal/services/thesubdb.py +++ b/lib/subliminal/services/thesubdb.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . from . import ServiceBase -from ..language import language_set +from ..language import language_set, Language from ..subtitles import get_subtitle_path, ResultSubtitle from ..videos import Episode, Movie, UnknownVideo import logging @@ -32,7 +32,7 @@ class TheSubDB(ServiceBase): api_based = True # Source: http://api.thesubdb.com/?action=languages languages = language_set(['af', 'cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'id', 'it', - 'la', 'nl', 'no', 'oc', 'pl', 'pt', 'ro', 'ru', 'sl', 'sr', 'sv', + 'la', 'nl', 'no', 'oc', 'pl', 'pb', 'ro', 'ru', 'sl', 'sr', 'sv', 'tr']) videos = [Movie, Episode, UnknownVideo] require_video = True @@ -49,6 +49,10 @@ def query(self, filepath, moviehash, languages): logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) return [] available_languages = language_set(r.content.split(',')) + #this is needed becase for theSubDB pt languages is Portoguese Brazil and not Portoguese# + #So we are deleting pt language and adding pb language + if Language('pt') in available_languages: + available_languages = available_languages - language_set(['pt']) | language_set(['pb']) languages &= available_languages if not languages: logger.debug(u'Could not find subtitles for hash %s with languages %r (only %r available)' % (moviehash, languages, available_languages)) diff --git a/lib/subliminal/services/tvsubtitles.py b/lib/subliminal/services/tvsubtitles.py index 27992226d2..f6b2fd52b6 100644 --- a/lib/subliminal/services/tvsubtitles.py +++ b/lib/subliminal/services/tvsubtitles.py @@ -43,10 +43,10 @@ class TvSubtitles(ServiceBase): api_based = False languages = language_set(['ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sv', 'tr', 'uk', - 'zh', 'pt-br']) + 'zh', 'pb']) #TODO: Find more exceptions language_map = {'gr': Language('gre'), 'cz': Language('cze'), 'ua': Language('ukr'), - 'cn': Language('chi')} + 'cn': Language('chi'), 'br': Language('pob')} videos = [Episode] require_video = False required_features = ['permissive'] From fab3dd828eef1e406e338d2d317d9d6c467775ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Tue, 28 May 2013 14:10:46 +0200 Subject: [PATCH 080/492] Corect display for config - post process --- data/css/config.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/css/config.css b/data/css/config.css index 6110e17940..504a6b6785 100644 --- a/data/css/config.css +++ b/data/css/config.css @@ -16,7 +16,7 @@ #config-components-border{float:left;width:auto;border-top:1px solid #999;padding:5px 0;} #config .title-group{border-bottom:1px dotted #666;position:relative;padding:25px 15px 25px;} #config .component-group{border-bottom:1px dotted #666;padding:15px 15px 25px;} -#config .component-group-desc{float:left;width:235px;} +#config .component-group-desc{float:left;width:235px;clear:left;} #config .component-group-desc h3{font-size:1.5em;} #config .component-group-desc p{width:85%;font-size:1.0em;color:#666;margin:.8em 0;} #config .component-group-desc p.note{width:90%;/*font-size:1.2em*/;color:#333;margin:.8em 0;} From 425c6372c36b8155e59e4c42d706ad3164a656ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Tue, 28 May 2013 14:13:01 +0200 Subject: [PATCH 081/492] Add option for seeding torrent. --- .../default/config_postProcessing.tmpl | 21 ++++ data/interfaces/default/inc_top.tmpl | 1 + sickbeard/__init__.py | 9 +- sickbeard/databases/mainDB.py | 16 ++- sickbeard/helpers.py | 20 ++++ sickbeard/postProcessor.py | 81 +++++++++++++++- sickbeard/processTV.py | 97 ++++++++++++------- sickbeard/tv.py | 5 + sickbeard/webserve.py | 17 +++- 9 files changed, 220 insertions(+), 47 deletions(-) diff --git a/data/interfaces/default/config_postProcessing.tmpl b/data/interfaces/default/config_postProcessing.tmpl index bc879148b6..ae3ac4516f 100644 --- a/data/interfaces/default/config_postProcessing.tmpl +++ b/data/interfaces/default/config_postProcessing.tmpl @@ -135,6 +135,27 @@ + + +

    diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index b8e3c0e094..9fc81fc1c8 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -80,6 +80,7 @@ \$("#SubMenu a:contains('Clear History')").addClass('btn confirm').html(' Clear History '); \$("#SubMenu a:contains('Trim History')").addClass('btn confirm').html(' Trim History '); \$("#SubMenu a:contains('Trunc Episode Links')").addClass('btn confirm').html(' Trunc Episode Links '); + \$("#SubMenu a:contains('Trunc Episode List Processed')").addClass('btn confirm').html(' Trunc Episode List Processed '); \$("#SubMenu a[href='/errorlogs/clearerrors']").addClass('btn').html(' Clear Errors '); \$("#SubMenu a:contains('Re-scan')").addClass('btn').html(' Re-scan '); \$("#SubMenu a:contains('Backlog Overview')").addClass('btn').html(' Backlog Overview '); diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index fffb1ee36f..3b42989bf9 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -182,6 +182,7 @@ PROCESS_AUTOMATICALLY = False PROCESS_AUTOMATICALLY_TORRENT = False KEEP_PROCESSED_DIR = False +PROCESS_METHOD = None MOVE_ASSOCIATED_FILES = False TV_DOWNLOAD_DIR = None TORRENT_DOWNLOAD_DIR = None @@ -437,7 +438,7 @@ def initialize(consoleLogging=True): USE_SYNOLOGYNOTIFIER, SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH, SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD, SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD, \ USE_MAIL, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SERVER, MAIL_SSL, MAIL_FROM, MAIL_TO, MAIL_NOTIFY_ONSNATCH, \ NZBMATRIX_APIKEY, versionCheckScheduler, VERSION_NOTIFY, PROCESS_AUTOMATICALLY, PROCESS_AUTOMATICALLY_TORRENT, \ - KEEP_PROCESSED_DIR, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ + KEEP_PROCESSED_DIR, PROCESS_METHOD, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ showQueueScheduler, searchQueueScheduler, ROOT_DIRS, CACHE_DIR, ACTUAL_CACHE_DIR, TVDB_API_PARMS, \ NAMING_PATTERN, NAMING_MULTI_EP, NAMING_FORCE_FOLDERS, NAMING_ABD_PATTERN, NAMING_CUSTOM_ABD, \ RENAME_EPISODES, properFinderScheduler, PROVIDER_ORDER, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, \ @@ -525,8 +526,8 @@ def initialize(consoleLogging=True): TVDB_API_PARMS['cache'] = os.path.join(CACHE_DIR, 'tvdb') TVDB_BASE_URL = 'http://thetvdb.com/api/' + TVDB_API_KEY - - TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') + + TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') QUALITY_DEFAULT = check_setting_int(CFG, 'General', 'quality_default', SD) STATUS_DEFAULT = check_setting_int(CFG, 'General', 'status_default', SKIPPED) AUDIO_SHOW_DEFAULT = check_setting_str(CFG, 'General', 'audio_show_default', 'fr' ) @@ -570,6 +571,7 @@ def initialize(consoleLogging=True): PROCESS_AUTOMATICALLY_TORRENT = check_setting_int(CFG, 'General', 'process_automatically_torrent', 0) RENAME_EPISODES = check_setting_int(CFG, 'General', 'rename_episodes', 1) KEEP_PROCESSED_DIR = check_setting_int(CFG, 'General', 'keep_processed_dir', 1) + PROCESS_METHOD = check_setting_str(CFG, 'General', 'process_method', 'copy' if KEEP_PROCESSED_DIR else 'move') MOVE_ASSOCIATED_FILES = check_setting_int(CFG, 'General', 'move_associated_files', 0) CREATE_MISSING_SHOW_DIRS = check_setting_int(CFG, 'General', 'create_missing_show_dirs', 0) ADD_SHOWS_WO_DIR = check_setting_int(CFG, 'General', 'add_shows_wo_dir', 0) @@ -1298,6 +1300,7 @@ def save_config(): new_config['General']['tv_download_dir'] = TV_DOWNLOAD_DIR new_config['General']['torrent_download_dir'] = TORRENT_DOWNLOAD_DIR new_config['General']['keep_processed_dir'] = int(KEEP_PROCESSED_DIR) + new_config['General']['process_method'] = PROCESS_METHOD new_config['General']['move_associated_files'] = int(MOVE_ASSOCIATED_FILES) new_config['General']['process_automatically'] = int(PROCESS_AUTOMATICALLY) new_config['General']['process_automatically_torrent'] = int(PROCESS_AUTOMATICALLY_TORRENT) diff --git a/sickbeard/databases/mainDB.py b/sickbeard/databases/mainDB.py index 825d066b26..ef67fcafb8 100644 --- a/sickbeard/databases/mainDB.py +++ b/sickbeard/databases/mainDB.py @@ -25,7 +25,7 @@ from sickbeard import encodingKludge as ek from sickbeard.name_parser.parser import NameParser, InvalidNameException -MAX_DB_VERSION = 15 +MAX_DB_VERSION = 16 class MainSanityCheck(db.DBSanityCheck): @@ -103,7 +103,7 @@ def execute(self): "CREATE TABLE history (action NUMERIC, date NUMERIC, showid NUMERIC, season NUMERIC, episode NUMERIC, quality NUMERIC, resource TEXT, provider NUMERIC);", "CREATE TABLE episode_links (episode_id INTEGER, link TEXT);", "CREATE TABLE imdb_info (tvdb_id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, year NUMERIC, akas TEXT, runtimes NUMERIC, genres TEXT, countries TEXT, country_codes TEXT, certificates TEXT, rating TEXT, votes INTEGER, last_update NUMERIC);" - + "CREATE TABLE processed_files (episode_id INTEGER, filename TEXT, md5 TEXT)" ] for query in queries: self.connection.action(query) @@ -717,6 +717,7 @@ def execute(self): if self.hasTable("episode_links") != True: self.connection.action("CREATE TABLE episode_links (episode_id INTEGER, link TEXT)") self.incDBVersion() + class AddIMDbInfo(AddEpisodeLinkTable): def test(self): return self.checkDBVersion() >= 15 @@ -724,4 +725,13 @@ def test(self): def execute(self): if self.hasTable("imdb_info") != True: self.connection.action("CREATE TABLE imdb_info (tvdb_id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, year NUMERIC, akas TEXT, runtimes NUMERIC, genres TEXT, countries TEXT, country_codes TEXT, certificates TEXT, rating TEXT, votes INTEGER, last_update NUMERIC)") - self.incDBVersion() \ No newline at end of file + self.incDBVersion() + +class AddProcessedFilesTable(AddIMDbInfo): + def test(self): + return self.checkDBVersion() >= 16 + + def execute(self): + if self.hasTable("processed_files") != True: + self.connection.action("CREATE TABLE processed_files (episode_id INTEGER, filename TEXT, md5 TEXT)") + self.incDBVersion() \ No newline at end of file diff --git a/sickbeard/helpers.py b/sickbeard/helpers.py index 93208c7eb6..0114f62918 100644 --- a/sickbeard/helpers.py +++ b/sickbeard/helpers.py @@ -41,6 +41,7 @@ from sickbeard import encodingKludge as ek from sickbeard import notifiers +#from lib.linktastic import linktastic from lib.tvdb_api import tvdb_api, tvdb_exceptions import xml.etree.cElementTree as etree @@ -480,6 +481,25 @@ def moveFile(srcFile, destFile): copyFile(srcFile, destFile) ek.ek(os.unlink, srcFile) +# def hardlinkFile(srcFile, destFile): +# try: +# ek.ek(linktastic.link, srcFile, destFile) +# fixSetGroupID(destFile) +# except OSError: +# logger.log(u"Failed to create hardlink of " + srcFile + " at " + destFile + ". Copying instead", logger.ERROR) +# copyFile(srcFile, destFile) +# ek.ek(os.unlink, srcFile) +# +# def moveAndSymlinkFile(srcFile, destFile): +# try: +# ek.ek(os.rename, srcFile, destFile) +# fixSetGroupID(destFile) +# ek.ek(linktastic.symlink, destFile, srcFile) +# except OSError: +# logger.log(u"Failed to create symlink of " + srcFile + " at " + destFile + ". Copying instead", logger.ERROR) +# copyFile(srcFile, destFile) +# ek.ek(os.unlink, srcFile) + def del_empty_dirs(s_dir): b_empty = True diff --git a/sickbeard/postProcessor.py b/sickbeard/postProcessor.py index fb4b64ecef..bb5155a6e9 100755 --- a/sickbeard/postProcessor.py +++ b/sickbeard/postProcessor.py @@ -25,6 +25,7 @@ import subprocess import sickbeard +import hashlib from sickbeard import db from sickbeard import classes @@ -361,6 +362,44 @@ def _int_copy (cur_file_path, new_file_path): self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_copy, subtitles=subtitles) + def _hardlink(self, file_path, new_path, new_base_name, associated_files=False): + """ + file_path: The full path of the media file to move + new_path: Destination path where we want to create a hard linked file + new_base_name: The base filename (no extension) to use during the link. Use None to keep the same name. + associated_files: Boolean, whether we should move similarly-named files too + """ + + def _int_hard_link(cur_file_path, new_file_path): + + self._log(u"Hard linking file from " + cur_file_path + " to " + new_file_path, logger.DEBUG) + try: + helpers.hardlinkFile(cur_file_path, new_file_path) + helpers.chmodAsParent(new_file_path) + except (IOError, OSError), e: + self._log("Unable to link file " + cur_file_path + " to " + new_file_path + ": "+ex(e), logger.ERROR) + raise e + self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_hard_link) + + def _moveAndSymlink(self, file_path, new_path, new_base_name, associated_files=False): + """ + file_path: The full path of the media file to move + new_path: Destination path where we want to move the file to create a symbolic link to + new_base_name: The base filename (no extension) to use during the link. Use None to keep the same name. + associated_files: Boolean, whether we should move similarly-named files too + """ + + def _int_move_and_sym_link(cur_file_path, new_file_path): + + self._log(u"Moving then symbolic linking file from " + cur_file_path + " to " + new_file_path, logger.DEBUG) + try: + helpers.moveAndSymlinkFile(cur_file_path, new_file_path) + helpers.chmodAsParent(new_file_path) + except (IOError, OSError), e: + self._log("Unable to link file " + cur_file_path + " to " + new_file_path + ": " + ex(e), logger.ERROR) + raise e + self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_move_and_sym_link) + def _history_lookup(self): """ Look up the NZB name in the history and see if it contains a record for self.nzb_name @@ -900,15 +939,49 @@ def process(self): new_base_name = None new_file_name = self.file_name + with open(self.file_path, 'rb') as fh: + m = hashlib.md5() + while True: + data = fh.read(8192) + if not data: + break + m.update(data) + MD5 = m.hexdigest() + try: - # move the episode and associated files to the show dir - if sickbeard.KEEP_PROCESSED_DIR: - self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + + path,file=os.path.split(self.file_path) + + if sickbeard.TORRENT_DOWNLOAD_DIR == path: + #Action possible pour les torrent + if sickbeard.PROCESS_METHOD == "copy": + self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + elif sickbeard.PROCESS_METHOD == "move": + self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + else: + logger.log(u"Unknown process method: " + str(sickbeard.PROCESS_METHOD), logger.ERROR) + raise exceptions.PostProcessingFailed("Unable to move the files to their new home") else: - self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + #action pour le reste des fichier + if sickbeard.KEEP_PROCESSED_DIR: + self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + else: + self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + except (OSError, IOError): raise exceptions.PostProcessingFailed("Unable to move the files to their new home") + myDB = db.DBConnection() + + ## INSERT MD5 of file + controlMD5 = {"episode_id" : int(ep_obj.tvdbid) } + NewValMD5 = {"filename" : new_base_name , + "md5" : MD5 + } + myDB.upsert("processed_files", NewValMD5, controlMD5) + + + # put the new location in the database for cur_ep in [ep_obj] + ep_obj.relatedEps: with cur_ep.lock: diff --git a/sickbeard/processTV.py b/sickbeard/processTV.py index 0d26c1b31d..d879b4fdf9 100644 --- a/sickbeard/processTV.py +++ b/sickbeard/processTV.py @@ -20,6 +20,7 @@ import os import shutil +import hashlib import sickbeard from sickbeard import postProcessor @@ -103,39 +104,65 @@ def processDir (dirName, nzbName=None, recurse=False): cur_video_file_path = ek.ek(os.path.join, dirName, cur_video_file_path) - try: - processor = postProcessor.PostProcessor(cur_video_file_path, nzbName) - process_result = processor.process() - process_fail_message = "" - except exceptions.PostProcessingFailed, e: - process_result = False - process_fail_message = ex(e) - - returnStr += processor.log - - # as long as the postprocessing was successful delete the old folder unless the config wants us not to - if process_result: - - if len(videoFiles) == 1 and not sickbeard.KEEP_PROCESSED_DIR and \ - ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TV_DOWNLOAD_DIR) and \ - ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TORRENT_DOWNLOAD_DIR) and \ - len(remainingFolders) == 0: - - returnStr += logHelper(u"Deleting folder " + dirName, logger.DEBUG) - - try: - shutil.rmtree(dirName) - except (OSError, IOError), e: - returnStr += logHelper(u"Warning: unable to remove the folder " + dirName + ": " + ex(e), logger.WARNING) - - returnStr += logHelper(u"Processing succeeded for "+cur_video_file_path) - - else: - returnStr += logHelper(u"Processing failed for "+cur_video_file_path+": "+process_fail_message, logger.WARNING) - if sickbeard.TV_DOWNLOAD_DIR !="": - for i in range(1,5): - helpers.del_empty_dirs(sickbeard.TV_DOWNLOAD_DIR) - if sickbeard.TORRENT_DOWNLOAD_DIR !="": - for i in range(1,5): - helpers.del_empty_dirs(sickbeard.TORRENT_DOWNLOAD_DIR) + # IF VIDEO_FILE ALREADY PROCESS THEN CONTINUE + # TODO + + myDB = db.DBConnection() + + with open(cur_video_file_path, 'rb') as fh: + m = hashlib.md5() + while True: + data = fh.read(8192) + if not data: + break + m.update(data) + MD5 = m.hexdigest() + + logger.log("MD5 search : " + MD5, logger.DEBUG) + + sqlResults = myDB.select("select * from processed_files where md5 = \"" + MD5 + "\"") + + process_file = True + + for sqlProcess in sqlResults: + if sqlProcess["md5"] == MD5: + logger.log("File " + cur_video_file_path + " already processed for " + sqlProcess["filename"]) + process_file = False + + if process_file: + try: + processor = postProcessor.PostProcessor(cur_video_file_path, nzbName) + process_result = processor.process() + process_fail_message = "" + except exceptions.PostProcessingFailed, e: + process_result = False + process_fail_message = ex(e) + + returnStr += processor.log + + # as long as the postprocessing was successful delete the old folder unless the config wants us not to + if process_result: + + if len(videoFiles) == 1 \ + and ( ( not sickbeard.KEEP_PROCESSED_DIR and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TV_DOWNLOAD_DIR) ) \ + or ( sickbeard.PROCESS_METHOD == "move" and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TORRENT_DOWNLOAD_DIR) ) ) \ + and len(remainingFolders) == 0: + + returnStr += logHelper(u"Deleting folder " + dirName, logger.DEBUG) + + try: + shutil.rmtree(dirName) + except (OSError, IOError), e: + returnStr += logHelper(u"Warning: unable to remove the folder " + dirName + ": " + ex(e), logger.WARNING) + + returnStr += logHelper(u"Processing succeeded for "+cur_video_file_path) + + else: + returnStr += logHelper(u"Processing failed for "+cur_video_file_path+": "+process_fail_message, logger.WARNING) + if sickbeard.TV_DOWNLOAD_DIR !="": + for i in range(1,5): + helpers.del_empty_dirs(sickbeard.TV_DOWNLOAD_DIR) + if sickbeard.TORRENT_DOWNLOAD_DIR !="": + for i in range(1,5): + helpers.del_empty_dirs(sickbeard.TORRENT_DOWNLOAD_DIR) return returnStr diff --git a/sickbeard/tv.py b/sickbeard/tv.py index 32debdd084..2cdb0ada54 100644 --- a/sickbeard/tv.py +++ b/sickbeard/tv.py @@ -24,6 +24,7 @@ import re import glob import traceback +import hashlib import sickbeard @@ -967,6 +968,7 @@ def saveToDB(self): myDB.upsert("tv_shows", newValueDict, controlValueDict) + if self.imdbid: controlValueDict = {"tvdb_id": self.tvdbid} newValueDict = self.imdb_info @@ -1616,9 +1618,12 @@ def saveToDB(self, forceSave=False): "season": self.season, "episode": self.episode} + + # use a custom update/insert method to get the data into the DB myDB.upsert("tv_episodes", newValueDict, controlValueDict) + def fullPath (self): if self.location == None or self.location == "": return None diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 906db3aadc..c041c3ce30 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -768,6 +768,7 @@ def index(self, limit=100): { 'title': 'Clear History', 'path': 'history/clearHistory' }, { 'title': 'Trim History', 'path': 'history/trimHistory' }, { 'title': 'Trunc Episode Links', 'path': 'history/truncEplinks' }, + { 'title': 'Trunc Episode List Processed', 'path': 'history/truncEpListProc' }, ] return _munge(t) @@ -801,6 +802,15 @@ def truncEplinks(self): ui.notifications.message('All Episode Links Removed', messnum) redirect("/history") + @cherrypy.expose + def truncEpListProc(self): + myDB = db.DBConnection() + nbep=myDB.select("SELECT count(*) from processed_files") + myDB.action("DELETE FROM processed_files WHERE 1=1") + messnum = str(nbep[0][0]) + ' record for file processed delete' + ui.notifications.message('Clear list of file processed', messnum) + redirect("/history") + ConfigMenu = [ { 'title': 'General', 'path': 'config/general/' }, @@ -1086,7 +1096,7 @@ def index(self): @cherrypy.expose def savePostProcessing(self, naming_pattern=None, naming_multi_ep=None, xbmc_data=None, xbmc__frodo__data=None, mediabrowser_data=None, synology_data=None, sony_ps3_data=None, wdtv_data=None, tivo_data=None, - use_banner=None, keep_processed_dir=None, process_automatically=None, process_automatically_torrent=None, rename_episodes=None, + use_banner=None, keep_processed_dir=None, process_method=None, process_automatically=None, process_automatically_torrent=None, rename_episodes=None, move_associated_files=None, tv_download_dir=None, torrent_download_dir=None, naming_custom_abd=None, naming_abd_pattern=None): results = [] @@ -1135,6 +1145,7 @@ def savePostProcessing(self, naming_pattern=None, naming_multi_ep=None, sickbeard.PROCESS_AUTOMATICALLY = process_automatically sickbeard.PROCESS_AUTOMATICALLY_TORRENT = process_automatically_torrent sickbeard.KEEP_PROCESSED_DIR = keep_processed_dir + sickbeard.PROCESS_METHOD = process_method sickbeard.RENAME_EPISODES = rename_episodes sickbeard.MOVE_ASSOCIATED_FILES = move_associated_files sickbeard.NAMING_CUSTOM_ABD = naming_custom_abd @@ -3495,6 +3506,7 @@ def setHomeLayout(self, layout): sickbeard.HOME_LAYOUT = layout redirect("/home") + @cherrypy.expose def setHomeSearch(self, search): @@ -3504,6 +3516,7 @@ def setHomeSearch(self, search): sickbeard.TOGGLE_SEARCH= search redirect("/home") + @cherrypy.expose def toggleDisplayShowSpecials(self, show): @@ -3741,4 +3754,4 @@ def calendar(self): errorlogs = ErrorLogs() - ui = UI() + ui = UI() \ No newline at end of file From b70c2654560c298b628f4153068709ceb71576fd Mon Sep 17 00:00:00 2001 From: foXaCe Date: Tue, 28 May 2013 20:06:45 +0200 Subject: [PATCH 082/492] update and rezise "search and corbeille .png" to 24px --- data/images/corbeille.png | Bin 16531 -> 1609 bytes data/images/search16.png | Bin 690 -> 726 bytes data/images/search32.png | Bin 1458 -> 1437 bytes data/interfaces/default/displayShow.tmpl | 6 +++--- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/images/corbeille.png b/data/images/corbeille.png index d057ce92d6a136700cbc91f37e9e76660fba1b30..bfb261ea406a76ebef0a71168bee936700847c91 100644 GIT binary patch literal 1609 zcmV-P2DbT$P)458701t+dGqEinX$=h_WeHMCRRlCu+D58Bpr}fa z+6WYyrqG2_+M+fpAfX1@(ug3!(o_mn5H1OeLvWSEI3XT;Jo~(TJH5{ppK816{rL3W z{oV6F_xv8h|G7?|KHVe;!WhSKJM#JbW22*^&n)*oF7_~c?~h}FkkBDXVv8J+lfe)l zX_7hdzHQx|E7S1@B5W}Ty?yRN{bw(}d;4@=o3xGk%)|=^Xj#C$2ak<)_jG^z{KYF> znq>#cbzt9J!}QVBGTxER02crdMUfoG!9XBD|D3#rxvC9Mzy2=@iwy;nvikg66UK~F(_3{r7ED9L>`Y$Kj2R@&XnxZ5|1q^ZQ1=}P` zHBqS=pcHCQpI6CLOf(e+(rk1r7d+1+2nCUAkAR)~VDHqck36|3pl{bt-s~D4xlwG2 zf*2Pe6hh!U8(bETu2`V3NNRIcuv81!=3vxKFlrXqh6BVJ#I=UODSZ4D1~|SbAhYw~ zKlFTI>o>%v7>Nl9`EUS2;Idq7^fy%5hLv1}OjeQ2qSC7-szn`4)mo?^*aF}bFAp)m zxkUk;w>-29z4Zm(OBr1&H_N0S3$3t1Yr&X zDU0mF<@EIHhYO1WR*&xA!z%bybY&XE#t85t2jQT{VsaKvpAihGl`Q!C*;xp2A%A1P zC!kkM0Eq*PDRpD!eEieXPtj68zZF~R5_nR{r@EaYLIjEG&@XhsY z@XoXfg_;3ft<7-awZDN;Mb>;`H%kSzYyc%hAiVan60l;! z*RXY9OQ~aULrhLbk#7itRw~hreSP@*KfMUUL+fDXa)HXlt2i<=Os7tLjCzFuDlDRu zgmz)#!KqgcKDMmv*4^{-_q#T1S}Uhx7>?EvaV6%~#;P z@d>Ec*>JG|_VZUtI#^*Ftn!87D{me-w=CduI}ZM0O?KN}HVY^wC1%ec2|+@SNv=3w zgZb$ad4`L=9vstMh^Wpg(7iGk#fiq5fBdGrETHq|hsJud+a8aklPD!+5Tqc9AwbUa zz+lsS?NWi1ii+eC*`|ZK?inXi&sZjdC| zw{PG6(V_rlL4Rd$PN ztdgQe&g_Xt_uXml+`jFr#bWVHE|+^Yo6Y`uNx)}o)lFk^fR_hd$LjVhy~Wn6f}@s* z`MSo2V1|8u$#u+$%JdoI(H|WSG^bO2?7uy?d-v}AXM6k`F|s&D46tim00000NkvXX Hu0mjf=FA7M literal 16531 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

    =X%u!< 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 b_y~-?u{$C24KD)!p#bnwPDQp%+9ddYXOEn_ diff --git a/data/images/search16.png b/data/images/search16.png index fd6160c7f04e47be34de1cd646a40cb2dd24b9a3..346e4744af9cbeca17a2be6239149bc037326cf9 100644 GIT binary patch delta 703 zcmV;w0zmz;1=a(_`g8%^e z{{R4h=>PzAFaQARU;qF*m;eA5Z<1fdMgRZ;TuDShRCwBq(N9Q|aU93-&+mDDzxB-J z^RzvW+J4N3{bA@cGmT)EP^q9>5ELDXpn?p#bnK*oX@3v>!GBA#)*(+S3(A8XEa=ch z2!w_R>2PDQ(9N+P+%wvGIwhJ@d>_9*c=6@?BiK?ZmAXbLy#zofgygE#YQpdLKS!Nx z+Xes-0GOGXA<<}bzECK1nx?t=D9bVwMaiwLt+lncx0iMW^7;I`Y&LtAQi|5rR=8X) zR4Nr@G8vT1Wq)wazcn>Ab=C!9u^8{|?agj%Y#7@p!;Fhh>o zb-3Ni#f~FKC+i+@Zf;61jpYHrxHtL1Eji9K96W^Xupd<>>(Bb!nLUC3 z>l&F_YLuj)%OxY)*;dH>$RB=v!T7yzfZu%7==SPARYh?svIlw~^l|j0b{YW4<|<@w z>*~`t2fh^P4b|EW2^_aguR002ovPDHLkV1k4jKl%Uw delta 667 zcmV;M0%ZNx1+oQ@BYy&UNklP z79w~PJg5f+Q4jSfCw_ zGYdj%($YW7&M-UQ?|V|PO}Sj|8pAM`@KK^DYF$;;q{riV0)KmmBA-kqLq#Hyua!zA z+-NkQ+wJN1(#YuOXnj{exm@kP)r+}ho``n~zPI6J{%73nYLrRqRDW!B96wtYA<8QmWHx6RGfL(_`g8%^e z{{R4h=>PzAFaQARU;qF*m;eA5Z<1fdMgRZ>BS}O-RCwC#)?aKCMH~n4@67J)&hFjz z?6uUU1X_;cdVgw+u|%acjRsK!Z7>1k!9-2;-58BY3HT?`@PA@7Aw-Q4OM=$;kMLkI zO~exG3%#5Wt#^^yLZ$YAl)LL)bKTw9@qz5pO4D$K`sDBJ=FiUOW`6VAB|-=gLa;N}0U+77ofAT&2q9m`&WGDFDH}9 zcb5$q8ygD%C~(ecxm*UN6t-=nwzd|bPza1M5JF&@CVz^>BBrLMAW0I05YTlUhG8I| z&v(XRv7u!FMn*=y(KKyGKA#7r6prH{8jV7dBoIO%Nzx6+kjZ3VSr&v42!%qhEbB@n z5?NI>V0?VM!Eu~ip-=#&6r6L!Vle=KuIr0FL_ z9^X=JAvYHQcp$eelgVIaW(ELY7=H#dO;cN1TFjdUq|@n(obx6(AzNEp z!8u=$eDeYG+bE^TX0s?13Lu0)RaF>H zY0S;dc{$SF-cG7Ig5lxePb|xN&dugXB;wtGr2w)l!*Lu84Gnq8DTEmB=;&Bi6(E&L zJ%2GfJA2$6z!*a`8U^PZj4|)r=L6hYF*-VmTrPJVO6hwYot-aLU5Ew;2MYjd++ApF zY(zst132gK`~BVyxCpl*j*N^zQIu;Bg9A;`)@*#>{=cdQoH})Ck7Ze(xgEA`qrSc# z;cytHX)eeKNs=%%HH8ZoE_k;@CIXi@`+sTYBb^T}`(-pRFmQ|z((QISj^jZBfdB|0 zs8lM*<#G#e2n5QOKzda>E?%DL`Cx1F+sl3(pEz;iM*v;(+Z@MPFmZl9_y|zp0*5Ap z09Ekly{vhe^?|;K_oPA4eB_RX>nIJvQ(KkuqS9LI^LT4K8OMkA# z*xxgegIk+NmjwWTlP6DxDwWE9&iOL{YX8;EeH5P`&dw;q)7VuP(4jL1RaGJTG&I)L zp!v?a(v`Bc>ix~DE6W1RkHNvgAb=JCJ^+(lU0suIUwm)xue!>T3e%ve*AP@@NHjNq zO3KKAhgK(V8L(8ez4e7E%*rKA)qiUg#ykY5icM{6F?DtB+k;zMo?0;g03=^Ox{h%E zr>5yp)awXA6l~cT!N~di{*Rtq|K^GT03f;V$YT;A{fxN?6^f!ml2vSO597D@B1^|H8o`1uAnxgK$ zwi608Rfo@~LRB^R{eiD~cdXxe1K@T?wCB@qKhuw~c}c0NI(&NIyDxXex2-q;0AhQ4 zcWbIHX&O7q6t>4v^gx2m^&NWl;nIHs0D!ypA9>oOdZ0#U=IK|qA6c}bt|Z`Q;(x$D a0{|BstKYD5UQ7T0002ovP6b4+LSTZ|evf4U delta 1441 zcmV;S1z!4{3$hE4BYy>XNklX-r&I6oAirGxIhE5C)LYv@9LA8KN=LsD*|Y zD~eT7A~yZuk81S4F)>C=>yl{t$7m#Vi4kc~FsWlx6Ty&(km4UUNh(4F+3W~y0|Ud* z+1@{Eq@lvL0mG0!{KF|j|hVB zZDnQU4cMay|F*cexM(LLt_h&1Nu}OyKkRV10f4CrOf?FDWUR z|K9+nrl#(}O@GPE%v3xc4^xB|Z+}S;K+`m^*=&HI1oEQ8;rM*d0I0kgEgm9;D=RA? ziXvO5Ifo$Synl~GB9N7p#bB0~mtS!@o$sd%FgrVI#coa4b&;$l&nW?m=aP<{mw98lCs<& z2!h1%O}pKmn=}AgpN9&U*VfjUa`XbACF}=e-A>^lp?}|wkB_r8dZ(bE0C0~}_-7YVg#mxti^sD*S%4omH#h4^ zIVmhHEsZIsnA7IexMG5!#-T<=MqH#JbQx=~S1Vg2GVPPS5O3Z=**LCGe^TV~rcB_0F07-9e z?|&t+HwKD6eSdq)Ooe(g<7cyWhw;Hb9@V0!?Q21_203qZ{&j$;3e%*lc?o50j!6WyU!0(Hm$IhQ9?oJ7Sq`SNOEmV3Y;fn}hH48AdqC=Nk zgfv;hgq1;4RJhN12xkBG+FKh6XHo(n>3{3%%SLyf#Uy+Vr&&85>lP6@+ortI@CrO@ zvudbVVMWMnQ6b-!0fl$kHg5VtxgQ=s;7D&J_os>zB| zLd#Wo3qmw2;3&)k(Iicrd+>mB&j5*}>YZ<7Bj~@b%36kmUf>WQ%5b#m5G<_*zJF_N zC~Dd_021e$Ulj|w{->&HAj!8O3_*hWBX*dW_niIg$-{5&8vuhibKy~wAapAVhmf&A zX<#zTaJ;$%ew$xy{PgjX%ligE;%I4q)nYN9Q!pPS1RzgXFe&dyw?bh-Hn>;9WnVs3 z^vAvdkd(Kyb*Ykka?2AELTCsfV}D3gEa}#Z7nSi^@!Q!Hr!}^j;bo4ND4$S9~?^H*vF?Iy0Q(Rv$Hc3 ziw%7O=VP;B%ZxTU^Ep7JeoMsY+>(Hi>h-!L!rE#elK0W9(HImpAwyH(nSY#3mtDH` zeE>v1UTe!(N$2n`nG-rXI_OL21#I@H0;+m+ziQE5h&DNHoCtsqF^TYZ=pTu3)P1f(v^i* v70|av1g+l07R1Z!R#Q_`I2!33*bn&!QRq`DrMsWB00000NkvXXu0mjf{o9}= diff --git a/data/interfaces/default/displayShow.tmpl b/data/interfaces/default/displayShow.tmpl index f97fa4bfe7..3ea189a8c5 100644 --- a/data/interfaces/default/displayShow.tmpl +++ b/data/interfaces/default/displayShow.tmpl @@ -351,14 +351,14 @@ $epLoc #end if

    From deee878bb9f14bb9bd4c85ee970d1d691f4ed3b1 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Tue, 28 May 2013 20:10:04 +0200 Subject: [PATCH 083/492] first try on attempt hardlink symlink copy move for torrent seeding (to test on syno) --- .../default/config_postProcessing.tmpl | 42 +- data/interfaces/default/home.tmpl | 4 +- data/interfaces/default/inc_top.tmpl | 2 +- lib/linktastic/README.txt | 19 + lib/linktastic/__init__.py | 0 lib/linktastic/linktastic.py | 76 + lib/linktastic/setup.py | 13 + lib/subliminal/api.py | 5 +- lib/subliminal/core.py | 2 +- lib/subliminal/language.py | 1 - lib/subliminal/services/__init__.py | 10 +- lib/subliminal/services/addic7ed.py | 6 +- lib/subliminal/services/opensubtitles.py | 6 +- lib/subliminal/services/podnapisi.py | 4 +- lib/subliminal/services/subswiki.py | 4 +- lib/subliminal/services/subtitulos.py | 6 +- lib/subliminal/services/thesubdb.py | 8 +- lib/subliminal/services/tvsubtitles.py | 4 +- sickbeard/__init__.py | 10 +- sickbeard/databases/mainDB.py | 24 +- sickbeard/helpers.py | 46 +- sickbeard/postProcessor.py | 2056 +++++++++-------- sickbeard/processTV.py | 336 +-- sickbeard/tv.py | 10 +- sickbeard/webserve.py | 26 +- 25 files changed, 1418 insertions(+), 1302 deletions(-) create mode 100644 lib/linktastic/README.txt create mode 100644 lib/linktastic/__init__.py create mode 100644 lib/linktastic/linktastic.py create mode 100644 lib/linktastic/setup.py diff --git a/data/interfaces/default/config_postProcessing.tmpl b/data/interfaces/default/config_postProcessing.tmpl index ae3ac4516f..5f4df17234 100644 --- a/data/interfaces/default/config_postProcessing.tmpl +++ b/data/interfaces/default/config_postProcessing.tmpl @@ -135,27 +135,27 @@ - - - + + +

    diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index c729955fea..902891dcce 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -109,9 +109,9 @@ \$("#showListTable:has(tbody tr)").tablesorter({ #if ($layout == 'poster'): - sortList: [[7,0],[2,0]], + sortList: [[7,1],[2,0]], #else: - sortList: [[6,0],[1,0]], + sortList: [[6,1],[1,0]], #end if textExtraction: { #if ( $layout == 'poster'): diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index 9fc81fc1c8..974fa672c8 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -80,7 +80,7 @@ \$("#SubMenu a:contains('Clear History')").addClass('btn confirm').html(' Clear History '); \$("#SubMenu a:contains('Trim History')").addClass('btn confirm').html(' Trim History '); \$("#SubMenu a:contains('Trunc Episode Links')").addClass('btn confirm').html(' Trunc Episode Links '); - \$("#SubMenu a:contains('Trunc Episode List Processed')").addClass('btn confirm').html(' Trunc Episode List Processed '); + \$("#SubMenu a:contains('Trunc Episode List Processed')").addClass('btn confirm').html(' Trunc Episode List Processed '); \$("#SubMenu a[href='/errorlogs/clearerrors']").addClass('btn').html(' Clear Errors '); \$("#SubMenu a:contains('Re-scan')").addClass('btn').html(' Re-scan '); \$("#SubMenu a:contains('Backlog Overview')").addClass('btn').html(' Backlog Overview '); diff --git a/lib/linktastic/README.txt b/lib/linktastic/README.txt new file mode 100644 index 0000000000..7fad24dbd0 --- /dev/null +++ b/lib/linktastic/README.txt @@ -0,0 +1,19 @@ +Linktastic + +Linktastic is an extension of the os.link and os.symlink functionality provided +by the python language since version 2. Python only supports file linking on +*NIX-based systems, even though it is relatively simple to engineer a solution +to utilize NTFS's built-in linking functionality. Linktastic attempts to unify +linking on the windows platform with linking on *NIX-based systems. + +Usage + +Linktastic is a single python module and can be imported as such. Examples: + +# Hard linking src to dest +import linktastic +linktastic.link(src, dest) + +# Symlinking src to dest +import linktastic +linktastic.symlink(src, dest) diff --git a/lib/linktastic/__init__.py b/lib/linktastic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/linktastic/linktastic.py b/lib/linktastic/linktastic.py new file mode 100644 index 0000000000..76687666e6 --- /dev/null +++ b/lib/linktastic/linktastic.py @@ -0,0 +1,76 @@ +# Linktastic Module +# - A python2/3 compatible module that can create hardlinks/symlinks on windows-based systems +# +# Linktastic is distributed under the MIT License. The follow are the terms and conditions of using Linktastic. +# +# The MIT License (MIT) +# Copyright (c) 2012 Solipsis Development +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +# associated documentation files (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all copies or substantial +# portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import subprocess +from subprocess import CalledProcessError +import os + + +# Prevent spaces from messing with us! +def _escape_param(param): + return '"%s"' % param + + +# Private function to create link on nt-based systems +def _link_windows(src, dest): + try: + subprocess.check_output( + 'cmd /C mklink /H %s %s' % (_escape_param(dest), _escape_param(src)), + stderr=subprocess.STDOUT) + except CalledProcessError as err: + + raise IOError(err.output.decode('utf-8')) + + # TODO, find out what kind of messages Windows sends us from mklink + # print(stdout) + # assume if they ret-coded 0 we're good + + +def _symlink_windows(src, dest): + try: + subprocess.check_output( + 'cmd /C mklink %s %s' % (_escape_param(dest), _escape_param(src)), + stderr=subprocess.STDOUT) + except CalledProcessError as err: + raise IOError(err.output.decode('utf-8')) + + # TODO, find out what kind of messages Windows sends us from mklink + # print(stdout) + # assume if they ret-coded 0 we're good + + +# Create a hard link to src named as dest +# This version of link, unlike os.link, supports nt systems as well +def link(src, dest): + if os.name == 'nt': + _link_windows(src, dest) + else: + os.link(src, dest) + + +# Create a symlink to src named as dest, but don't fail if you're on nt +def symlink(src, dest): + if os.name == 'nt': + _symlink_windows(src, dest) + else: + os.symlink(src, dest) diff --git a/lib/linktastic/setup.py b/lib/linktastic/setup.py new file mode 100644 index 0000000000..e15cc2b7f3 --- /dev/null +++ b/lib/linktastic/setup.py @@ -0,0 +1,13 @@ +from distutils.core import setup + +setup( + name='Linktastic', + version='0.1.0', + author='Jon "Berkona" Monroe', + author_email='solipsis.dev@gmail.com', + py_modules=['linktastic'], + url="http://github.com/berkona/linktastic", + license='MIT License - See http://opensource.org/licenses/MIT for details', + description='Truly platform-independent file linking', + long_description=open('README.txt').read(), +) diff --git a/lib/subliminal/api.py b/lib/subliminal/api.py index f95fda3b48..3b6f9139d2 100644 --- a/lib/subliminal/api.py +++ b/lib/subliminal/api.py @@ -94,10 +94,7 @@ def download_subtitles(paths, languages=None, services=None, force=True, multi=F order = order or [LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE] subtitles_by_video = list_subtitles(paths, languages, services, force, multi, cache_dir, max_depth, scan_filter) for video, subtitles in subtitles_by_video.iteritems(): - try: - subtitles.sort(key=lambda s: key_subtitles(s, video, languages, services, order), reverse=True) - except StopIteration: - break + subtitles.sort(key=lambda s: key_subtitles(s, video, languages, services, order), reverse=True) results = [] service_instances = {} tasks = create_download_tasks(subtitles_by_video, languages, multi) diff --git a/lib/subliminal/core.py b/lib/subliminal/core.py index 80c7f024f8..1b8c840d12 100644 --- a/lib/subliminal/core.py +++ b/lib/subliminal/core.py @@ -32,7 +32,7 @@ 'create_list_tasks', 'create_download_tasks', 'consume_task', 'matching_confidence', 'key_subtitles', 'group_by_video'] logger = logging.getLogger("subliminal") -SERVICES = ['opensubtitles', 'bierdopje', 'subswiki', 'subtitulos', 'thesubdb', 'addic7ed', 'tvsubtitles', 'itasa'] +SERVICES = ['opensubtitles', 'bierdopje', 'subswiki', 'subtitulos', 'thesubdb', 'addic7ed', 'tvsubtitles'] LANGUAGE_INDEX, SERVICE_INDEX, SERVICE_CONFIDENCE, MATCHING_CONFIDENCE = range(4) diff --git a/lib/subliminal/language.py b/lib/subliminal/language.py index 6403bcc0a5..c89e7abc0b 100644 --- a/lib/subliminal/language.py +++ b/lib/subliminal/language.py @@ -619,7 +619,6 @@ ('pli', '', 'pi', u'Pali', u'pali'), ('pol', '', 'pl', u'Polish', u'polonais'), ('pon', '', '', u'Pohnpeian', u'pohnpei'), - ('pob', '', 'pb', u'Brazilian Portuguese', u'brazilian portuguese'), ('por', '', 'pt', u'Portuguese', u'portugais'), ('pra', '', '', u'Prakrit languages', u'prâkrit, langues'), ('pro', '', '', u'Provençal, Old (to 1500)', u'provençal ancien (jusqu\'à 1500)'), diff --git a/lib/subliminal/services/__init__.py b/lib/subliminal/services/__init__.py index 9a21666c00..7cad1cd6a1 100644 --- a/lib/subliminal/services/__init__.py +++ b/lib/subliminal/services/__init__.py @@ -219,10 +219,18 @@ def download_zip_file(self, url, filepath): # TODO: could check if maybe we already have a text file and # download it directly raise DownloadFailedError('Downloaded file is not a zip file') +# with zipfile.ZipFile(zippath) as zipsub: +# for subfile in zipsub.namelist(): +# if os.path.splitext(subfile)[1] in EXTENSIONS: +# with open(filepath, 'w') as f: +# f.write(zipsub.open(subfile).read()) +# break +# else: +# raise DownloadFailedError('No subtitles found in zip file') zipsub = zipfile.ZipFile(zippath) for subfile in zipsub.namelist(): if os.path.splitext(subfile)[1] in EXTENSIONS: - with open(filepath, 'wb') as f: + with open(filepath, 'w') as f: f.write(zipsub.open(subfile).read()) break else: diff --git a/lib/subliminal/services/addic7ed.py b/lib/subliminal/services/addic7ed.py index 6f7f0f8790..1080cb4798 100644 --- a/lib/subliminal/services/addic7ed.py +++ b/lib/subliminal/services/addic7ed.py @@ -38,12 +38,13 @@ class Addic7ed(ServiceBase): api_based = False #TODO: Complete this languages = language_set(['ar', 'ca', 'de', 'el', 'en', 'es', 'eu', 'fr', 'ga', 'gl', 'he', 'hr', 'hu', - 'it', 'pl', 'pt', 'ro', 'ru', 'se', 'pb']) - language_map = {'Portuguese (Brazilian)': Language('pob'), 'Greek': Language('gre'), + 'it', 'pl', 'pt', 'ro', 'ru', 'se', 'pt-br']) + language_map = {'Portuguese (Brazilian)': Language('por-BR'), 'Greek': Language('gre'), 'Spanish (Latin America)': Language('spa'), 'Galego': Language('glg'), u'Català': Language('cat')} videos = [Episode] require_video = False + required_features = ['permissive'] @cachedmethod def get_series_id(self, name): @@ -63,7 +64,6 @@ def list_checked(self, video, languages): return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) def query(self, filepath, languages, keywords, series, season, episode): - logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) self.init_cache() try: diff --git a/lib/subliminal/services/opensubtitles.py b/lib/subliminal/services/opensubtitles.py index 65599d2450..fba8e4091d 100644 --- a/lib/subliminal/services/opensubtitles.py +++ b/lib/subliminal/services/opensubtitles.py @@ -74,9 +74,9 @@ class OpenSubtitles(ServiceBase): 'twi', 'tyv', 'udm', 'uga', 'uig', 'ukr', 'umb', 'urd', 'uzb', 'vai', 'ven', 'vie', 'vol', 'vot', 'wak', 'wal', 'war', 'was', 'wel', 'wen', 'wln', 'wol', 'xal', 'xho', 'yao', 'yap', 'yid', 'yor', 'ypk', 'zap', 'zen', 'zha', 'znd', 'zul', 'zun', - 'pob', 'rum-MD']) - language_map = {'mol': Language('rum-MD'), 'scc': Language('srp'), - Language('rum-MD'): 'mol', Language('srp'): 'scc'} + 'por-BR', 'rum-MD']) + language_map = {'mol': Language('rum-MD'), 'scc': Language('srp'), 'pob': Language('por-BR'), + Language('rum-MD'): 'mol', Language('srp'): 'scc', Language('por-BR'): 'pob'} language_code = 'alpha3' videos = [Episode, Movie] require_video = False diff --git a/lib/subliminal/services/podnapisi.py b/lib/subliminal/services/podnapisi.py index be02dd51d5..108de211ba 100644 --- a/lib/subliminal/services/podnapisi.py +++ b/lib/subliminal/services/podnapisi.py @@ -37,10 +37,10 @@ class Podnapisi(ServiceBase): 'es', 'et', 'fa', 'fi', 'fr', 'ga', 'he', 'hi', 'hr', 'hu', 'id', 'is', 'it', 'ja', 'ko', 'lt', 'lv', 'mk', 'ms', 'nl', 'nn', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sq', 'sr', 'sv', 'th', 'tr', 'uk', - 'vi', 'zh', 'es-ar', 'pb']) + 'vi', 'zh', 'es-ar', 'pt-br']) language_map = {'jp': Language('jpn'), Language('jpn'): 'jp', 'gr': Language('gre'), Language('gre'): 'gr', -# 'pb': Language('por-BR'), Language('por-BR'): 'pb', + 'pb': Language('por-BR'), Language('por-BR'): 'pb', 'ag': Language('spa-AR'), Language('spa-AR'): 'ag', 'cyr': Language('srp')} videos = [Episode, Movie] diff --git a/lib/subliminal/services/subswiki.py b/lib/subliminal/services/subswiki.py index 9f9a341413..2a3d57f8a5 100644 --- a/lib/subliminal/services/subswiki.py +++ b/lib/subliminal/services/subswiki.py @@ -33,9 +33,9 @@ class SubsWiki(ServiceBase): server_url = 'http://www.subswiki.com' site_url = 'http://www.subswiki.com' api_based = False - languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'pob', 'por', 'spa-ES', u'spa', u'ita', u'cat']) + languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'por-BR', 'por', 'spa-ES', u'spa', u'ita', u'cat']) language_map = {u'Español': Language('spa'), u'Español (España)': Language('spa'), u'Español (Latinoamérica)': Language('spa'), - u'Català': Language('cat'), u'Brazilian': Language('pob'), u'English (US)': Language('eng-US'), + u'Català': Language('cat'), u'Brazilian': Language('por-BR'), u'English (US)': Language('eng-US'), u'English (UK)': Language('eng-GB')} language_code = 'name' videos = [Episode, Movie] diff --git a/lib/subliminal/services/subtitulos.py b/lib/subliminal/services/subtitulos.py index 6dd085a3b4..103b241c97 100644 --- a/lib/subliminal/services/subtitulos.py +++ b/lib/subliminal/services/subtitulos.py @@ -34,9 +34,9 @@ class Subtitulos(ServiceBase): server_url = 'http://www.subtitulos.es' site_url = 'http://www.subtitulos.es' api_based = False - languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'pob', 'por', 'spa-ES', u'spa', u'ita', u'cat']) + languages = language_set(['eng-US', 'eng-GB', 'eng', 'fre', 'por-BR', 'por', 'spa-ES', u'spa', u'ita', u'cat']) language_map = {u'Español': Language('spa'), u'Español (España)': Language('spa'), #u'Español (Latinoamérica)': Language('spa'), - u'Català': Language('cat'), u'Brazilian': Language('pob'), u'English (US)': Language('eng-US'), + u'Català': Language('cat'), u'Brazilian': Language('por-BR'), u'English (US)': Language('eng-US'), u'English (UK)': Language('eng-GB'), 'Galego': Language('glg')} language_code = 'name' videos = [Episode] @@ -52,7 +52,7 @@ def list_checked(self, video, languages): return self.query(video.path or video.release, languages, get_keywords(video.guess), video.series, video.season, video.episode) def query(self, filepath, languages, keywords, series, season, episode): - request_series = series.lower().replace(' ', '-').replace('&', '@').replace('(','').replace(')','') + request_series = series.lower().replace(' ', '_').replace('&', '@').replace('(','').replace(')','') if isinstance(request_series, unicode): request_series = unicodedata.normalize('NFKD', request_series).encode('ascii', 'ignore') logger.debug(u'Getting subtitles for %s season %d episode %d with languages %r' % (series, season, episode, languages)) diff --git a/lib/subliminal/services/thesubdb.py b/lib/subliminal/services/thesubdb.py index 93787ad62e..9d2ced82bf 100644 --- a/lib/subliminal/services/thesubdb.py +++ b/lib/subliminal/services/thesubdb.py @@ -16,7 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with subliminal. If not, see . from . import ServiceBase -from ..language import language_set, Language +from ..language import language_set from ..subtitles import get_subtitle_path, ResultSubtitle from ..videos import Episode, Movie, UnknownVideo import logging @@ -32,7 +32,7 @@ class TheSubDB(ServiceBase): api_based = True # Source: http://api.thesubdb.com/?action=languages languages = language_set(['af', 'cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'id', 'it', - 'la', 'nl', 'no', 'oc', 'pl', 'pb', 'ro', 'ru', 'sl', 'sr', 'sv', + 'la', 'nl', 'no', 'oc', 'pl', 'pt', 'ro', 'ru', 'sl', 'sr', 'sv', 'tr']) videos = [Movie, Episode, UnknownVideo] require_video = True @@ -49,10 +49,6 @@ def query(self, filepath, moviehash, languages): logger.error(u'Request %s returned status code %d' % (r.url, r.status_code)) return [] available_languages = language_set(r.content.split(',')) - #this is needed becase for theSubDB pt languages is Portoguese Brazil and not Portoguese# - #So we are deleting pt language and adding pb language - if Language('pt') in available_languages: - available_languages = available_languages - language_set(['pt']) | language_set(['pb']) languages &= available_languages if not languages: logger.debug(u'Could not find subtitles for hash %s with languages %r (only %r available)' % (moviehash, languages, available_languages)) diff --git a/lib/subliminal/services/tvsubtitles.py b/lib/subliminal/services/tvsubtitles.py index f6b2fd52b6..27992226d2 100644 --- a/lib/subliminal/services/tvsubtitles.py +++ b/lib/subliminal/services/tvsubtitles.py @@ -43,10 +43,10 @@ class TvSubtitles(ServiceBase): api_based = False languages = language_set(['ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ro', 'ru', 'sv', 'tr', 'uk', - 'zh', 'pb']) + 'zh', 'pt-br']) #TODO: Find more exceptions language_map = {'gr': Language('gre'), 'cz': Language('cze'), 'ua': Language('ukr'), - 'cn': Language('chi'), 'br': Language('pob')} + 'cn': Language('chi')} videos = [Episode] require_video = False required_features = ['permissive'] diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 3b42989bf9..bb9db86be7 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -182,7 +182,7 @@ PROCESS_AUTOMATICALLY = False PROCESS_AUTOMATICALLY_TORRENT = False KEEP_PROCESSED_DIR = False -PROCESS_METHOD = None +PROCESS_METHOD = None MOVE_ASSOCIATED_FILES = False TV_DOWNLOAD_DIR = None TORRENT_DOWNLOAD_DIR = None @@ -438,7 +438,7 @@ def initialize(consoleLogging=True): USE_SYNOLOGYNOTIFIER, SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH, SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD, SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD, \ USE_MAIL, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SERVER, MAIL_SSL, MAIL_FROM, MAIL_TO, MAIL_NOTIFY_ONSNATCH, \ NZBMATRIX_APIKEY, versionCheckScheduler, VERSION_NOTIFY, PROCESS_AUTOMATICALLY, PROCESS_AUTOMATICALLY_TORRENT, \ - KEEP_PROCESSED_DIR, PROCESS_METHOD, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ + KEEP_PROCESSED_DIR, PROCESS_METHOD, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ showQueueScheduler, searchQueueScheduler, ROOT_DIRS, CACHE_DIR, ACTUAL_CACHE_DIR, TVDB_API_PARMS, \ NAMING_PATTERN, NAMING_MULTI_EP, NAMING_FORCE_FOLDERS, NAMING_ABD_PATTERN, NAMING_CUSTOM_ABD, \ RENAME_EPISODES, properFinderScheduler, PROVIDER_ORDER, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, \ @@ -527,7 +527,7 @@ def initialize(consoleLogging=True): TVDB_BASE_URL = 'http://thetvdb.com/api/' + TVDB_API_KEY - TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') + TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') QUALITY_DEFAULT = check_setting_int(CFG, 'General', 'quality_default', SD) STATUS_DEFAULT = check_setting_int(CFG, 'General', 'status_default', SKIPPED) AUDIO_SHOW_DEFAULT = check_setting_str(CFG, 'General', 'audio_show_default', 'fr' ) @@ -571,7 +571,7 @@ def initialize(consoleLogging=True): PROCESS_AUTOMATICALLY_TORRENT = check_setting_int(CFG, 'General', 'process_automatically_torrent', 0) RENAME_EPISODES = check_setting_int(CFG, 'General', 'rename_episodes', 1) KEEP_PROCESSED_DIR = check_setting_int(CFG, 'General', 'keep_processed_dir', 1) - PROCESS_METHOD = check_setting_str(CFG, 'General', 'process_method', 'copy' if KEEP_PROCESSED_DIR else 'move') + PROCESS_METHOD = check_setting_str(CFG, 'General', 'process_method', 'copy' if KEEP_PROCESSED_DIR else 'move') MOVE_ASSOCIATED_FILES = check_setting_int(CFG, 'General', 'move_associated_files', 0) CREATE_MISSING_SHOW_DIRS = check_setting_int(CFG, 'General', 'create_missing_show_dirs', 0) ADD_SHOWS_WO_DIR = check_setting_int(CFG, 'General', 'add_shows_wo_dir', 0) @@ -1300,7 +1300,7 @@ def save_config(): new_config['General']['tv_download_dir'] = TV_DOWNLOAD_DIR new_config['General']['torrent_download_dir'] = TORRENT_DOWNLOAD_DIR new_config['General']['keep_processed_dir'] = int(KEEP_PROCESSED_DIR) - new_config['General']['process_method'] = PROCESS_METHOD + new_config['General']['process_method'] = PROCESS_METHOD new_config['General']['move_associated_files'] = int(MOVE_ASSOCIATED_FILES) new_config['General']['process_automatically'] = int(PROCESS_AUTOMATICALLY) new_config['General']['process_automatically_torrent'] = int(PROCESS_AUTOMATICALLY_TORRENT) diff --git a/sickbeard/databases/mainDB.py b/sickbeard/databases/mainDB.py index ef67fcafb8..c45cf0fa9c 100644 --- a/sickbeard/databases/mainDB.py +++ b/sickbeard/databases/mainDB.py @@ -25,7 +25,7 @@ from sickbeard import encodingKludge as ek from sickbeard.name_parser.parser import NameParser, InvalidNameException -MAX_DB_VERSION = 16 +MAX_DB_VERSION = 16 class MainSanityCheck(db.DBSanityCheck): @@ -103,7 +103,7 @@ def execute(self): "CREATE TABLE history (action NUMERIC, date NUMERIC, showid NUMERIC, season NUMERIC, episode NUMERIC, quality NUMERIC, resource TEXT, provider NUMERIC);", "CREATE TABLE episode_links (episode_id INTEGER, link TEXT);", "CREATE TABLE imdb_info (tvdb_id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, year NUMERIC, akas TEXT, runtimes NUMERIC, genres TEXT, countries TEXT, country_codes TEXT, certificates TEXT, rating TEXT, votes INTEGER, last_update NUMERIC);" - "CREATE TABLE processed_files (episode_id INTEGER, filename TEXT, md5 TEXT)" + "CREATE TABLE processed_files (episode_id INTEGER, filename TEXT, md5 TEXT)" ] for query in queries: self.connection.action(query) @@ -717,7 +717,7 @@ def execute(self): if self.hasTable("episode_links") != True: self.connection.action("CREATE TABLE episode_links (episode_id INTEGER, link TEXT)") self.incDBVersion() - + class AddIMDbInfo(AddEpisodeLinkTable): def test(self): return self.checkDBVersion() >= 15 @@ -725,13 +725,13 @@ def test(self): def execute(self): if self.hasTable("imdb_info") != True: self.connection.action("CREATE TABLE imdb_info (tvdb_id INTEGER PRIMARY KEY, imdb_id TEXT, title TEXT, year NUMERIC, akas TEXT, runtimes NUMERIC, genres TEXT, countries TEXT, country_codes TEXT, certificates TEXT, rating TEXT, votes INTEGER, last_update NUMERIC)") - self.incDBVersion() - -class AddProcessedFilesTable(AddIMDbInfo): - def test(self): - return self.checkDBVersion() >= 16 - - def execute(self): - if self.hasTable("processed_files") != True: - self.connection.action("CREATE TABLE processed_files (episode_id INTEGER, filename TEXT, md5 TEXT)") + self.incDBVersion() + +class AddProcessedFilesTable(AddIMDbInfo): + def test(self): + return self.checkDBVersion() >= 16 + + def execute(self): + if self.hasTable("processed_files") != True: + self.connection.action("CREATE TABLE processed_files (episode_id INTEGER, filename TEXT, md5 TEXT)") self.incDBVersion() \ No newline at end of file diff --git a/sickbeard/helpers.py b/sickbeard/helpers.py index 0114f62918..a19411d5ad 100644 --- a/sickbeard/helpers.py +++ b/sickbeard/helpers.py @@ -24,6 +24,7 @@ import shutil import traceback import time, sys +from lib.linktastic import linktastic import hashlib @@ -41,7 +42,7 @@ from sickbeard import encodingKludge as ek from sickbeard import notifiers -#from lib.linktastic import linktastic +#from lib.linktastic import linktastic from lib.tvdb_api import tvdb_api, tvdb_exceptions import xml.etree.cElementTree as etree @@ -481,25 +482,25 @@ def moveFile(srcFile, destFile): copyFile(srcFile, destFile) ek.ek(os.unlink, srcFile) -# def hardlinkFile(srcFile, destFile): -# try: -# ek.ek(linktastic.link, srcFile, destFile) -# fixSetGroupID(destFile) -# except OSError: -# logger.log(u"Failed to create hardlink of " + srcFile + " at " + destFile + ". Copying instead", logger.ERROR) -# copyFile(srcFile, destFile) -# ek.ek(os.unlink, srcFile) -# -# def moveAndSymlinkFile(srcFile, destFile): -# try: -# ek.ek(os.rename, srcFile, destFile) -# fixSetGroupID(destFile) -# ek.ek(linktastic.symlink, destFile, srcFile) -# except OSError: -# logger.log(u"Failed to create symlink of " + srcFile + " at " + destFile + ". Copying instead", logger.ERROR) -# copyFile(srcFile, destFile) -# ek.ek(os.unlink, srcFile) - +def hardlinkFile(srcFile, destFile): + try: + ek.ek(linktastic.link, srcFile, destFile) + fixSetGroupID(destFile) + except OSError: + logger.log(u"Failed to create hardlink of " + srcFile + " at " + destFile + ". Copying instead", logger.ERROR) + copyFile(srcFile, destFile) + ek.ek(os.unlink, srcFile) + +def moveAndSymlinkFile(srcFile, destFile): + try: + ek.ek(os.rename, srcFile, destFile) + fixSetGroupID(destFile) + ek.ek(linktastic.symlink, destFile, srcFile) + except OSError: + logger.log(u"Failed to create symlink of " + srcFile + " at " + destFile + ". Copying instead", logger.ERROR) + copyFile(srcFile, destFile) + ek.ek(os.unlink, srcFile) + def del_empty_dirs(s_dir): b_empty = True @@ -512,8 +513,9 @@ def del_empty_dirs(s_dir): b_empty = False if b_empty: - logger.log(u"Deleting " + s_dir.decode('utf-8')+ ' because it is empty') - os.rmdir(s_dir) + if s_dir!=sickbeard.TORRENT_DOWNLOAD_DIR and s_dir!=sickbeard.TV_DOWNLOAD_DIR: + logger.log(u"Deleting " + s_dir.decode('utf-8')+ ' because it is empty') + os.rmdir(s_dir) def make_dirs(path): """ diff --git a/sickbeard/postProcessor.py b/sickbeard/postProcessor.py index bb5155a6e9..e8d8dcb095 100755 --- a/sickbeard/postProcessor.py +++ b/sickbeard/postProcessor.py @@ -1,1025 +1,1031 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from __future__ import with_statement - -import glob -import os -import re -import shlex -import subprocess - -import sickbeard -import hashlib - -from sickbeard import db -from sickbeard import classes -from sickbeard import common -from sickbeard import exceptions -from sickbeard import helpers -from sickbeard import history -from sickbeard import logger -from sickbeard import notifiers -from sickbeard import show_name_helpers -from sickbeard import scene_exceptions - -from sickbeard import encodingKludge as ek -from sickbeard.exceptions import ex - -from sickbeard.name_parser.parser import NameParser, InvalidNameException - -from lib.tvdb_api import tvdb_api, tvdb_exceptions - -class PostProcessor(object): - """ - A class which will process a media file according to the post processing settings in the config. - """ - - EXISTS_LARGER = 1 - EXISTS_SAME = 2 - EXISTS_SMALLER = 3 - DOESNT_EXIST = 4 - - IGNORED_FILESTRINGS = [ "/.AppleDouble/", ".DS_Store" ] - - NZB_NAME = 1 - FOLDER_NAME = 2 - FILE_NAME = 3 - - def __init__(self, file_path, nzb_name = None): - """ - Creates a new post processor with the given file path and optionally an NZB name. - - file_path: The path to the file to be processed - nzb_name: The name of the NZB which resulted in this file being downloaded (optional) - """ - # absolute path to the folder that is being processed - self.folder_path = ek.ek(os.path.dirname, ek.ek(os.path.abspath, file_path)) - - # full path to file - self.file_path = file_path - - # file name only - self.file_name = ek.ek(os.path.basename, file_path) - - # the name of the folder only - self.folder_name = ek.ek(os.path.basename, self.folder_path) - - # name of the NZB that resulted in this folder - self.nzb_name = nzb_name - - self.in_history = False - self.release_group = None - self.is_proper = False - - self.good_results = {self.NZB_NAME: False, - self.FOLDER_NAME: False, - self.FILE_NAME: False} - - self.log = '' - - def _log(self, message, level=logger.MESSAGE): - """ - A wrapper for the internal logger which also keeps track of messages and saves them to a string for later. - - message: The string to log (unicode) - level: The log level to use (optional) - """ - logger.log(message, level) - self.log += message + '\n' - - def _checkForExistingFile(self, existing_file): - """ - Checks if a file exists already and if it does whether it's bigger or smaller than - the file we are post processing - - existing_file: The file to compare to - - Returns: - DOESNT_EXIST if the file doesn't exist - EXISTS_LARGER if the file exists and is larger than the file we are post processing - EXISTS_SMALLER if the file exists and is smaller than the file we are post processing - EXISTS_SAME if the file exists and is the same size as the file we are post processing - """ - - if not existing_file: - self._log(u"There is no existing file so there's no worries about replacing it", logger.DEBUG) - return PostProcessor.DOESNT_EXIST - - # if the new file exists, return the appropriate code depending on the size - if ek.ek(os.path.isfile, existing_file): - - # see if it's bigger than our old file - if ek.ek(os.path.getsize, existing_file) > ek.ek(os.path.getsize, self.file_path): - self._log(u"File "+existing_file+" is larger than "+self.file_path, logger.DEBUG) - return PostProcessor.EXISTS_LARGER - - elif ek.ek(os.path.getsize, existing_file) == ek.ek(os.path.getsize, self.file_path): - self._log(u"File "+existing_file+" is the same size as "+self.file_path, logger.DEBUG) - return PostProcessor.EXISTS_SAME - - else: - self._log(u"File "+existing_file+" is smaller than "+self.file_path, logger.DEBUG) - return PostProcessor.EXISTS_SMALLER - - else: - self._log(u"File "+existing_file+" doesn't exist so there's no worries about replacing it", logger.DEBUG) - return PostProcessor.DOESNT_EXIST - - def _list_associated_files(self, file_path, subtitles_only=False): - """ - For a given file path searches for files with the same name but different extension and returns their absolute paths - - file_path: The file to check for associated files - - Returns: A list containing all files which are associated to the given file - """ - - if not file_path: - return [] - - file_path_list = [] - dumb_files_list =[] - - base_name = file_path.rpartition('.')[0]+'.' - - # don't strip it all and use cwd by accident - if not base_name: - return [] - - # don't confuse glob with chars we didn't mean to use - base_name = re.sub(r'[\[\]\*\?]', r'[\g<0>]', base_name) - - for associated_file_path in ek.ek(glob.glob, base_name+'*'): - # only add associated to list - if associated_file_path == file_path: - continue - # only list it if the only non-shared part is the extension or if it is a subtitle - - if '.' in associated_file_path[len(base_name):]: - continue - if subtitles_only and not associated_file_path[len(associated_file_path)-3:] in common.subtitleExtensions: - continue - - file_path_list.append(associated_file_path) - - return file_path_list - def _list_dummy_files(self, file_path, oribasename=None,directory=None): - """ - For a given file path searches for dummy files - - Returns: deletes all files which are dummy to the given file - """ - - if not file_path: - return [] - dumb_files_list =[] - if oribasename: - base_name=oribasename - else: - base_name = file_path.rpartition('.')[0]+'.' - - # don't strip it all and use cwd by accident - if not base_name: - return [] - - # don't confuse glob with chars we didn't mean to use - base_name = re.sub(r'[\[\]\*\?]', r'[\g<0>]', base_name) - if directory =="d": - cur_dir=file_path - else: - cur_dir=self.folder_path - ass_files=ek.ek(glob.glob, base_name+'*') - dum_files=ek.ek(glob.glob, cur_dir+'\*') - for dummy_file_path in dum_files: - if os.path.isdir(dummy_file_path): - self._list_dummy_files(dummy_file_path, base_name,"d") - elif dummy_file_path==self.file_path or dummy_file_path[len(dummy_file_path)-3:] in common.mediaExtensions or sickbeard.MOVE_ASSOCIATED_FILES: - continue - else: - dumb_files_list.append(dummy_file_path) - for cur_file in dumb_files_list: - self._log(u"Deleting file "+cur_file, logger.DEBUG) - if ek.ek(os.path.isfile, cur_file): - ek.ek(os.remove, cur_file) - - return - def _delete(self, file_path, associated_files=False): - """ - Deletes the file and optionally all associated files. - - file_path: The file to delete - associated_files: True to delete all files which differ only by extension, False to leave them - """ - - if not file_path: - return - - # figure out which files we want to delete - file_list = [file_path] - self._list_dummy_files(file_path) - if associated_files: - file_list = file_list + self._list_associated_files(file_path) - - if not file_list: - self._log(u"There were no files associated with " + file_path + ", not deleting anything", logger.DEBUG) - return - - # delete the file and any other files which we want to delete - for cur_file in file_list: - self._log(u"Deleting file "+cur_file, logger.DEBUG) - if ek.ek(os.path.isfile, cur_file): - ek.ek(os.remove, cur_file) - # do the library update for synoindex - notifiers.synoindex_notifier.deleteFile(cur_file) - - def _combined_file_operation (self, file_path, new_path, new_base_name, associated_files=False, action=None, subtitles=False): - """ - Performs a generic operation (move or copy) on a file. Can rename the file as well as change its location, - and optionally move associated files too. - - file_path: The full path of the media file to act on - new_path: Destination path where we want to move/copy the file to - new_base_name: The base filename (no extension) to use during the copy. Use None to keep the same name. - associated_files: Boolean, whether we should copy similarly-named files too - action: function that takes an old path and new path and does an operation with them (move/copy) - """ - - if not action: - self._log(u"Must provide an action for the combined file operation", logger.ERROR) - return - - file_list = [file_path] - self._list_dummy_files(file_path) - if associated_files: - file_list = file_list + self._list_associated_files(file_path) - elif subtitles: - file_list = file_list + self._list_associated_files(file_path, True) - - if not file_list: - self._log(u"There were no files associated with " + file_path + ", not moving anything", logger.DEBUG) - return - - # deal with all files - for cur_file_path in file_list: - - cur_file_name = ek.ek(os.path.basename, cur_file_path) - - # get the extension - cur_extension = cur_file_path.rpartition('.')[-1] - - # check if file have language of subtitles - if cur_extension in common.subtitleExtensions: - cur_lang = cur_file_path.rpartition('.')[0].rpartition('.')[-1] - if cur_lang in sickbeard.SUBTITLES_LANGUAGES: - cur_extension = cur_lang + '.' + cur_extension - - # replace .nfo with .nfo-orig to avoid conflicts - if cur_extension == 'nfo': - cur_extension = 'nfo-orig' - - # If new base name then convert name - if new_base_name: - new_file_name = new_base_name +'.' + cur_extension - # if we're not renaming we still want to change extensions sometimes - else: - new_file_name = helpers.replaceExtension(cur_file_name, cur_extension) - - if sickbeard.SUBTITLES_DIR and cur_extension in common.subtitleExtensions: - subs_new_path = ek.ek(os.path.join, new_path, sickbeard.SUBTITLES_DIR) - dir_exists = helpers.makeDir(subs_new_path) - if not dir_exists: - logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) - else: - helpers.chmodAsParent(subs_new_path) - new_file_path = ek.ek(os.path.join, subs_new_path, new_file_name) - else: - if sickbeard.SUBTITLES_DIR_SUB and cur_extension in common.subtitleExtensions: - subs_new_path = os.path.join(os.path.dirname(file.path),"Subs") - dir_exists = helpers.makeDir(subs_new_path) - if not dir_exists: - logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) - else: - helpers.chmodAsParent(subs_new_path) - new_file_path = ek.ek(os.path.join, subs_new_path, new_file_name) - else : - new_file_path = ek.ek(os.path.join, new_path, new_file_name) - - action(cur_file_path, new_file_path) - - def _move(self, file_path, new_path, new_base_name, associated_files=False, subtitles=False): - """ - file_path: The full path of the media file to move - new_path: Destination path where we want to move the file to - new_base_name: The base filename (no extension) to use during the move. Use None to keep the same name. - associated_files: Boolean, whether we should move similarly-named files too - """ - - def _int_move(cur_file_path, new_file_path): - - self._log(u"Moving file from "+cur_file_path+" to "+new_file_path, logger.DEBUG) - try: - helpers.moveFile(cur_file_path, new_file_path) - helpers.chmodAsParent(new_file_path) - except (IOError, OSError), e: - self._log("Unable to move file "+cur_file_path+" to "+new_file_path+": "+ex(e), logger.ERROR) - raise e - - self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_move, subtitles=subtitles) - - def _copy(self, file_path, new_path, new_base_name, associated_files=False, subtitles=False): - """ - file_path: The full path of the media file to copy - new_path: Destination path where we want to copy the file to - new_base_name: The base filename (no extension) to use during the copy. Use None to keep the same name. - associated_files: Boolean, whether we should copy similarly-named files too - """ - - def _int_copy (cur_file_path, new_file_path): - - self._log(u"Copying file from "+cur_file_path+" to "+new_file_path, logger.DEBUG) - try: - helpers.copyFile(cur_file_path, new_file_path) - helpers.chmodAsParent(new_file_path) - except (IOError, OSError), e: - logger.log("Unable to copy file "+cur_file_path+" to "+new_file_path+": "+ex(e), logger.ERROR) - raise e - - self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_copy, subtitles=subtitles) - - def _hardlink(self, file_path, new_path, new_base_name, associated_files=False): - """ - file_path: The full path of the media file to move - new_path: Destination path where we want to create a hard linked file - new_base_name: The base filename (no extension) to use during the link. Use None to keep the same name. - associated_files: Boolean, whether we should move similarly-named files too - """ - - def _int_hard_link(cur_file_path, new_file_path): - - self._log(u"Hard linking file from " + cur_file_path + " to " + new_file_path, logger.DEBUG) - try: - helpers.hardlinkFile(cur_file_path, new_file_path) - helpers.chmodAsParent(new_file_path) - except (IOError, OSError), e: - self._log("Unable to link file " + cur_file_path + " to " + new_file_path + ": "+ex(e), logger.ERROR) - raise e - self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_hard_link) - - def _moveAndSymlink(self, file_path, new_path, new_base_name, associated_files=False): - """ - file_path: The full path of the media file to move - new_path: Destination path where we want to move the file to create a symbolic link to - new_base_name: The base filename (no extension) to use during the link. Use None to keep the same name. - associated_files: Boolean, whether we should move similarly-named files too - """ - - def _int_move_and_sym_link(cur_file_path, new_file_path): - - self._log(u"Moving then symbolic linking file from " + cur_file_path + " to " + new_file_path, logger.DEBUG) - try: - helpers.moveAndSymlinkFile(cur_file_path, new_file_path) - helpers.chmodAsParent(new_file_path) - except (IOError, OSError), e: - self._log("Unable to link file " + cur_file_path + " to " + new_file_path + ": " + ex(e), logger.ERROR) - raise e - self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_move_and_sym_link) - - def _history_lookup(self): - """ - Look up the NZB name in the history and see if it contains a record for self.nzb_name - - Returns a (tvdb_id, season, []) tuple. The first two may be None if none were found. - """ - - to_return = (None, None, []) - - # if we don't have either of these then there's nothing to use to search the history for anyway - if not self.nzb_name and not self.folder_name: - self.in_history = False - return to_return - - # make a list of possible names to use in the search - names = [] - if self.nzb_name: - names.append(self.nzb_name) - if '.' in self.nzb_name: - names.append(self.nzb_name.rpartition(".")[0]) - if self.folder_name: - names.append(self.folder_name) - - myDB = db.DBConnection() - - # search the database for a possible match and return immediately if we find one - for curName in names: - sql_results = myDB.select("SELECT * FROM history WHERE resource LIKE ?", [re.sub("[\.\-\ ]", "_", curName)]) - - if len(sql_results) == 0: - continue - - tvdb_id = int(sql_results[0]["showid"]) - season = int(sql_results[0]["season"]) - - self.in_history = True - to_return = (tvdb_id, season, []) - self._log("Found result in history: "+str(to_return), logger.DEBUG) - - if curName == self.nzb_name: - self.good_results[self.NZB_NAME] = True - elif curName == self.folder_name: - self.good_results[self.FOLDER_NAME] = True - elif curName == self.file_name: - self.good_results[self.FILE_NAME] = True - - return to_return - - self.in_history = False - return to_return - - def _analyze_name(self, name, file=True): - """ - Takes a name and tries to figure out a show, season, and episode from it. - - name: A string which we want to analyze to determine show info from (unicode) - - Returns a (tvdb_id, season, [episodes]) tuple. The first two may be None and episodes may be [] - if none were found. - """ - - logger.log(u"Analyzing name "+repr(name)) - - to_return = (None, None, []) - - if not name: - return to_return - - # parse the name to break it into show name, season, and episode - np = NameParser(file) - parse_result = np.parse(name) - self._log("Parsed "+name+" into "+str(parse_result).decode('utf-8'), logger.DEBUG) - - if parse_result.air_by_date: - season = -1 - episodes = [parse_result.air_date] - else: - season = parse_result.season_number - episodes = parse_result.episode_numbers - - to_return = (None, season, episodes) - - # do a scene reverse-lookup to get a list of all possible names - name_list = show_name_helpers.sceneToNormalShowNames(parse_result.series_name) - - if not name_list: - return (None, season, episodes) - - def _finalize(parse_result): - self.release_group = parse_result.release_group - - # remember whether it's a proper - if parse_result.extra_info: - self.is_proper = re.search('(^|[\. _-])(proper|repack)([\. _-]|$)', parse_result.extra_info, re.I) != None - - # if the result is complete then remember that for later - if parse_result.series_name and parse_result.season_number != None and parse_result.episode_numbers and parse_result.release_group: - test_name = os.path.basename(name) - if test_name == self.nzb_name: - self.good_results[self.NZB_NAME] = True - elif test_name == self.folder_name: - self.good_results[self.FOLDER_NAME] = True - elif test_name == self.file_name: - self.good_results[self.FILE_NAME] = True - else: - logger.log(u"Nothing was good, found "+repr(test_name)+" and wanted either "+repr(self.nzb_name)+", "+repr(self.folder_name)+", or "+repr(self.file_name)) - else: - logger.log("Parse result not suficent(all folowing have to be set). will not save release name", logger.DEBUG) - logger.log("Parse result(series_name): " + str(parse_result.series_name), logger.DEBUG) - logger.log("Parse result(season_number): " + str(parse_result.season_number), logger.DEBUG) - logger.log("Parse result(episode_numbers): " + str(parse_result.episode_numbers), logger.DEBUG) - logger.log("Parse result(release_group): " + str(parse_result.release_group), logger.DEBUG) - - # for each possible interpretation of that scene name - for cur_name in name_list: - self._log(u"Checking scene exceptions for a match on "+cur_name, logger.DEBUG) - scene_id = scene_exceptions.get_scene_exception_by_name(cur_name) - if scene_id: - self._log(u"Scene exception lookup got tvdb id "+str(scene_id)+u", using that", logger.DEBUG) - _finalize(parse_result) - return (scene_id, season, episodes) - - # see if we can find the name directly in the DB, if so use it - for cur_name in name_list: - self._log(u"Looking up "+cur_name+u" in the DB", logger.DEBUG) - db_result = helpers.searchDBForShow(cur_name) - if db_result: - self._log(u"Lookup successful, using tvdb id "+str(db_result[0]), logger.DEBUG) - _finalize(parse_result) - return (int(db_result[0]), season, episodes) - - # see if we can find the name with a TVDB lookup - for cur_name in name_list: - try: - t = tvdb_api.Tvdb(custom_ui=classes.ShowListUI, **sickbeard.TVDB_API_PARMS) - - self._log(u"Looking up name "+cur_name+u" on TVDB", logger.DEBUG) - showObj = t[cur_name] - except (tvdb_exceptions.tvdb_exception): - # if none found, search on all languages - try: - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - ltvdb_api_parms['search_all_languages'] = True - t = tvdb_api.Tvdb(custom_ui=classes.ShowListUI, **ltvdb_api_parms) - - self._log(u"Looking up name "+cur_name+u" in all languages on TVDB", logger.DEBUG) - showObj = t[cur_name] - except (tvdb_exceptions.tvdb_exception, IOError): - pass - - continue - except (IOError): - continue - - self._log(u"Lookup successful, using tvdb id "+str(showObj["id"]), logger.DEBUG) - _finalize(parse_result) - return (int(showObj["id"]), season, episodes) - - _finalize(parse_result) - return to_return - - - def _find_info(self): - """ - For a given file try to find the showid, season, and episode. - """ - - tvdb_id = season = None - episodes = [] - - # try to look up the nzb in history - attempt_list = [self._history_lookup, - - # try to analyze the nzb name - lambda: self._analyze_name(self.nzb_name), - - # try to analyze the file name - lambda: self._analyze_name(self.file_name), - - # try to analyze the dir name - lambda: self._analyze_name(self.folder_name), - - # try to analyze the file+dir names together - lambda: self._analyze_name(self.file_path), - - # try to analyze the dir + file name together as one name - lambda: self._analyze_name(self.folder_name + u' ' + self.file_name) - - ] - - # attempt every possible method to get our info - for cur_attempt in attempt_list: - - try: - (cur_tvdb_id, cur_season, cur_episodes) = cur_attempt() - except InvalidNameException, e: - logger.log(u"Unable to parse, skipping: "+ex(e), logger.DEBUG) - continue - - # if we already did a successful history lookup then keep that tvdb_id value - if cur_tvdb_id and not (self.in_history and tvdb_id): - tvdb_id = cur_tvdb_id - if cur_season != None: - season = cur_season - if cur_episodes: - episodes = cur_episodes - - # for air-by-date shows we need to look up the season/episode from tvdb - if season == -1 and tvdb_id and episodes: - self._log(u"Looks like this is an air-by-date show, attempting to convert the date to season/episode", logger.DEBUG) - - # try to get language set for this show - tvdb_lang = None - try: - showObj = helpers.findCertainShow(sickbeard.showList, tvdb_id) - if(showObj != None): - tvdb_lang = showObj.lang - except exceptions.MultipleShowObjectsException: - raise #TODO: later I'll just log this, for now I want to know about it ASAP - - try: - # There's gotta be a better way of doing this but we don't wanna - # change the language value elsewhere - ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() - - if tvdb_lang and not tvdb_lang == 'en': - ltvdb_api_parms['language'] = tvdb_lang - - t = tvdb_api.Tvdb(**ltvdb_api_parms) - epObj = t[tvdb_id].airedOn(episodes[0])[0] - season = int(epObj["seasonnumber"]) - episodes = [int(epObj["episodenumber"])] - self._log(u"Got season " + str(season) + " episodes " + str(episodes), logger.DEBUG) - except tvdb_exceptions.tvdb_episodenotfound, e: - self._log(u"Unable to find episode with date " + str(episodes[0]) + u" for show " + str(tvdb_id) + u", skipping", logger.DEBUG) - # we don't want to leave dates in the episode list if we couldn't convert them to real episode numbers - episodes = [] - continue - except tvdb_exceptions.tvdb_error, e: - logger.log(u"Unable to contact TVDB: " + ex(e), logger.WARNING) - episodes = [] - continue - - # if there's no season then we can hopefully just use 1 automatically - elif season == None and tvdb_id: - myDB = db.DBConnection() - numseasonsSQlResult = myDB.select("SELECT COUNT(DISTINCT season) as numseasons FROM tv_episodes WHERE showid = ? and season != 0", [tvdb_id]) - if int(numseasonsSQlResult[0][0]) == 1 and season == None: - self._log(u"Don't have a season number, but this show appears to only have 1 season, setting seasonnumber to 1...", logger.DEBUG) - season = 1 - - if tvdb_id and season != None and episodes: - return (tvdb_id, season, episodes) - - return (tvdb_id, season, episodes) - - def _get_ep_obj(self, tvdb_id, season, episodes): - """ - Retrieve the TVEpisode object requested. - - tvdb_id: The TVDBID of the show (int) - season: The season of the episode (int) - episodes: A list of episodes to find (list of ints) - - If the episode(s) can be found then a TVEpisode object with the correct related eps will - be instantiated and returned. If the episode can't be found then None will be returned. - """ - - show_obj = None - - self._log(u"Loading show object for tvdb_id "+str(tvdb_id), logger.DEBUG) - # find the show in the showlist - try: - show_obj = helpers.findCertainShow(sickbeard.showList, tvdb_id) - except exceptions.MultipleShowObjectsException: - raise #TODO: later I'll just log this, for now I want to know about it ASAP - - # if we can't find the show then there's nothing we can really do - if not show_obj: - self._log(("This show (tvdb_id=%d) isn't in your list, you need to add it to SB before post-processing an episode" % tvdb_id), logger.ERROR) - raise exceptions.PostProcessingFailed() - - root_ep = None - for cur_episode in episodes: - episode = int(cur_episode) - - self._log(u"Retrieving episode object for " + str(season) + "x" + str(episode), logger.DEBUG) - - # now that we've figured out which episode this file is just load it manually - try: - curEp = show_obj.getEpisode(season, episode) - except exceptions.EpisodeNotFoundException, e: - self._log(u"Unable to create episode: "+ex(e), logger.DEBUG) - raise exceptions.PostProcessingFailed() - - # associate all the episodes together under a single root episode - if root_ep == None: - root_ep = curEp - root_ep.relatedEps = [] - elif curEp not in root_ep.relatedEps: - root_ep.relatedEps.append(curEp) - - return root_ep - - def _get_quality(self, ep_obj): - """ - Determines the quality of the file that is being post processed, first by checking if it is directly - available in the TVEpisode's status or otherwise by parsing through the data available. - - ep_obj: The TVEpisode object related to the file we are post processing - - Returns: A quality value found in common.Quality - """ - - ep_quality = common.Quality.UNKNOWN - - # if there is a quality available in the status then we don't need to bother guessing from the filename - if ep_obj.status in common.Quality.SNATCHED + common.Quality.SNATCHED_PROPER: - oldStatus, ep_quality = common.Quality.splitCompositeStatus(ep_obj.status) #@UnusedVariable - if ep_quality != common.Quality.UNKNOWN: - self._log(u"The old status had a quality in it, using that: "+common.Quality.qualityStrings[ep_quality], logger.DEBUG) - return ep_quality - - # nzb name is the most reliable if it exists, followed by folder name and lastly file name - name_list = [self.nzb_name, self.folder_name, self.file_name] - - # search all possible names for our new quality, in case the file or dir doesn't have it - for cur_name in name_list: - - # some stuff might be None at this point still - if not cur_name: - continue - - ep_quality = common.Quality.nameQuality(cur_name) - self._log(u"Looking up quality for name "+cur_name+u", got "+common.Quality.qualityStrings[ep_quality], logger.DEBUG) - - # if we find a good one then use it - if ep_quality != common.Quality.UNKNOWN: - logger.log(cur_name+u" looks like it has quality "+common.Quality.qualityStrings[ep_quality]+", using that", logger.DEBUG) - return ep_quality - - # if we didn't get a quality from one of the names above, try assuming from each of the names - ep_quality = common.Quality.assumeQuality(self.file_name) - self._log(u"Guessing quality for name "+self.file_name+u", got "+common.Quality.qualityStrings[ep_quality], logger.DEBUG) - if ep_quality != common.Quality.UNKNOWN: - logger.log(self.file_name+u" looks like it has quality "+common.Quality.qualityStrings[ep_quality]+", using that", logger.DEBUG) - return ep_quality - - return ep_quality - - def _run_extra_scripts(self, ep_obj): - """ - Executes any extra scripts defined in the config. - - ep_obj: The object to use when calling the extra script - """ - for curScriptName in sickbeard.EXTRA_SCRIPTS: - - # generate a safe command line string to execute the script and provide all the parameters - script_cmd = shlex.split(curScriptName) + [ep_obj.location, self.file_path, str(ep_obj.show.tvdbid), str(ep_obj.season), str(ep_obj.episode), str(ep_obj.airdate)] - - # use subprocess to run the command and capture output - self._log(u"Executing command "+str(script_cmd)) - self._log(u"Absolute path to script: "+ek.ek(os.path.abspath, script_cmd[0]), logger.DEBUG) - try: - p = subprocess.Popen(script_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=sickbeard.PROG_DIR) - out, err = p.communicate() #@UnusedVariable - self._log(u"Script result: "+str(out), logger.DEBUG) - except OSError, e: - self._log(u"Unable to run extra_script: "+ex(e)) - - def _is_priority(self, ep_obj, new_ep_quality): - """ - Determines if the episode is a priority download or not (if it is expected). Episodes which are expected - (snatched) or larger than the existing episode are priority, others are not. - - ep_obj: The TVEpisode object in question - new_ep_quality: The quality of the episode that is being processed - - Returns: True if the episode is priority, False otherwise. - """ - - # if SB downloaded this on purpose then this is a priority download - if self.in_history or ep_obj.status in common.Quality.SNATCHED + common.Quality.SNATCHED_PROPER: - self._log(u"SB snatched this episode so I'm marking it as priority", logger.DEBUG) - return True - - # if the user downloaded it manually and it's higher quality than the existing episode then it's priority - if new_ep_quality > ep_obj and new_ep_quality != common.Quality.UNKNOWN: - self._log(u"This was manually downloaded but it appears to be better quality than what we have so I'm marking it as priority", logger.DEBUG) - return True - - # if the user downloaded it manually and it appears to be a PROPER/REPACK then it's priority - old_ep_status, old_ep_quality = common.Quality.splitCompositeStatus(ep_obj.status) #@UnusedVariable - if self.is_proper and new_ep_quality >= old_ep_quality: - self._log(u"This was manually downloaded but it appears to be a proper so I'm marking it as priority", logger.DEBUG) - return True - - return False - - def process(self): - """ - Post-process a given file - """ - - self._log(u"Processing " + self.file_path + " (" + str(self.nzb_name) + ")") - - if os.path.isdir(self.file_path): - self._log(u"File " + self.file_path + " seems to be a directory") - return False - for ignore_file in self.IGNORED_FILESTRINGS: - if ignore_file in self.file_path: - self._log(u"File " + self.file_path + " is ignored type, skipping") - return False - # reset per-file stuff - self.in_history = False - - # try to find the file info - (tvdb_id, season, episodes) = self._find_info() - - # if we don't have it then give up - if not tvdb_id or season == None or not episodes: - return False - - # retrieve/create the corresponding TVEpisode objects - ep_obj = self._get_ep_obj(tvdb_id, season, episodes) - - # get the quality of the episode we're processing - new_ep_quality = self._get_quality(ep_obj) - logger.log(u"Quality of the episode we're processing: " + str(new_ep_quality), logger.DEBUG) - - # see if this is a priority download (is it snatched, in history, or PROPER) - priority_download = self._is_priority(ep_obj, new_ep_quality) - self._log(u"Is ep a priority download: " + str(priority_download), logger.DEBUG) - - # set the status of the episodes - for curEp in [ep_obj] + ep_obj.relatedEps: - curEp.status = common.Quality.compositeStatus(common.SNATCHED, new_ep_quality) - - # check for an existing file - existing_file_status = self._checkForExistingFile(ep_obj.location) - - # if it's not priority then we don't want to replace smaller files in case it was a mistake - if not priority_download: - - # if there's an existing file that we don't want to replace stop here - if existing_file_status in (PostProcessor.EXISTS_LARGER, PostProcessor.EXISTS_SAME): - self._log(u"File exists and we are not going to replace it because it's not smaller, quitting post-processing", logger.DEBUG) - return False - elif existing_file_status == PostProcessor.EXISTS_SMALLER: - self._log(u"File exists and is smaller than the new file so I'm going to replace it", logger.DEBUG) - elif existing_file_status != PostProcessor.DOESNT_EXIST: - self._log(u"Unknown existing file status. This should never happen, please log this as a bug.", logger.ERROR) - return False - - # if the file is priority then we're going to replace it even if it exists - else: - self._log(u"This download is marked a priority download so I'm going to replace an existing file if I find one", logger.DEBUG) - - # delete the existing file (and company) - for cur_ep in [ep_obj] + ep_obj.relatedEps: - try: - self._delete(cur_ep.location, associated_files=True) - # clean up any left over folders - if cur_ep.location: - helpers.delete_empty_folders(ek.ek(os.path.dirname, cur_ep.location), keep_dir=ep_obj.show._location) - except (OSError, IOError): - raise exceptions.PostProcessingFailed("Unable to delete the existing files") - - # if the show directory doesn't exist then make it if allowed - if not ek.ek(os.path.isdir, ep_obj.show._location) and sickbeard.CREATE_MISSING_SHOW_DIRS: - self._log(u"Show directory doesn't exist, creating it", logger.DEBUG) - try: - ek.ek(os.mkdir, ep_obj.show._location) - # do the library update for synoindex - notifiers.synoindex_notifier.addFolder(ep_obj.show._location) - - except (OSError, IOError): - raise exceptions.PostProcessingFailed("Unable to create the show directory: " + ep_obj.show._location) - - # get metadata for the show (but not episode because it hasn't been fully processed) - ep_obj.show.writeMetadata(True) - - # update the ep info before we rename so the quality & release name go into the name properly - for cur_ep in [ep_obj] + ep_obj.relatedEps: - with cur_ep.lock: - cur_release_name = None - - # use the best possible representation of the release name - if self.good_results[self.NZB_NAME]: - cur_release_name = self.nzb_name - if cur_release_name.lower().endswith('.nzb'): - cur_release_name = cur_release_name.rpartition('.')[0] - elif self.good_results[self.FOLDER_NAME]: - cur_release_name = self.folder_name - elif self.good_results[self.FILE_NAME]: - cur_release_name = self.file_name - # take the extension off the filename, it's not needed - if '.' in self.file_name: - cur_release_name = self.file_name.rpartition('.')[0] - - if cur_release_name: - self._log("Found release name " + cur_release_name, logger.DEBUG) - cur_ep.release_name = cur_release_name - else: - logger.log("good results: " + repr(self.good_results), logger.DEBUG) - - cur_ep.status = common.Quality.compositeStatus(common.DOWNLOADED, new_ep_quality) - - cur_ep.saveToDB() - - # find the destination folder - try: - proper_path = ep_obj.proper_path() - proper_absolute_path = ek.ek(os.path.join, ep_obj.show.location, proper_path) - - dest_path = ek.ek(os.path.dirname, proper_absolute_path) - except exceptions.ShowDirNotFoundException: - raise exceptions.PostProcessingFailed(u"Unable to post-process an episode if the show dir doesn't exist, quitting") - - self._log(u"Destination folder for this episode: " + dest_path, logger.DEBUG) - - # create any folders we need - helpers.make_dirs(dest_path) - - # figure out the base name of the resulting episode file - if sickbeard.RENAME_EPISODES: - orig_extension = self.file_name.rpartition('.')[-1] - new_base_name = ek.ek(os.path.basename, proper_path) - new_file_name = new_base_name + '.' + orig_extension - - else: - # if we're not renaming then there's no new base name, we'll just use the existing name - new_base_name = None - new_file_name = self.file_name - - with open(self.file_path, 'rb') as fh: - m = hashlib.md5() - while True: - data = fh.read(8192) - if not data: - break - m.update(data) - MD5 = m.hexdigest() - - try: - - path,file=os.path.split(self.file_path) - - if sickbeard.TORRENT_DOWNLOAD_DIR == path: - #Action possible pour les torrent - if sickbeard.PROCESS_METHOD == "copy": - self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) - elif sickbeard.PROCESS_METHOD == "move": - self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) - else: - logger.log(u"Unknown process method: " + str(sickbeard.PROCESS_METHOD), logger.ERROR) - raise exceptions.PostProcessingFailed("Unable to move the files to their new home") - else: - #action pour le reste des fichier - if sickbeard.KEEP_PROCESSED_DIR: - self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) - else: - self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) - - except (OSError, IOError): - raise exceptions.PostProcessingFailed("Unable to move the files to their new home") - - myDB = db.DBConnection() - - ## INSERT MD5 of file - controlMD5 = {"episode_id" : int(ep_obj.tvdbid) } - NewValMD5 = {"filename" : new_base_name , - "md5" : MD5 - } - myDB.upsert("processed_files", NewValMD5, controlMD5) - - - - # put the new location in the database - for cur_ep in [ep_obj] + ep_obj.relatedEps: - with cur_ep.lock: - cur_ep.location = ek.ek(os.path.join, dest_path, new_file_name) - cur_ep.saveToDB() - - # log it to history - history.logDownload(ep_obj, self.file_path, new_ep_quality, self.release_group) - - # download subtitles - if sickbeard.USE_SUBTITLES and ep_obj.show.subtitles: - cur_ep.downloadSubtitles() - - # send notifications - notifiers.notify_download(ep_obj.prettyName()) - - # generate nfo/tbn - ep_obj.createMetaFiles() - ep_obj.saveToDB() - - # do the library update for XBMC - notifiers.xbmc_notifier.update_library(ep_obj.show.name) - - # do the library update for Plex - notifiers.plex_notifier.update_library() - - # do the library update for NMJ - # nmj_notifier kicks off its library update when the notify_download is issued (inside notifiers) - - # do the library update for Synology Indexer - notifiers.synoindex_notifier.addFile(ep_obj.location) - - # do the library update for pyTivo - notifiers.pytivo_notifier.update_library(ep_obj) - - # do the library update for Trakt - notifiers.trakt_notifier.update_library(ep_obj) - - self._run_extra_scripts(ep_obj) - - return True +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from __future__ import with_statement + +import glob +import os +import re +import shlex +import subprocess + +import sickbeard +import hashlib + +from sickbeard import db +from sickbeard import classes +from sickbeard import common +from sickbeard import exceptions +from sickbeard import helpers +from sickbeard import history +from sickbeard import logger +from sickbeard import notifiers +from sickbeard import show_name_helpers +from sickbeard import scene_exceptions + +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex + +from sickbeard.name_parser.parser import NameParser, InvalidNameException + +from lib.tvdb_api import tvdb_api, tvdb_exceptions + +class PostProcessor(object): + """ + A class which will process a media file according to the post processing settings in the config. + """ + + EXISTS_LARGER = 1 + EXISTS_SAME = 2 + EXISTS_SMALLER = 3 + DOESNT_EXIST = 4 + + IGNORED_FILESTRINGS = [ "/.AppleDouble/", ".DS_Store" ] + + NZB_NAME = 1 + FOLDER_NAME = 2 + FILE_NAME = 3 + + def __init__(self, file_path, nzb_name = None): + """ + Creates a new post processor with the given file path and optionally an NZB name. + + file_path: The path to the file to be processed + nzb_name: The name of the NZB which resulted in this file being downloaded (optional) + """ + # absolute path to the folder that is being processed + self.folder_path = ek.ek(os.path.dirname, ek.ek(os.path.abspath, file_path)) + + # full path to file + self.file_path = file_path + + # file name only + self.file_name = ek.ek(os.path.basename, file_path) + + # the name of the folder only + self.folder_name = ek.ek(os.path.basename, self.folder_path) + + # name of the NZB that resulted in this folder + self.nzb_name = nzb_name + + self.in_history = False + self.release_group = None + self.is_proper = False + + self.good_results = {self.NZB_NAME: False, + self.FOLDER_NAME: False, + self.FILE_NAME: False} + + self.log = '' + + def _log(self, message, level=logger.MESSAGE): + """ + A wrapper for the internal logger which also keeps track of messages and saves them to a string for later. + + message: The string to log (unicode) + level: The log level to use (optional) + """ + logger.log(message, level) + self.log += message + '\n' + + def _checkForExistingFile(self, existing_file): + """ + Checks if a file exists already and if it does whether it's bigger or smaller than + the file we are post processing + + existing_file: The file to compare to + + Returns: + DOESNT_EXIST if the file doesn't exist + EXISTS_LARGER if the file exists and is larger than the file we are post processing + EXISTS_SMALLER if the file exists and is smaller than the file we are post processing + EXISTS_SAME if the file exists and is the same size as the file we are post processing + """ + + if not existing_file: + self._log(u"There is no existing file so there's no worries about replacing it", logger.DEBUG) + return PostProcessor.DOESNT_EXIST + + # if the new file exists, return the appropriate code depending on the size + if ek.ek(os.path.isfile, existing_file): + + # see if it's bigger than our old file + if ek.ek(os.path.getsize, existing_file) > ek.ek(os.path.getsize, self.file_path): + self._log(u"File "+existing_file+" is larger than "+self.file_path, logger.DEBUG) + return PostProcessor.EXISTS_LARGER + + elif ek.ek(os.path.getsize, existing_file) == ek.ek(os.path.getsize, self.file_path): + self._log(u"File "+existing_file+" is the same size as "+self.file_path, logger.DEBUG) + return PostProcessor.EXISTS_SAME + + else: + self._log(u"File "+existing_file+" is smaller than "+self.file_path, logger.DEBUG) + return PostProcessor.EXISTS_SMALLER + + else: + self._log(u"File "+existing_file+" doesn't exist so there's no worries about replacing it", logger.DEBUG) + return PostProcessor.DOESNT_EXIST + + def _list_associated_files(self, file_path, subtitles_only=False): + """ + For a given file path searches for files with the same name but different extension and returns their absolute paths + + file_path: The file to check for associated files + + Returns: A list containing all files which are associated to the given file + """ + + if not file_path: + return [] + + file_path_list = [] + dumb_files_list =[] + + base_name = file_path.rpartition('.')[0]+'.' + + # don't strip it all and use cwd by accident + if not base_name: + return [] + + # don't confuse glob with chars we didn't mean to use + base_name = re.sub(r'[\[\]\*\?]', r'[\g<0>]', base_name) + + for associated_file_path in ek.ek(glob.glob, base_name+'*'): + # only add associated to list + if associated_file_path == file_path: + continue + # only list it if the only non-shared part is the extension or if it is a subtitle + + if '.' in associated_file_path[len(base_name):]: + continue + if subtitles_only and not associated_file_path[len(associated_file_path)-3:] in common.subtitleExtensions: + continue + + file_path_list.append(associated_file_path) + + return file_path_list + def _list_dummy_files(self, file_path, oribasename=None,directory=None): + """ + For a given file path searches for dummy files + + Returns: deletes all files which are dummy to the given file + """ + + if not file_path: + return [] + dumb_files_list =[] + if oribasename: + base_name=oribasename + else: + base_name = file_path.rpartition('.')[0]+'.' + + # don't strip it all and use cwd by accident + if not base_name: + return [] + + # don't confuse glob with chars we didn't mean to use + base_name = re.sub(r'[\[\]\*\?]', r'[\g<0>]', base_name) + if directory =="d": + cur_dir=file_path + else: + cur_dir=self.folder_path + ass_files=ek.ek(glob.glob, base_name+'*') + dum_files=ek.ek(glob.glob, cur_dir+'\*') + for dummy_file_path in dum_files: + if os.path.isdir(dummy_file_path): + self._list_dummy_files(dummy_file_path, base_name,"d") + print sickbeard.TORRENT_DOWNLOAD_DIR + print cur_dir + elif dummy_file_path==self.file_path or dummy_file_path[len(dummy_file_path)-3:] in common.mediaExtensions or sickbeard.MOVE_ASSOCIATED_FILES or (sickbeard.TORRENT_DOWNLOAD_DIR in cur_dir and sickbeard.PROCESS_METHOD in ['copy','hardlink','symlink']): + continue + else: + dumb_files_list.append(dummy_file_path) + for cur_file in dumb_files_list: + self._log(u"Deleting file "+cur_file, logger.DEBUG) + if ek.ek(os.path.isfile, cur_file): + ek.ek(os.remove, cur_file) + + return + def _delete(self, file_path, associated_files=False): + """ + Deletes the file and optionally all associated files. + + file_path: The file to delete + associated_files: True to delete all files which differ only by extension, False to leave them + """ + + if not file_path: + return + + # figure out which files we want to delete + file_list = [file_path] + self._list_dummy_files(file_path) + if associated_files: + file_list = file_list + self._list_associated_files(file_path) + + if not file_list: + self._log(u"There were no files associated with " + file_path + ", not deleting anything", logger.DEBUG) + return + + # delete the file and any other files which we want to delete + for cur_file in file_list: + self._log(u"Deleting file "+cur_file, logger.DEBUG) + if ek.ek(os.path.isfile, cur_file): + ek.ek(os.remove, cur_file) + # do the library update for synoindex + notifiers.synoindex_notifier.deleteFile(cur_file) + + def _combined_file_operation (self, file_path, new_path, new_base_name, associated_files=False, action=None, subtitles=False): + """ + Performs a generic operation (move or copy) on a file. Can rename the file as well as change its location, + and optionally move associated files too. + + file_path: The full path of the media file to act on + new_path: Destination path where we want to move/copy the file to + new_base_name: The base filename (no extension) to use during the copy. Use None to keep the same name. + associated_files: Boolean, whether we should copy similarly-named files too + action: function that takes an old path and new path and does an operation with them (move/copy) + """ + + if not action: + self._log(u"Must provide an action for the combined file operation", logger.ERROR) + return + + file_list = [file_path] + self._list_dummy_files(file_path) + if associated_files: + file_list = file_list + self._list_associated_files(file_path) + elif subtitles: + file_list = file_list + self._list_associated_files(file_path, True) + + if not file_list: + self._log(u"There were no files associated with " + file_path + ", not moving anything", logger.DEBUG) + return + + # deal with all files + for cur_file_path in file_list: + + cur_file_name = ek.ek(os.path.basename, cur_file_path) + + # get the extension + cur_extension = cur_file_path.rpartition('.')[-1] + + # check if file have language of subtitles + if cur_extension in common.subtitleExtensions: + cur_lang = cur_file_path.rpartition('.')[0].rpartition('.')[-1] + if cur_lang in sickbeard.SUBTITLES_LANGUAGES: + cur_extension = cur_lang + '.' + cur_extension + + # replace .nfo with .nfo-orig to avoid conflicts + if cur_extension == 'nfo': + cur_extension = 'nfo-orig' + + # If new base name then convert name + if new_base_name: + new_file_name = new_base_name +'.' + cur_extension + # if we're not renaming we still want to change extensions sometimes + else: + new_file_name = helpers.replaceExtension(cur_file_name, cur_extension) + + if sickbeard.SUBTITLES_DIR and cur_extension in common.subtitleExtensions: + subs_new_path = ek.ek(os.path.join, new_path, sickbeard.SUBTITLES_DIR) + dir_exists = helpers.makeDir(subs_new_path) + if not dir_exists: + logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) + else: + helpers.chmodAsParent(subs_new_path) + new_file_path = ek.ek(os.path.join, subs_new_path, new_file_name) + else: + if sickbeard.SUBTITLES_DIR_SUB and cur_extension in common.subtitleExtensions: + subs_new_path = os.path.join(os.path.dirname(file.path),"Subs") + dir_exists = helpers.makeDir(subs_new_path) + if not dir_exists: + logger.log(u"Unable to create subtitles folder "+subs_new_path, logger.ERROR) + else: + helpers.chmodAsParent(subs_new_path) + new_file_path = ek.ek(os.path.join, subs_new_path, new_file_name) + else : + new_file_path = ek.ek(os.path.join, new_path, new_file_name) + + action(cur_file_path, new_file_path) + + def _move(self, file_path, new_path, new_base_name, associated_files=False, subtitles=False): + """ + file_path: The full path of the media file to move + new_path: Destination path where we want to move the file to + new_base_name: The base filename (no extension) to use during the move. Use None to keep the same name. + associated_files: Boolean, whether we should move similarly-named files too + """ + + def _int_move(cur_file_path, new_file_path): + + self._log(u"Moving file from "+cur_file_path+" to "+new_file_path, logger.DEBUG) + try: + helpers.moveFile(cur_file_path, new_file_path) + helpers.chmodAsParent(new_file_path) + except (IOError, OSError), e: + self._log("Unable to move file "+cur_file_path+" to "+new_file_path+": "+ex(e), logger.ERROR) + raise e + + self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_move, subtitles=subtitles) + + def _copy(self, file_path, new_path, new_base_name, associated_files=False, subtitles=False): + """ + file_path: The full path of the media file to copy + new_path: Destination path where we want to copy the file to + new_base_name: The base filename (no extension) to use during the copy. Use None to keep the same name. + associated_files: Boolean, whether we should copy similarly-named files too + """ + + def _int_copy (cur_file_path, new_file_path): + + self._log(u"Copying file from "+cur_file_path+" to "+new_file_path, logger.DEBUG) + try: + helpers.copyFile(cur_file_path, new_file_path) + helpers.chmodAsParent(new_file_path) + except (IOError, OSError), e: + logger.log("Unable to copy file "+cur_file_path+" to "+new_file_path+": "+ex(e), logger.ERROR) + raise e + + self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_copy, subtitles=subtitles) + + def _hardlink(self, file_path, new_path, new_base_name, associated_files=False): + """ + file_path: The full path of the media file to move + new_path: Destination path where we want to create a hard linked file + new_base_name: The base filename (no extension) to use during the link. Use None to keep the same name. + associated_files: Boolean, whether we should move similarly-named files too + """ + + def _int_hard_link(cur_file_path, new_file_path): + + self._log(u"Hard linking file from " + cur_file_path + " to " + new_file_path, logger.DEBUG) + try: + helpers.hardlinkFile(cur_file_path, new_file_path) + helpers.chmodAsParent(new_file_path) + except (IOError, OSError), e: + self._log("Unable to link file " + cur_file_path + " to " + new_file_path + ": "+ex(e), logger.ERROR) + raise e + self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_hard_link) + + def _moveAndSymlink(self, file_path, new_path, new_base_name, associated_files=False): + """ + file_path: The full path of the media file to move + new_path: Destination path where we want to move the file to create a symbolic link to + new_base_name: The base filename (no extension) to use during the link. Use None to keep the same name. + associated_files: Boolean, whether we should move similarly-named files too + """ + + def _int_move_and_sym_link(cur_file_path, new_file_path): + + self._log(u"Moving then symbolic linking file from " + cur_file_path + " to " + new_file_path, logger.DEBUG) + try: + helpers.moveAndSymlinkFile(cur_file_path, new_file_path) + helpers.chmodAsParent(new_file_path) + except (IOError, OSError), e: + self._log("Unable to link file " + cur_file_path + " to " + new_file_path + ": " + ex(e), logger.ERROR) + raise e + self._combined_file_operation(file_path, new_path, new_base_name, associated_files, action=_int_move_and_sym_link) + + def _history_lookup(self): + """ + Look up the NZB name in the history and see if it contains a record for self.nzb_name + + Returns a (tvdb_id, season, []) tuple. The first two may be None if none were found. + """ + + to_return = (None, None, []) + + # if we don't have either of these then there's nothing to use to search the history for anyway + if not self.nzb_name and not self.folder_name: + self.in_history = False + return to_return + + # make a list of possible names to use in the search + names = [] + if self.nzb_name: + names.append(self.nzb_name) + if '.' in self.nzb_name: + names.append(self.nzb_name.rpartition(".")[0]) + if self.folder_name: + names.append(self.folder_name) + + myDB = db.DBConnection() + + # search the database for a possible match and return immediately if we find one + for curName in names: + sql_results = myDB.select("SELECT * FROM history WHERE resource LIKE ?", [re.sub("[\.\-\ ]", "_", curName)]) + + if len(sql_results) == 0: + continue + + tvdb_id = int(sql_results[0]["showid"]) + season = int(sql_results[0]["season"]) + + self.in_history = True + to_return = (tvdb_id, season, []) + self._log("Found result in history: "+str(to_return), logger.DEBUG) + + if curName == self.nzb_name: + self.good_results[self.NZB_NAME] = True + elif curName == self.folder_name: + self.good_results[self.FOLDER_NAME] = True + elif curName == self.file_name: + self.good_results[self.FILE_NAME] = True + + return to_return + + self.in_history = False + return to_return + + def _analyze_name(self, name, file=True): + """ + Takes a name and tries to figure out a show, season, and episode from it. + + name: A string which we want to analyze to determine show info from (unicode) + + Returns a (tvdb_id, season, [episodes]) tuple. The first two may be None and episodes may be [] + if none were found. + """ + + logger.log(u"Analyzing name "+repr(name)) + + to_return = (None, None, []) + + if not name: + return to_return + + # parse the name to break it into show name, season, and episode + np = NameParser(file) + parse_result = np.parse(name) + self._log("Parsed "+name+" into "+str(parse_result).decode('utf-8'), logger.DEBUG) + + if parse_result.air_by_date: + season = -1 + episodes = [parse_result.air_date] + else: + season = parse_result.season_number + episodes = parse_result.episode_numbers + + to_return = (None, season, episodes) + + # do a scene reverse-lookup to get a list of all possible names + name_list = show_name_helpers.sceneToNormalShowNames(parse_result.series_name) + + if not name_list: + return (None, season, episodes) + + def _finalize(parse_result): + self.release_group = parse_result.release_group + + # remember whether it's a proper + if parse_result.extra_info: + self.is_proper = re.search('(^|[\. _-])(proper|repack)([\. _-]|$)', parse_result.extra_info, re.I) != None + + # if the result is complete then remember that for later + if parse_result.series_name and parse_result.season_number != None and parse_result.episode_numbers and parse_result.release_group: + test_name = os.path.basename(name) + if test_name == self.nzb_name: + self.good_results[self.NZB_NAME] = True + elif test_name == self.folder_name: + self.good_results[self.FOLDER_NAME] = True + elif test_name == self.file_name: + self.good_results[self.FILE_NAME] = True + else: + logger.log(u"Nothing was good, found "+repr(test_name)+" and wanted either "+repr(self.nzb_name)+", "+repr(self.folder_name)+", or "+repr(self.file_name)) + else: + logger.log("Parse result not suficent(all folowing have to be set). will not save release name", logger.DEBUG) + logger.log("Parse result(series_name): " + str(parse_result.series_name), logger.DEBUG) + logger.log("Parse result(season_number): " + str(parse_result.season_number), logger.DEBUG) + logger.log("Parse result(episode_numbers): " + str(parse_result.episode_numbers), logger.DEBUG) + logger.log("Parse result(release_group): " + str(parse_result.release_group), logger.DEBUG) + + # for each possible interpretation of that scene name + for cur_name in name_list: + self._log(u"Checking scene exceptions for a match on "+cur_name, logger.DEBUG) + scene_id = scene_exceptions.get_scene_exception_by_name(cur_name) + if scene_id: + self._log(u"Scene exception lookup got tvdb id "+str(scene_id)+u", using that", logger.DEBUG) + _finalize(parse_result) + return (scene_id, season, episodes) + + # see if we can find the name directly in the DB, if so use it + for cur_name in name_list: + self._log(u"Looking up "+cur_name+u" in the DB", logger.DEBUG) + db_result = helpers.searchDBForShow(cur_name) + if db_result: + self._log(u"Lookup successful, using tvdb id "+str(db_result[0]), logger.DEBUG) + _finalize(parse_result) + return (int(db_result[0]), season, episodes) + + # see if we can find the name with a TVDB lookup + for cur_name in name_list: + try: + t = tvdb_api.Tvdb(custom_ui=classes.ShowListUI, **sickbeard.TVDB_API_PARMS) + + self._log(u"Looking up name "+cur_name+u" on TVDB", logger.DEBUG) + showObj = t[cur_name] + except (tvdb_exceptions.tvdb_exception): + # if none found, search on all languages + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + ltvdb_api_parms['search_all_languages'] = True + t = tvdb_api.Tvdb(custom_ui=classes.ShowListUI, **ltvdb_api_parms) + + self._log(u"Looking up name "+cur_name+u" in all languages on TVDB", logger.DEBUG) + showObj = t[cur_name] + except (tvdb_exceptions.tvdb_exception, IOError): + pass + + continue + except (IOError): + continue + + self._log(u"Lookup successful, using tvdb id "+str(showObj["id"]), logger.DEBUG) + _finalize(parse_result) + return (int(showObj["id"]), season, episodes) + + _finalize(parse_result) + return to_return + + + def _find_info(self): + """ + For a given file try to find the showid, season, and episode. + """ + + tvdb_id = season = None + episodes = [] + + # try to look up the nzb in history + attempt_list = [self._history_lookup, + + # try to analyze the nzb name + lambda: self._analyze_name(self.nzb_name), + + # try to analyze the file name + lambda: self._analyze_name(self.file_name), + + # try to analyze the dir name + lambda: self._analyze_name(self.folder_name), + + # try to analyze the file+dir names together + lambda: self._analyze_name(self.file_path), + + # try to analyze the dir + file name together as one name + lambda: self._analyze_name(self.folder_name + u' ' + self.file_name) + + ] + + # attempt every possible method to get our info + for cur_attempt in attempt_list: + + try: + (cur_tvdb_id, cur_season, cur_episodes) = cur_attempt() + except InvalidNameException, e: + logger.log(u"Unable to parse, skipping: "+ex(e), logger.DEBUG) + continue + + # if we already did a successful history lookup then keep that tvdb_id value + if cur_tvdb_id and not (self.in_history and tvdb_id): + tvdb_id = cur_tvdb_id + if cur_season != None: + season = cur_season + if cur_episodes: + episodes = cur_episodes + + # for air-by-date shows we need to look up the season/episode from tvdb + if season == -1 and tvdb_id and episodes: + self._log(u"Looks like this is an air-by-date show, attempting to convert the date to season/episode", logger.DEBUG) + + # try to get language set for this show + tvdb_lang = None + try: + showObj = helpers.findCertainShow(sickbeard.showList, tvdb_id) + if(showObj != None): + tvdb_lang = showObj.lang + except exceptions.MultipleShowObjectsException: + raise #TODO: later I'll just log this, for now I want to know about it ASAP + + try: + # There's gotta be a better way of doing this but we don't wanna + # change the language value elsewhere + ltvdb_api_parms = sickbeard.TVDB_API_PARMS.copy() + + if tvdb_lang and not tvdb_lang == 'en': + ltvdb_api_parms['language'] = tvdb_lang + + t = tvdb_api.Tvdb(**ltvdb_api_parms) + epObj = t[tvdb_id].airedOn(episodes[0])[0] + season = int(epObj["seasonnumber"]) + episodes = [int(epObj["episodenumber"])] + self._log(u"Got season " + str(season) + " episodes " + str(episodes), logger.DEBUG) + except tvdb_exceptions.tvdb_episodenotfound, e: + self._log(u"Unable to find episode with date " + str(episodes[0]) + u" for show " + str(tvdb_id) + u", skipping", logger.DEBUG) + # we don't want to leave dates in the episode list if we couldn't convert them to real episode numbers + episodes = [] + continue + except tvdb_exceptions.tvdb_error, e: + logger.log(u"Unable to contact TVDB: " + ex(e), logger.WARNING) + episodes = [] + continue + + # if there's no season then we can hopefully just use 1 automatically + elif season == None and tvdb_id: + myDB = db.DBConnection() + numseasonsSQlResult = myDB.select("SELECT COUNT(DISTINCT season) as numseasons FROM tv_episodes WHERE showid = ? and season != 0", [tvdb_id]) + if int(numseasonsSQlResult[0][0]) == 1 and season == None: + self._log(u"Don't have a season number, but this show appears to only have 1 season, setting seasonnumber to 1...", logger.DEBUG) + season = 1 + + if tvdb_id and season != None and episodes: + return (tvdb_id, season, episodes) + + return (tvdb_id, season, episodes) + + def _get_ep_obj(self, tvdb_id, season, episodes): + """ + Retrieve the TVEpisode object requested. + + tvdb_id: The TVDBID of the show (int) + season: The season of the episode (int) + episodes: A list of episodes to find (list of ints) + + If the episode(s) can be found then a TVEpisode object with the correct related eps will + be instantiated and returned. If the episode can't be found then None will be returned. + """ + + show_obj = None + + self._log(u"Loading show object for tvdb_id "+str(tvdb_id), logger.DEBUG) + # find the show in the showlist + try: + show_obj = helpers.findCertainShow(sickbeard.showList, tvdb_id) + except exceptions.MultipleShowObjectsException: + raise #TODO: later I'll just log this, for now I want to know about it ASAP + + # if we can't find the show then there's nothing we can really do + if not show_obj: + self._log(("This show (tvdb_id=%d) isn't in your list, you need to add it to SB before post-processing an episode" % tvdb_id), logger.ERROR) + raise exceptions.PostProcessingFailed() + + root_ep = None + for cur_episode in episodes: + episode = int(cur_episode) + + self._log(u"Retrieving episode object for " + str(season) + "x" + str(episode), logger.DEBUG) + + # now that we've figured out which episode this file is just load it manually + try: + curEp = show_obj.getEpisode(season, episode) + except exceptions.EpisodeNotFoundException, e: + self._log(u"Unable to create episode: "+ex(e), logger.DEBUG) + raise exceptions.PostProcessingFailed() + + # associate all the episodes together under a single root episode + if root_ep == None: + root_ep = curEp + root_ep.relatedEps = [] + elif curEp not in root_ep.relatedEps: + root_ep.relatedEps.append(curEp) + + return root_ep + + def _get_quality(self, ep_obj): + """ + Determines the quality of the file that is being post processed, first by checking if it is directly + available in the TVEpisode's status or otherwise by parsing through the data available. + + ep_obj: The TVEpisode object related to the file we are post processing + + Returns: A quality value found in common.Quality + """ + + ep_quality = common.Quality.UNKNOWN + + # if there is a quality available in the status then we don't need to bother guessing from the filename + if ep_obj.status in common.Quality.SNATCHED + common.Quality.SNATCHED_PROPER: + oldStatus, ep_quality = common.Quality.splitCompositeStatus(ep_obj.status) #@UnusedVariable + if ep_quality != common.Quality.UNKNOWN: + self._log(u"The old status had a quality in it, using that: "+common.Quality.qualityStrings[ep_quality], logger.DEBUG) + return ep_quality + + # nzb name is the most reliable if it exists, followed by folder name and lastly file name + name_list = [self.nzb_name, self.folder_name, self.file_name] + + # search all possible names for our new quality, in case the file or dir doesn't have it + for cur_name in name_list: + + # some stuff might be None at this point still + if not cur_name: + continue + + ep_quality = common.Quality.nameQuality(cur_name) + self._log(u"Looking up quality for name "+cur_name+u", got "+common.Quality.qualityStrings[ep_quality], logger.DEBUG) + + # if we find a good one then use it + if ep_quality != common.Quality.UNKNOWN: + logger.log(cur_name+u" looks like it has quality "+common.Quality.qualityStrings[ep_quality]+", using that", logger.DEBUG) + return ep_quality + + # if we didn't get a quality from one of the names above, try assuming from each of the names + ep_quality = common.Quality.assumeQuality(self.file_name) + self._log(u"Guessing quality for name "+self.file_name+u", got "+common.Quality.qualityStrings[ep_quality], logger.DEBUG) + if ep_quality != common.Quality.UNKNOWN: + logger.log(self.file_name+u" looks like it has quality "+common.Quality.qualityStrings[ep_quality]+", using that", logger.DEBUG) + return ep_quality + + return ep_quality + + def _run_extra_scripts(self, ep_obj): + """ + Executes any extra scripts defined in the config. + + ep_obj: The object to use when calling the extra script + """ + for curScriptName in sickbeard.EXTRA_SCRIPTS: + + # generate a safe command line string to execute the script and provide all the parameters + script_cmd = shlex.split(curScriptName) + [ep_obj.location, self.file_path, str(ep_obj.show.tvdbid), str(ep_obj.season), str(ep_obj.episode), str(ep_obj.airdate)] + + # use subprocess to run the command and capture output + self._log(u"Executing command "+str(script_cmd)) + self._log(u"Absolute path to script: "+ek.ek(os.path.abspath, script_cmd[0]), logger.DEBUG) + try: + p = subprocess.Popen(script_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=sickbeard.PROG_DIR) + out, err = p.communicate() #@UnusedVariable + self._log(u"Script result: "+str(out), logger.DEBUG) + except OSError, e: + self._log(u"Unable to run extra_script: "+ex(e)) + + def _is_priority(self, ep_obj, new_ep_quality): + """ + Determines if the episode is a priority download or not (if it is expected). Episodes which are expected + (snatched) or larger than the existing episode are priority, others are not. + + ep_obj: The TVEpisode object in question + new_ep_quality: The quality of the episode that is being processed + + Returns: True if the episode is priority, False otherwise. + """ + + # if SB downloaded this on purpose then this is a priority download + if self.in_history or ep_obj.status in common.Quality.SNATCHED + common.Quality.SNATCHED_PROPER: + self._log(u"SB snatched this episode so I'm marking it as priority", logger.DEBUG) + return True + + # if the user downloaded it manually and it's higher quality than the existing episode then it's priority + if new_ep_quality > ep_obj and new_ep_quality != common.Quality.UNKNOWN: + self._log(u"This was manually downloaded but it appears to be better quality than what we have so I'm marking it as priority", logger.DEBUG) + return True + + # if the user downloaded it manually and it appears to be a PROPER/REPACK then it's priority + old_ep_status, old_ep_quality = common.Quality.splitCompositeStatus(ep_obj.status) #@UnusedVariable + if self.is_proper and new_ep_quality >= old_ep_quality: + self._log(u"This was manually downloaded but it appears to be a proper so I'm marking it as priority", logger.DEBUG) + return True + + return False + + def process(self): + """ + Post-process a given file + """ + + self._log(u"Processing " + self.file_path + " (" + str(self.nzb_name) + ")") + + if os.path.isdir(self.file_path): + self._log(u"File " + self.file_path + " seems to be a directory") + return False + for ignore_file in self.IGNORED_FILESTRINGS: + if ignore_file in self.file_path: + self._log(u"File " + self.file_path + " is ignored type, skipping") + return False + # reset per-file stuff + self.in_history = False + + # try to find the file info + (tvdb_id, season, episodes) = self._find_info() + + # if we don't have it then give up + if not tvdb_id or season == None or not episodes: + return False + + # retrieve/create the corresponding TVEpisode objects + ep_obj = self._get_ep_obj(tvdb_id, season, episodes) + + # get the quality of the episode we're processing + new_ep_quality = self._get_quality(ep_obj) + logger.log(u"Quality of the episode we're processing: " + str(new_ep_quality), logger.DEBUG) + + # see if this is a priority download (is it snatched, in history, or PROPER) + priority_download = self._is_priority(ep_obj, new_ep_quality) + self._log(u"Is ep a priority download: " + str(priority_download), logger.DEBUG) + + # set the status of the episodes + for curEp in [ep_obj] + ep_obj.relatedEps: + curEp.status = common.Quality.compositeStatus(common.SNATCHED, new_ep_quality) + + # check for an existing file + existing_file_status = self._checkForExistingFile(ep_obj.location) + + # if it's not priority then we don't want to replace smaller files in case it was a mistake + if not priority_download: + + # if there's an existing file that we don't want to replace stop here + if existing_file_status in (PostProcessor.EXISTS_LARGER, PostProcessor.EXISTS_SAME): + self._log(u"File exists and we are not going to replace it because it's not smaller, quitting post-processing", logger.DEBUG) + return False + elif existing_file_status == PostProcessor.EXISTS_SMALLER: + self._log(u"File exists and is smaller than the new file so I'm going to replace it", logger.DEBUG) + elif existing_file_status != PostProcessor.DOESNT_EXIST: + self._log(u"Unknown existing file status. This should never happen, please log this as a bug.", logger.ERROR) + return False + + # if the file is priority then we're going to replace it even if it exists + else: + self._log(u"This download is marked a priority download so I'm going to replace an existing file if I find one", logger.DEBUG) + + # delete the existing file (and company) + for cur_ep in [ep_obj] + ep_obj.relatedEps: + try: + self._delete(cur_ep.location, associated_files=True) + # clean up any left over folders + if cur_ep.location: + helpers.delete_empty_folders(ek.ek(os.path.dirname, cur_ep.location), keep_dir=ep_obj.show._location) + except (OSError, IOError): + raise exceptions.PostProcessingFailed("Unable to delete the existing files") + + # if the show directory doesn't exist then make it if allowed + if not ek.ek(os.path.isdir, ep_obj.show._location) and sickbeard.CREATE_MISSING_SHOW_DIRS: + self._log(u"Show directory doesn't exist, creating it", logger.DEBUG) + try: + ek.ek(os.mkdir, ep_obj.show._location) + # do the library update for synoindex + notifiers.synoindex_notifier.addFolder(ep_obj.show._location) + + except (OSError, IOError): + raise exceptions.PostProcessingFailed("Unable to create the show directory: " + ep_obj.show._location) + + # get metadata for the show (but not episode because it hasn't been fully processed) + ep_obj.show.writeMetadata(True) + + # update the ep info before we rename so the quality & release name go into the name properly + for cur_ep in [ep_obj] + ep_obj.relatedEps: + with cur_ep.lock: + cur_release_name = None + + # use the best possible representation of the release name + if self.good_results[self.NZB_NAME]: + cur_release_name = self.nzb_name + if cur_release_name.lower().endswith('.nzb'): + cur_release_name = cur_release_name.rpartition('.')[0] + elif self.good_results[self.FOLDER_NAME]: + cur_release_name = self.folder_name + elif self.good_results[self.FILE_NAME]: + cur_release_name = self.file_name + # take the extension off the filename, it's not needed + if '.' in self.file_name: + cur_release_name = self.file_name.rpartition('.')[0] + + if cur_release_name: + self._log("Found release name " + cur_release_name, logger.DEBUG) + cur_ep.release_name = cur_release_name + else: + logger.log("good results: " + repr(self.good_results), logger.DEBUG) + + cur_ep.status = common.Quality.compositeStatus(common.DOWNLOADED, new_ep_quality) + + cur_ep.saveToDB() + + # find the destination folder + try: + proper_path = ep_obj.proper_path() + proper_absolute_path = ek.ek(os.path.join, ep_obj.show.location, proper_path) + + dest_path = ek.ek(os.path.dirname, proper_absolute_path) + except exceptions.ShowDirNotFoundException: + raise exceptions.PostProcessingFailed(u"Unable to post-process an episode if the show dir doesn't exist, quitting") + + self._log(u"Destination folder for this episode: " + dest_path, logger.DEBUG) + + # create any folders we need + helpers.make_dirs(dest_path) + + # figure out the base name of the resulting episode file + if sickbeard.RENAME_EPISODES: + orig_extension = self.file_name.rpartition('.')[-1] + new_base_name = ek.ek(os.path.basename, proper_path) + new_file_name = new_base_name + '.' + orig_extension + + else: + # if we're not renaming then there's no new base name, we'll just use the existing name + new_base_name = None + new_file_name = self.file_name + + with open(self.file_path, 'rb') as fh: + m = hashlib.md5() + while True: + data = fh.read(8192) + if not data: + break + m.update(data) + MD5 = m.hexdigest() + + try: + + path,file=os.path.split(self.file_path) + + if sickbeard.TORRENT_DOWNLOAD_DIR == path: + #Action possible pour les torrent + if sickbeard.PROCESS_METHOD == "copy": + self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + elif sickbeard.PROCESS_METHOD == "move": + self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + elif sickbeard.PROCESS_METHOD == "hardlink": + self._hardlink(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + elif sickbeard.PROCESS_METHOD == "symlink": + self._moveAndSymlink(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + else: + logger.log(u"Unknown process method: " + str(sickbeard.PROCESS_METHOD), logger.ERROR) + raise exceptions.PostProcessingFailed("Unable to move the files to their new home") + else: + #action pour le reste des fichier + if sickbeard.KEEP_PROCESSED_DIR: + self._copy(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + else: + self._move(self.file_path, dest_path, new_base_name, sickbeard.MOVE_ASSOCIATED_FILES) + + except (OSError, IOError): + raise exceptions.PostProcessingFailed("Unable to move the files to their new home") + + myDB = db.DBConnection() + + ## INSERT MD5 of file + controlMD5 = {"episode_id" : int(ep_obj.tvdbid) } + NewValMD5 = {"filename" : new_base_name , + "md5" : MD5 + } + myDB.upsert("processed_files", NewValMD5, controlMD5) + + + + # put the new location in the database + for cur_ep in [ep_obj] + ep_obj.relatedEps: + with cur_ep.lock: + cur_ep.location = ek.ek(os.path.join, dest_path, new_file_name) + cur_ep.saveToDB() + + # log it to history + history.logDownload(ep_obj, self.file_path, new_ep_quality, self.release_group) + + # download subtitles + if sickbeard.USE_SUBTITLES and ep_obj.show.subtitles: + cur_ep.downloadSubtitles() + + # send notifications + notifiers.notify_download(ep_obj.prettyName()) + + # generate nfo/tbn + ep_obj.createMetaFiles() + ep_obj.saveToDB() + + # do the library update for XBMC + notifiers.xbmc_notifier.update_library(ep_obj.show.name) + + # do the library update for Plex + notifiers.plex_notifier.update_library() + + # do the library update for NMJ + # nmj_notifier kicks off its library update when the notify_download is issued (inside notifiers) + + # do the library update for Synology Indexer + notifiers.synoindex_notifier.addFile(ep_obj.location) + + # do the library update for pyTivo + notifiers.pytivo_notifier.update_library(ep_obj) + + # do the library update for Trakt + notifiers.trakt_notifier.update_library(ep_obj) + + self._run_extra_scripts(ep_obj) + + return True diff --git a/sickbeard/processTV.py b/sickbeard/processTV.py index d879b4fdf9..5639041cc0 100644 --- a/sickbeard/processTV.py +++ b/sickbeard/processTV.py @@ -1,168 +1,168 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -from __future__ import with_statement - -import os -import shutil -import hashlib - -import sickbeard -from sickbeard import postProcessor -from sickbeard import db, helpers, exceptions - -from sickbeard import encodingKludge as ek -from sickbeard.exceptions import ex - -from sickbeard import logger - -def logHelper (logMessage, logLevel=logger.MESSAGE): - logger.log(logMessage, logLevel) - return logMessage + u"\n" - -def processDir (dirName, nzbName=None, recurse=False): - """ - Scans through the files in dirName and processes whatever media files it finds - - dirName: The folder name to look in - nzbName: The NZB name which resulted in this folder being downloaded - recurse: Boolean for whether we should descend into subfolders or not - """ - - returnStr = '' - - returnStr += logHelper(u"Processing folder "+dirName, logger.DEBUG) - - # if they passed us a real dir then assume it's the one we want - if ek.ek(os.path.isdir, dirName): - dirName = ek.ek(os.path.realpath, dirName) - - # if they've got a download dir configured then use it - elif sickbeard.TV_DOWNLOAD_DIR and ek.ek(os.path.isdir, sickbeard.TV_DOWNLOAD_DIR) \ - and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TV_DOWNLOAD_DIR): - dirName = ek.ek(os.path.join, sickbeard.TV_DOWNLOAD_DIR, ek.ek(os.path.abspath, dirName).split(os.path.sep)[-1]) - returnStr += logHelper(u"Trying to use folder "+dirName, logger.DEBUG) - - # if we didn't find a real dir then quit - if not ek.ek(os.path.isdir, dirName): - returnStr += logHelper(u"Unable to figure out what folder to process. If your downloader and Sick Beard aren't on the same PC make sure you fill out your TV download dir in the config.", logger.DEBUG) - return returnStr - - # TODO: check if it's failed and deal with it if it is - if ek.ek(os.path.basename, dirName).startswith('_FAILED_'): - returnStr += logHelper(u"The directory name indicates it failed to extract, cancelling", logger.DEBUG) - return returnStr - elif ek.ek(os.path.basename, dirName).startswith('_UNDERSIZED_'): - returnStr += logHelper(u"The directory name indicates that it was previously rejected for being undersized, cancelling", logger.DEBUG) - return returnStr - elif ek.ek(os.path.basename, dirName).startswith('_UNPACK_'): - returnStr += logHelper(u"The directory name indicates that this release is in the process of being unpacked, skipping", logger.DEBUG) - return returnStr - - # make sure the dir isn't inside a show dir - myDB = db.DBConnection() - sqlResults = myDB.select("SELECT * FROM tv_shows") - for sqlShow in sqlResults: - if dirName.lower().startswith(ek.ek(os.path.realpath, sqlShow["location"]).lower()+os.sep) or dirName.lower() == ek.ek(os.path.realpath, sqlShow["location"]).lower(): - returnStr += logHelper(u"You're trying to post process an episode that's already been moved to its show dir", logger.ERROR) - return returnStr - - fileList = ek.ek(os.listdir, dirName) - - # split the list into video files and folders - folders = filter(lambda x: ek.ek(os.path.isdir, ek.ek(os.path.join, dirName, x)), fileList) - videoFiles = filter(helpers.isMediaFile, fileList) - - # recursively process all the folders - for curFolder in folders: - returnStr += logHelper(u"Recursively processing a folder: "+curFolder, logger.DEBUG) - returnStr += processDir(ek.ek(os.path.join, dirName, curFolder), recurse=True) - - remainingFolders = filter(lambda x: ek.ek(os.path.isdir, ek.ek(os.path.join, dirName, x)), fileList) - - # If nzbName is set and there's more than one videofile in the folder, files will be lost (overwritten). - if nzbName != None and len(videoFiles) >= 2: - nzbName = None - - # process any files in the dir - for cur_video_file_path in videoFiles: - - cur_video_file_path = ek.ek(os.path.join, dirName, cur_video_file_path) - - # IF VIDEO_FILE ALREADY PROCESS THEN CONTINUE - # TODO - - myDB = db.DBConnection() - - with open(cur_video_file_path, 'rb') as fh: - m = hashlib.md5() - while True: - data = fh.read(8192) - if not data: - break - m.update(data) - MD5 = m.hexdigest() - - logger.log("MD5 search : " + MD5, logger.DEBUG) - - sqlResults = myDB.select("select * from processed_files where md5 = \"" + MD5 + "\"") - - process_file = True - - for sqlProcess in sqlResults: - if sqlProcess["md5"] == MD5: - logger.log("File " + cur_video_file_path + " already processed for " + sqlProcess["filename"]) - process_file = False - - if process_file: - try: - processor = postProcessor.PostProcessor(cur_video_file_path, nzbName) - process_result = processor.process() - process_fail_message = "" - except exceptions.PostProcessingFailed, e: - process_result = False - process_fail_message = ex(e) - - returnStr += processor.log - - # as long as the postprocessing was successful delete the old folder unless the config wants us not to - if process_result: - - if len(videoFiles) == 1 \ - and ( ( not sickbeard.KEEP_PROCESSED_DIR and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TV_DOWNLOAD_DIR) ) \ - or ( sickbeard.PROCESS_METHOD == "move" and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TORRENT_DOWNLOAD_DIR) ) ) \ - and len(remainingFolders) == 0: - - returnStr += logHelper(u"Deleting folder " + dirName, logger.DEBUG) - - try: - shutil.rmtree(dirName) - except (OSError, IOError), e: - returnStr += logHelper(u"Warning: unable to remove the folder " + dirName + ": " + ex(e), logger.WARNING) - - returnStr += logHelper(u"Processing succeeded for "+cur_video_file_path) - - else: - returnStr += logHelper(u"Processing failed for "+cur_video_file_path+": "+process_fail_message, logger.WARNING) - if sickbeard.TV_DOWNLOAD_DIR !="": - for i in range(1,5): - helpers.del_empty_dirs(sickbeard.TV_DOWNLOAD_DIR) - if sickbeard.TORRENT_DOWNLOAD_DIR !="": - for i in range(1,5): - helpers.del_empty_dirs(sickbeard.TORRENT_DOWNLOAD_DIR) - return returnStr +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +from __future__ import with_statement + +import os +import shutil +import hashlib + +import sickbeard +from sickbeard import postProcessor +from sickbeard import db, helpers, exceptions + +from sickbeard import encodingKludge as ek +from sickbeard.exceptions import ex + +from sickbeard import logger + +def logHelper (logMessage, logLevel=logger.MESSAGE): + logger.log(logMessage, logLevel) + return logMessage + u"\n" + +def processDir (dirName, nzbName=None, recurse=False): + """ + Scans through the files in dirName and processes whatever media files it finds + + dirName: The folder name to look in + nzbName: The NZB name which resulted in this folder being downloaded + recurse: Boolean for whether we should descend into subfolders or not + """ + + returnStr = '' + + returnStr += logHelper(u"Processing folder "+dirName, logger.DEBUG) + + # if they passed us a real dir then assume it's the one we want + if ek.ek(os.path.isdir, dirName): + dirName = ek.ek(os.path.realpath, dirName) + + # if they've got a download dir configured then use it + elif sickbeard.TV_DOWNLOAD_DIR and ek.ek(os.path.isdir, sickbeard.TV_DOWNLOAD_DIR) \ + and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TV_DOWNLOAD_DIR): + dirName = ek.ek(os.path.join, sickbeard.TV_DOWNLOAD_DIR, ek.ek(os.path.abspath, dirName).split(os.path.sep)[-1]) + returnStr += logHelper(u"Trying to use folder "+dirName, logger.DEBUG) + + # if we didn't find a real dir then quit + if not ek.ek(os.path.isdir, dirName): + returnStr += logHelper(u"Unable to figure out what folder to process. If your downloader and Sick Beard aren't on the same PC make sure you fill out your TV download dir in the config.", logger.DEBUG) + return returnStr + + # TODO: check if it's failed and deal with it if it is + if ek.ek(os.path.basename, dirName).startswith('_FAILED_'): + returnStr += logHelper(u"The directory name indicates it failed to extract, cancelling", logger.DEBUG) + return returnStr + elif ek.ek(os.path.basename, dirName).startswith('_UNDERSIZED_'): + returnStr += logHelper(u"The directory name indicates that it was previously rejected for being undersized, cancelling", logger.DEBUG) + return returnStr + elif ek.ek(os.path.basename, dirName).startswith('_UNPACK_'): + returnStr += logHelper(u"The directory name indicates that this release is in the process of being unpacked, skipping", logger.DEBUG) + return returnStr + + # make sure the dir isn't inside a show dir + myDB = db.DBConnection() + sqlResults = myDB.select("SELECT * FROM tv_shows") + for sqlShow in sqlResults: + if dirName.lower().startswith(ek.ek(os.path.realpath, sqlShow["location"]).lower()+os.sep) or dirName.lower() == ek.ek(os.path.realpath, sqlShow["location"]).lower(): + returnStr += logHelper(u"You're trying to post process an episode that's already been moved to its show dir", logger.ERROR) + return returnStr + + fileList = ek.ek(os.listdir, dirName) + + # split the list into video files and folders + folders = filter(lambda x: ek.ek(os.path.isdir, ek.ek(os.path.join, dirName, x)), fileList) + videoFiles = filter(helpers.isMediaFile, fileList) + + # recursively process all the folders + for curFolder in folders: + returnStr += logHelper(u"Recursively processing a folder: "+curFolder, logger.DEBUG) + returnStr += processDir(ek.ek(os.path.join, dirName, curFolder), recurse=True) + + remainingFolders = filter(lambda x: ek.ek(os.path.isdir, ek.ek(os.path.join, dirName, x)), fileList) + + # If nzbName is set and there's more than one videofile in the folder, files will be lost (overwritten). + if nzbName != None and len(videoFiles) >= 2: + nzbName = None + + # process any files in the dir + for cur_video_file_path in videoFiles: + + cur_video_file_path = ek.ek(os.path.join, dirName, cur_video_file_path) + + # IF VIDEO_FILE ALREADY PROCESS THEN CONTINUE + # TODO + + myDB = db.DBConnection() + + with open(cur_video_file_path, 'rb') as fh: + m = hashlib.md5() + while True: + data = fh.read(8192) + if not data: + break + m.update(data) + MD5 = m.hexdigest() + + logger.log("MD5 search : " + MD5, logger.DEBUG) + + sqlResults = myDB.select("select * from processed_files where md5 = \"" + MD5 + "\"") + + process_file = True + + for sqlProcess in sqlResults: + if sqlProcess["md5"] == MD5: + logger.log("File " + cur_video_file_path + " already processed for ") + process_file = False + + if process_file: + try: + processor = postProcessor.PostProcessor(cur_video_file_path, nzbName) + process_result = processor.process() + process_fail_message = "" + except exceptions.PostProcessingFailed, e: + process_result = False + process_fail_message = ex(e) + + returnStr += processor.log + + # as long as the postprocessing was successful delete the old folder unless the config wants us not to + if process_result: + + if len(videoFiles) == 1 \ + and ( ( not sickbeard.KEEP_PROCESSED_DIR and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TV_DOWNLOAD_DIR) ) \ + or ( sickbeard.PROCESS_METHOD == "move" and ek.ek(os.path.normpath, dirName) != ek.ek(os.path.normpath, sickbeard.TORRENT_DOWNLOAD_DIR) ) ) \ + and len(remainingFolders) == 0: + + returnStr += logHelper(u"Deleting folder " + dirName, logger.DEBUG) + + try: + shutil.rmtree(dirName) + except (OSError, IOError), e: + returnStr += logHelper(u"Warning: unable to remove the folder " + dirName + ": " + ex(e), logger.WARNING) + + returnStr += logHelper(u"Processing succeeded for "+cur_video_file_path) + + else: + returnStr += logHelper(u"Processing failed for "+cur_video_file_path+": "+process_fail_message, logger.WARNING) + if sickbeard.TV_DOWNLOAD_DIR !="": + for i in range(1,5): + helpers.del_empty_dirs(sickbeard.TV_DOWNLOAD_DIR) + if sickbeard.TORRENT_DOWNLOAD_DIR !="": + for i in range(1,5): + helpers.del_empty_dirs(sickbeard.TORRENT_DOWNLOAD_DIR) + return returnStr diff --git a/sickbeard/tv.py b/sickbeard/tv.py index 2cdb0ada54..bf7199031b 100644 --- a/sickbeard/tv.py +++ b/sickbeard/tv.py @@ -24,7 +24,7 @@ import re import glob import traceback -import hashlib +import hashlib import sickbeard @@ -968,7 +968,7 @@ def saveToDB(self): myDB.upsert("tv_shows", newValueDict, controlValueDict) - + if self.imdbid: controlValueDict = {"tvdb_id": self.tvdbid} newValueDict = self.imdb_info @@ -1618,12 +1618,12 @@ def saveToDB(self, forceSave=False): "season": self.season, "episode": self.episode} - - + + # use a custom update/insert method to get the data into the DB myDB.upsert("tv_episodes", newValueDict, controlValueDict) - + def fullPath (self): if self.location == None or self.location == "": return None diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index c041c3ce30..2570ec371a 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -768,7 +768,7 @@ def index(self, limit=100): { 'title': 'Clear History', 'path': 'history/clearHistory' }, { 'title': 'Trim History', 'path': 'history/trimHistory' }, { 'title': 'Trunc Episode Links', 'path': 'history/truncEplinks' }, - { 'title': 'Trunc Episode List Processed', 'path': 'history/truncEpListProc' }, + { 'title': 'Trunc Episode List Processed', 'path': 'history/truncEpListProc' }, ] return _munge(t) @@ -802,15 +802,15 @@ def truncEplinks(self): ui.notifications.message('All Episode Links Removed', messnum) redirect("/history") - @cherrypy.expose - def truncEpListProc(self): - myDB = db.DBConnection() - nbep=myDB.select("SELECT count(*) from processed_files") - myDB.action("DELETE FROM processed_files WHERE 1=1") - messnum = str(nbep[0][0]) + ' record for file processed delete' - ui.notifications.message('Clear list of file processed', messnum) - redirect("/history") - + @cherrypy.expose + def truncEpListProc(self): + myDB = db.DBConnection() + nbep=myDB.select("SELECT count(*) from processed_files") + myDB.action("DELETE FROM processed_files WHERE 1=1") + messnum = str(nbep[0][0]) + ' record for file processed delete' + ui.notifications.message('Clear list of file processed', messnum) + redirect("/history") + ConfigMenu = [ { 'title': 'General', 'path': 'config/general/' }, @@ -1096,7 +1096,7 @@ def index(self): @cherrypy.expose def savePostProcessing(self, naming_pattern=None, naming_multi_ep=None, xbmc_data=None, xbmc__frodo__data=None, mediabrowser_data=None, synology_data=None, sony_ps3_data=None, wdtv_data=None, tivo_data=None, - use_banner=None, keep_processed_dir=None, process_method=None, process_automatically=None, process_automatically_torrent=None, rename_episodes=None, + use_banner=None, keep_processed_dir=None, process_method=None, process_automatically=None, process_automatically_torrent=None, rename_episodes=None, move_associated_files=None, tv_download_dir=None, torrent_download_dir=None, naming_custom_abd=None, naming_abd_pattern=None): results = [] @@ -1145,7 +1145,7 @@ def savePostProcessing(self, naming_pattern=None, naming_multi_ep=None, sickbeard.PROCESS_AUTOMATICALLY = process_automatically sickbeard.PROCESS_AUTOMATICALLY_TORRENT = process_automatically_torrent sickbeard.KEEP_PROCESSED_DIR = keep_processed_dir - sickbeard.PROCESS_METHOD = process_method + sickbeard.PROCESS_METHOD = process_method sickbeard.RENAME_EPISODES = rename_episodes sickbeard.MOVE_ASSOCIATED_FILES = move_associated_files sickbeard.NAMING_CUSTOM_ABD = naming_custom_abd @@ -3516,7 +3516,7 @@ def setHomeSearch(self, search): sickbeard.TOGGLE_SEARCH= search redirect("/home") - + @cherrypy.expose def toggleDisplayShowSpecials(self, show): From b5413b662020dcd9eeb9f38a6f7e85a5ca187ad4 Mon Sep 17 00:00:00 2001 From: foXaCe Date: Tue, 28 May 2013 20:40:56 +0200 Subject: [PATCH 084/492] update and rezise corbeille and saerch png to 24px --- data/interfaces/default/displayShow.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/displayShow.tmpl b/data/interfaces/default/displayShow.tmpl index 3ea189a8c5..68b3b03e24 100644 --- a/data/interfaces/default/displayShow.tmpl +++ b/data/interfaces/default/displayShow.tmpl @@ -351,7 +351,7 @@ $epLoc #end if
    From ace25cbf797d611683c951284978137748cbff24 Mon Sep 17 00:00:00 2001 From: a_dartois Date: Wed, 29 May 2013 07:04:27 +0200 Subject: [PATCH 090/492] FTP Working ... TODO : optmize UI --- .gitignore | 1 + sickbeard/SentFTPChecker.py | 50 ++++++++++++++++++------------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 0b70c8781b..6cda8b36fb 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ tests/cache.db *.tmproj *.tmproject *.sw? +/.idea # OS generated files # ###################### diff --git a/sickbeard/SentFTPChecker.py b/sickbeard/SentFTPChecker.py index 723a523609..4a2e0b90ed 100644 --- a/sickbeard/SentFTPChecker.py +++ b/sickbeard/SentFTPChecker.py @@ -24,57 +24,57 @@ from sickbeard import logger class SentFTPChecker(): - def __init__(self): - self.todoWanted = [] - self.todoBacklog = [] - def run(self): if sickbeard.USE_TORRENT_FTP: - # upload all torrent file to remote FTP - logger.log("Sending torrent file to FTP", logger.DEBUG) - self._sendToFTP("*.torrent", sickbeard.TORRENT_DIR) + # upload all torrent file to remote FTP + logger.log("Sending torrent file to FTP", logger.DEBUG) + self._sendToFTP("*.torrent", sickbeard.TORRENT_DIR) - def _sendToFTP(filter, dir): + def _sendToFTP(self, filter, dir): """ Send all torrent of the specified filter (eg "*.torrent") to the appropriate FTP. """ - # Connect to the FTP server - logger.log(u"Initializing FTP Session", logger.DEBUG) - session = ftp.FTP_Host(sickbeard.FTP_HOST, sickbeard.FTP_LOGIN, sickbeard.FTP_PASSWORD) - # Assign FTP Port logger.log(u"Assign FTP Port", logger.DEBUG) ftp.FTP_PORT = sickbeard.FTP_PORT + # Assign FTP Timeout + logger.log(u"Assign FTP Timeout", logger.DEBUG) + ftp.timeout = sickbeard.FTP_TIMEOUT + + # Connect to the FTP server + logger.log(u"Initializing FTP Session", logger.DEBUG) + session = ftp.FTP(sickbeard.FTP_HOST, sickbeard.FTP_LOGIN, sickbeard.FTP_PASSWORD) + # Assign passive mode logger.log(u"Assign Session Passive Mode", logger.DEBUG) session.set_pasv(sickbeard.FTP_PASSIVE) - # get welcome message - welcome = session.getwelcome() - if welcome != '': - logger(u"If a welcome message is detected, we log it :" + session.welcome, logger.DEBUG) - # change remote directory - logger(u"Set Remote Directory : " + sickbeard.FTP_DIR, logger.DEBUG) - session.chdir(sickbeard.FTP_DIR) + logger.log(u"Set Remote Directory : %s" % sickbeard.FTP_DIR, logger.DEBUG) + session.cwd(sickbeard.FTP_DIR) - os.chdir(dir) + for fileName in glob.glob(os.path.join(dir,filter)): - for fileName in glob.glob(filter): + bufsize = 1024 + file_handler = open(fileName, 'rb') # Send the file - logger(u"Send local file : " + fileName, logger.DEBUG) - session.upload(fileName, os.path.basename(fileName), "b") + logger.log(u"Send local file : " + fileName, logger.DEBUG) + session.set_debuglevel(1) + session.storbinary('STOR %s' % os.path.basename(fileName), file_handler, bufsize) + session.set_debuglevel(0) + + file_handler.close() # delete local file - logger(u"Deleting local file : " + fileName, logger.DEBUG) + logger.log(u"Deleting local file : " + fileName, logger.DEBUG) os.remove(fileName) # Close FTP session logger.log(u"Close FTP Session", logger.DEBUG) session.quit() - logger(u"It's working ... hop a beer !", logger.DEBUG) \ No newline at end of file + logger.log(u"It's working ... hop a beer !", logger.DEBUG) \ No newline at end of file From 8a5f42cff6371d8a03ef67bab24b034f2aa5332b Mon Sep 17 00:00:00 2001 From: a_dartois Date: Wed, 29 May 2013 08:57:30 +0200 Subject: [PATCH 091/492] Changes on FTP Thread (support port & passive mode) Changes on UI --- data/interfaces/default/config_search.tmpl | 20 +++++++++---- sickbeard/SentFTPChecker.py | 35 +++++++++++----------- sickbeard/__init__.py | 6 ++-- sickbeard/webserve.py | 14 ++++++++- 4 files changed, 48 insertions(+), 27 deletions(-) diff --git a/data/interfaces/default/config_search.tmpl b/data/interfaces/default/config_search.tmpl index 66d1b8e14c..76f3c41d66 100644 --- a/data/interfaces/default/config_search.tmpl +++ b/data/interfaces/default/config_search.tmpl @@ -302,9 +302,9 @@ NOTE: This method will not working for ThePirateBay Provider
    - -
    #else if $layout == 'banner': - "}f==="rtl"&&(g=g+i);a.p.pginput===true&&(e="");if(a.p.pgbuttons===true){k=["first"+d,"prev"+d,"next"+d,"last"+d];f==="rtl"&&k.reverse();g=g+("");g=g+("");g=g+(e!==""?""+e+"": +"")+("");g=g+("")}else e!==""&&(g=g+e);f==="ltr"&&(g=g+i);g=g+"
    Next EpPosterShowNetworkQualityDownloadsFrench EpActiveAudioStatus
    Next EpPosterShowNetworkQualityDownloadsFrench EpActiveAudioStatus
      Add Show
    $curShow.name $curShow.name
    @@ -251,7 +290,6 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name))
    $curShow.name #if int($epResult["season"]) != 0: - search + search #end if #if $sickbeard.USE_SUBTITLES and $show.subtitles and len(set(str($epResult["subtitles"]).split(',')).intersection(set($subtitles.wantedLanguages()))) < len($subtitles.wantedLanguages()) and $epResult["location"] - search subtitles + search subtitles #end if - trunc + trunc
    #if int($epResult["season"]) != 0: - search + search #end if #if $sickbeard.USE_SUBTITLES and $show.subtitles and len(set(str($epResult["subtitles"]).split(',')).intersection(set($subtitles.wantedLanguages()))) < len($subtitles.wantedLanguages()) and $epResult["location"] search subtitles From e31b7002358665e4308f3b46f90d809583062fe0 Mon Sep 17 00:00:00 2001 From: foXaCe Date: Tue, 28 May 2013 21:17:53 +0200 Subject: [PATCH 085/492] add "Les" on sorting show lists. --- data/interfaces/default/comingEpisodes.tmpl | 2 +- data/interfaces/default/config_general.tmpl | 2 +- data/interfaces/default/home.tmpl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/interfaces/default/comingEpisodes.tmpl b/data/interfaces/default/comingEpisodes.tmpl index 5ddff47af9..3ef37fe029 100644 --- a/data/interfaces/default/comingEpisodes.tmpl +++ b/data/interfaces/default/comingEpisodes.tmpl @@ -46,7 +46,7 @@ if (s.indexOf('Loading...') == 0) return s.replace('Loading...','000'); #if not $sickbeard.SORT_ARTICLE: - return (s || '').replace(/^(The|A|An|Le|La|Un|Une)\s/i,''); + return (s || '').replace(/^(The|A|An|Un|Une\Le|La|Les)\s/i,''); #else: return (s || ''); #end if diff --git a/data/interfaces/default/config_general.tmpl b/data/interfaces/default/config_general.tmpl index a6b5e8bf8f..cecae5e4f9 100644 --- a/data/interfaces/default/config_general.tmpl +++ b/data/interfaces/default/config_general.tmpl @@ -71,7 +71,7 @@ diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index 902891dcce..5636c3b1a5 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -56,7 +56,7 @@ if (s.indexOf('Loading...') == 0) return s.replace('Loading...','000'); #if $sort != 1: - return (s || '').replace(/^(The|A|An|Un|Une\Le|La)\s/i,''); + return (s || '').replace(/^(The|A|An|Un|Une\Le|La|Les)\s/i,''); #else: return (s || ''); #end if From 21b8342f726afcdf0b59eaa0de5a946d6123891d Mon Sep 17 00:00:00 2001 From: foXaCe Date: Tue, 28 May 2013 21:34:21 +0200 Subject: [PATCH 086/492] fix --- data/interfaces/default/comingEpisodes.tmpl | 2 +- data/interfaces/default/home.tmpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/interfaces/default/comingEpisodes.tmpl b/data/interfaces/default/comingEpisodes.tmpl index 3ef37fe029..71465bb86f 100644 --- a/data/interfaces/default/comingEpisodes.tmpl +++ b/data/interfaces/default/comingEpisodes.tmpl @@ -46,7 +46,7 @@ if (s.indexOf('Loading...') == 0) return s.replace('Loading...','000'); #if not $sickbeard.SORT_ARTICLE: - return (s || '').replace(/^(The|A|An|Un|Une\Le|La|Les)\s/i,''); + return (s || '').replace(/^(The|A|An|Un|Une|Le|La|Les)\s/i,''); #else: return (s || ''); #end if diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index 5636c3b1a5..7a1a9a4368 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -56,7 +56,7 @@ if (s.indexOf('Loading...') == 0) return s.replace('Loading...','000'); #if $sort != 1: - return (s || '').replace(/^(The|A|An|Un|Une\Le|La|Les)\s/i,''); + return (s || '').replace(/^(The|A|An|Un|Une|Le|La|Les)\s/i,''); #else: return (s || ''); #end if From fcbe571981c096c6734a1d8dcb2bcdc04f39c4b5 Mon Sep 17 00:00:00 2001 From: a_dartois Date: Tue, 28 May 2013 22:15:37 +0200 Subject: [PATCH 087/492] 1er tentative --- data/interfaces/default/config_search.tmpl | 175 ++++++++++++++++----- data/js/configSearch.js | 8 +- sickbeard/SentFTPChecker.py | 80 ++++++++++ sickbeard/__init__.py | 66 +++++++- sickbeard/show_queue.py | 3 + sickbeard/webserve.py | 22 ++- 6 files changed, 300 insertions(+), 54 deletions(-) create mode 100644 sickbeard/SentFTPChecker.py diff --git a/data/interfaces/default/config_search.tmpl b/data/interfaces/default/config_search.tmpl index 3bfd7bdd53..66d1b8e14c 100644 --- a/data/interfaces/default/config_search.tmpl +++ b/data/interfaces/default/config_search.tmpl @@ -11,9 +11,9 @@ -#if $varExists('header') +#if $varExists('header')

    $header

    -#else +#else

    $title

    #end if
    @@ -25,11 +25,11 @@ - - + +
    @@ -45,7 +45,7 @@ Replace original download with "Proper/Repack" if nuked?
    - +
    - +
    - +

    - +
    @@ -154,7 +154,7 @@ (eg. http://localhost:8000/)
    - +
    - +
    - +
    - +
    - +
    Click below to test.

    - + - + + @@ -280,28 +281,128 @@ #else #set $torrent_method = "" #end if - + #end for - + - + +
    + +
    + +

    -
    - - + +
    - +
    - +
    - -
    Click below to test.
    -
    -
    - + + + +
    @@ -418,20 +518,19 @@

    - - -
    - + + + - +
    - +
    All non-absolute folder locations are relative to $sickbeard.DATA_DIR
    - + @@ -447,7 +546,7 @@ jQuery('#torrent_dir').fileBrowser({ title: 'Select Torrent Black Hole/Watch Directory' }); jQuery('#torrent_path').fileBrowser({ title: 'Select Torrent Download Directory' }); jQuery('#tv_download_dir').fileBrowser({ title: 'Select TV Download Directory' }); - + //--> diff --git a/data/js/configSearch.js b/data/js/configSearch.js index c615e8f99c..b845223f6c 100644 --- a/data/js/configSearch.js +++ b/data/js/configSearch.js @@ -118,16 +118,16 @@ $(document).ready(function(){ $('#testTorrent').click(function(){ $('#testTorrent-result').html(loading); - var torrent_method = $('#torrent_method :selected').val(); + var torrent_method = $('#torrent_method :selected').val(); var torrent_host = $('#torrent_host').val(); var torrent_username = $('#torrent_username').val(); var torrent_password = $('#torrent_password').val(); - - $.get(sbRoot+"/home/testTorrent", {'torrent_method': torrent_method, 'host': torrent_host, 'username': torrent_username, 'password': torrent_password}, + + $.get(sbRoot+"/home/testTorrent", {'torrent_method': torrent_method, 'host': torrent_host, 'username': torrent_username, 'password': torrent_password}, function (data){ $('#testTorrent-result').html(data); }); }); $('#prefered_method').change($(this).prefered_method_handler); $(this).prefered_method_handler(); - + }); diff --git a/sickbeard/SentFTPChecker.py b/sickbeard/SentFTPChecker.py new file mode 100644 index 0000000000..723a523609 --- /dev/null +++ b/sickbeard/SentFTPChecker.py @@ -0,0 +1,80 @@ +# Author: Arnaud Dartois +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . +import time +import os +import sickbeard +import ftplib as ftp +import glob + +from sickbeard import logger + +class SentFTPChecker(): + def __init__(self): + self.todoWanted = [] + self.todoBacklog = [] + + def run(self): + if sickbeard.USE_TORRENT_FTP: + # upload all torrent file to remote FTP + logger.log("Sending torrent file to FTP", logger.DEBUG) + self._sendToFTP("*.torrent", sickbeard.TORRENT_DIR) + + def _sendToFTP(filter, dir): + """ + Send all torrent of the specified filter (eg "*.torrent") to the appropriate FTP. + + """ + + # Connect to the FTP server + logger.log(u"Initializing FTP Session", logger.DEBUG) + session = ftp.FTP_Host(sickbeard.FTP_HOST, sickbeard.FTP_LOGIN, sickbeard.FTP_PASSWORD) + + # Assign FTP Port + logger.log(u"Assign FTP Port", logger.DEBUG) + ftp.FTP_PORT = sickbeard.FTP_PORT + + # Assign passive mode + logger.log(u"Assign Session Passive Mode", logger.DEBUG) + session.set_pasv(sickbeard.FTP_PASSIVE) + + # get welcome message + welcome = session.getwelcome() + if welcome != '': + logger(u"If a welcome message is detected, we log it :" + session.welcome, logger.DEBUG) + + # change remote directory + logger(u"Set Remote Directory : " + sickbeard.FTP_DIR, logger.DEBUG) + session.chdir(sickbeard.FTP_DIR) + + os.chdir(dir) + + for fileName in glob.glob(filter): + + # Send the file + logger(u"Send local file : " + fileName, logger.DEBUG) + session.upload(fileName, os.path.basename(fileName), "b") + + # delete local file + logger(u"Deleting local file : " + fileName, logger.DEBUG) + os.remove(fileName) + + # Close FTP session + logger.log(u"Close FTP Session", logger.DEBUG) + session.quit() + + logger(u"It's working ... hop a beer !", logger.DEBUG) \ No newline at end of file diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index bb9db86be7..09499c54ec 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -33,7 +33,7 @@ from providers import ezrss, tvtorrents, torrentleech, btn, nzbsrus, newznab, womble, nzbx, omgwtfnzbs, binnewz, t411, cpasbien, piratebay, gks, kat from sickbeard.config import CheckSection, check_setting_int, check_setting_str, ConfigMigrator -from sickbeard import searchCurrent, searchBacklog, showUpdater, versionChecker, properFinder, autoPostProcesser, subtitles, traktWatchListChecker +from sickbeard import searchCurrent, searchBacklog, showUpdater, versionChecker, properFinder, autoPostProcesser, subtitles, traktWatchListChecker, SentFTPChecker from sickbeard import helpers, db, exceptions, show_queue, search_queue, scheduler from sickbeard import logger from sickbeard import naming @@ -79,6 +79,7 @@ autoTorrentPostProcesserScheduler = None subtitlesFinderScheduler = None traktWatchListCheckerSchedular = None +sentFTPSchedular = None showList = None loadingShowList = None @@ -150,7 +151,6 @@ USE_TORRENTS = None NZB_METHOD = None -NZB_DIR = None USENET_RETENTION = None TORRENT_METHOD = None TORRENT_DIR = None @@ -390,6 +390,15 @@ SUBTITLES_SERVICES_ENABLED = [] SUBTITLES_HISTORY = False +USE_TORRENT_FTP = False +FTP_HOST = '' +FTP_LOGIN = '' +FTP_PASSWORD = '' +FTP_PORT = 21 +FTP_TIMEOUT = 120 +FTP_DIR = '' +FTP_PASSIVE = False + DISPLAY_POSTERS = None TOGGLE_SEARCH = False @@ -438,7 +447,7 @@ def initialize(consoleLogging=True): USE_SYNOLOGYNOTIFIER, SYNOLOGYNOTIFIER_NOTIFY_ONSNATCH, SYNOLOGYNOTIFIER_NOTIFY_ONDOWNLOAD, SYNOLOGYNOTIFIER_NOTIFY_ONSUBTITLEDOWNLOAD, \ USE_MAIL, MAIL_USERNAME, MAIL_PASSWORD, MAIL_SERVER, MAIL_SSL, MAIL_FROM, MAIL_TO, MAIL_NOTIFY_ONSNATCH, \ NZBMATRIX_APIKEY, versionCheckScheduler, VERSION_NOTIFY, PROCESS_AUTOMATICALLY, PROCESS_AUTOMATICALLY_TORRENT, \ - KEEP_PROCESSED_DIR, PROCESS_METHOD, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ + KEEP_PROCESSED_DIR, TV_DOWNLOAD_DIR, TORRENT_DOWNLOAD_DIR, TVDB_BASE_URL, MIN_SEARCH_FREQUENCY, \ showQueueScheduler, searchQueueScheduler, ROOT_DIRS, CACHE_DIR, ACTUAL_CACHE_DIR, TVDB_API_PARMS, \ NAMING_PATTERN, NAMING_MULTI_EP, NAMING_FORCE_FOLDERS, NAMING_ABD_PATTERN, NAMING_CUSTOM_ABD, \ RENAME_EPISODES, properFinderScheduler, PROVIDER_ORDER, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, \ @@ -452,8 +461,8 @@ def initialize(consoleLogging=True): NEWZBIN, NEWZBIN_USERNAME, NEWZBIN_PASSWORD, GIT_PATH, MOVE_ASSOCIATED_FILES, \ GKS, GKS_KEY, \ HOME_LAYOUT, DISPLAY_SHOW_SPECIALS, COMING_EPS_LAYOUT, COMING_EPS_SORT, COMING_EPS_DISPLAY_PAUSED, COMING_EPS_MISSED_RANGE, METADATA_WDTV, METADATA_TIVO, IGNORE_WORDS, CREATE_MISSING_SHOW_DIRS, \ - ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler, TOGGLE_SEARCH - + ADD_SHOWS_WO_DIR, USE_SUBTITLES, SUBTITLES_LANGUAGES, SUBTITLES_DIR, SUBTITLES_DIR_SUB, SUBSNOLANG, SUBTITLES_SERVICES_LIST, SUBTITLES_SERVICES_ENABLED, SUBTITLES_HISTORY, subtitlesFinderScheduler, TOGGLE_SEARCH, \ + USE_TORRENT_FTP, FTP_HOST, FTP_LOGIN, FTP_PASSWORD, FTP_PORT, FTP_TIMEOUT, FTP_DIR, FTP_PASSIVE, sentFTPSchedular if __INITIALIZED__: return False @@ -527,7 +536,7 @@ def initialize(consoleLogging=True): TVDB_BASE_URL = 'http://thetvdb.com/api/' + TVDB_API_KEY - TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') + TOGGLE_SEARCH = check_setting_int(CFG, 'General', 'toggle_search', '') QUALITY_DEFAULT = check_setting_int(CFG, 'General', 'quality_default', SD) STATUS_DEFAULT = check_setting_int(CFG, 'General', 'status_default', SKIPPED) AUDIO_SHOW_DEFAULT = check_setting_str(CFG, 'General', 'audio_show_default', 'fr' ) @@ -571,7 +580,7 @@ def initialize(consoleLogging=True): PROCESS_AUTOMATICALLY_TORRENT = check_setting_int(CFG, 'General', 'process_automatically_torrent', 0) RENAME_EPISODES = check_setting_int(CFG, 'General', 'rename_episodes', 1) KEEP_PROCESSED_DIR = check_setting_int(CFG, 'General', 'keep_processed_dir', 1) - PROCESS_METHOD = check_setting_str(CFG, 'General', 'process_method', 'copy' if KEEP_PROCESSED_DIR else 'move') + PROCESS_METHOD = check_setting_str(CFG, 'General', 'process_method', 'copy' if KEEP_PROCESSED_DIR else 'move') MOVE_ASSOCIATED_FILES = check_setting_int(CFG, 'General', 'move_associated_files', 0) CREATE_MISSING_SHOW_DIRS = check_setting_int(CFG, 'General', 'create_missing_show_dirs', 0) ADD_SHOWS_WO_DIR = check_setting_int(CFG, 'General', 'add_shows_wo_dir', 0) @@ -909,6 +918,17 @@ def initialize(consoleLogging=True): SUBTITLES_SERVICES_ENABLED = [int(x) for x in check_setting_str(CFG, 'Subtitles', 'SUBTITLES_SERVICES_ENABLED', '').split('|') if x] SUBTITLES_DEFAULT = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_default', 0)) SUBTITLES_HISTORY = bool(check_setting_int(CFG, 'Subtitles', 'subtitles_history', 0)) + + CheckSection(CFG, 'FTP') + USE_TORRENT_FTP = bool(check_setting_int(CFG, 'FTP', 'ftp_useftp', 0)) + FTP_HOST = check_setting_str(CFG, 'FTP', 'ftp_host', '') + FTP_LOGIN = check_setting_str(CFG, 'FTP', 'ftp_login', '') + FTP_PASSWORD = check_setting_str(CFG, 'FTP', 'ftp_password', '') + FTP_PORT = check_setting_int(CFG, 'FTP', 'ftp_port', 21) + FTP_TIMEOUT = check_setting_int(CFG, 'FTP', 'ftp_timeout', 120) + FTP_DIR = check_setting_str(CFG, 'FTP', 'ftp_remotedir', '') + FTP_PASSIVE = bool(check_setting_int(CFG, 'FTP', 'ftp_passive', 0)) + # start up all the threads logger.sb_log_instance.initLogging(consoleLogging=consoleLogging) @@ -995,6 +1015,12 @@ def initialize(consoleLogging=True): threadName="FINDSUBTITLES", runImmediately=True) + logger.log("Initializing FTP Thread", logger.DEBUG) + sentFTPSchedular = scheduler.Scheduler(SentFTPChecker.SentFTPChecker(), + cycleTime=datetime.timedelta(minutes=10), + threadName="FTP", + runImmediately=True) + showList = [] loadingShowList = {} @@ -1008,7 +1034,8 @@ def start(): showUpdateScheduler, versionCheckScheduler, showQueueScheduler, \ properFinderScheduler, autoPostProcesserScheduler, autoTorrentPostProcesserScheduler, searchQueueScheduler, \ subtitlesFinderScheduler, started, USE_SUBTITLES, \ - traktWatchListCheckerSchedular, started + traktWatchListCheckerSchedular, started, \ + sentFTPSchedular, started with INIT_LOCK: @@ -1048,6 +1075,12 @@ def start(): # start the trakt watchlist if USE_TRAKT: traktWatchListCheckerSchedular.thread.start() + + # start the FTP Scheduler + if USE_TORRENT_FTP: + logger.log("Starting FTP Thread", logger.DEBUG) + sentFTPSchedular.thread.start() + if UPDATE_SHOWS_ON_START: myDB = db.DBConnection() listshow=myDB.select("SELECT tvdb_id from tv_shows") @@ -1137,6 +1170,13 @@ def halt(): except: pass + sentFTPSchedular.abort = True + logger.log(u"Waiting for the TORRENT FTP thread to exit") + try: + sentFTPSchedular.thread.join(10) + except: + pass + properFinderScheduler.abort = True logger.log(u"Waiting for the PROPERFINDER thread to exit") try: @@ -1574,6 +1614,16 @@ def save_config(): new_config['Subtitles']['subtitles_default'] = int(SUBTITLES_DEFAULT) new_config['Subtitles']['subtitles_history'] = int(SUBTITLES_HISTORY) + new_config['FTP'] = {} + new_config['FTP']['ftp_useftp'] = int(USE_TORRENT_FTP) + new_config['FTP']['ftp_host'] = FTP_HOST + new_config['FTP']['ftp_login'] = FTP_LOGIN + new_config['FTP']['ftp_password'] = FTP_PASSWORD + new_config['FTP']['ftp_port'] = int(FTP_PORT) + new_config['FTP']['ftp_timeout'] = int(FTP_TIMEOUT) + new_config['FTP']['ftp_remotedir'] = FTP_DIR + new_config['FTP']['ftp_passive'] = FTP_PASSIVE + new_config['General']['config_version'] = CONFIG_VERSION new_config.write() diff --git a/sickbeard/show_queue.py b/sickbeard/show_queue.py index 146e36d191..3bbbd6bb54 100644 --- a/sickbeard/show_queue.py +++ b/sickbeard/show_queue.py @@ -352,6 +352,9 @@ def execute(self): # if there are specific episodes that need to be added by trakt sickbeard.traktWatchListCheckerSchedular.action.manageNewShow(self.show) + # if there is any episode we must upload to FTP + sickbeard.SentFTPSchedular.action.Send() + self.finish() def _finishEarly(self): diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 2570ec371a..9b9d3fa09f 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -991,7 +991,7 @@ def index(self): def saveSearch(self, use_nzbs=None, use_torrents=None, nzb_dir=None, sab_username=None, sab_password=None, sab_apikey=None, sab_category=None, sab_host=None, nzbget_password=None, nzbget_category=None, nzbget_host=None, torrent_dir=None,torrent_method=None, nzb_method=None, usenet_retention=None, search_frequency=None, download_propers=None, torrent_username=None, torrent_password=None, torrent_host=None, torrent_label=None, torrent_path=None, - torrent_ratio=None, torrent_paused=None, ignore_words=None, prefered_method=None): + torrent_ratio=None, torrent_paused=None, ignore_words=None, prefered_method=None, torrent_use_ftp = None, ftp_host=None, ftp_port=None, ftp_timeout=None, ftp_login=None, ftp_password=None, ftp_remotedir=None): results = [] @@ -1024,6 +1024,12 @@ def saveSearch(self, use_nzbs=None, use_torrents=None, nzb_dir=None, sab_usernam if ignore_words == None: ignore_words = "" + if ftp_port == None: + ftp_port = 21 + + if ftp_timeout == None: + ftp_timeout = 120 + sickbeard.USE_NZBS = use_nzbs sickbeard.USE_TORRENTS = use_torrents @@ -1072,6 +1078,14 @@ def saveSearch(self, use_nzbs=None, use_torrents=None, nzb_dir=None, sab_usernam sickbeard.TORRENT_HOST = torrent_host + sickbeard.USE_TORRENT_FTP = torrent_use_ftp + sickbeard.FTP_HOST = ftp_host + sickbeard.FTP_PORT = ftp_port + sickbeard.FTP_TIMEOUT = ftp_timeout + sickbeard.FTP_LOGIN = ftp_login + sickbeard.FTP_PASSWORD = ftp_password + sickbeard.FTP_DIR = ftp_remotedir + sickbeard.save_config() if len(results) > 0: @@ -2002,7 +2016,7 @@ def HomeMenu(): { 'title': 'Manual Post-Processing', 'path': 'home/postprocess/' }, { 'title': 'Update XBMC', 'path': 'home/updateXBMC/', 'requires': haveXBMC }, { 'title': 'Update Plex', 'path': 'home/updatePLEX/', 'requires': havePLEX }, - { 'title': 'Update', 'path': 'manage/manageSearches/forceVersionCheck', 'confirm': True}, + { 'title': 'Update', 'path': 'manage/manageSearches/forceVersionCheck', 'confirm': True}, { 'title': 'Restart', 'path': 'home/restart/?pid='+str(sickbeard.PID), 'confirm': True }, { 'title': 'Shutdown', 'path': 'home/shutdown/?pid='+str(sickbeard.PID), 'confirm': True }, ] @@ -3506,7 +3520,7 @@ def setHomeLayout(self, layout): sickbeard.HOME_LAYOUT = layout redirect("/home") - + @cherrypy.expose def setHomeSearch(self, search): @@ -3516,7 +3530,7 @@ def setHomeSearch(self, search): sickbeard.TOGGLE_SEARCH= search redirect("/home") - + @cherrypy.expose def toggleDisplayShowSpecials(self, show): From 1301fb3ca44111af61e72f1e8521be71acd2046a Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Wed, 29 May 2013 00:59:25 +0200 Subject: [PATCH 088/492] improved subtitles a little --- .../default/config_postProcessing.tmpl | 4 +- lib/guessit/__init__.py | 89 +++++++++- lib/guessit/__main__.py | 115 ++++++++++++ lib/guessit/fileutils.py | 3 +- lib/guessit/language.py | 12 +- lib/guessit/matcher.py | 22 ++- lib/guessit/patterns.py | 2 +- lib/guessit/slogging.py | 51 ++++-- lib/guessit/textutils.py | 11 ++ lib/guessit/transfo/guess_country.py | 2 +- lib/guessit/transfo/guess_language.py | 15 +- .../guess_movie_title_from_position.py | 1 + lib/guessit/transfo/guess_release_group.py | 31 ++-- lib/guessit/transfo/post_process.py | 11 +- lib/subliminal/api.py | 5 +- lib/subliminal/core.py | 2 +- lib/subliminal/language.py | 1 + lib/subliminal/services/__init__.py | 10 +- lib/subliminal/services/addic7ed.py | 7 +- lib/subliminal/services/opensubtitles.py | 6 +- lib/subliminal/services/podnapisi.py | 4 +- lib/subliminal/services/subswiki.py | 6 +- lib/subliminal/services/subtitulos.py | 6 +- lib/subliminal/services/thesubdb.py | 8 +- lib/subliminal/services/tvsubtitles.py | 6 +- lib/subliminal/videos.py | 4 - sickbeard/__init__.py | 2 +- sickbeard/scheduler.py | 164 +++++++++--------- sickbeard/subtitles.py | 17 +- 29 files changed, 441 insertions(+), 176 deletions(-) create mode 100644 lib/guessit/__main__.py diff --git a/data/interfaces/default/config_postProcessing.tmpl b/data/interfaces/default/config_postProcessing.tmpl index 5f4df17234..99709c41ef 100644 --- a/data/interfaces/default/config_postProcessing.tmpl +++ b/data/interfaces/default/config_postProcessing.tmpl @@ -139,8 +139,8 @@ Process Episode Method:
    #if int($epResult["season"]) != 0: - search + search #end if #if $sickbeard.USE_SUBTITLES and $show.subtitles and len(set(str($epResult["subtitles"]).split(',')).intersection(set($subtitles.wantedLanguages()))) < len($subtitles.wantedLanguages()) and $epResult["location"] - search subtitles + search subtitles #end if - trunc + trunc
    $curShow.name + $curShow.name
    From f69b4ec6e3a3673c9ab3478ede86e9a488b9eb64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Thu, 30 May 2013 16:28:13 +0200 Subject: [PATCH 104/492] correct little bug --- data/css/default.css | 1 + 1 file changed, 1 insertion(+) diff --git a/data/css/default.css b/data/css/default.css index 67f7c78181..99fbedf8b1 100644 --- a/data/css/default.css +++ b/data/css/default.css @@ -419,6 +419,7 @@ input:not(.btn){margin-right:6px;margin-top:5px;padding-top:4px;padding-bottom:4 /* --------------- alignment ------------------- */ .float-left { float: left; + clear: left; } .float-right { float: right; From 46961fc79a15ab276fcdabc60cbd3ab37cdeb58b Mon Sep 17 00:00:00 2001 From: brinbois Date: Thu, 30 May 2013 19:33:12 +0200 Subject: [PATCH 105/492] Modifie l'affichage display show --- data/css/default.css | 5 ++--- data/interfaces/default/displayShow.tmpl | 17 +++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/data/css/default.css b/data/css/default.css index 99fbedf8b1..6365575983 100644 --- a/data/css/default.css +++ b/data/css/default.css @@ -282,8 +282,7 @@ a{ float: right; height: auto; margin-bottom: 0px; - margin-top: 73px; - margin-right: 0px; + margin-right: 20px; overflow: hidden; text-indent: -3000px; width: 200px; @@ -750,7 +749,7 @@ tr.snatched { } .showInfo { min-width: 745px; - width : 80%; + width : 100%; float: left; margin-left: 0px; padding-top: 2px; diff --git a/data/interfaces/default/displayShow.tmpl b/data/interfaces/default/displayShow.tmpl index f97fa4bfe7..11fcaf7c0b 100644 --- a/data/interfaces/default/displayShow.tmpl +++ b/data/interfaces/default/displayShow.tmpl @@ -158,7 +158,14 @@
    - + #end if - + "},T=function(b,c,d,e){e='";return'"},M=function(a,b,c,d){c=(parseInt(c,10)-1)*parseInt(d,10)+1+b;return'"},$=function(b){var c,d=[],e=0,g;for(g=0;g0?this.rows[0]:null;b(this.firstChild).empty().append(d)}if(a&&this.p.scroll){b(this.grid.bDiv.firstChild).css({height:"auto"});b(this.grid.bDiv.firstChild.firstChild).css({height:0,display:"none"});if(this.grid.bDiv.scrollTop!==0)this.grid.bDiv.scrollTop= +0}if(c===true&&this.p.treeGrid){this.p.data=[];this.p._index={}}},N=function(){var c=a.p.data.length,d,e,g;d=a.p.rownumbers===true?1:0;e=a.p.multiselect===true?1:0;g=a.p.subGrid===true?1:0;d=a.p.keyIndex===false||a.p.loadonce===true?a.p.localReader.id:a.p.colModel[a.p.keyIndex+e+g+d].name;for(e=0;e"},J=function(c,d,e,g,f){var h=new Date,j=a.p.datatype!=="local"&&a.p.loadonce||a.p.datatype==="xmlstring",i=a.p.xmlReader,k=a.p.datatype==="local"?"local":"xml";if(j){a.p.data=[];a.p._index={};a.p.localReader.id="_id_"}a.p.reccount=0;if(b.isXMLDoc(c)){if(a.p.treeANode===-1&&!a.p.scroll){V.call(a,false,true);e=1}else e=e>1?e:1;var F=b(a),y,G,l=0,o,s=a.p.multiselect===true?1:0,u=0,n,m=a.p.rownumbers=== +true?1:0,t,p=[],E,q={},x,D,r=[],K=a.p.altRows===true?a.p.altclass:"",v;if(a.p.subGrid===true){u=1;n=b.jgrid.getMethod("addSubGridCell")}i.repeatitems||(p=$(k));t=a.p.keyIndex===false?b.isFunction(i.id)?i.id.call(a,c):i.id:a.p.keyIndex;if(p.length>0&&!isNaN(t)){a.p.remapColumns&&a.p.remapColumns.length&&(t=b.inArray(t,a.p.remapColumns));t=p[t]}k=(""+t).indexOf("[")===-1?p.length?function(a,c){return b(t,a).text()||c}:function(a,c){return b(i.cell,a).eq(t).text()||c}:function(a,b){return a.getAttribute(t.replace(/[\[\]]/g, +""))||b};a.p.userData={};a.p.page=b.jgrid.getXmlData(c,i.page)||a.p.page||0;a.p.lastpage=b.jgrid.getXmlData(c,i.total);if(a.p.lastpage===void 0)a.p.lastpage=1;a.p.records=b.jgrid.getXmlData(c,i.records)||0;b.isFunction(i.userdata)?a.p.userData=i.userdata.call(a,c)||{}:b.jgrid.getXmlData(c,i.userdata,true).each(function(){a.p.userData[this.getAttribute("name")]=b(this).text()});c=b.jgrid.getXmlData(c,i.root,true);(c=b.jgrid.getXmlData(c,i.row,true))||(c=[]);var w=c.length,L=0,B=[],C=parseInt(a.p.rowNum, +10),H=a.p.scroll?b.jgrid.randId():1;if(w>0&&a.p.page<=0)a.p.page=1;if(c&&w){f&&(C=C*(f+1));var f=b.isFunction(a.p.afterInsertRow),J=false,I;if(a.p.grouping){J=a.p.groupingView.groupCollapse===true;I=b.jgrid.getMethod("groupingPrepare")}for(;L");if(a.p.grouping){B=I.call(F,r,B,q,L);r=[]}if(j||a.p.treeGrid===true){q._id_=b.jgrid.stripPref(a.p.idPrefix,D);a.p.data.push(q);a.p._index[q._id_]=a.p.data.length-1}if(a.p.gridview===false){b("tbody:first", +d).append(r.join(""));F.triggerHandler("jqGridAfterInsertRow",[D,q,x]);f&&a.p.afterInsertRow.call(a,D,q,x);r=[]}q={};l++;L++;if(l===C)break}}if(a.p.gridview===true){G=a.p.treeANode>-1?a.p.treeANode:0;if(a.p.grouping){F.jqGrid("groupingRender",B,a.p.colModel.length);B=null}else a.p.treeGrid===true&&G>0?b(a.rows[G]).after(r.join("")):b("tbody:first",d).append(r.join(""))}if(a.p.subGrid===true)try{F.jqGrid("addSubGrid",s+m)}catch(Q){}a.p.totaltime=new Date-h;if(l>0&&a.p.records===0)a.p.records=w;r=null; +if(a.p.treeGrid===true)try{F.jqGrid("setTreeNode",G+1,l+G+1)}catch(S){}if(!a.p.treeGrid&&!a.p.scroll)a.grid.bDiv.scrollTop=0;a.p.reccount=l;a.p.treeANode=-1;a.p.userDataOnFooter&&F.jqGrid("footerData","set",a.p.userData,true);if(j){a.p.records=w;a.p.lastpage=Math.ceil(w/C)}g||a.updatepager(false,true);if(j)for(;l1?e:1;var i,j=a.p.datatype!=="local"&&a.p.loadonce||a.p.datatype==="jsonstring";if(j){a.p.data=[];a.p._index={};a.p.localReader.id="_id_"}a.p.reccount=0;if(a.p.datatype=== +"local"){d=a.p.localReader;i="local"}else{d=a.p.jsonReader;i="json"}var k=b(a),l=0,y,o,n,m=[],s=a.p.multiselect?1:0,u=a.p.subGrid===true?1:0,t,p=a.p.rownumbers===true?1:0,w=U(s+u+p);i=$(i);var v,E,q,x={},D,r,K=[],B=a.p.altRows===true?a.p.altclass:"",C;a.p.page=b.jgrid.getAccessor(c,d.page)||a.p.page||0;E=b.jgrid.getAccessor(c,d.total);u&&(t=b.jgrid.getMethod("addSubGridCell"));a.p.lastpage=E===void 0?1:E;a.p.records=b.jgrid.getAccessor(c,d.records)||0;a.p.userData=b.jgrid.getAccessor(c,d.userdata)|| +{};q=a.p.keyIndex===false?b.isFunction(d.id)?d.id.call(a,c):d.id:a.p.keyIndex;if(!d.repeatitems){m=i;if(m.length>0&&!isNaN(q)){a.p.remapColumns&&a.p.remapColumns.length&&(q=b.inArray(q,a.p.remapColumns));q=m[q]}}E=b.jgrid.getAccessor(c,d.root);E==null&&b.isArray(c)&&(E=c);E||(E=[]);c=E.length;o=0;if(c>0&&a.p.page<=0)a.p.page=1;var L=parseInt(a.p.rowNum,10),H=a.p.scroll?b.jgrid.randId():1,J=false,I;f&&(L=L*(f+1));a.p.datatype==="local"&&!a.p.deselectAfterSort&&(J=true);var O=b.isFunction(a.p.afterInsertRow), +N=[],P=false,Q;if(a.p.grouping){P=a.p.groupingView.groupCollapse===true;Q=b.jgrid.getMethod("groupingPrepare")}for(;o");if(a.p.grouping){N=Q.call(k,K,N,x,o);K=[]}if(j||a.p.treeGrid===true){x._id_=b.jgrid.stripPref(a.p.idPrefix,r);a.p.data.push(x);a.p._index[x._id_]= +a.p.data.length-1}if(a.p.gridview===false){b("#"+b.jgrid.jqID(a.p.id)+" tbody:first").append(K.join(""));k.triggerHandler("jqGridAfterInsertRow",[r,x,f]);O&&a.p.afterInsertRow.call(a,r,x,f);K=[]}x={};l++;o++;if(l===L)break}if(a.p.gridview===true){D=a.p.treeANode>-1?a.p.treeANode:0;a.p.grouping?k.jqGrid("groupingRender",N,a.p.colModel.length):a.p.treeGrid===true&&D>0?b(a.rows[D]).after(K.join("")):b("#"+b.jgrid.jqID(a.p.id)+" tbody:first").append(K.join(""))}if(a.p.subGrid===true)try{k.jqGrid("addSubGrid", +s+p)}catch(aa){}a.p.totaltime=new Date-h;if(l>0&&a.p.records===0)a.p.records=c;if(a.p.treeGrid===true)try{k.jqGrid("setTreeNode",D+1,l+D+1)}catch(W){}if(!a.p.treeGrid&&!a.p.scroll)a.grid.bDiv.scrollTop=0;a.p.reccount=l;a.p.treeANode=-1;a.p.userDataOnFooter&&k.jqGrid("footerData","set",a.p.userData,true);if(j){a.p.records=c;a.p.lastpage=Math.ceil(c/L)}g||a.updatepager(false,true);if(j)for(;l0&&e&&s.or();try{c(a.groups[d])}catch(j){alert(j)}b++}e&&s.orEnd()}if(a.rules!=null)try{(g=a.rules.length&&a.groupOp.toString().toUpperCase()==="OR")&&s.orBegin();for(d=0;d0&&h&&h==="OR"&&(s=s.or());s=p[i.op](s,h)(i.field,i.data,f[i.field])}b++}g&&s.orEnd()}catch(qa){alert(qa)}} +var d=a.p.multiSort?[]:"",e=[],g=false,f={},h=[],i=[],j,k,l;if(b.isArray(a.p.data)){var o=a.p.grouping?a.p.groupingView:false,n,m;b.each(a.p.colModel,function(){k=this.sorttype||"text";if(k==="date"||k==="datetime"){if(this.formatter&&typeof this.formatter==="string"&&this.formatter==="date"){j=this.formatoptions&&this.formatoptions.srcformat?this.formatoptions.srcformat:b.jgrid.formatter.date.srcformat;l=this.formatoptions&&this.formatoptions.newformat?this.formatoptions.newformat:b.jgrid.formatter.date.newformat}else j= +l=this.datefmt||"Y-m-d";f[this.name]={stype:k,srcfmt:j,newfmt:l}}else f[this.name]={stype:k,srcfmt:"",newfmt:""};if(a.p.grouping){m=0;for(n=o.groupField.length;m1)if(f.npage!==null){e[f.npage]=c;j=c-1;c=1}else i=function(b){a.p.page++;a.grid.hDiv.loading=false;h&&a.p.loadComplete.call(a,b);O(c-1)};else f.npage!==null&&delete a.p.postData[f.npage];if(a.p.grouping){b(a).jqGrid("groupingSetup");var k=a.p.groupingView,l,o="";for(l=0;l1,j):S(e,a.grid.bDiv,n,c>1,j);b(a).triggerHandler("jqGridLoadComplete",[e]);i&&i.call(a,e);b(a).triggerHandler("jqGridAfterLoadComplete",[e]);d&&a.grid.populateVisible();if(a.p.loadonce||a.p.treeGrid)a.p.datatype="local";c===1&&P()}},error:function(d,e,f){b.isFunction(a.p.loadError)&&a.p.loadError.call(a, +d,e,f);c===1&&P()},beforeSend:function(c,d){var e=true;b.isFunction(a.p.loadBeforeSend)&&(e=a.p.loadBeforeSend.call(a,c,d));e===void 0&&(e=true);if(e===false)return false;aa()}},b.jgrid.ajaxOptions,a.p.ajaxGridOptions));break;case "xmlstring":aa();e=typeof a.p.datastr!=="string"?a.p.datastr:b.parseXML(a.p.datastr);J(e,a.grid.bDiv);b(a).triggerHandler("jqGridLoadComplete",[e]);h&&a.p.loadComplete.call(a,e);b(a).triggerHandler("jqGridAfterLoadComplete",[e]);a.p.datatype="local";a.p.datastr=null;P(); +break;case "jsonstring":aa();e=typeof a.p.datastr==="string"?b.jgrid.parse(a.p.datastr):a.p.datastr;S(e,a.grid.bDiv);b(a).triggerHandler("jqGridLoadComplete",[e]);h&&a.p.loadComplete.call(a,e);b(a).triggerHandler("jqGridAfterLoadComplete",[e]);a.p.datatype="local";a.p.datastr=null;P();break;case "local":case "clientside":aa();a.p.datatype="local";e=ja();S(e,a.grid.bDiv,n,c>1,j);b(a).triggerHandler("jqGridLoadComplete",[e]);i&&i.call(a,e);b(a).triggerHandler("jqGridAfterLoadComplete",[e]);d&&a.grid.populateVisible(); +P()}}}},ca=function(c){b("#cb_"+b.jgrid.jqID(a.p.id),a.grid.hDiv)[a.p.useProp?"prop":"attr"]("checked",c);if(a.p.frozenColumns&&a.p.id+"_frozen")b("#cb_"+b.jgrid.jqID(a.p.id),a.grid.fhDiv)[a.p.useProp?"prop":"attr"]("checked",c)},ka=function(c,d){var e="",g="
    + + + #if $show.network and $show.airs: @@ -200,7 +207,7 @@ #end if
    Airs: $show.airs on $show.network
    -
    + @@ -223,12 +230,6 @@ -#if $sickbeard.USE_SUBTITLES -
    $show.tvdbid& -#else -
    $show.tvdbid& -#end if -
    #set $curSeason = -1 #set $odd = 0 From 645217d6b23b8502a9e0db3b1dcb2c885f89a70d Mon Sep 17 00:00:00 2001 From: brinbois Date: Thu, 30 May 2013 20:22:22 +0200 Subject: [PATCH 106/492] correct displayshow poster --- data/css/default.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/css/default.css b/data/css/default.css index 6365575983..69396002a6 100644 --- a/data/css/default.css +++ b/data/css/default.css @@ -279,7 +279,7 @@ a{ -webkit-box-shadow: 1px 1px 2px 0 #555555; -o-box-shadow: 1px 1px 2px 0 #555555; box-shadow: 1px 1px 2px 0 #555555; - float: right; + float: left; height: auto; margin-bottom: 0px; margin-right: 20px; From e8f434d773efe824eb7a45f9a4ab8e8f75f94470 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 30 May 2013 20:28:35 +0200 Subject: [PATCH 107/492] thread won't start if not needed --- sickbeard/__init__.py | 58 +++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index eaeba84796..46ef8ba486 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -1060,7 +1060,8 @@ def start(): searchQueueScheduler.thread.start() # start the queue checker - properFinderScheduler.thread.start() + if DOWNLOAD_PROPERS: + properFinderScheduler.thread.start() if autoPostProcesserScheduler: autoPostProcesserScheduler.thread.start() @@ -1163,33 +1164,36 @@ def halt(): autoTorrentPostProcesserScheduler.thread.join(10) except: pass - traktWatchListCheckerSchedular.abort = True - logger.log(u"Waiting for the TRAKTWATCHLIST thread to exit") - try: - traktWatchListCheckerSchedular.thread.join(10) - except: - pass - - sentFTPSchedular.abort = True - logger.log(u"Waiting for the TORRENT FTP thread to exit") - try: - sentFTPSchedular.thread.join(10) - except: - pass - - properFinderScheduler.abort = True - logger.log(u"Waiting for the PROPERFINDER thread to exit") - try: - properFinderScheduler.thread.join(10) - except: - pass + if traktWatchListCheckerSchedular: + traktWatchListCheckerSchedular.abort = True + logger.log(u"Waiting for the TRAKTWATCHLIST thread to exit") + try: + traktWatchListCheckerSchedular.thread.join(10) + except: + pass + + if sentFTPSchedular: + sentFTPSchedular.abort = True + logger.log(u"Waiting for the TORRENT FTP thread to exit") + try: + sentFTPSchedular.thread.join(10) + except: + pass - subtitlesFinderScheduler.abort = True - logger.log(u"Waiting for the SUBTITLESFINDER thread to exit") - try: - subtitlesFinderScheduler.thread.join(10) - except: - pass + if properFinderScheduler: + properFinderScheduler.abort = True + logger.log(u"Waiting for the PROPERFINDER thread to exit") + try: + properFinderScheduler.thread.join(10) + except: + pass + + subtitlesFinderScheduler.abort = True + logger.log(u"Waiting for the SUBTITLESFINDER thread to exit") + try: + subtitlesFinderScheduler.thread.join(10) + except: + pass __INITIALIZED__ = False From 4675f3142a110a5287888562e66e4667b9c246c8 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 30 May 2013 20:37:28 +0200 Subject: [PATCH 108/492] removed unused log --- sickbeard/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sickbeard/__init__.py b/sickbeard/__init__.py index 46ef8ba486..830ad415f6 100755 --- a/sickbeard/__init__.py +++ b/sickbeard/__init__.py @@ -1013,13 +1013,13 @@ def initialize(consoleLogging=True): subtitlesFinderScheduler = scheduler.Scheduler(subtitles.SubtitlesFinder(), cycleTime=datetime.timedelta(hours=1), threadName="FINDSUBTITLES", - runImmediately=True) + runImmediately=False) - logger.log("Initializing FTP Thread", logger.DEBUG) + sentFTPSchedular = scheduler.Scheduler(SentFTPChecker.SentFTPChecker(), - cycleTime=datetime.timedelta(minutes=10), + cycleTime=datetime.timedelta(hours=1), threadName="FTP", - runImmediately=True) + runImmediately=False) showList = [] loadingShowList = {} From 5a0b6ff12aa5dc185efc1d6d5524ebca0128ad36 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 30 May 2013 20:42:47 +0200 Subject: [PATCH 109/492] corrected mispelling --- data/interfaces/default/config_search.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/config_search.tmpl b/data/interfaces/default/config_search.tmpl index 4ccd193381..b4c1d816ab 100644 --- a/data/interfaces/default/config_search.tmpl +++ b/data/interfaces/default/config_search.tmpl @@ -299,7 +299,7 @@
    From 5cf4e60294bfdf9802b2f1c6eca9328c4ea695ad Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 30 May 2013 20:47:28 +0200 Subject: [PATCH 110/492] changed message level when xbmc is off --- sickbeard/notifiers/xbmc.py | 1028 +++++++++++++++++------------------ 1 file changed, 514 insertions(+), 514 deletions(-) diff --git a/sickbeard/notifiers/xbmc.py b/sickbeard/notifiers/xbmc.py index 064142af18..44da0cc472 100644 --- a/sickbeard/notifiers/xbmc.py +++ b/sickbeard/notifiers/xbmc.py @@ -1,514 +1,514 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -import urllib -import urllib2 -import socket -import base64 -import time - -import sickbeard - -from sickbeard import logger -from sickbeard import common -from sickbeard.exceptions import ex -from sickbeard.encodingKludge import fixStupidEncodings - -try: - import xml.etree.cElementTree as etree -except ImportError: - import xml.etree.ElementTree as etree - -try: - import json -except ImportError: - from lib import simplejson as json - - -class XBMCNotifier: - - sb_logo_url = 'http://www.sickbeard.com/xbmc-notify.png' - - def _get_xbmc_version(self, host, username, password): - """Returns XBMC JSON-RPC API version (odd # = dev, even # = stable) - - Sends a request to the XBMC host using the JSON-RPC to determine if - the legacy API or if the JSON-RPC API functions should be used. - - Fallback to testing legacy HTTPAPI before assuming it is just a badly configured host. - - Args: - host: XBMC webserver host:port - username: XBMC webserver username - password: XBMC webserver password - - Returns: - Returns API number or False - - List of possible known values: - API | XBMC Version - -----+--------------- - 2 | v10 (Dharma) - 3 | (pre Eden) - 4 | v11 (Eden) - 5 | (pre Frodo) - 6 | v12 (Frodo) - - """ - - # since we need to maintain python 2.5 compatability we can not pass a timeout delay to urllib2 directly (python 2.6+) - # override socket timeout to reduce delay for this call alone - socket.setdefaulttimeout(10) - - checkCommand = '{"jsonrpc":"2.0","method":"JSONRPC.Version","id":1}' - result = self._send_to_xbmc_json(checkCommand, host, username, password) - - # revert back to default socket timeout - socket.setdefaulttimeout(sickbeard.SOCKET_TIMEOUT) - - if result: - return result["result"]["version"] - else: - # fallback to legacy HTTPAPI method - testCommand = {'command': 'Help'} - request = self._send_to_xbmc(testCommand, host, username, password) - if request: - # return a fake version number, so it uses the legacy method - return 1 - else: - return False - - def _notify_xbmc(self, message, title="Sick Beard", host=None, username=None, password=None, force=False): - """Internal wrapper for the notify_snatch and notify_download functions - - Detects JSON-RPC version then branches the logic for either the JSON-RPC or legacy HTTP API methods. - - Args: - message: Message body of the notice to send - title: Title of the notice to send - host: XBMC webserver host:port - username: XBMC webserver username - password: XBMC webserver password - force: Used for the Test method to override config saftey checks - - Returns: - Returns a list results in the format of host:ip:result - The result will either be 'OK' or False, this is used to be parsed by the calling function. - - """ - - # fill in omitted parameters - if not host: - host = sickbeard.XBMC_HOST - if not username: - username = sickbeard.XBMC_USERNAME - if not password: - password = sickbeard.XBMC_PASSWORD - - # suppress notifications if the notifier is disabled but the notify options are checked - if not sickbeard.USE_XBMC and not force: - logger.log("Notification for XBMC not enabled, skipping this notification", logger.DEBUG) - return False - - result = '' - for curHost in [x.strip() for x in host.split(",")]: - logger.log(u"Sending XBMC notification to '" + curHost + "' - " + message, logger.MESSAGE) - - xbmcapi = self._get_xbmc_version(curHost, username, password) - if xbmcapi: - if (xbmcapi <= 4): - logger.log(u"Detected XBMC version <= 11, using XBMC HTTP API", logger.DEBUG) - command = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + title.encode("utf-8") + ',' + message.encode("utf-8") + ')'} - notifyResult = self._send_to_xbmc(command, curHost, username, password) - if notifyResult: - result += curHost + ':' + str(notifyResult) - else: - logger.log(u"Detected XBMC version >= 12, using XBMC JSON API", logger.DEBUG) - command = '{"jsonrpc":"2.0","method":"GUI.ShowNotification","params":{"title":"%s","message":"%s", "image": "%s"},"id":1}' % (title.encode("utf-8"), message.encode("utf-8"), self.sb_logo_url) - notifyResult = self._send_to_xbmc_json(command, curHost, username, password) - if notifyResult: - result += curHost + ':' + notifyResult["result"].decode(sickbeard.SYS_ENCODING) - else: - logger.log(u"Failed to detect XBMC version for '" + curHost + "', check configuration and try again.", logger.ERROR) - result += curHost + ':False' - - return result - -############################################################################## -# Legacy HTTP API (pre XBMC 12) methods -############################################################################## - - def _send_to_xbmc(self, command, host=None, username=None, password=None): - """Handles communication to XBMC servers via HTTP API - - Args: - command: Dictionary of field/data pairs, encoded via urllib and passed to the XBMC API via HTTP - host: XBMC webserver host:port - username: XBMC webserver username - password: XBMC webserver password - - Returns: - Returns response.result for successful commands or False if there was an error - - """ - - # fill in omitted parameters - if not username: - username = sickbeard.XBMC_USERNAME - if not password: - password = sickbeard.XBMC_PASSWORD - - if not host: - logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) - return False - - for key in command: - if type(command[key]) == unicode: - command[key] = command[key].encode('utf-8') - - enc_command = urllib.urlencode(command) - logger.log(u"XBMC encoded API command: " + enc_command, logger.DEBUG) - - url = 'http://%s/xbmcCmds/xbmcHttp/?%s' % (host, enc_command) - try: - req = urllib2.Request(url) - # if we have a password, use authentication - if password: - base64string = base64.encodestring('%s:%s' % (username, password))[:-1] - authheader = "Basic %s" % base64string - req.add_header("Authorization", authheader) - logger.log(u"Contacting XBMC (with auth header) via url: " + fixStupidEncodings(url), logger.DEBUG) - else: - logger.log(u"Contacting XBMC via url: " + fixStupidEncodings(url), logger.DEBUG) - - response = urllib2.urlopen(req) - result = response.read().decode(sickbeard.SYS_ENCODING) - response.close() - - logger.log(u"XBMC HTTP response: " + result.replace('\n', ''), logger.DEBUG) - return result - - except (urllib2.URLError, IOError), e: - logger.log(u"Warning: Couldn't contact XBMC HTTP at " + fixStupidEncodings(url) + " " + ex(e), logger.WARNING) - return False - - def _update_library(self, host=None, showName=None): - """Handles updating XBMC host via HTTP API - - Attempts to update the XBMC video library for a specific tv show if passed, - otherwise update the whole library if enabled. - - Args: - host: XBMC webserver host:port - showName: Name of a TV show to specifically target the library update for - - Returns: - Returns True or False - - """ - - if not host: - logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) - return False - - logger.log(u"Updating XMBC library via HTTP method for host: " + host, logger.DEBUG) - - # if we're doing per-show - if showName: - logger.log(u"Updating library in XBMC via HTTP method for show " + showName, logger.DEBUG) - - pathSql = 'select path.strPath from path, tvshow, tvshowlinkpath where ' \ - 'tvshow.c00 = "%s" and tvshowlinkpath.idShow = tvshow.idShow ' \ - 'and tvshowlinkpath.idPath = path.idPath' % (showName) - - # use this to get xml back for the path lookups - xmlCommand = {'command': 'SetResponseFormat(webheader;false;webfooter;false;header;;footer;;opentag;;closetag;;closefinaltag;false)'} - # sql used to grab path(s) - sqlCommand = {'command': 'QueryVideoDatabase(%s)' % (pathSql)} - # set output back to default - resetCommand = {'command': 'SetResponseFormat()'} - - # set xml response format, if this fails then don't bother with the rest - request = self._send_to_xbmc(xmlCommand, host) - if not request: - return False - - sqlXML = self._send_to_xbmc(sqlCommand, host) - request = self._send_to_xbmc(resetCommand, host) - - if not sqlXML: - logger.log(u"Invalid response for " + showName + " on " + host, logger.DEBUG) - return False - - encSqlXML = urllib.quote(sqlXML, ':\\/<>') - try: - et = etree.fromstring(encSqlXML) - except SyntaxError, e: - logger.log(u"Unable to parse XML returned from XBMC: " + ex(e), logger.ERROR) - return False - - paths = et.findall('.//field') - - if not paths: - logger.log(u"No valid paths found for " + showName + " on " + host, logger.DEBUG) - return False - - for path in paths: - # we do not need it double-encoded, gawd this is dumb - unEncPath = urllib.unquote(path.text).decode(sickbeard.SYS_ENCODING) - logger.log(u"XBMC Updating " + showName + " on " + host + " at " + unEncPath, logger.DEBUG) - updateCommand = {'command': 'ExecBuiltIn', 'parameter': 'XBMC.updatelibrary(video, %s)' % (unEncPath)} - request = self._send_to_xbmc(updateCommand, host) - if not request: - logger.log(u"Update of show directory failed on " + showName + " on " + host + " at " + unEncPath, logger.ERROR) - return False - # sleep for a few seconds just to be sure xbmc has a chance to finish each directory - if len(paths) > 1: - time.sleep(5) - # do a full update if requested - else: - logger.log(u"Doing Full Library XBMC update on host: " + host, logger.DEBUG) - updateCommand = {'command': 'ExecBuiltIn', 'parameter': 'XBMC.updatelibrary(video)'} - request = self._send_to_xbmc(updateCommand, host) - - if not request: - logger.log(u"XBMC Full Library update failed on: " + host, logger.ERROR) - return False - - return True - -############################################################################## -# JSON-RPC API (XBMC 12+) methods -############################################################################## - - def _send_to_xbmc_json(self, command, host=None, username=None, password=None): - """Handles communication to XBMC servers via JSONRPC - - Args: - command: Dictionary of field/data pairs, encoded via urllib and passed to the XBMC JSON-RPC via HTTP - host: XBMC webserver host:port - username: XBMC webserver username - password: XBMC webserver password - - Returns: - Returns response.result for successful commands or False if there was an error - - """ - - # fill in omitted parameters - if not username: - username = sickbeard.XBMC_USERNAME - if not password: - password = sickbeard.XBMC_PASSWORD - - if not host: - logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) - return False - - command = command.encode('utf-8') - logger.log(u"XBMC JSON command: " + command, logger.DEBUG) - - url = 'http://%s/jsonrpc' % (host) - try: - req = urllib2.Request(url, command) - req.add_header("Content-type", "application/json") - # if we have a password, use authentication - if password: - base64string = base64.encodestring('%s:%s' % (username, password))[:-1] - authheader = "Basic %s" % base64string - req.add_header("Authorization", authheader) - logger.log(u"Contacting XBMC (with auth header) via url: " + fixStupidEncodings(url), logger.DEBUG) - else: - logger.log(u"Contacting XBMC via url: " + fixStupidEncodings(url), logger.DEBUG) - - try: - response = urllib2.urlopen(req) - except urllib2.URLError, e: - logger.log(u"Error while trying to retrieve XBMC API version for " + host + ": " + ex(e), logger.WARNING) - return False - - # parse the json result - try: - result = json.load(response) - response.close() - logger.log(u"XBMC JSON response: " + str(result), logger.DEBUG) - return result # need to return response for parsing - except ValueError, e: - logger.log(u"Unable to decode JSON: " + response, logger.WARNING) - return False - - except IOError, e: - logger.log(u"Warning: Couldn't contact XBMC JSON API at " + fixStupidEncodings(url) + " " + ex(e), logger.WARNING) - return False - - def _update_library_json(self, host=None, showName=None): - """Handles updating XBMC host via HTTP JSON-RPC - - Attempts to update the XBMC video library for a specific tv show if passed, - otherwise update the whole library if enabled. - - Args: - host: XBMC webserver host:port - showName: Name of a TV show to specifically target the library update for - - Returns: - Returns True or False - - """ - - if not host: - logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) - return False - - logger.log(u"Updating XMBC library via JSON method for host: " + host, logger.MESSAGE) - - # if we're doing per-show - if showName: - tvshowid = -1 - logger.log(u"Updating library in XBMC via JSON method for show " + showName, logger.DEBUG) - - # get tvshowid by showName - showsCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.GetTVShows","id":1}' - showsResponse = self._send_to_xbmc_json(showsCommand, host) - if (showsResponse == False): - return False - shows = showsResponse["result"]["tvshows"] - - for show in shows: - if (show["label"] == showName): - tvshowid = show["tvshowid"] - break # exit out of loop otherwise the label and showname will not match up - - # this can be big, so free some memory - del shows - - # we didn't find the show (exact match), thus revert to just doing a full update if enabled - if (tvshowid == -1): - logger.log(u'Exact show name not matched in XBMC TV show list', logger.DEBUG) - return False - - # lookup tv-show path - pathCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.GetTVShowDetails","params":{"tvshowid":%d, "properties": ["file"]},"id":1}' % (tvshowid) - pathResponse = self._send_to_xbmc_json(pathCommand, host) - - path = pathResponse["result"]["tvshowdetails"]["file"] - logger.log(u"Received Show: " + show["label"] + " with ID: " + str(tvshowid) + " Path: " + path, logger.DEBUG) - - if (len(path) < 1): - logger.log(u"No valid path found for " + showName + " with ID: " + str(tvshowid) + " on " + host, logger.WARNING) - return False - - logger.log(u"XBMC Updating " + showName + " on " + host + " at " + path, logger.DEBUG) - updateCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.Scan","params":{"directory":%s},"id":1}' % (json.dumps(path)) - request = self._send_to_xbmc_json(updateCommand, host) - if not request: - logger.log(u"Update of show directory failed on " + showName + " on " + host + " at " + path, logger.ERROR) - return False - - # catch if there was an error in the returned request - for r in request: - if 'error' in r: - logger.log(u"Error while attempting to update show directory for " + showName + " on " + host + " at " + path, logger.ERROR) - return False - - # do a full update if requested - else: - logger.log(u"Doing Full Library XBMC update on host: " + host, logger.MESSAGE) - updateCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.Scan","id":1}' - request = self._send_to_xbmc_json(updateCommand, host, sickbeard.XBMC_USERNAME, sickbeard.XBMC_PASSWORD) - - if not request: - logger.log(u"XBMC Full Library update failed on: " + host, logger.ERROR) - return False - - return True - -############################################################################## -# Public functions which will call the JSON or Legacy HTTP API methods -############################################################################## - - def notify_snatch(self, ep_name): - if sickbeard.XBMC_NOTIFY_ONSNATCH: - self._notify_xbmc(ep_name, common.notifyStrings[common.NOTIFY_SNATCH]) - - def notify_download(self, ep_name): - if sickbeard.XBMC_NOTIFY_ONDOWNLOAD: - self._notify_xbmc(ep_name, common.notifyStrings[common.NOTIFY_DOWNLOAD]) - - def notify_subtitle_download(self, ep_name, lang): - if sickbeard.XBMC_NOTIFY_ONSUBTITLEDOWNLOAD: - self._notify_xbmc(ep_name + ": " + lang, common.notifyStrings[common.NOTIFY_SUBTITLE_DOWNLOAD]) - - def test_notify(self, host, username, password): - return self._notify_xbmc("Testing XBMC notifications from Sick Beard", "Test Notification", host, username, password, force=True) - - def update_library(self, showName=None): - """Public wrapper for the update library functions to branch the logic for JSON-RPC or legacy HTTP API - - Checks the XBMC API version to branch the logic to call either the legacy HTTP API or the newer JSON-RPC over HTTP methods. - Do the ability of accepting a list of hosts deliminated by comma, we split off the first host to send the update to. - This is a workaround for SQL backend users as updating multiple clients causes duplicate entries. - Future plan is to revist how we store the host/ip/username/pw/options so that it may be more flexible. - - Args: - showName: Name of a TV show to specifically target the library update for - - Returns: - Returns True or False - - """ - - if sickbeard.USE_XBMC and sickbeard.XBMC_UPDATE_LIBRARY: - if not sickbeard.XBMC_HOST: - logger.log(u"No XBMC hosts specified, check your settings", logger.DEBUG) - return False - - if sickbeard.XBMC_UPDATE_ONLYFIRST: - # only send update to first host in the list if requested -- workaround for xbmc sql backend users - host = sickbeard.XBMC_HOST.split(",")[0].strip() - else: - host = sickbeard.XBMC_HOST - - result = 0 - for curHost in [x.strip() for x in host.split(",")]: - logger.log(u"Sending request to update library for XBMC host: '" + curHost + "'", logger.MESSAGE) - - xbmcapi = self._get_xbmc_version(curHost, sickbeard.XBMC_USERNAME, sickbeard.XBMC_PASSWORD) - if xbmcapi: - if (xbmcapi <= 4): - # try to update for just the show, if it fails, do full update if enabled - if not self._update_library(curHost, showName) and sickbeard.XBMC_UPDATE_FULL: - logger.log(u"Single show update failed, falling back to full update", logger.WARNING) - self._update_library(curHost) - else: - # try to update for just the show, if it fails, do full update if enabled - if not self._update_library_json(curHost, showName) and sickbeard.XBMC_UPDATE_FULL: - logger.log(u"Single show update failed, falling back to full update", logger.WARNING) - self._update_library_json(curHost) - else: - logger.log(u"Failed to detect XBMC version for '" + curHost + "', check configuration and try again.", logger.ERROR) - result = result + 1 - - # needed for the 'update xbmc' submenu command - # as it only cares of the final result vs the individual ones - if result == 0: - return True - else: - return False - -notifier = XBMCNotifier +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +import urllib +import urllib2 +import socket +import base64 +import time + +import sickbeard + +from sickbeard import logger +from sickbeard import common +from sickbeard.exceptions import ex +from sickbeard.encodingKludge import fixStupidEncodings + +try: + import xml.etree.cElementTree as etree +except ImportError: + import xml.etree.ElementTree as etree + +try: + import json +except ImportError: + from lib import simplejson as json + + +class XBMCNotifier: + + sb_logo_url = 'http://www.sickbeard.com/xbmc-notify.png' + + def _get_xbmc_version(self, host, username, password): + """Returns XBMC JSON-RPC API version (odd # = dev, even # = stable) + + Sends a request to the XBMC host using the JSON-RPC to determine if + the legacy API or if the JSON-RPC API functions should be used. + + Fallback to testing legacy HTTPAPI before assuming it is just a badly configured host. + + Args: + host: XBMC webserver host:port + username: XBMC webserver username + password: XBMC webserver password + + Returns: + Returns API number or False + + List of possible known values: + API | XBMC Version + -----+--------------- + 2 | v10 (Dharma) + 3 | (pre Eden) + 4 | v11 (Eden) + 5 | (pre Frodo) + 6 | v12 (Frodo) + + """ + + # since we need to maintain python 2.5 compatability we can not pass a timeout delay to urllib2 directly (python 2.6+) + # override socket timeout to reduce delay for this call alone + socket.setdefaulttimeout(10) + + checkCommand = '{"jsonrpc":"2.0","method":"JSONRPC.Version","id":1}' + result = self._send_to_xbmc_json(checkCommand, host, username, password) + + # revert back to default socket timeout + socket.setdefaulttimeout(sickbeard.SOCKET_TIMEOUT) + + if result: + return result["result"]["version"] + else: + # fallback to legacy HTTPAPI method + testCommand = {'command': 'Help'} + request = self._send_to_xbmc(testCommand, host, username, password) + if request: + # return a fake version number, so it uses the legacy method + return 1 + else: + return False + + def _notify_xbmc(self, message, title="Sick Beard", host=None, username=None, password=None, force=False): + """Internal wrapper for the notify_snatch and notify_download functions + + Detects JSON-RPC version then branches the logic for either the JSON-RPC or legacy HTTP API methods. + + Args: + message: Message body of the notice to send + title: Title of the notice to send + host: XBMC webserver host:port + username: XBMC webserver username + password: XBMC webserver password + force: Used for the Test method to override config saftey checks + + Returns: + Returns a list results in the format of host:ip:result + The result will either be 'OK' or False, this is used to be parsed by the calling function. + + """ + + # fill in omitted parameters + if not host: + host = sickbeard.XBMC_HOST + if not username: + username = sickbeard.XBMC_USERNAME + if not password: + password = sickbeard.XBMC_PASSWORD + + # suppress notifications if the notifier is disabled but the notify options are checked + if not sickbeard.USE_XBMC and not force: + logger.log("Notification for XBMC not enabled, skipping this notification", logger.DEBUG) + return False + + result = '' + for curHost in [x.strip() for x in host.split(",")]: + logger.log(u"Sending XBMC notification to '" + curHost + "' - " + message, logger.MESSAGE) + + xbmcapi = self._get_xbmc_version(curHost, username, password) + if xbmcapi: + if (xbmcapi <= 4): + logger.log(u"Detected XBMC version <= 11, using XBMC HTTP API", logger.DEBUG) + command = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + title.encode("utf-8") + ',' + message.encode("utf-8") + ')'} + notifyResult = self._send_to_xbmc(command, curHost, username, password) + if notifyResult: + result += curHost + ':' + str(notifyResult) + else: + logger.log(u"Detected XBMC version >= 12, using XBMC JSON API", logger.DEBUG) + command = '{"jsonrpc":"2.0","method":"GUI.ShowNotification","params":{"title":"%s","message":"%s", "image": "%s"},"id":1}' % (title.encode("utf-8"), message.encode("utf-8"), self.sb_logo_url) + notifyResult = self._send_to_xbmc_json(command, curHost, username, password) + if notifyResult: + result += curHost + ':' + notifyResult["result"].decode(sickbeard.SYS_ENCODING) + else: + logger.log(u"Failed to detect XBMC version for '" + curHost + "', check configuration and try again.", logger.DEBUG) + result += curHost + ':False' + + return result + +############################################################################## +# Legacy HTTP API (pre XBMC 12) methods +############################################################################## + + def _send_to_xbmc(self, command, host=None, username=None, password=None): + """Handles communication to XBMC servers via HTTP API + + Args: + command: Dictionary of field/data pairs, encoded via urllib and passed to the XBMC API via HTTP + host: XBMC webserver host:port + username: XBMC webserver username + password: XBMC webserver password + + Returns: + Returns response.result for successful commands or False if there was an error + + """ + + # fill in omitted parameters + if not username: + username = sickbeard.XBMC_USERNAME + if not password: + password = sickbeard.XBMC_PASSWORD + + if not host: + logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) + return False + + for key in command: + if type(command[key]) == unicode: + command[key] = command[key].encode('utf-8') + + enc_command = urllib.urlencode(command) + logger.log(u"XBMC encoded API command: " + enc_command, logger.DEBUG) + + url = 'http://%s/xbmcCmds/xbmcHttp/?%s' % (host, enc_command) + try: + req = urllib2.Request(url) + # if we have a password, use authentication + if password: + base64string = base64.encodestring('%s:%s' % (username, password))[:-1] + authheader = "Basic %s" % base64string + req.add_header("Authorization", authheader) + logger.log(u"Contacting XBMC (with auth header) via url: " + fixStupidEncodings(url), logger.DEBUG) + else: + logger.log(u"Contacting XBMC via url: " + fixStupidEncodings(url), logger.DEBUG) + + response = urllib2.urlopen(req) + result = response.read().decode(sickbeard.SYS_ENCODING) + response.close() + + logger.log(u"XBMC HTTP response: " + result.replace('\n', ''), logger.DEBUG) + return result + + except (urllib2.URLError, IOError), e: + logger.log(u"Warning: Couldn't contact XBMC HTTP at " + fixStupidEncodings(url) + " " + ex(e), logger.WARNING) + return False + + def _update_library(self, host=None, showName=None): + """Handles updating XBMC host via HTTP API + + Attempts to update the XBMC video library for a specific tv show if passed, + otherwise update the whole library if enabled. + + Args: + host: XBMC webserver host:port + showName: Name of a TV show to specifically target the library update for + + Returns: + Returns True or False + + """ + + if not host: + logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) + return False + + logger.log(u"Updating XMBC library via HTTP method for host: " + host, logger.DEBUG) + + # if we're doing per-show + if showName: + logger.log(u"Updating library in XBMC via HTTP method for show " + showName, logger.DEBUG) + + pathSql = 'select path.strPath from path, tvshow, tvshowlinkpath where ' \ + 'tvshow.c00 = "%s" and tvshowlinkpath.idShow = tvshow.idShow ' \ + 'and tvshowlinkpath.idPath = path.idPath' % (showName) + + # use this to get xml back for the path lookups + xmlCommand = {'command': 'SetResponseFormat(webheader;false;webfooter;false;header;;footer;;opentag;;closetag;;closefinaltag;false)'} + # sql used to grab path(s) + sqlCommand = {'command': 'QueryVideoDatabase(%s)' % (pathSql)} + # set output back to default + resetCommand = {'command': 'SetResponseFormat()'} + + # set xml response format, if this fails then don't bother with the rest + request = self._send_to_xbmc(xmlCommand, host) + if not request: + return False + + sqlXML = self._send_to_xbmc(sqlCommand, host) + request = self._send_to_xbmc(resetCommand, host) + + if not sqlXML: + logger.log(u"Invalid response for " + showName + " on " + host, logger.DEBUG) + return False + + encSqlXML = urllib.quote(sqlXML, ':\\/<>') + try: + et = etree.fromstring(encSqlXML) + except SyntaxError, e: + logger.log(u"Unable to parse XML returned from XBMC: " + ex(e), logger.ERROR) + return False + + paths = et.findall('.//field') + + if not paths: + logger.log(u"No valid paths found for " + showName + " on " + host, logger.DEBUG) + return False + + for path in paths: + # we do not need it double-encoded, gawd this is dumb + unEncPath = urllib.unquote(path.text).decode(sickbeard.SYS_ENCODING) + logger.log(u"XBMC Updating " + showName + " on " + host + " at " + unEncPath, logger.DEBUG) + updateCommand = {'command': 'ExecBuiltIn', 'parameter': 'XBMC.updatelibrary(video, %s)' % (unEncPath)} + request = self._send_to_xbmc(updateCommand, host) + if not request: + logger.log(u"Update of show directory failed on " + showName + " on " + host + " at " + unEncPath, logger.ERROR) + return False + # sleep for a few seconds just to be sure xbmc has a chance to finish each directory + if len(paths) > 1: + time.sleep(5) + # do a full update if requested + else: + logger.log(u"Doing Full Library XBMC update on host: " + host, logger.DEBUG) + updateCommand = {'command': 'ExecBuiltIn', 'parameter': 'XBMC.updatelibrary(video)'} + request = self._send_to_xbmc(updateCommand, host) + + if not request: + logger.log(u"XBMC Full Library update failed on: " + host, logger.ERROR) + return False + + return True + +############################################################################## +# JSON-RPC API (XBMC 12+) methods +############################################################################## + + def _send_to_xbmc_json(self, command, host=None, username=None, password=None): + """Handles communication to XBMC servers via JSONRPC + + Args: + command: Dictionary of field/data pairs, encoded via urllib and passed to the XBMC JSON-RPC via HTTP + host: XBMC webserver host:port + username: XBMC webserver username + password: XBMC webserver password + + Returns: + Returns response.result for successful commands or False if there was an error + + """ + + # fill in omitted parameters + if not username: + username = sickbeard.XBMC_USERNAME + if not password: + password = sickbeard.XBMC_PASSWORD + + if not host: + logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) + return False + + command = command.encode('utf-8') + logger.log(u"XBMC JSON command: " + command, logger.DEBUG) + + url = 'http://%s/jsonrpc' % (host) + try: + req = urllib2.Request(url, command) + req.add_header("Content-type", "application/json") + # if we have a password, use authentication + if password: + base64string = base64.encodestring('%s:%s' % (username, password))[:-1] + authheader = "Basic %s" % base64string + req.add_header("Authorization", authheader) + logger.log(u"Contacting XBMC (with auth header) via url: " + fixStupidEncodings(url), logger.DEBUG) + else: + logger.log(u"Contacting XBMC via url: " + fixStupidEncodings(url), logger.DEBUG) + + try: + response = urllib2.urlopen(req) + except urllib2.URLError, e: + logger.log(u"Error while trying to retrieve XBMC API version for " + host + ": " + ex(e), logger.WARNING) + return False + + # parse the json result + try: + result = json.load(response) + response.close() + logger.log(u"XBMC JSON response: " + str(result), logger.DEBUG) + return result # need to return response for parsing + except ValueError, e: + logger.log(u"Unable to decode JSON: " + response, logger.WARNING) + return False + + except IOError, e: + logger.log(u"Warning: Couldn't contact XBMC JSON API at " + fixStupidEncodings(url) + " " + ex(e), logger.WARNING) + return False + + def _update_library_json(self, host=None, showName=None): + """Handles updating XBMC host via HTTP JSON-RPC + + Attempts to update the XBMC video library for a specific tv show if passed, + otherwise update the whole library if enabled. + + Args: + host: XBMC webserver host:port + showName: Name of a TV show to specifically target the library update for + + Returns: + Returns True or False + + """ + + if not host: + logger.log(u'No XBMC host passed, aborting update', logger.DEBUG) + return False + + logger.log(u"Updating XMBC library via JSON method for host: " + host, logger.MESSAGE) + + # if we're doing per-show + if showName: + tvshowid = -1 + logger.log(u"Updating library in XBMC via JSON method for show " + showName, logger.DEBUG) + + # get tvshowid by showName + showsCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.GetTVShows","id":1}' + showsResponse = self._send_to_xbmc_json(showsCommand, host) + if (showsResponse == False): + return False + shows = showsResponse["result"]["tvshows"] + + for show in shows: + if (show["label"] == showName): + tvshowid = show["tvshowid"] + break # exit out of loop otherwise the label and showname will not match up + + # this can be big, so free some memory + del shows + + # we didn't find the show (exact match), thus revert to just doing a full update if enabled + if (tvshowid == -1): + logger.log(u'Exact show name not matched in XBMC TV show list', logger.DEBUG) + return False + + # lookup tv-show path + pathCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.GetTVShowDetails","params":{"tvshowid":%d, "properties": ["file"]},"id":1}' % (tvshowid) + pathResponse = self._send_to_xbmc_json(pathCommand, host) + + path = pathResponse["result"]["tvshowdetails"]["file"] + logger.log(u"Received Show: " + show["label"] + " with ID: " + str(tvshowid) + " Path: " + path, logger.DEBUG) + + if (len(path) < 1): + logger.log(u"No valid path found for " + showName + " with ID: " + str(tvshowid) + " on " + host, logger.WARNING) + return False + + logger.log(u"XBMC Updating " + showName + " on " + host + " at " + path, logger.DEBUG) + updateCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.Scan","params":{"directory":%s},"id":1}' % (json.dumps(path)) + request = self._send_to_xbmc_json(updateCommand, host) + if not request: + logger.log(u"Update of show directory failed on " + showName + " on " + host + " at " + path, logger.ERROR) + return False + + # catch if there was an error in the returned request + for r in request: + if 'error' in r: + logger.log(u"Error while attempting to update show directory for " + showName + " on " + host + " at " + path, logger.ERROR) + return False + + # do a full update if requested + else: + logger.log(u"Doing Full Library XBMC update on host: " + host, logger.MESSAGE) + updateCommand = '{"jsonrpc":"2.0","method":"VideoLibrary.Scan","id":1}' + request = self._send_to_xbmc_json(updateCommand, host, sickbeard.XBMC_USERNAME, sickbeard.XBMC_PASSWORD) + + if not request: + logger.log(u"XBMC Full Library update failed on: " + host, logger.ERROR) + return False + + return True + +############################################################################## +# Public functions which will call the JSON or Legacy HTTP API methods +############################################################################## + + def notify_snatch(self, ep_name): + if sickbeard.XBMC_NOTIFY_ONSNATCH: + self._notify_xbmc(ep_name, common.notifyStrings[common.NOTIFY_SNATCH]) + + def notify_download(self, ep_name): + if sickbeard.XBMC_NOTIFY_ONDOWNLOAD: + self._notify_xbmc(ep_name, common.notifyStrings[common.NOTIFY_DOWNLOAD]) + + def notify_subtitle_download(self, ep_name, lang): + if sickbeard.XBMC_NOTIFY_ONSUBTITLEDOWNLOAD: + self._notify_xbmc(ep_name + ": " + lang, common.notifyStrings[common.NOTIFY_SUBTITLE_DOWNLOAD]) + + def test_notify(self, host, username, password): + return self._notify_xbmc("Testing XBMC notifications from Sick Beard", "Test Notification", host, username, password, force=True) + + def update_library(self, showName=None): + """Public wrapper for the update library functions to branch the logic for JSON-RPC or legacy HTTP API + + Checks the XBMC API version to branch the logic to call either the legacy HTTP API or the newer JSON-RPC over HTTP methods. + Do the ability of accepting a list of hosts deliminated by comma, we split off the first host to send the update to. + This is a workaround for SQL backend users as updating multiple clients causes duplicate entries. + Future plan is to revist how we store the host/ip/username/pw/options so that it may be more flexible. + + Args: + showName: Name of a TV show to specifically target the library update for + + Returns: + Returns True or False + + """ + + if sickbeard.USE_XBMC and sickbeard.XBMC_UPDATE_LIBRARY: + if not sickbeard.XBMC_HOST: + logger.log(u"No XBMC hosts specified, check your settings", logger.DEBUG) + return False + + if sickbeard.XBMC_UPDATE_ONLYFIRST: + # only send update to first host in the list if requested -- workaround for xbmc sql backend users + host = sickbeard.XBMC_HOST.split(",")[0].strip() + else: + host = sickbeard.XBMC_HOST + + result = 0 + for curHost in [x.strip() for x in host.split(",")]: + logger.log(u"Sending request to update library for XBMC host: '" + curHost + "'", logger.MESSAGE) + + xbmcapi = self._get_xbmc_version(curHost, sickbeard.XBMC_USERNAME, sickbeard.XBMC_PASSWORD) + if xbmcapi: + if (xbmcapi <= 4): + # try to update for just the show, if it fails, do full update if enabled + if not self._update_library(curHost, showName) and sickbeard.XBMC_UPDATE_FULL: + logger.log(u"Single show update failed, falling back to full update", logger.WARNING) + self._update_library(curHost) + else: + # try to update for just the show, if it fails, do full update if enabled + if not self._update_library_json(curHost, showName) and sickbeard.XBMC_UPDATE_FULL: + logger.log(u"Single show update failed, falling back to full update", logger.WARNING) + self._update_library_json(curHost) + else: + logger.log(u"Failed to detect XBMC version for '" + curHost + "', check configuration and try again.", logger.ERROR) + result = result + 1 + + # needed for the 'update xbmc' submenu command + # as it only cares of the final result vs the individual ones + if result == 0: + return True + else: + return False + +notifier = XBMCNotifier From ae7661a8791a05948d270534a95ab55bf2ba0262 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 30 May 2013 20:52:22 +0200 Subject: [PATCH 111/492] another one --- sickbeard/notifiers/xbmc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sickbeard/notifiers/xbmc.py b/sickbeard/notifiers/xbmc.py index 44da0cc472..9b815181f1 100644 --- a/sickbeard/notifiers/xbmc.py +++ b/sickbeard/notifiers/xbmc.py @@ -501,7 +501,7 @@ def update_library(self, showName=None): logger.log(u"Single show update failed, falling back to full update", logger.WARNING) self._update_library_json(curHost) else: - logger.log(u"Failed to detect XBMC version for '" + curHost + "', check configuration and try again.", logger.ERROR) + logger.log(u"Failed to detect XBMC version for '" + curHost + "', check configuration and try again.", logger.DEBUG) result = result + 1 # needed for the 'update xbmc' submenu command From 763d01722b197e14479e85fe6aa25964f4cf252a Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Thu, 30 May 2013 22:04:24 +0200 Subject: [PATCH 112/492] added snatched dynamic filter --- data/interfaces/default/displayShow.tmpl | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/data/interfaces/default/displayShow.tmpl b/data/interfaces/default/displayShow.tmpl index 11fcaf7c0b..dab0e48999 100644 --- a/data/interfaces/default/displayShow.tmpl +++ b/data/interfaces/default/displayShow.tmpl @@ -236,7 +236,7 @@
    - Change selected episodes to + Change episodes to - - - + + + + +
    @@ -263,7 +264,7 @@

    - Change Audio of selected episodes to + Change audio of episodes to ') + + this.source = {} + this.strict = true + + var options = this.$target.find('option') + var $option; + for (var i=0; i 0 ? li.find('.item-text').text() : li.text() + + val = this.updater(val, 'value') + text = this.updater(text, 'text') + + this.$element + .val(text) + .attr('data-value', val) + + this.text = text + + if (typeof this.$target != 'undefined') { + this.$target + .val(val) + .trigger('change') + } + + this.$element.trigger('change') + + return this.hide() + } + + , updater: function (text, type) { + return text + } + + , show: function () { + var pos = $.extend({}, this.$element.position(), { + height: this.$element[0].offsetHeight + }) + + this.$menu + .insertAfter(this.$element) + .css({ + top: pos.top + pos.height + , left: pos.left + }) + .show() + + this.shown = true + return this + } + + , hide: function () { + this.$menu.hide() + this.shown = false + return this + } + + , lookup: function (event) { + var items + + this.query = this.$element.val() + + if (!this.query || this.query.length < this.options.minLength) { + return this.shown ? this.hide() : this + } + + items = $.isFunction(this.source) ? this.source(this.query, $.proxy(this.process, this)) : this.source + + return items ? this.process(items) : this + } + + , process: function (items) { + return $.isArray(items) ? this.processArray(items) : this.processObject(items) + } + + , processArray: function (items) { + var that = this + + items = $.grep(items, function (item) { + return that.matcher(item) + }) + + items = this.sorter(items) + + if (!items.length) { + return this.shown ? this.hide() : this + } + + return this.render(items.slice(0, this.options.items)).show() + } + + , processObject: function (itemsIn) { + var that = this + , items = {} + , i = 0 + + $.each(itemsIn, function (key, item) { + if (that.matcher(item)) items[key] = item + }) + + items = this.sorter(items) + + if ($.isEmptyObject(items)) { + return this.shown ? this.hide() : this + } + + $.each(items, function(key, item) { + if (i++ >= that.options.items) delete items[key] + }) + + return this.render(items).show() + } + + , searchAjax: function (query, process) { + var that = this + + if (this.ajaxTimeout) clearTimeout(this.ajaxTimeout) + + this.ajaxTimeout = setTimeout(function () { + if (that.ajaxTimeout) clearTimeout(that.ajaxTimeout) + + if (query === "") { + that.hide() + return + } + + $.get(that.url, {'q': query, 'limit': that.options.items }, function (items) { + if (typeof items == 'string') items = JSON.parse(items) + process(items) + }) + }, this.options.ajaxdelay) + } + + , matcher: function (item) { + return ~item.toLowerCase().indexOf(this.query.toLowerCase()) + } + + , sorter: function (items) { + return $.isArray(items) ? this.sortArray(items) : this.sortObject(items) + } + + , sortArray: function (items) { + var beginswith = [] + , caseSensitive = [] + , caseInsensitive = [] + , item + + while (item = items.shift()) { + if (!item.toLowerCase().indexOf(this.query.toLowerCase())) beginswith.push(item) + else if (~item.indexOf(this.query)) caseSensitive.push(item) + else caseInsensitive.push(item) + } + + return beginswith.concat(caseSensitive, caseInsensitive) + } + + , sortObject: function (items) { + var sorted = {} + , key; + + for (key in items) { + if (!items[key].toLowerCase().indexOf(this.query.toLowerCase())) { + sorted[key] = items[key]; + delete items[key] + } + } + + for (key in items) { + if (~items[key].indexOf(this.query)) { + sorted[key] = items[key]; + delete items[key] + } + } + + for (key in items) { + sorted[key] = items[key] + } + + return sorted + } + + , highlighter: function (item) { + var query = this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, '\\$&') + return item.replace(new RegExp('(' + query + ')', 'ig'), function ($1, match) { + return '' + match + '' + }) + } + + , render: function (items) { + var that = this + , list = $([]) + + $.map(items, function (item, value) { + if (list.length >= that.options.items) return + + var li + , a + + if ($.isArray(items)) value = item + + li = $(that.options.item) + a = li.find('a').length ? li.find('a') : li + a.html(that.highlighter(item)) + + li.attr('data-value', value) + if (li.find('a').length === 0) li.addClass('dropdown-header') + + list.push(li[0]) + }) + + list.not('.dropdown-header').first().addClass('active') + + this.$menu.html(list) + + return this + } + + , next: function (event) { + var active = this.$menu.find('.active').removeClass('active') + , next = active.nextAll('li:not(.dropdown-header)').first() + + if (!next.length) { + next = $(this.$menu.find('li:not(.dropdown-header)')[0]) + } + + next.addClass('active') + } + + , prev: function (event) { + var active = this.$menu.find('.active').removeClass('active') + , prev = active.prevAll('li:not(.dropdown-header)').first() + + if (!prev.length) { + prev = this.$menu.find('li:not(.dropdown-header)').last() + } + + prev.addClass('active') + } + + , listen: function () { + this.$element + .on('focus', $.proxy(this.focus, this)) + .on('blur', $.proxy(this.blur, this)) + .on('change', $.proxy(this.change, this)) + .on('keypress', $.proxy(this.keypress, this)) + .on('keyup', $.proxy(this.keyup, this)) + + if (this.eventSupported('keydown')) { + this.$element.on('keydown', $.proxy(this.keydown, this)) + } + + this.$menu + .on('click', $.proxy(this.click, this)) + .on('mouseenter', 'li', $.proxy(this.mouseenter, this)) + .on('mouseleave', 'li', $.proxy(this.mouseleave, this)) + + $(window).on('unload', $.proxy(this.destroyReplacement, this)) + } + + , eventSupported: function(eventName) { + var isSupported = eventName in this.$element + if (!isSupported) { + this.$element.setAttribute(eventName, 'return;') + isSupported = typeof this.$element[eventName] === 'function' + } + return isSupported + } + + , move: function (e) { + if (!this.shown) return + + switch(e.keyCode) { + case 9: // tab + case 13: // enter + case 27: // escape + e.preventDefault() + break + + case 38: // up arrow + e.preventDefault() + this.prev() + break + + case 40: // down arrow + e.preventDefault() + this.next() + break + } + + e.stopPropagation() + } + + , keydown: function (e) { + this.suppressKeyPressRepeat = ~$.inArray(e.keyCode, [40,38,9,13,27]) + this.move(e) + } + + , keypress: function (e) { + if (this.suppressKeyPressRepeat) return + this.move(e) + } + + , keyup: function (e) { + switch(e.keyCode) { + case 40: // down arrow + case 38: // up arrow + case 16: // shift + case 17: // ctrl + case 18: // alt + break + + case 9: // tab + case 13: // enter + if (!this.shown) return + this.select() + break + + case 27: // escape + if (!this.shown) return + this.hide() + break + + default: + this.lookup() + } + + e.stopPropagation() + e.preventDefault() + } + + , change: function (e) { + var value + + if (this.$element.val() != this.text) { + value = this.$element.val() === '' || this.strict ? '' : this.$element.val() + + this.$element.val(value) + this.$element.attr('data-value', value) + this.text = value + if (typeof this.$target != 'undefined') this.$target.val(value) + } + } + + , focus: function (e) { + this.focused = true + } + + , blur: function (e) { + this.focused = false + if (!this.mousedover && this.shown) this.hide() + } + + , click: function (e) { + e.stopPropagation() + e.preventDefault() + this.select() + this.$element.focus() + } + + , mouseenter: function (e) { + this.mousedover = true + this.$menu.find('.active').removeClass('active') + $(e.currentTarget).addClass('active') + } + + , mouseleave: function (e) { + this.mousedover = false + if (!this.focused && this.shown) this.hide() + } + + } + + + /* TYPEAHEAD PLUGIN DEFINITION + * =========================== */ + + var old = $.fn.typeahead + + $.fn.typeahead = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('typeahead') + , options = typeof option == 'object' && option + if (!data) $this.data('typeahead', (data = new Typeahead(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + $.fn.typeahead.defaults = { + source: [] + , items: 8 + , menu: '' + , item: '
  • ' + , ajaxdelay: 400 + , minLength: 1 + } + + $.fn.typeahead.Constructor = Typeahead + + + /* TYPEAHEAD NO CONFLICT + * =================== */ + + $.fn.typeahead.noConflict = function () { + $.fn.typeahead = old + return this + } + + + /* TYPEAHEAD DATA-API + * ================== */ + + $(document) + .off('focus.typeahead.data-api') // overwriting Twitter's typeahead + .on('focus.typeahead.data-api', '[data-provide="typeahead"]', function (e) { + var $this = $(this) + if ($this.data('typeahead')) return + if ($this.is('select')) $this.attr('autofocus', true) + e.preventDefault() + $this.typeahead($this.data()) + }) + +}(window.jQuery); +/* =========================================================== + * bootstrap-inputmask.js j2 + * http://twitter.github.com/bootstrap/javascript.html#tooltips + * Based on Masked Input plugin by Josh Bush (digitalbush.com) + * =========================================================== + * Copyright 2012 Jasny BV, Netherlands. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + +!function ($) { + + "use strict"; // jshint ;_; + + var isIphone = (window.orientation !== undefined), + isAndroid = navigator.userAgent.toLowerCase().indexOf("android") > -1 + + + /* INPUTMASK PUBLIC CLASS DEFINITION + * ================================= */ + + var Inputmask = function (element, options) { + if (isAndroid) return // No support because caret positioning doesn't work on Android + + this.$element = $(element) + this.options = $.extend({}, $.fn.inputmask.defaults, options) + this.mask = String(options.mask) + + this.init() + this.listen() + + this.checkVal() //Perform initial check for existing values + } + + Inputmask.prototype = { + + init: function() { + var defs = this.options.definitions + var len = this.mask.length + + this.tests = [] + this.partialPosition = this.mask.length + this.firstNonMaskPos = null + + $.each(this.mask.split(""), $.proxy(function(i, c) { + if (c == '?') { + len-- + this.partialPosition = i + } else if (defs[c]) { + this.tests.push(new RegExp(defs[c])) + if(this.firstNonMaskPos === null) + this.firstNonMaskPos = this.tests.length - 1 + } else { + this.tests.push(null) + } + }, this)) + + this.buffer = $.map(this.mask.split(""), $.proxy(function(c, i) { + if (c != '?') return defs[c] ? this.options.placeholder : c + }, this)) + + this.focusText = this.$element.val() + + this.$element.data("rawMaskFn", $.proxy(function() { + return $.map(this.buffer, function(c, i) { + return this.tests[i] && c != this.options.placeholder ? c : null + }).join('') + }, this)) + }, + + listen: function() { + if (this.$element.attr("readonly")) return + + var pasteEventName = (navigator.userAgent.match(/msie/i) ? 'paste' : 'input') + ".mask" + + this.$element + .on("unmask", $.proxy(this.unmask, this)) + + .on("focus.mask", $.proxy(this.focusEvent, this)) + .on("blur.mask", $.proxy(this.blurEvent, this)) + + .on("keydown.mask", $.proxy(this.keydownEvent, this)) + .on("keypress.mask", $.proxy(this.keypressEvent, this)) + + .on(pasteEventName, $.proxy(this.pasteEvent, this)) + }, + + //Helper Function for Caret positioning + caret: function(begin, end) { + if (this.$element.length === 0) return + if (typeof begin == 'number') { + end = (typeof end == 'number') ? end : begin + return this.$element.each(function() { + if (this.setSelectionRange) { + this.setSelectionRange(begin, end) + } else if (this.createTextRange) { + var range = this.createTextRange() + range.collapse(true) + range.moveEnd('character', end) + range.moveStart('character', begin) + range.select() + } + }) + } else { + if (this.$element[0].setSelectionRange) { + begin = this.$element[0].selectionStart + end = this.$element[0].selectionEnd + } else if (document.selection && document.selection.createRange) { + var range = document.selection.createRange() + begin = 0 - range.duplicate().moveStart('character', -100000) + end = begin + range.text.length + } + return { + begin: begin, + end: end + } + } + }, + + seekNext: function(pos) { + var len = this.mask.length + while (++pos <= len && !this.tests[pos]); + + return pos + }, + + seekPrev: function(pos) { + while (--pos >= 0 && !this.tests[pos]); + + return pos + }, + + shiftL: function(begin,end) { + var len = this.mask.length + + if(begin<0) return + + for (var i = begin,j = this.seekNext(end); i < len; i++) { + if (this.tests[i]) { + if (j < len && this.tests[i].test(this.buffer[j])) { + this.buffer[i] = this.buffer[j] + this.buffer[j] = this.options.placeholder + } else + break + j = this.seekNext(j) + } + } + this.writeBuffer() + this.caret(Math.max(this.firstNonMaskPos, begin)) + }, + + shiftR: function(pos) { + var len = this.mask.length + + for (var i = pos, c = this.options.placeholder; i < len; i++) { + if (this.tests[i]) { + var j = this.seekNext(i) + var t = this.buffer[i] + this.buffer[i] = c + if (j < len && this.tests[j].test(t)) + c = t + else + break + } + } + }, + + unmask: function() { + this.$element + .unbind(".mask") + .removeData("inputmask") + }, + + focusEvent: function() { + this.focusText = this.$element.val() + var len = this.mask.length + var pos = this.checkVal() + this.writeBuffer() + + var that = this + var moveCaret = function() { + if (pos == len) + that.caret(0, pos) + else + that.caret(pos) + } + + if ($.browser.msie) + moveCaret() + else + setTimeout(moveCaret, 0) + }, + + blurEvent: function() { + this.checkVal() + if (this.$element.val() != this.focusText) + this.$element.trigger('change') + }, + + keydownEvent: function(e) { + var k=e.which + + //backspace, delete, and escape get special treatment + if (k == 8 || k == 46 || (isIphone && k == 127)) { + var pos = this.caret(), + begin = pos.begin, + end = pos.end + + if (end-begin === 0) { + begin = k!=46 ? this.seekPrev(begin) : (end=this.seekNext(begin-1)) + end = k==46 ? this.seekNext(end) : end + } + this.clearBuffer(begin, end) + this.shiftL(begin,end-1) + + return false + } else if (k == 27) {//escape + this.$element.val(this.focusText) + this.caret(0, this.checkVal()) + return false + } + }, + + keypressEvent: function(e) { + var len = this.mask.length + + var k = e.which, + pos = this.caret() + + if (e.ctrlKey || e.altKey || e.metaKey || k<32) {//Ignore + return true + } else if (k) { + if (pos.end - pos.begin !== 0) { + this.clearBuffer(pos.begin, pos.end) + this.shiftL(pos.begin, pos.end-1) + } + + var p = this.seekNext(pos.begin - 1) + if (p < len) { + var c = String.fromCharCode(k) + if (this.tests[p].test(c)) { + this.shiftR(p) + this.buffer[p] = c + this.writeBuffer() + var next = this.seekNext(p) + this.caret(next) + } + } + return false + } + }, + + pasteEvent: function() { + var that = this + + setTimeout(function() { + that.caret(that.checkVal(true)) + }, 0) + }, + + clearBuffer: function(start, end) { + var len = this.mask.length + + for (var i = start; i < end && i < len; i++) { + if (this.tests[i]) + this.buffer[i] = this.options.placeholder + } + }, + + writeBuffer: function() { + return this.$element.val(this.buffer.join('')).val() + }, + + checkVal: function(allow) { + var len = this.mask.length + //try to place characters where they belong + var test = this.$element.val() + var lastMatch = -1 + + for (var i = 0, pos = 0; i < len; i++) { + if (this.tests[i]) { + this.buffer[i] = this.options.placeholder + while (pos++ < test.length) { + var c = test.charAt(pos - 1) + if (this.tests[i].test(c)) { + this.buffer[i] = c + lastMatch = i + break + } + } + if (pos > test.length) + break + } else if (this.buffer[i] == test.charAt(pos) && i != this.partialPosition) { + pos++ + lastMatch = i + } + } + if (!allow && lastMatch + 1 < this.partialPosition) { + this.$element.val("") + this.clearBuffer(0, len) + } else if (allow || lastMatch + 1 >= this.partialPosition) { + this.writeBuffer() + if (!allow) this.$element.val(this.$element.val().substring(0, lastMatch + 1)) + } + return (this.partialPosition ? i : this.firstNonMaskPos) + } + } + + + /* INPUTMASK PLUGIN DEFINITION + * =========================== */ + + $.fn.inputmask = function (options) { + return this.each(function () { + var $this = $(this) + , data = $this.data('inputmask') + if (!data) $this.data('inputmask', (data = new Inputmask(this, options))) + }) + } + + $.fn.inputmask.defaults = { + mask: "", + placeholder: "_", + definitions: { + '9': "[0-9]", + 'a': "[A-Za-z]", + '?': "[A-Za-z0-9]", + '*': "." + } + } + + $.fn.inputmask.Constructor = Inputmask + + + /* INPUTMASK DATA-API + * ================== */ + + $(document).on('focus.inputmask.data-api', '[data-mask]', function (e) { + var $this = $(this) + if ($this.data('inputmask')) return + e.preventDefault() + $this.inputmask($this.data()) + }) + +}(window.jQuery); +/* ============================================================ + * bootstrap-rowlink.js j1 + * http://jasny.github.com/bootstrap/javascript.html#rowlink + * ============================================================ + * Copyright 2012 Jasny BV, Netherlands. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ============================================================ */ + +!function ($) { + + "use strict"; // jshint ;_; + + var Rowlink = function (element, options) { + options = $.extend({}, $.fn.rowlink.defaults, options) + var tr = element.nodeName.toLowerCase() == 'tr' ? $(element) : $(element).find('tr:has(td)') + + tr.each(function() { + var link = $(this).find(options.target).first() + if (!link.length) return + + var href = link.attr('href') + + $(this).find('td').not('.nolink').click(function() { + window.location = href; + }) + + $(this).addClass('rowlink') + link.replaceWith(link.html()) + }) + } + + + /* ROWLINK PLUGIN DEFINITION + * =========================== */ + + $.fn.rowlink = function (options) { + return this.each(function () { + var $this = $(this) + , data = $this.data('rowlink') + if (!data) $this.data('rowlink', (data = new Rowlink(this, options))) + }) + } + + $.fn.rowlink.defaults = { + target: "a" + } + + $.fn.rowlink.Constructor = Rowlink + + + /* ROWLINK DATA-API + * ================== */ + + $(function () { + $('[data-provide="rowlink"],[data-provides="rowlink"]').each(function () { + $(this).rowlink($(this).data()) + }) + }) + +}(window.jQuery); +/* =========================================================== + * bootstrap-fileupload.js j2 + * http://jasny.github.com/bootstrap/javascript.html#fileupload + * =========================================================== + * Copyright 2012 Jasny BV, Netherlands. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + +!function ($) { + + "use strict"; // jshint ;_ + + /* FILEUPLOAD PUBLIC CLASS DEFINITION + * ================================= */ + + var Fileupload = function (element, options) { + this.$element = $(element) + this.type = this.$element.data('uploadtype') || (this.$element.find('.thumbnail').length > 0 ? "image" : "file") + + this.$input = this.$element.find(':file') + if (this.$input.length === 0) return + + this.name = this.$input.attr('name') || options.name + + this.$hidden = this.$element.find('input[type=hidden][name="'+this.name+'"]') + if (this.$hidden.length === 0) { + this.$hidden = $('') + this.$element.prepend(this.$hidden) + } + + this.$preview = this.$element.find('.fileupload-preview') + var height = this.$preview.css('height') + if (this.$preview.css('display') != 'inline' && height != '0px' && height != 'none') this.$preview.css('line-height', height) + + this.original = { + 'exists': this.$element.hasClass('fileupload-exists'), + 'preview': this.$preview.html(), + 'hiddenVal': this.$hidden.val() + } + + this.$remove = this.$element.find('[data-dismiss="fileupload"]') + + this.$element.find('[data-trigger="fileupload"]').on('click.fileupload', $.proxy(this.trigger, this)) + + this.listen() + } + + Fileupload.prototype = { + + listen: function() { + this.$input.on('change.fileupload', $.proxy(this.change, this)) + $(this.$input[0].form).on('reset.fileupload', $.proxy(this.reset, this)) + if (this.$remove) this.$remove.on('click.fileupload', $.proxy(this.clear, this)) + }, + + change: function(e, invoked) { + if (invoked === 'clear') return + + var file = e.target.files !== undefined ? e.target.files[0] : (e.target.value ? { name: e.target.value.replace(/^.+\\/, '') } : null) + + if (!file) { + this.clear() + return + } + + this.$hidden.val('') + this.$hidden.attr('name', '') + this.$input.attr('name', this.name) + + if (this.type === "image" && this.$preview.length > 0 && (typeof file.type !== "undefined" ? file.type.match('image.*') : file.name.match(/\.(gif|png|jpe?g)$/i)) && typeof FileReader !== "undefined") { + var reader = new FileReader() + var preview = this.$preview + var element = this.$element + + reader.onload = function(e) { + preview.html('') + element.addClass('fileupload-exists').removeClass('fileupload-new') + } + + reader.readAsDataURL(file) + } else { + this.$preview.text(file.name) + this.$element.addClass('fileupload-exists').removeClass('fileupload-new') + } + }, + + clear: function(e) { + this.$hidden.val('') + this.$hidden.attr('name', this.name) + this.$input.attr('name', '') + + //ie8+ doesn't support changing the value of input with type=file so clone instead + if (navigator.userAgent.match(/msie/i)){ + var inputClone = this.$input.clone(true); + this.$input.after(inputClone); + this.$input.remove(); + this.$input = inputClone; + }else{ + this.$input.val('') + } + + this.$preview.html('') + this.$element.addClass('fileupload-new').removeClass('fileupload-exists') + + if (e) { + this.$input.trigger('change', [ 'clear' ]) + e.preventDefault() + } + }, + + reset: function(e) { + this.clear() + + this.$hidden.val(this.original.hiddenVal) + this.$preview.html(this.original.preview) + + if (this.original.exists) this.$element.addClass('fileupload-exists').removeClass('fileupload-new') + else this.$element.addClass('fileupload-new').removeClass('fileupload-exists') + }, + + trigger: function(e) { + this.$input.trigger('click') + e.preventDefault() + } + } + + + /* FILEUPLOAD PLUGIN DEFINITION + * =========================== */ + + $.fn.fileupload = function (options) { + return this.each(function () { + var $this = $(this) + , data = $this.data('fileupload') + if (!data) $this.data('fileupload', (data = new Fileupload(this, options))) + if (typeof options == 'string') data[options]() + }) + } + + $.fn.fileupload.Constructor = Fileupload + + + /* FILEUPLOAD DATA-API + * ================== */ + + $(document).on('click.fileupload.data-api', '[data-provides="fileupload"]', function (e) { + var $this = $(this) + if ($this.data('fileupload')) return + $this.fileupload($this.data()) + + var $target = $(e.target).closest('[data-dismiss="fileupload"],[data-trigger="fileupload"]'); + if ($target.length > 0) { + $target.trigger('click.fileupload') + e.preventDefault() + } + }) + +}(window.jQuery); +/* ========================================================== + * bootstrap-affix.js v2.3.1 + * http://twitter.github.com/bootstrap/javascript.html#affix + * ========================================================== + * Copyright 2012 Twitter, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================== */ + + +!function ($) { + + "use strict"; // jshint ;_; + + + /* AFFIX CLASS DEFINITION + * ====================== */ + + var Affix = function (element, options) { + this.options = $.extend({}, $.fn.affix.defaults, options) + this.$window = $(window) + .on('scroll.affix.data-api', $.proxy(this.checkPosition, this)) + .on('click.affix.data-api', $.proxy(function () { setTimeout($.proxy(this.checkPosition, this), 1) }, this)) + this.$element = $(element) + this.checkPosition() + } + + Affix.prototype.checkPosition = function () { + if (!this.$element.is(':visible')) return + + var scrollHeight = $(document).height() + , scrollTop = this.$window.scrollTop() + , position = this.$element.offset() + , offset = this.options.offset + , offsetBottom = offset.bottom + , offsetTop = offset.top + , reset = 'affix affix-top affix-bottom' + , affix + + if (typeof offset != 'object') offsetBottom = offsetTop = offset + if (typeof offsetTop == 'function') offsetTop = offset.top() + if (typeof offsetBottom == 'function') offsetBottom = offset.bottom() + + affix = this.unpin != null && (scrollTop + this.unpin <= position.top) ? + false : offsetBottom != null && (position.top + this.$element.height() >= scrollHeight - offsetBottom) ? + 'bottom' : offsetTop != null && scrollTop <= offsetTop ? + 'top' : false + + if (this.affixed === affix) return + + this.affixed = affix + this.unpin = affix == 'bottom' ? position.top - scrollTop : null + + this.$element.removeClass(reset).addClass('affix' + (affix ? '-' + affix : '')) + } + + + /* AFFIX PLUGIN DEFINITION + * ======================= */ + + var old = $.fn.affix + + $.fn.affix = function (option) { + return this.each(function () { + var $this = $(this) + , data = $this.data('affix') + , options = typeof option == 'object' && option + if (!data) $this.data('affix', (data = new Affix(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + $.fn.affix.Constructor = Affix + + $.fn.affix.defaults = { + offset: 0 + } + + + /* AFFIX NO CONFLICT + * ================= */ + + $.fn.affix.noConflict = function () { + $.fn.affix = old + return this + } + + + /* AFFIX DATA-API + * ============== */ + + $(window).on('load', function () { + $('[data-spy="affix"]').each(function () { + var $spy = $(this) + , data = $spy.data() + + data.offset = data.offset || {} + + data.offsetBottom && (data.offset.bottom = data.offsetBottom) + data.offsetTop && (data.offset.top = data.offsetTop) + + $spy.affix(data) + }) + }) + + +}(window.jQuery); \ No newline at end of file diff --git a/data/css/lib/bootstrap/js/bootstrap.min.js b/data/css/lib/bootstrap/js/bootstrap.min.js new file mode 100644 index 0000000000..30eccd4a43 --- /dev/null +++ b/data/css/lib/bootstrap/js/bootstrap.min.js @@ -0,0 +1,7 @@ +/*! +* Bootstrap.js by @fat & @mdo extended by @ArnoldDaniels +* Copyright 2012 Twitter, Inc. +* http://www.apache.org/licenses/LICENSE-2.0.txt +*/ +!function(e){"use strict";e(function(){e.support.transition=function(){var e=function(){var e=document.createElement("bootstrap"),t={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"},n;for(n in t)if(e.style[n]!==undefined)return t[n]}();return e&&{end:e}}()})}(window.jQuery),!function(e){"use strict";var t='[data-dismiss="alert"]',n=function(n){e(n).on("click",t,this.close)};n.prototype.close=function(t){function s(){i.trigger("closed").remove()}var n=e(this),r=n.attr("data-target"),i;r||(r=n.attr("href"),r=r&&r.replace(/.*(?=#[^\s]*$)/,"")),i=e(r),t&&t.preventDefault(),i.length||(i=n.hasClass("alert")?n:n.parent()),i.trigger(t=e.Event("close"));if(t.isDefaultPrevented())return;i.removeClass("in"),e.support.transition&&i.hasClass("fade")?i.on(e.support.transition.end,s):s()};var r=e.fn.alert;e.fn.alert=function(t){return this.each(function(){var r=e(this),i=r.data("alert");i||r.data("alert",i=new n(this)),typeof t=="string"&&i[t].call(r)})},e.fn.alert.Constructor=n,e.fn.alert.noConflict=function(){return e.fn.alert=r,this},e(document).on("click.alert.data-api",t,n.prototype.close)}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.button.defaults,n)};t.prototype.setState=function(e){var t="disabled",n=this.$element,r=n.data(),i=n.is("input")?"val":"html";e+="Text",r.resetText||n.data("resetText",n[i]()),n[i](r[e]||this.options[e]),setTimeout(function(){e=="loadingText"?n.addClass(t).attr(t,t):n.removeClass(t).removeAttr(t)},0)},t.prototype.toggle=function(){var e=this.$element.closest('[data-toggle="buttons-radio"]');e&&e.find(".active").removeClass("active"),this.$element.toggleClass("active")};var n=e.fn.button;e.fn.button=function(n){return this.each(function(){var r=e(this),i=r.data("button"),s=typeof n=="object"&&n;i||r.data("button",i=new t(this,s)),n=="toggle"?i.toggle():n&&i.setState(n)})},e.fn.button.defaults={loadingText:"loading..."},e.fn.button.Constructor=t,e.fn.button.noConflict=function(){return e.fn.button=n,this},e(document).on("click.button.data-api","[data-toggle^=button]",function(t){var n=e(t.target);n.hasClass("btn")||(n=n.closest(".btn")),n.button("toggle")})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.$indicators=this.$element.find(".carousel-indicators"),this.options=n,this.options.pause=="hover"&&this.$element.on("mouseenter",e.proxy(this.pause,this)).on("mouseleave",e.proxy(this.cycle,this))};t.prototype={cycle:function(t){return t||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(e.proxy(this.next,this),this.options.interval)),this},getActiveIndex:function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},to:function(t){var n=this.getActiveIndex(),r=this;if(t>this.$items.length-1||t<0)return;return this.sliding?this.$element.one("slid",function(){r.to(t)}):n==t?this.pause().cycle():this.slide(t>n?"next":"prev",e(this.$items[t]))},pause:function(t){return t||(this.paused=!0),this.$element.find(".next, .prev").length&&e.support.transition.end&&(this.$element.trigger(e.support.transition.end),this.cycle(!0)),clearInterval(this.interval),this.interval=null,this},next:function(){if(this.sliding)return;return this.slide("next")},prev:function(){if(this.sliding)return;return this.slide("prev")},slide:function(t,n){var r=this.$element.find(".item.active"),i=n||r[t](),s=this.interval,o=t=="next"?"left":"right",u=t=="next"?"first":"last",a=this,f;this.sliding=!0,s&&this.pause(),i=i.length?i:this.$element.find(".item")[u](),f=e.Event("slide",{relatedTarget:i[0],direction:o});if(i.hasClass("active"))return;this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid",function(){var t=e(a.$indicators.children()[a.getActiveIndex()]);t&&t.addClass("active")}));if(e.support.transition&&this.$element.hasClass("slide")){this.$element.trigger(f);if(f.isDefaultPrevented())return;i.addClass(t),i[0].offsetWidth,r.addClass(o),i.addClass(o),this.$element.one(e.support.transition.end,function(){i.removeClass([t,o].join(" ")).addClass("active"),r.removeClass(["active",o].join(" ")),a.sliding=!1,setTimeout(function(){a.$element.trigger("slid")},0)})}else{this.$element.trigger(f);if(f.isDefaultPrevented())return;r.removeClass("active"),i.addClass("active"),this.sliding=!1,this.$element.trigger("slid")}return s&&this.cycle(),this}};var n=e.fn.carousel;e.fn.carousel=function(n){return this.each(function(){var r=e(this),i=r.data("carousel"),s=e.extend({},e.fn.carousel.defaults,typeof n=="object"&&n),o=typeof n=="string"?n:s.slide;i||r.data("carousel",i=new t(this,s)),typeof n=="number"?i.to(n):o?i[o]():s.interval&&i.pause().cycle()})},e.fn.carousel.defaults={interval:5e3,pause:"hover"},e.fn.carousel.Constructor=t,e.fn.carousel.noConflict=function(){return e.fn.carousel=n,this},e(document).on("click.carousel.data-api","[data-slide], [data-slide-to]",function(t){var n=e(this),r,i=e(n.attr("data-target")||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,"")),s=e.extend({},i.data(),n.data()),o;i.carousel(s),(o=n.attr("data-slide-to"))&&i.data("carousel").pause().to(o).cycle(),t.preventDefault()})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.collapse.defaults,n),this.options.parent&&(this.$parent=e(this.options.parent)),this.options.toggle&&this.toggle()};t.prototype={constructor:t,dimension:function(){var e=this.$element.hasClass("width");return e?"width":"height"},show:function(){var t,n,r,i;if(this.transitioning||this.$element.hasClass("in"))return;t=this.dimension(),n=e.camelCase(["scroll",t].join("-")),r=this.$parent&&this.$parent.find("> .accordion-group > .in");if(r&&r.length){i=r.data("collapse");if(i&&i.transitioning)return;r.collapse("hide"),i||r.data("collapse",null)}this.$element[t](0),this.transition("addClass",e.Event("show"),"shown"),e.support.transition&&this.$element[t](this.$element[0][n])},hide:function(){var t;if(this.transitioning||!this.$element.hasClass("in"))return;t=this.dimension(),this.reset(this.$element[t]()),this.transition("removeClass",e.Event("hide"),"hidden"),this.$element[t](0)},reset:function(e){var t=this.dimension();return this.$element.removeClass("collapse")[t](e||"auto")[0].offsetWidth,this.$element[e!==null?"addClass":"removeClass"]("collapse"),this},transition:function(t,n,r){var i=this,s=function(){n.type=="show"&&i.reset(),i.transitioning=0,i.$element.trigger(r)};this.$element.trigger(n);if(n.isDefaultPrevented())return;this.transitioning=1,this.$element[t]("in"),e.support.transition&&this.$element.hasClass("collapse")?this.$element.one(e.support.transition.end,s):s()},toggle:function(){this[this.$element.hasClass("in")?"hide":"show"]()}};var n=e.fn.collapse;e.fn.collapse=function(n){return this.each(function(){var r=e(this),i=r.data("collapse"),s=e.extend({},e.fn.collapse.defaults,r.data(),typeof n=="object"&&n);i||r.data("collapse",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.collapse.defaults={toggle:!0},e.fn.collapse.Constructor=t,e.fn.collapse.noConflict=function(){return e.fn.collapse=n,this},e(document).on("click.collapse.data-api","[data-toggle=collapse]",function(t){var n=e(this),r,i=n.attr("data-target")||t.preventDefault()||(r=n.attr("href"))&&r.replace(/.*(?=#[^\s]+$)/,""),s=e(i).data("collapse")?"toggle":n.data();n[e(i).hasClass("in")?"addClass":"removeClass"]("collapsed"),e(i).collapse(s)})}(window.jQuery),!function(e){"use strict";function r(){e(t).each(function(){i(e(this)).removeClass("open")})}function i(t){var n=t.attr("data-target"),r;n||(n=t.attr("href"),n=n&&/#/.test(n)&&n.replace(/.*(?=#[^\s]*$)/,"")),r=n&&e(n);if(!r||!r.length)r=t.parent();return r}var t="[data-toggle=dropdown]",n=function(t){var n=e(t).on("click.dropdown.data-api",this.toggle);e("html").on("click.dropdown.data-api",function(){n.parent().removeClass("open")})};n.prototype={constructor:n,toggle:function(t){var n=e(this),s,o;if(n.is(".disabled, :disabled"))return;return s=i(n),o=s.hasClass("open"),r(),o||s.toggleClass("open"),n.focus(),!1},keydown:function(n){var r,s,o,u,a,f;if(!/(38|40|27)/.test(n.keyCode))return;r=e(this),n.preventDefault(),n.stopPropagation();if(r.is(".disabled, :disabled"))return;u=i(r),a=u.hasClass("open");if(!a||a&&n.keyCode==27)return n.which==27&&u.find(t).focus(),r.click();s=e("[role=menu] li:not(.divider):visible a",u);if(!s.length)return;f=s.index(s.filter(":focus")),n.keyCode==38&&f>0&&f--,n.keyCode==40&&f').appendTo(document.body),this.$backdrop.click(this.options.backdrop=="static"?e.proxy(this.$element[0].focus,this.$element[0]):e.proxy(this.hide,this)),i&&this.$backdrop[0].offsetWidth,this.$backdrop.addClass("in");if(!t)return;i?this.$backdrop.one(e.support.transition.end,t):t()}else!this.isShown&&this.$backdrop?(this.$backdrop.removeClass("in"),e.support.transition&&this.$element.hasClass("fade")?this.$backdrop.one(e.support.transition.end,t):t()):t&&t()}};var n=e.fn.modal;e.fn.modal=function(n){return this.each(function(){var r=e(this),i=r.data("modal"),s=e.extend({},e.fn.modal.defaults,r.data(),typeof n=="object"&&n);i||r.data("modal",i=new t(this,s)),typeof n=="string"?i[n]():s.show&&i.show()})},e.fn.modal.defaults={backdrop:!0,keyboard:!0,show:!0},e.fn.modal.Constructor=t,e.fn.modal.noConflict=function(){return e.fn.modal=n,this},e(document).on("click.modal.data-api",'[data-toggle="modal"]',function(t){var n=e(this),r=n.attr("href"),i=e(n.attr("data-target")||r&&r.replace(/.*(?=#[^\s]+$)/,"")),s=i.data("modal")?"toggle":e.extend({remote:!/#/.test(r)&&r},i.data(),n.data());t.preventDefault(),i.modal(s).one("hide",function(){n.focus()})})}(window.jQuery),!function(e){"use strict";var t=function(e,t){this.init("tooltip",e,t)};t.prototype={constructor:t,init:function(t,n,r){var i,s,o,u,a;this.type=t,this.$element=e(n),this.options=this.getOptions(r),this.enabled=!0,o=this.options.trigger.split(" ");for(a=o.length;a--;)u=o[a],u=="click"?this.$element.on("click."+this.type,this.options.selector,e.proxy(this.toggle,this)):u!="manual"&&(i=u=="hover"?"mouseenter":"focus",s=u=="hover"?"mouseleave":"blur",this.$element.on(i+"."+this.type,this.options.selector,e.proxy(this.enter,this)),this.$element.on(s+"."+this.type,this.options.selector,e.proxy(this.leave,this)));this.options.selector?this._options=e.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},getOptions:function(t){return t=e.extend({},e.fn[this.type].defaults,this.$element.data(),t),t.delay&&typeof t.delay=="number"&&(t.delay={show:t.delay,hide:t.delay}),t},enter:function(t){var n=e.fn[this.type].defaults,r={},i;this._options&&e.each(this._options,function(e,t){n[e]!=t&&(r[e]=t)},this),i=e(t.currentTarget)[this.type](r).data(this.type);if(!i.options.delay||!i.options.delay.show)return i.show();clearTimeout(this.timeout),i.hoverState="in",this.timeout=setTimeout(function(){i.hoverState=="in"&&i.show()},i.options.delay.show)},leave:function(t){var n=e(t.currentTarget)[this.type](this._options).data(this.type);this.timeout&&clearTimeout(this.timeout);if(!n.options.delay||!n.options.delay.hide)return n.hide();n.hoverState="out",this.timeout=setTimeout(function(){n.hoverState=="out"&&n.hide()},n.options.delay.hide)},show:function(){var t,n,r,i,s,o,u=e.Event("show");if(this.hasContent()&&this.enabled){this.$element.trigger(u);if(u.isDefaultPrevented())return;t=this.tip(),this.setContent(),this.options.animation&&t.addClass("fade"),s=typeof this.options.placement=="function"?this.options.placement.call(this,t[0],this.$element[0]):this.options.placement,t.detach().css({top:0,left:0,display:"block"}),this.options.container?t.appendTo(this.options.container):t.insertAfter(this.$element),n=this.getPosition(),r=t[0].offsetWidth,i=t[0].offsetHeight;switch(s){case"bottom":o={top:n.top+n.height,left:n.left+n.width/2-r/2};break;case"top":o={top:n.top-i,left:n.left+n.width/2-r/2};break;case"left":o={top:n.top+n.height/2-i/2,left:n.left-r};break;case"right":o={top:n.top+n.height/2-i/2,left:n.left+n.width}}this.applyPlacement(o,s),this.$element.trigger("shown")}},applyPlacement:function(e,t){var n=this.tip(),r=n[0].offsetWidth,i=n[0].offsetHeight,s,o,u,a;n.offset(e).addClass(t).addClass("in"),s=n[0].offsetWidth,o=n[0].offsetHeight,t=="top"&&o!=i&&(e.top=e.top+i-o,a=!0),t=="bottom"||t=="top"?(u=0,e.left<0&&(u=e.left*-2,e.left=0,n.offset(e),s=n[0].offsetWidth,o=n[0].offsetHeight),this.replaceArrow(u-r+s,s,"left")):this.replaceArrow(o-i,o,"top"),a&&n.offset(e)},replaceArrow:function(e,t,n){this.arrow().css(n,e?50*(1-e/t)+"%":"")},setContent:function(){var e=this.tip(),t=this.getTitle();e.find(".tooltip-inner")[this.options.html?"html":"text"](t),e.removeClass("fade in top bottom left right")},hide:function(){function i(){var t=setTimeout(function(){n.off(e.support.transition.end).detach()},500);n.one(e.support.transition.end,function(){clearTimeout(t),n.detach()})}var t=this,n=this.tip(),r=e.Event("hide");this.$element.trigger(r);if(r.isDefaultPrevented())return;return n.removeClass("in"),e.support.transition&&this.$tip.hasClass("fade")?i():n.detach(),this.$element.trigger("hidden"),this},fixTitle:function(){var e=this.$element;(e.attr("title")||typeof e.attr("data-original-title")!="string")&&e.attr("data-original-title",e.attr("title")||"").attr("title","")},hasContent:function(){return this.getTitle()},getPosition:function(){var t=this.$element[0];return e.extend({},typeof t.getBoundingClientRect=="function"?t.getBoundingClientRect():{width:t.offsetWidth,height:t.offsetHeight},this.$element.offset())},getTitle:function(){var e,t=this.$element,n=this.options;return e=t.attr("data-original-title")||(typeof n.title=="function"?n.title.call(t[0]):n.title),e},tip:function(){return this.$tip=this.$tip||e(this.options.template)},arrow:function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},validate:function(){this.$element[0].parentNode||(this.hide(),this.$element=null,this.options=null)},enable:function(){this.enabled=!0},disable:function(){this.enabled=!1},toggleEnabled:function(){this.enabled=!this.enabled},toggle:function(t){var n=t?e(t.currentTarget)[this.type](this._options).data(this.type):this;n.tip().hasClass("in")?n.hide():n.show()},destroy:function(){this.hide().$element.off("."+this.type).removeData(this.type)}};var n=e.fn.tooltip;e.fn.tooltip=function(n){return this.each(function(){var r=e(this),i=r.data("tooltip"),s=typeof n=="object"&&n;i||r.data("tooltip",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.tooltip.Constructor=t,e.fn.tooltip.defaults={animation:!0,placement:"top",selector:!1,template:'
    ',trigger:"hover focus",title:"",delay:0,html:!1,container:!1},e.fn.tooltip.noConflict=function(){return e.fn.tooltip=n,this}}(window.jQuery),!function(e){"use strict";var t=function(e,t){this.init("popover",e,t)};t.prototype=e.extend({},e.fn.tooltip.Constructor.prototype,{constructor:t,setContent:function(){var e=this.tip(),t=this.getTitle(),n=this.getContent();e.find(".popover-title")[this.options.html?"html":"text"](t),e.find(".popover-content")[this.options.html?"html":"text"](n),e.removeClass("fade top bottom left right in")},hasContent:function(){return this.getTitle()||this.getContent()},getContent:function(){var e,t=this.$element,n=this.options;return e=(typeof n.content=="function"?n.content.call(t[0]):n.content)||t.attr("data-content"),e},tip:function(){return this.$tip||(this.$tip=e(this.options.template)),this.$tip},destroy:function(){this.hide().$element.off("."+this.type).removeData(this.type)}});var n=e.fn.popover;e.fn.popover=function(n){return this.each(function(){var r=e(this),i=r.data("popover"),s=typeof n=="object"&&n;i||r.data("popover",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.popover.Constructor=t,e.fn.popover.defaults=e.extend({},e.fn.tooltip.defaults,{placement:"right",trigger:"click",content:"",template:'

    '}),e.fn.popover.noConflict=function(){return e.fn.popover=n,this}}(window.jQuery),!function(e){"use strict";function t(t,n){var r=e.proxy(this.process,this),i=e(t).is("body")?e(window):e(t),s;this.options=e.extend({},e.fn.scrollspy.defaults,n),this.$scrollElement=i.on("scroll.scroll-spy.data-api",r),this.selector=(this.options.target||(s=e(t).attr("href"))&&s.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.$body=e("body"),this.refresh(),this.process()}t.prototype={constructor:t,refresh:function(){var t=this,n;this.offsets=e([]),this.targets=e([]),n=this.$body.find(this.selector).map(function(){var n=e(this),r=n.data("target")||n.attr("href"),i=/^#\w/.test(r)&&e(r);return i&&i.length&&[[i.position().top+(!e.isWindow(t.$scrollElement.get(0))&&t.$scrollElement.scrollTop()),r]]||null}).sort(function(e,t){return e[0]-t[0]}).each(function(){t.offsets.push(this[0]),t.targets.push(this[1])})},process:function(){var e=this.$scrollElement.scrollTop()+this.options.offset,t=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,n=t-this.$scrollElement.height(),r=this.offsets,i=this.targets,s=this.activeTarget,o;if(e>=n)return s!=(o=i.last()[0])&&this.activate(o);for(o=r.length;o--;)s!=i[o]&&e>=r[o]&&(!r[o+1]||e<=r[o+1])&&this.activate(i[o])},activate:function(t){var n,r;this.activeTarget=t,e(this.selector).parent(".active").removeClass("active"),r=this.selector+'[data-target="'+t+'"],'+this.selector+'[href="'+t+'"]',n=e(r).parent("li").addClass("active"),n.parent(".dropdown-menu").length&&(n=n.closest("li.dropdown").addClass("active")),n.trigger("activate")}};var n=e.fn.scrollspy;e.fn.scrollspy=function(n){return this.each(function(){var r=e(this),i=r.data("scrollspy"),s=typeof n=="object"&&n;i||r.data("scrollspy",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.scrollspy.Constructor=t,e.fn.scrollspy.defaults={offset:10},e.fn.scrollspy.noConflict=function(){return e.fn.scrollspy=n,this},e(window).on("load",function(){e('[data-spy="scroll"]').each(function(){var t=e(this);t.scrollspy(t.data())})})}(window.jQuery),!function(e){"use strict";var t=function(t){this.element=e(t)};t.prototype={constructor:t,show:function(){var t=this.element,n=t.closest("ul:not(.dropdown-menu)"),r=t.attr("data-target"),i,s,o;r||(r=t.attr("href"),r=r&&r.replace(/.*(?=#[^\s]*$)/,""));if(t.parent("li").hasClass("active"))return;i=n.find(".active:last a")[0],o=e.Event("show",{relatedTarget:i}),t.trigger(o);if(o.isDefaultPrevented())return;s=e(r),this.activate(t.parent("li"),n),this.activate(s,s.parent(),function(){t.trigger({type:"shown",relatedTarget:i})})},activate:function(t,n,r){function o(){i.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),t.addClass("active"),s?(t[0].offsetWidth,t.addClass("in")):t.removeClass("fade"),t.parent(".dropdown-menu")&&t.closest("li.dropdown").addClass("active"),r&&r()}var i=n.find("> .active"),s=r&&e.support.transition&&i.hasClass("fade");s?i.one(e.support.transition.end,o):o(),i.removeClass("in")}};var n=e.fn.tab;e.fn.tab=function(n){return this.each(function(){var r=e(this),i=r.data("tab");i||r.data("tab",i=new t(this)),typeof n=="string"&&i[n]()})},e.fn.tab.Constructor=t,e.fn.tab.noConflict=function(){return e.fn.tab=n,this},e(document).on("click.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(t){t.preventDefault(),e(this).tab("show")})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.options=e.extend({},e.fn.typeahead.defaults,n),this.options.target&&(this.$target=e(this.options.target)),this.matcher=this.options.matcher||this.matcher,this.sorter=this.options.sorter||this.sorter,this.highlighter=this.options.highlighter||this.highlighter,this.updater=this.options.updater||this.updater,this.source=this.options.source,this.strict=this.options.strict,this.$menu=e(this.options.menu),this.shown=!1,typeof this.source=="string"&&(this.url=this.source,this.source=this.searchAjax),t.nodeName=="SELECT"&&this.replaceSelect(),this.text=this.$element.val(),this.$element.attr("data-text",this.value).attr("autocomplete","off"),typeof this.$target!="undefined"?this.$element.attr("data-value",this.$target.val()):typeof this.$element.attr("data-value")=="undefined"&&this.$element.attr("data-value",this.strict?"":this.value),this.$menu.css("min-width",this.$element.width()+12),this.listen()};t.prototype={constructor:t,replaceSelect:function(){this.$target=this.$element,this.$element=e(''),this.source={},this.strict=!0;var t=this.$target.find("option"),n;for(var r=0;r0?e.find(".item-text").text():e.text();return t=this.updater(t,"value"),n=this.updater(n,"text"),this.$element.val(n).attr("data-value",t),this.text=n,typeof this.$target!="undefined"&&this.$target.val(t).trigger("change"),this.$element.trigger("change"),this.hide()},updater:function(e,t){return e},show:function(){var t=e.extend({},this.$element.position(),{height:this.$element[0].offsetHeight});return this.$menu.insertAfter(this.$element).css({top:t.top+t.height,left:t.left}).show(),this.shown=!0,this},hide:function(){return this.$menu.hide(),this.shown=!1,this},lookup:function(t){var n;return this.query=this.$element.val(),!this.query||this.query.length=n.options.items&&delete r[e]}),this.render(r).show())},searchAjax:function(t,n){var r=this;this.ajaxTimeout&&clearTimeout(this.ajaxTimeout),this.ajaxTimeout=setTimeout(function(){r.ajaxTimeout&&clearTimeout(r.ajaxTimeout);if(t===""){r.hide();return}e.get(r.url,{q:t,limit:r.options.items},function(e){typeof e=="string"&&(e=JSON.parse(e)),n(e)})},this.options.ajaxdelay)},matcher:function(e){return~e.toLowerCase().indexOf(this.query.toLowerCase())},sorter:function(t){return e.isArray(t)?this.sortArray(t):this.sortObject(t)},sortArray:function(e){var t=[],n=[],r=[],i;while(i=e.shift())i.toLowerCase().indexOf(this.query.toLowerCase())?~i.indexOf(this.query)?n.push(i):r.push(i):t.push(i);return t.concat(n,r)},sortObject:function(e){var t={},n;for(n in e)e[n].toLowerCase().indexOf(this.query.toLowerCase())||(t[n]=e[n],delete e[n]);for(n in e)~e[n].indexOf(this.query)&&(t[n]=e[n],delete e[n]);for(n in e)t[n]=e[n];return t},highlighter:function(e){var t=this.query.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g,"\\$&");return e.replace(new RegExp("("+t+")","ig"),function(e,t){return""+t+""})},render:function(t){var n=this,r=e([]);return e.map(t,function(i,s){if(r.length>=n.options.items)return;var o,u;e.isArray(t)&&(s=i),o=e(n.options.item),u=o.find("a").length?o.find("a"):o,u.html(n.highlighter(i)),o.attr("data-value",s),o.find("a").length===0&&o.addClass("dropdown-header"),r.push(o[0])}),r.not(".dropdown-header").first().addClass("active"),this.$menu.html(r),this},next:function(t){var n=this.$menu.find(".active").removeClass("active"),r=n.nextAll("li:not(.dropdown-header)").first();r.length||(r=e(this.$menu.find("li:not(.dropdown-header)")[0])),r.addClass("active")},prev:function(e){var t=this.$menu.find(".active").removeClass("active"),n=t.prevAll("li:not(.dropdown-header)").first();n.length||(n=this.$menu.find("li:not(.dropdown-header)").last()),n.addClass("active")},listen:function(){this.$element.on("focus",e.proxy(this.focus,this)).on("blur",e.proxy(this.blur,this)).on("change",e.proxy(this.change,this)).on("keypress",e.proxy(this.keypress,this)).on("keyup",e.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.on("keydown",e.proxy(this.keydown,this)),this.$menu.on("click",e.proxy(this.click,this)).on("mouseenter","li",e.proxy(this.mouseenter,this)).on("mouseleave","li",e.proxy(this.mouseleave,this)),e(window).on("unload",e.proxy(this.destroyReplacement,this))},eventSupported:function(e){var t=e in this.$element;return t||(this.$element.setAttribute(e,"return;"),t=typeof this.$element[e]=="function"),t},move:function(e){if(!this.shown)return;switch(e.keyCode){case 9:case 13:case 27:e.preventDefault();break;case 38:e.preventDefault(),this.prev();break;case 40:e.preventDefault(),this.next()}e.stopPropagation()},keydown:function(t){this.suppressKeyPressRepeat=~e.inArray(t.keyCode,[40,38,9,13,27]),this.move(t)},keypress:function(e){if(this.suppressKeyPressRepeat)return;this.move(e)},keyup:function(e){switch(e.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 9:case 13:if(!this.shown)return;this.select();break;case 27:if(!this.shown)return;this.hide();break;default:this.lookup()}e.stopPropagation(),e.preventDefault()},change:function(e){var t;this.$element.val()!=this.text&&(t=this.$element.val()===""||this.strict?"":this.$element.val(),this.$element.val(t),this.$element.attr("data-value",t),this.text=t,typeof this.$target!="undefined"&&this.$target.val(t))},focus:function(e){this.focused=!0},blur:function(e){this.focused=!1,!this.mousedover&&this.shown&&this.hide()},click:function(e){e.stopPropagation(),e.preventDefault(),this.select(),this.$element.focus()},mouseenter:function(t){this.mousedover=!0,this.$menu.find(".active").removeClass("active"),e(t.currentTarget).addClass("active")},mouseleave:function(e){this.mousedover=!1,!this.focused&&this.shown&&this.hide()}};var n=e.fn.typeahead;e.fn.typeahead=function(n){return this.each(function(){var r=e(this),i=r.data("typeahead"),s=typeof n=="object"&&n;i||r.data("typeahead",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.typeahead.defaults={source:[],items:8,menu:'',item:'
  • ',ajaxdelay:400,minLength:1},e.fn.typeahead.Constructor=t,e.fn.typeahead.noConflict=function(){return e.fn.typeahead=n,this},e(document).off("focus.typeahead.data-api").on("focus.typeahead.data-api",'[data-provide="typeahead"]',function(t){var n=e(this);if(n.data("typeahead"))return;n.is("select")&&n.attr("autofocus",!0),t.preventDefault(),n.typeahead(n.data())})}(window.jQuery),!function(e){"use strict";var t=window.orientation!==undefined,n=navigator.userAgent.toLowerCase().indexOf("android")>-1,r=function(t,r){if(n)return;this.$element=e(t),this.options=e.extend({},e.fn.inputmask.defaults,r),this.mask=String(r.mask),this.init(),this.listen(),this.checkVal()};r.prototype={init:function(){var t=this.options.definitions,n=this.mask.length;this.tests=[],this.partialPosition=this.mask.length,this.firstNonMaskPos=null,e.each(this.mask.split(""),e.proxy(function(e,r){r=="?"?(n--,this.partialPosition=e):t[r]?(this.tests.push(new RegExp(t[r])),this.firstNonMaskPos===null&&(this.firstNonMaskPos=this.tests.length-1)):this.tests.push(null)},this)),this.buffer=e.map(this.mask.split(""),e.proxy(function(e,n){if(e!="?")return t[e]?this.options.placeholder:e},this)),this.focusText=this.$element.val(),this.$element.data("rawMaskFn",e.proxy(function(){return e.map(this.buffer,function(e,t){return this.tests[t]&&e!=this.options.placeholder?e:null}).join("")},this))},listen:function(){if(this.$element.attr("readonly"))return;var t=(navigator.userAgent.match(/msie/i)?"paste":"input")+".mask";this.$element.on("unmask",e.proxy(this.unmask,this)).on("focus.mask",e.proxy(this.focusEvent,this)).on("blur.mask",e.proxy(this.blurEvent,this)).on("keydown.mask",e.proxy(this.keydownEvent,this)).on("keypress.mask",e.proxy(this.keypressEvent,this)).on(t,e.proxy(this.pasteEvent,this))},caret:function(e,t){if(this.$element.length===0)return;if(typeof e=="number")return t=typeof t=="number"?t:e,this.$element.each(function(){if(this.setSelectionRange)this.setSelectionRange(e,t);else if(this.createTextRange){var n=this.createTextRange();n.collapse(!0),n.moveEnd("character",t),n.moveStart("character",e),n.select()}});if(this.$element[0].setSelectionRange)e=this.$element[0].selectionStart,t=this.$element[0].selectionEnd;else if(document.selection&&document.selection.createRange){var n=document.selection.createRange();e=0-n.duplicate().moveStart("character",-1e5),t=e+n.text.length}return{begin:e,end:t}},seekNext:function(e){var t=this.mask.length;while(++e<=t&&!this.tests[e]);return e},seekPrev:function(e){while(--e>=0&&!this.tests[e]);return e},shiftL:function(e,t){var n=this.mask.length;if(e<0)return;for(var r=e,i=this.seekNext(t);rn.length)break}else this.buffer[i]==n.charAt(s)&&i!=this.partialPosition&&(s++,r=i);if(!e&&r+1=this.partialPosition)this.writeBuffer(),e||this.$element.val(this.$element.val().substring(0,r+1));return this.partialPosition?i:this.firstNonMaskPos}},e.fn.inputmask=function(t){return this.each(function(){var n=e(this),i=n.data("inputmask");i||n.data("inputmask",i=new r(this,t))})},e.fn.inputmask.defaults={mask:"",placeholder:"_",definitions:{9:"[0-9]",a:"[A-Za-z]","?":"[A-Za-z0-9]","*":"."}},e.fn.inputmask.Constructor=r,e(document).on("focus.inputmask.data-api","[data-mask]",function(t){var n=e(this);if(n.data("inputmask"))return;t.preventDefault(),n.inputmask(n.data())})}(window.jQuery),!function(e){"use strict";var t=function(t,n){n=e.extend({},e.fn.rowlink.defaults,n);var r=t.nodeName.toLowerCase()=="tr"?e(t):e(t).find("tr:has(td)");r.each(function(){var t=e(this).find(n.target).first();if(!t.length)return;var r=t.attr("href");e(this).find("td").not(".nolink").click(function(){window.location=r}),e(this).addClass("rowlink"),t.replaceWith(t.html())})};e.fn.rowlink=function(n){return this.each(function(){var r=e(this),i=r.data("rowlink");i||r.data("rowlink",i=new t(this,n))})},e.fn.rowlink.defaults={target:"a"},e.fn.rowlink.Constructor=t,e(function(){e('[data-provide="rowlink"],[data-provides="rowlink"]').each(function(){e(this).rowlink(e(this).data())})})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.$element=e(t),this.type=this.$element.data("uploadtype")||(this.$element.find(".thumbnail").length>0?"image":"file"),this.$input=this.$element.find(":file");if(this.$input.length===0)return;this.name=this.$input.attr("name")||n.name,this.$hidden=this.$element.find('input[type=hidden][name="'+this.name+'"]'),this.$hidden.length===0&&(this.$hidden=e(''),this.$element.prepend(this.$hidden)),this.$preview=this.$element.find(".fileupload-preview");var r=this.$preview.css("height");this.$preview.css("display")!="inline"&&r!="0px"&&r!="none"&&this.$preview.css("line-height",r),this.original={exists:this.$element.hasClass("fileupload-exists"),preview:this.$preview.html(),hiddenVal:this.$hidden.val()},this.$remove=this.$element.find('[data-dismiss="fileupload"]'),this.$element.find('[data-trigger="fileupload"]').on("click.fileupload",e.proxy(this.trigger,this)),this.listen()};t.prototype={listen:function(){this.$input.on("change.fileupload",e.proxy(this.change,this)),e(this.$input[0].form).on("reset.fileupload",e.proxy(this.reset,this)),this.$remove&&this.$remove.on("click.fileupload",e.proxy(this.clear,this))},change:function(e,t){if(t==="clear")return;var n=e.target.files!==undefined?e.target.files[0]:e.target.value?{name:e.target.value.replace(/^.+\\/,"")}:null;if(!n){this.clear();return}this.$hidden.val(""),this.$hidden.attr("name",""),this.$input.attr("name",this.name);if(this.type==="image"&&this.$preview.length>0&&(typeof n.type!="undefined"?n.type.match("image.*"):n.name.match(/\.(gif|png|jpe?g)$/i))&&typeof FileReader!="undefined"){var r=new FileReader,i=this.$preview,s=this.$element;r.onload=function(e){i.html('"),s.addClass("fileupload-exists").removeClass("fileupload-new")},r.readAsDataURL(n)}else this.$preview.text(n.name),this.$element.addClass("fileupload-exists").removeClass("fileupload-new")},clear:function(e){this.$hidden.val(""),this.$hidden.attr("name",this.name),this.$input.attr("name","");if(navigator.userAgent.match(/msie/i)){var t=this.$input.clone(!0);this.$input.after(t),this.$input.remove(),this.$input=t}else this.$input.val("");this.$preview.html(""),this.$element.addClass("fileupload-new").removeClass("fileupload-exists"),e&&(this.$input.trigger("change",["clear"]),e.preventDefault())},reset:function(e){this.clear(),this.$hidden.val(this.original.hiddenVal),this.$preview.html(this.original.preview),this.original.exists?this.$element.addClass("fileupload-exists").removeClass("fileupload-new"):this.$element.addClass("fileupload-new").removeClass("fileupload-exists")},trigger:function(e){this.$input.trigger("click"),e.preventDefault()}},e.fn.fileupload=function(n){return this.each(function(){var r=e(this),i=r.data("fileupload");i||r.data("fileupload",i=new t(this,n)),typeof n=="string"&&i[n]()})},e.fn.fileupload.Constructor=t,e(document).on("click.fileupload.data-api",'[data-provides="fileupload"]',function(t){var n=e(this);if(n.data("fileupload"))return;n.fileupload(n.data());var r=e(t.target).closest('[data-dismiss="fileupload"],[data-trigger="fileupload"]');r.length>0&&(r.trigger("click.fileupload"),t.preventDefault())})}(window.jQuery),!function(e){"use strict";var t=function(t,n){this.options=e.extend({},e.fn.affix.defaults,n),this.$window=e(window).on("scroll.affix.data-api",e.proxy(this.checkPosition,this)).on("click.affix.data-api",e.proxy(function(){setTimeout(e.proxy(this.checkPosition,this),1)},this)),this.$element=e(t),this.checkPosition()};t.prototype.checkPosition=function(){if(!this.$element.is(":visible"))return;var t=e(document).height(),n=this.$window.scrollTop(),r=this.$element.offset(),i=this.options.offset,s=i.bottom,o=i.top,u="affix affix-top affix-bottom",a;typeof i!="object"&&(s=o=i),typeof o=="function"&&(o=i.top()),typeof s=="function"&&(s=i.bottom()),a=this.unpin!=null&&n+this.unpin<=r.top?!1:s!=null&&r.top+this.$element.height()>=t-s?"bottom":o!=null&&n<=o?"top":!1;if(this.affixed===a)return;this.affixed=a,this.unpin=a=="bottom"?r.top-n:null,this.$element.removeClass(u).addClass("affix"+(a?"-"+a:""))};var n=e.fn.affix;e.fn.affix=function(n){return this.each(function(){var r=e(this),i=r.data("affix"),s=typeof n=="object"&&n;i||r.data("affix",i=new t(this,s)),typeof n=="string"&&i[n]()})},e.fn.affix.Constructor=t,e.fn.affix.defaults={offset:0},e.fn.affix.noConflict=function(){return e.fn.affix=n,this},e(window).on("load",function(){e('[data-spy="affix"]').each(function(){var t=e(this),n=t.data();n.offset=n.offset||{},n.offsetBottom&&(n.offset.bottom=n.offsetBottom),n.offsetTop&&(n.offset.top=n.offsetTop),t.affix(n)})})}(window.jQuery); \ No newline at end of file diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index 964bb58b22..99ac297eae 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -15,6 +15,7 @@ #set $myDB = $db.DBConnection() #set $today = str($datetime.date.today().toordinal()) #set $downloadedEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE (status IN ("+",".join([str(x) for x in $Quality.DOWNLOADED + [$ARCHIVED]])+") OR (status IN ("+",".join([str(x) for x in $Quality.SNATCHED + $Quality.SNATCHED_PROPER])+") AND location != '')) AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") +#set $snatchedEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE (status IN ("+",".join([str(x) for x in $Quality.DOWNLOADED + [$ARCHIVED]])+") OR (status IN ("+",".join([str(x) for x in $Quality.SNATCHED + $Quality.SNATCHED_PROPER])+") AND location != '')) AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") #set $allEps = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE season != 0 and episode != 0 AND (airdate != 1 OR status IN ("+",".join([str(x) for x in ($Quality.DOWNLOADED + $Quality.SNATCHED + $Quality.SNATCHED_PROPER) + [$ARCHIVED]])+")) AND airdate <= "+$today+" AND status != "+str($IGNORED)+" GROUP BY showid") #set $fr = $myDB.select("SELECT showid, COUNT(*) FROM tv_episodes WHERE audio_langs = 'fr' AND location != '' AND season != 0 and episode != 0 AND airdate <= "+$today+" GROUP BY showid") #set $layout = $sickbeard.HOME_LAYOUT @@ -235,6 +236,7 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name)) #set $curEp = $curShow.nextEpisode() #set $curShowDownloads = [x[1] for x in $downloadedEps if int(x[0]) == $curShow.tvdbid] +#set $curShowSnatched = [x[1] for x in $snatchedEps if int(x[0]) == $curShow.tvdbid] #set $curfr = [x[1] for x in $fr if int(x[0]) == $curShow.tvdbid] #set $curShowAll = [x[1] for x in $allEps if int(x[0]) == $curShow.tvdbid] #if len($curShowAll) != 0: @@ -310,7 +312,8 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name)) #else:
    #end if - - #else if $layout == 'simple': @@ -389,18 +390,18 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name))
    Flat Folders: \"Y"
    Paused: \"Y"
    Custom$dlStat
    +
    $dlStat +
    + +
    +
    +
    +
    +
    $frStat
    + + + - -
    -
    -
    -
    -
    +
    $frStat
    +
    $frStat + +
    +
    +
    +
    +
    + + +
    \"Y\"" diff --git a/data/interfaces/default/inc_top.tmpl b/data/interfaces/default/inc_top.tmpl index 735d64d4b9..d346341d6f 100644 --- a/data/interfaces/default/inc_top.tmpl +++ b/data/interfaces/default/inc_top.tmpl @@ -32,9 +32,7 @@ - - - - -

    %(status)s

    -

    %(message)s

    -
    %(traceback)s
    -
    - Powered by CherryPy %(version)s -
    - - -''' - -def get_error_page(status, **kwargs): - """Return an HTML page, containing a pretty error response. - - status should be an int or a str. - kwargs will be interpolated into the page template. - """ - import cherrypy - - try: - code, reason, message = _httputil.valid_status(status) - except ValueError, x: - raise cherrypy.HTTPError(500, x.args[0]) - - # We can't use setdefault here, because some - # callers send None for kwarg values. - if kwargs.get('status') is None: - kwargs['status'] = "%s %s" % (code, reason) - if kwargs.get('message') is None: - kwargs['message'] = message - if kwargs.get('traceback') is None: - kwargs['traceback'] = '' - if kwargs.get('version') is None: - kwargs['version'] = cherrypy.__version__ - - for k, v in kwargs.iteritems(): - if v is None: - kwargs[k] = "" - else: - kwargs[k] = _escape(kwargs[k]) - - # Use a custom template or callable for the error page? - pages = cherrypy.serving.request.error_page - error_page = pages.get(code) or pages.get('default') - if error_page: - try: - if callable(error_page): - return error_page(**kwargs) - else: - return open(error_page, 'rb').read() % kwargs - except: - e = _format_exception(*_exc_info())[-1] - m = kwargs['message'] - if m: - m += "
    " - m += "In addition, the custom error page failed:\n
    %s" % e - kwargs['message'] = m - - return _HTTPErrorTemplate % kwargs - - -_ie_friendly_error_sizes = { - 400: 512, 403: 256, 404: 512, 405: 256, - 406: 512, 408: 512, 409: 512, 410: 256, - 500: 512, 501: 512, 505: 512, - } - - -def _be_ie_unfriendly(status): - import cherrypy - response = cherrypy.serving.response - - # For some statuses, Internet Explorer 5+ shows "friendly error - # messages" instead of our response.body if the body is smaller - # than a given size. Fix this by returning a body over that size - # (by adding whitespace). - # See http://support.microsoft.com/kb/q218155/ - s = _ie_friendly_error_sizes.get(status, 0) - if s: - s += 1 - # Since we are issuing an HTTP error status, we assume that - # the entity is short, and we should just collapse it. - content = response.collapse_body() - l = len(content) - if l and l < s: - # IN ADDITION: the response must be written to IE - # in one chunk or it will still get replaced! Bah. - content = content + (" " * (s - l)) - response.body = content - response.headers[u'Content-Length'] = str(len(content)) - - -def format_exc(exc=None): - """Return exc (or sys.exc_info if None), formatted.""" - if exc is None: - exc = _exc_info() - if exc == (None, None, None): - return "" - import traceback - return "".join(traceback.format_exception(*exc)) - -def bare_error(extrabody=None): - """Produce status, headers, body for a critical error. - - Returns a triple without calling any other questionable functions, - so it should be as error-free as possible. Call it from an HTTP server - if you get errors outside of the request. - - If extrabody is None, a friendly but rather unhelpful error message - is set in the body. If extrabody is a string, it will be appended - as-is to the body. - """ - - # The whole point of this function is to be a last line-of-defense - # in handling errors. That is, it must not raise any errors itself; - # it cannot be allowed to fail. Therefore, don't add to it! - # In particular, don't call any other CP functions. - - body = "Unrecoverable error in the server." - if extrabody is not None: - if not isinstance(extrabody, str): - extrabody = extrabody.encode('utf-8') - body += "\n" + extrabody - - return ("500 Internal Server Error", - [('Content-Type', 'text/plain'), - ('Content-Length', str(len(body)))], - [body]) - - +"""Error classes for CherryPy.""" + +from cgi import escape as _escape +from sys import exc_info as _exc_info +from traceback import format_exception as _format_exception +from urlparse import urljoin as _urljoin +from cherrypy.lib import httputil as _httputil + + +class CherryPyException(Exception): + pass + + +class TimeoutError(CherryPyException): + """Exception raised when Response.timed_out is detected.""" + pass + + +class InternalRedirect(CherryPyException): + """Exception raised to switch to the handler for a different URL. + + Any request.params must be supplied in a query string. + """ + + def __init__(self, path, query_string=""): + import cherrypy + self.request = cherrypy.serving.request + + self.query_string = query_string + if "?" in path: + # Separate any params included in the path + path, self.query_string = path.split("?", 1) + + # Note that urljoin will "do the right thing" whether url is: + # 1. a URL relative to root (e.g. "/dummy") + # 2. a URL relative to the current path + # Note that any query string will be discarded. + path = _urljoin(self.request.path_info, path) + + # Set a 'path' member attribute so that code which traps this + # error can have access to it. + self.path = path + + CherryPyException.__init__(self, path, self.query_string) + + +class HTTPRedirect(CherryPyException): + """Exception raised when the request should be redirected. + + The new URL must be passed as the first argument to the Exception, + e.g., HTTPRedirect(newUrl). Multiple URLs are allowed. If a URL is + absolute, it will be used as-is. If it is relative, it is assumed + to be relative to the current cherrypy.request.path_info. + """ + + def __init__(self, urls, status=None): + import cherrypy + request = cherrypy.serving.request + + if isinstance(urls, basestring): + urls = [urls] + + abs_urls = [] + for url in urls: + # Note that urljoin will "do the right thing" whether url is: + # 1. a complete URL with host (e.g. "http://www.example.com/test") + # 2. a URL relative to root (e.g. "/dummy") + # 3. a URL relative to the current path + # Note that any query string in cherrypy.request is discarded. + url = _urljoin(cherrypy.url(), url) + abs_urls.append(url) + self.urls = abs_urls + + # RFC 2616 indicates a 301 response code fits our goal; however, + # browser support for 301 is quite messy. Do 302/303 instead. See + # http://www.alanflavell.org.uk/www/post-redirect.html + if status is None: + if request.protocol >= (1, 1): + status = 303 + else: + status = 302 + else: + status = int(status) + if status < 300 or status > 399: + raise ValueError("status must be between 300 and 399.") + + self.status = status + CherryPyException.__init__(self, abs_urls, status) + + def set_response(self): + """Modify cherrypy.response status, headers, and body to represent self. + + CherryPy uses this internally, but you can also use it to create an + HTTPRedirect object and set its output without *raising* the exception. + """ + import cherrypy + response = cherrypy.serving.response + response.status = status = self.status + + if status in (300, 301, 302, 303, 307): + response.headers['Content-Type'] = "text/html;charset=utf-8" + # "The ... URI SHOULD be given by the Location field + # in the response." + response.headers['Location'] = self.urls[0] + + # "Unless the request method was HEAD, the entity of the response + # SHOULD contain a short hypertext note with a hyperlink to the + # new URI(s)." + msg = {300: "This resource can be found at %s.", + 301: "This resource has permanently moved to %s.", + 302: "This resource resides temporarily at %s.", + 303: "This resource can be found at %s.", + 307: "This resource has moved temporarily to %s.", + }[status] + msgs = [msg % (u, u) for u in self.urls] + response.body = "
    \n".join(msgs) + # Previous code may have set C-L, so we have to reset it + # (allow finalize to set it). + response.headers.pop('Content-Length', None) + elif status == 304: + # Not Modified. + # "The response MUST include the following header fields: + # Date, unless its omission is required by section 14.18.1" + # The "Date" header should have been set in Response.__init__ + + # "...the response SHOULD NOT include other entity-headers." + for key in ('Allow', 'Content-Encoding', 'Content-Language', + 'Content-Length', 'Content-Location', 'Content-MD5', + 'Content-Range', 'Content-Type', 'Expires', + 'Last-Modified'): + if key in response.headers: + del response.headers[key] + + # "The 304 response MUST NOT contain a message-body." + response.body = None + # Previous code may have set C-L, so we have to reset it. + response.headers.pop('Content-Length', None) + elif status == 305: + # Use Proxy. + # self.urls[0] should be the URI of the proxy. + response.headers['Location'] = self.urls[0] + response.body = None + # Previous code may have set C-L, so we have to reset it. + response.headers.pop('Content-Length', None) + else: + raise ValueError("The %s status code is unknown." % status) + + def __call__(self): + """Use this exception as a request.handler (raise self).""" + raise self + + +def clean_headers(status): + """Remove any headers which should not apply to an error response.""" + import cherrypy + + response = cherrypy.serving.response + + # Remove headers which applied to the original content, + # but do not apply to the error page. + respheaders = response.headers + for key in ["Accept-Ranges", "Age", "ETag", "Location", "Retry-After", + "Vary", "Content-Encoding", "Content-Length", "Expires", + "Content-Location", "Content-MD5", "Last-Modified"]: + if key in respheaders: + del respheaders[key] + + if status != 416: + # A server sending a response with status code 416 (Requested + # range not satisfiable) SHOULD include a Content-Range field + # with a byte-range-resp-spec of "*". The instance-length + # specifies the current length of the selected resource. + # A response with status code 206 (Partial Content) MUST NOT + # include a Content-Range field with a byte-range- resp-spec of "*". + if "Content-Range" in respheaders: + del respheaders["Content-Range"] + + +class HTTPError(CherryPyException): + """ Exception used to return an HTTP error code (4xx-5xx) to the client. + This exception will automatically set the response status and body. + + A custom message (a long description to display in the browser) + can be provided in place of the default. + """ + + def __init__(self, status=500, message=None): + self.status = status + try: + self.code, self.reason, defaultmsg = _httputil.valid_status(status) + except ValueError, x: + raise self.__class__(500, x.args[0]) + + if self.code < 400 or self.code > 599: + raise ValueError("status must be between 400 and 599.") + + # See http://www.python.org/dev/peps/pep-0352/ + # self.message = message + self._message = message or defaultmsg + CherryPyException.__init__(self, status, message) + + def set_response(self): + """Modify cherrypy.response status, headers, and body to represent self. + + CherryPy uses this internally, but you can also use it to create an + HTTPError object and set its output without *raising* the exception. + """ + import cherrypy + + response = cherrypy.serving.response + + clean_headers(self.code) + + # In all cases, finalize will be called after this method, + # so don't bother cleaning up response values here. + response.status = self.status + tb = None + if cherrypy.serving.request.show_tracebacks: + tb = format_exc() + response.headers['Content-Type'] = "text/html;charset=utf-8" + response.headers.pop('Content-Length', None) + + content = self.get_error_page(self.status, traceback=tb, + message=self._message) + response.body = content + + _be_ie_unfriendly(self.code) + + def get_error_page(self, *args, **kwargs): + return get_error_page(*args, **kwargs) + + def __call__(self): + """Use this exception as a request.handler (raise self).""" + raise self + + +class NotFound(HTTPError): + """Exception raised when a URL could not be mapped to any handler (404).""" + + def __init__(self, path=None): + if path is None: + import cherrypy + request = cherrypy.serving.request + path = request.script_name + request.path_info + self.args = (path,) + HTTPError.__init__(self, 404, "The path '%s' was not found." % path) + + +_HTTPErrorTemplate = ''' + + + + %(status)s + + + +

    %(status)s

    +

    %(message)s

    +
    %(traceback)s
    +
    + Powered by CherryPy %(version)s +
    + + +''' + +def get_error_page(status, **kwargs): + """Return an HTML page, containing a pretty error response. + + status should be an int or a str. + kwargs will be interpolated into the page template. + """ + import cherrypy + + try: + code, reason, message = _httputil.valid_status(status) + except ValueError, x: + raise cherrypy.HTTPError(500, x.args[0]) + + # We can't use setdefault here, because some + # callers send None for kwarg values. + if kwargs.get('status') is None: + kwargs['status'] = "%s %s" % (code, reason) + if kwargs.get('message') is None: + kwargs['message'] = message + if kwargs.get('traceback') is None: + kwargs['traceback'] = '' + if kwargs.get('version') is None: + kwargs['version'] = cherrypy.__version__ + + for k, v in kwargs.iteritems(): + if v is None: + kwargs[k] = "" + else: + kwargs[k] = _escape(kwargs[k]) + + # Use a custom template or callable for the error page? + pages = cherrypy.serving.request.error_page + error_page = pages.get(code) or pages.get('default') + if error_page: + try: + if callable(error_page): + return error_page(**kwargs) + else: + return open(error_page, 'rb').read() % kwargs + except: + e = _format_exception(*_exc_info())[-1] + m = kwargs['message'] + if m: + m += "
    " + m += "In addition, the custom error page failed:\n
    %s" % e + kwargs['message'] = m + + return _HTTPErrorTemplate % kwargs + + +_ie_friendly_error_sizes = { + 400: 512, 403: 256, 404: 512, 405: 256, + 406: 512, 408: 512, 409: 512, 410: 256, + 500: 512, 501: 512, 505: 512, + } + + +def _be_ie_unfriendly(status): + import cherrypy + response = cherrypy.serving.response + + # For some statuses, Internet Explorer 5+ shows "friendly error + # messages" instead of our response.body if the body is smaller + # than a given size. Fix this by returning a body over that size + # (by adding whitespace). + # See http://support.microsoft.com/kb/q218155/ + s = _ie_friendly_error_sizes.get(status, 0) + if s: + s += 1 + # Since we are issuing an HTTP error status, we assume that + # the entity is short, and we should just collapse it. + content = response.collapse_body() + l = len(content) + if l and l < s: + # IN ADDITION: the response must be written to IE + # in one chunk or it will still get replaced! Bah. + content = content + (" " * (s - l)) + response.body = content + response.headers[u'Content-Length'] = str(len(content)) + + +def format_exc(exc=None): + """Return exc (or sys.exc_info if None), formatted.""" + if exc is None: + exc = _exc_info() + if exc == (None, None, None): + return "" + import traceback + return "".join(traceback.format_exception(*exc)) + +def bare_error(extrabody=None): + """Produce status, headers, body for a critical error. + + Returns a triple without calling any other questionable functions, + so it should be as error-free as possible. Call it from an HTTP server + if you get errors outside of the request. + + If extrabody is None, a friendly but rather unhelpful error message + is set in the body. If extrabody is a string, it will be appended + as-is to the body. + """ + + # The whole point of this function is to be a last line-of-defense + # in handling errors. That is, it must not raise any errors itself; + # it cannot be allowed to fail. Therefore, don't add to it! + # In particular, don't call any other CP functions. + + body = "Unrecoverable error in the server." + if extrabody is not None: + if not isinstance(extrabody, str): + extrabody = extrabody.encode('utf-8') + body += "\n" + extrabody + + return ("500 Internal Server Error", + [('Content-Type', 'text/plain'), + ('Content-Length', str(len(body)))], + [body]) + + diff --git a/cherrypy/_cpmodpy.py b/cherrypy/_cpmodpy.py index 1bec8a1e30..c2a66db38e 100644 --- a/cherrypy/_cpmodpy.py +++ b/cherrypy/_cpmodpy.py @@ -1,333 +1,333 @@ -"""Native adapter for serving CherryPy via mod_python - -Basic usage: - -########################################## -# Application in a module called myapp.py -########################################## - -import cherrypy - -class Root: - @cherrypy.expose - def index(self): - return 'Hi there, Ho there, Hey there' - - -# We will use this method from the mod_python configuration -# as the entry point to our application -def setup_server(): - cherrypy.tree.mount(Root()) - cherrypy.config.update({'environment': 'production', - 'log.screen': False, - 'show_tracebacks': False}) - -########################################## -# mod_python settings for apache2 -# This should reside in your httpd.conf -# or a file that will be loaded at -# apache startup -########################################## - -# Start -DocumentRoot "/" -Listen 8080 -LoadModule python_module /usr/lib/apache2/modules/mod_python.so - - - PythonPath "sys.path+['/path/to/my/application']" - SetHandler python-program - PythonHandler cherrypy._cpmodpy::handler - PythonOption cherrypy.setup myapp::setup_server - PythonDebug On - -# End - -The actual path to your mod_python.so is dependent on your -environment. In this case we suppose a global mod_python -installation on a Linux distribution such as Ubuntu. - -We do set the PythonPath configuration setting so that -your application can be found by from the user running -the apache2 instance. Of course if your application -resides in the global site-package this won't be needed. - -Then restart apache2 and access http://127.0.0.1:8080 -""" - -import logging -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - -import cherrypy -from cherrypy._cperror import format_exc, bare_error -from cherrypy.lib import httputil - - -# ------------------------------ Request-handling - - - -def setup(req): - from mod_python import apache - - # Run any setup functions defined by a "PythonOption cherrypy.setup" directive. - options = req.get_options() - if 'cherrypy.setup' in options: - for function in options['cherrypy.setup'].split(): - atoms = function.split('::', 1) - if len(atoms) == 1: - mod = __import__(atoms[0], globals(), locals()) - else: - modname, fname = atoms - mod = __import__(modname, globals(), locals(), [fname]) - func = getattr(mod, fname) - func() - - cherrypy.config.update({'log.screen': False, - "tools.ignore_headers.on": True, - "tools.ignore_headers.headers": ['Range'], - }) - - engine = cherrypy.engine - if hasattr(engine, "signal_handler"): - engine.signal_handler.unsubscribe() - if hasattr(engine, "console_control_handler"): - engine.console_control_handler.unsubscribe() - engine.autoreload.unsubscribe() - cherrypy.server.unsubscribe() - - def _log(msg, level): - newlevel = apache.APLOG_ERR - if logging.DEBUG >= level: - newlevel = apache.APLOG_DEBUG - elif logging.INFO >= level: - newlevel = apache.APLOG_INFO - elif logging.WARNING >= level: - newlevel = apache.APLOG_WARNING - # On Windows, req.server is required or the msg will vanish. See - # http://www.modpython.org/pipermail/mod_python/2003-October/014291.html. - # Also, "When server is not specified...LogLevel does not apply..." - apache.log_error(msg, newlevel, req.server) - engine.subscribe('log', _log) - - engine.start() - - def cherrypy_cleanup(data): - engine.exit() - try: - # apache.register_cleanup wasn't available until 3.1.4. - apache.register_cleanup(cherrypy_cleanup) - except AttributeError: - req.server.register_cleanup(req, cherrypy_cleanup) - - -class _ReadOnlyRequest: - expose = ('read', 'readline', 'readlines') - def __init__(self, req): - for method in self.expose: - self.__dict__[method] = getattr(req, method) - - -recursive = False - -_isSetUp = False -def handler(req): - from mod_python import apache - try: - global _isSetUp - if not _isSetUp: - setup(req) - _isSetUp = True - - # Obtain a Request object from CherryPy - local = req.connection.local_addr - local = httputil.Host(local[0], local[1], req.connection.local_host or "") - remote = req.connection.remote_addr - remote = httputil.Host(remote[0], remote[1], req.connection.remote_host or "") - - scheme = req.parsed_uri[0] or 'http' - req.get_basic_auth_pw() - - try: - # apache.mpm_query only became available in mod_python 3.1 - q = apache.mpm_query - threaded = q(apache.AP_MPMQ_IS_THREADED) - forked = q(apache.AP_MPMQ_IS_FORKED) - except AttributeError: - bad_value = ("You must provide a PythonOption '%s', " - "either 'on' or 'off', when running a version " - "of mod_python < 3.1") - - threaded = options.get('multithread', '').lower() - if threaded == 'on': - threaded = True - elif threaded == 'off': - threaded = False - else: - raise ValueError(bad_value % "multithread") - - forked = options.get('multiprocess', '').lower() - if forked == 'on': - forked = True - elif forked == 'off': - forked = False - else: - raise ValueError(bad_value % "multiprocess") - - sn = cherrypy.tree.script_name(req.uri or "/") - if sn is None: - send_response(req, '404 Not Found', [], '') - else: - app = cherrypy.tree.apps[sn] - method = req.method - path = req.uri - qs = req.args or "" - reqproto = req.protocol - headers = req.headers_in.items() - rfile = _ReadOnlyRequest(req) - prev = None - - try: - redirections = [] - while True: - request, response = app.get_serving(local, remote, scheme, - "HTTP/1.1") - request.login = req.user - request.multithread = bool(threaded) - request.multiprocess = bool(forked) - request.app = app - request.prev = prev - - # Run the CherryPy Request object and obtain the response - try: - request.run(method, path, qs, reqproto, headers, rfile) - break - except cherrypy.InternalRedirect, ir: - app.release_serving() - prev = request - - if not recursive: - if ir.path in redirections: - raise RuntimeError("InternalRedirector visited the " - "same URL twice: %r" % ir.path) - else: - # Add the *previous* path_info + qs to redirections. - if qs: - qs = "?" + qs - redirections.append(sn + path + qs) - - # Munge environment and try again. - method = "GET" - path = ir.path - qs = ir.query_string - rfile = StringIO() - - send_response(req, response.status, response.header_list, - response.body, response.stream) - finally: - app.release_serving() - except: - tb = format_exc() - cherrypy.log(tb, 'MOD_PYTHON', severity=logging.ERROR) - s, h, b = bare_error() - send_response(req, s, h, b) - return apache.OK - - -def send_response(req, status, headers, body, stream=False): - # Set response status - req.status = int(status[:3]) - - # Set response headers - req.content_type = "text/plain" - for header, value in headers: - if header.lower() == 'content-type': - req.content_type = value - continue - req.headers_out.add(header, value) - - if stream: - # Flush now so the status and headers are sent immediately. - req.flush() - - # Set response body - if isinstance(body, basestring): - req.write(body) - else: - for seg in body: - req.write(seg) - - - -# --------------- Startup tools for CherryPy + mod_python --------------- # - - -import os -import re - - -def read_process(cmd, args=""): - pipein, pipeout = os.popen4("%s %s" % (cmd, args)) - try: - firstline = pipeout.readline() - if (re.search(r"(not recognized|No such file|not found)", firstline, - re.IGNORECASE)): - raise IOError('%s must be on your system path.' % cmd) - output = firstline + pipeout.read() - finally: - pipeout.close() - return output - - -class ModPythonServer(object): - - template = """ -# Apache2 server configuration file for running CherryPy with mod_python. - -DocumentRoot "/" -Listen %(port)s -LoadModule python_module modules/mod_python.so - - - SetHandler python-program - PythonHandler %(handler)s - PythonDebug On -%(opts)s - -""" - - def __init__(self, loc="/", port=80, opts=None, apache_path="apache", - handler="cherrypy._cpmodpy::handler"): - self.loc = loc - self.port = port - self.opts = opts - self.apache_path = apache_path - self.handler = handler - - def start(self): - opts = "".join([" PythonOption %s %s\n" % (k, v) - for k, v in self.opts]) - conf_data = self.template % {"port": self.port, - "loc": self.loc, - "opts": opts, - "handler": self.handler, - } - - mpconf = os.path.join(os.path.dirname(__file__), "cpmodpy.conf") - f = open(mpconf, 'wb') - try: - f.write(conf_data) - finally: - f.close() - - response = read_process(self.apache_path, "-k start -f %s" % mpconf) - self.ready = True - return response - - def stop(self): - os.popen("apache -k stop") - self.ready = False - +"""Native adapter for serving CherryPy via mod_python + +Basic usage: + +########################################## +# Application in a module called myapp.py +########################################## + +import cherrypy + +class Root: + @cherrypy.expose + def index(self): + return 'Hi there, Ho there, Hey there' + + +# We will use this method from the mod_python configuration +# as the entry point to our application +def setup_server(): + cherrypy.tree.mount(Root()) + cherrypy.config.update({'environment': 'production', + 'log.screen': False, + 'show_tracebacks': False}) + +########################################## +# mod_python settings for apache2 +# This should reside in your httpd.conf +# or a file that will be loaded at +# apache startup +########################################## + +# Start +DocumentRoot "/" +Listen 8080 +LoadModule python_module /usr/lib/apache2/modules/mod_python.so + + + PythonPath "sys.path+['/path/to/my/application']" + SetHandler python-program + PythonHandler cherrypy._cpmodpy::handler + PythonOption cherrypy.setup myapp::setup_server + PythonDebug On + +# End + +The actual path to your mod_python.so is dependent on your +environment. In this case we suppose a global mod_python +installation on a Linux distribution such as Ubuntu. + +We do set the PythonPath configuration setting so that +your application can be found by from the user running +the apache2 instance. Of course if your application +resides in the global site-package this won't be needed. + +Then restart apache2 and access http://127.0.0.1:8080 +""" + +import logging +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +import cherrypy +from cherrypy._cperror import format_exc, bare_error +from cherrypy.lib import httputil + + +# ------------------------------ Request-handling + + + +def setup(req): + from mod_python import apache + + # Run any setup functions defined by a "PythonOption cherrypy.setup" directive. + options = req.get_options() + if 'cherrypy.setup' in options: + for function in options['cherrypy.setup'].split(): + atoms = function.split('::', 1) + if len(atoms) == 1: + mod = __import__(atoms[0], globals(), locals()) + else: + modname, fname = atoms + mod = __import__(modname, globals(), locals(), [fname]) + func = getattr(mod, fname) + func() + + cherrypy.config.update({'log.screen': False, + "tools.ignore_headers.on": True, + "tools.ignore_headers.headers": ['Range'], + }) + + engine = cherrypy.engine + if hasattr(engine, "signal_handler"): + engine.signal_handler.unsubscribe() + if hasattr(engine, "console_control_handler"): + engine.console_control_handler.unsubscribe() + engine.autoreload.unsubscribe() + cherrypy.server.unsubscribe() + + def _log(msg, level): + newlevel = apache.APLOG_ERR + if logging.DEBUG >= level: + newlevel = apache.APLOG_DEBUG + elif logging.INFO >= level: + newlevel = apache.APLOG_INFO + elif logging.WARNING >= level: + newlevel = apache.APLOG_WARNING + # On Windows, req.server is required or the msg will vanish. See + # http://www.modpython.org/pipermail/mod_python/2003-October/014291.html. + # Also, "When server is not specified...LogLevel does not apply..." + apache.log_error(msg, newlevel, req.server) + engine.subscribe('log', _log) + + engine.start() + + def cherrypy_cleanup(data): + engine.exit() + try: + # apache.register_cleanup wasn't available until 3.1.4. + apache.register_cleanup(cherrypy_cleanup) + except AttributeError: + req.server.register_cleanup(req, cherrypy_cleanup) + + +class _ReadOnlyRequest: + expose = ('read', 'readline', 'readlines') + def __init__(self, req): + for method in self.expose: + self.__dict__[method] = getattr(req, method) + + +recursive = False + +_isSetUp = False +def handler(req): + from mod_python import apache + try: + global _isSetUp + if not _isSetUp: + setup(req) + _isSetUp = True + + # Obtain a Request object from CherryPy + local = req.connection.local_addr + local = httputil.Host(local[0], local[1], req.connection.local_host or "") + remote = req.connection.remote_addr + remote = httputil.Host(remote[0], remote[1], req.connection.remote_host or "") + + scheme = req.parsed_uri[0] or 'http' + req.get_basic_auth_pw() + + try: + # apache.mpm_query only became available in mod_python 3.1 + q = apache.mpm_query + threaded = q(apache.AP_MPMQ_IS_THREADED) + forked = q(apache.AP_MPMQ_IS_FORKED) + except AttributeError: + bad_value = ("You must provide a PythonOption '%s', " + "either 'on' or 'off', when running a version " + "of mod_python < 3.1") + + threaded = options.get('multithread', '').lower() + if threaded == 'on': + threaded = True + elif threaded == 'off': + threaded = False + else: + raise ValueError(bad_value % "multithread") + + forked = options.get('multiprocess', '').lower() + if forked == 'on': + forked = True + elif forked == 'off': + forked = False + else: + raise ValueError(bad_value % "multiprocess") + + sn = cherrypy.tree.script_name(req.uri or "/") + if sn is None: + send_response(req, '404 Not Found', [], '') + else: + app = cherrypy.tree.apps[sn] + method = req.method + path = req.uri + qs = req.args or "" + reqproto = req.protocol + headers = req.headers_in.items() + rfile = _ReadOnlyRequest(req) + prev = None + + try: + redirections = [] + while True: + request, response = app.get_serving(local, remote, scheme, + "HTTP/1.1") + request.login = req.user + request.multithread = bool(threaded) + request.multiprocess = bool(forked) + request.app = app + request.prev = prev + + # Run the CherryPy Request object and obtain the response + try: + request.run(method, path, qs, reqproto, headers, rfile) + break + except cherrypy.InternalRedirect, ir: + app.release_serving() + prev = request + + if not recursive: + if ir.path in redirections: + raise RuntimeError("InternalRedirector visited the " + "same URL twice: %r" % ir.path) + else: + # Add the *previous* path_info + qs to redirections. + if qs: + qs = "?" + qs + redirections.append(sn + path + qs) + + # Munge environment and try again. + method = "GET" + path = ir.path + qs = ir.query_string + rfile = StringIO() + + send_response(req, response.status, response.header_list, + response.body, response.stream) + finally: + app.release_serving() + except: + tb = format_exc() + cherrypy.log(tb, 'MOD_PYTHON', severity=logging.ERROR) + s, h, b = bare_error() + send_response(req, s, h, b) + return apache.OK + + +def send_response(req, status, headers, body, stream=False): + # Set response status + req.status = int(status[:3]) + + # Set response headers + req.content_type = "text/plain" + for header, value in headers: + if header.lower() == 'content-type': + req.content_type = value + continue + req.headers_out.add(header, value) + + if stream: + # Flush now so the status and headers are sent immediately. + req.flush() + + # Set response body + if isinstance(body, basestring): + req.write(body) + else: + for seg in body: + req.write(seg) + + + +# --------------- Startup tools for CherryPy + mod_python --------------- # + + +import os +import re + + +def read_process(cmd, args=""): + pipein, pipeout = os.popen4("%s %s" % (cmd, args)) + try: + firstline = pipeout.readline() + if (re.search(r"(not recognized|No such file|not found)", firstline, + re.IGNORECASE)): + raise IOError('%s must be on your system path.' % cmd) + output = firstline + pipeout.read() + finally: + pipeout.close() + return output + + +class ModPythonServer(object): + + template = """ +# Apache2 server configuration file for running CherryPy with mod_python. + +DocumentRoot "/" +Listen %(port)s +LoadModule python_module modules/mod_python.so + + + SetHandler python-program + PythonHandler %(handler)s + PythonDebug On +%(opts)s + +""" + + def __init__(self, loc="/", port=80, opts=None, apache_path="apache", + handler="cherrypy._cpmodpy::handler"): + self.loc = loc + self.port = port + self.opts = opts + self.apache_path = apache_path + self.handler = handler + + def start(self): + opts = "".join([" PythonOption %s %s\n" % (k, v) + for k, v in self.opts]) + conf_data = self.template % {"port": self.port, + "loc": self.loc, + "opts": opts, + "handler": self.handler, + } + + mpconf = os.path.join(os.path.dirname(__file__), "cpmodpy.conf") + f = open(mpconf, 'wb') + try: + f.write(conf_data) + finally: + f.close() + + response = read_process(self.apache_path, "-k start -f %s" % mpconf) + self.ready = True + return response + + def stop(self): + os.popen("apache -k stop") + self.ready = False + diff --git a/cherrypy/_cpreqbody.py b/cherrypy/_cpreqbody.py index 0df0e21b2b..3514f09806 100644 --- a/cherrypy/_cpreqbody.py +++ b/cherrypy/_cpreqbody.py @@ -1,723 +1,723 @@ -"""Request body processing for CherryPy. - -When an HTTP request includes an entity body, it is often desirable to -provide that information to applications in a form other than the raw bytes. -Different content types demand different approaches. Examples: - - * For a GIF file, we want the raw bytes in a stream. - * An HTML form is better parsed into its component fields, and each text field - decoded from bytes to unicode. - * A JSON body should be deserialized into a Python dict or list. - -When the request contains a Content-Type header, the media type is used as a -key to look up a value in the 'request.body.processors' dict. If the full media -type is not found, then the major type is tried; for example, if no processor -is found for the 'image/jpeg' type, then we look for a processor for the 'image' -types altogether. If neither the full type nor the major type has a matching -processor, then a default processor is used (self.default_proc). For most -types, this means no processing is done, and the body is left unread as a -raw byte stream. Processors are configurable in an 'on_start_resource' hook. - -Some processors, especially those for the 'text' types, attempt to decode bytes -to unicode. If the Content-Type request header includes a 'charset' parameter, -this is used to decode the entity. Otherwise, one or more default charsets may -be attempted, although this decision is up to each processor. If a processor -successfully decodes an Entity or Part, it should set the 'charset' attribute -on the Entity or Part to the name of the successful charset, so that -applications can easily re-encode or transcode the value if they wish. - -If the Content-Type of the request entity is of major type 'multipart', then -the above parsing process, and possibly a decoding process, is performed for -each part. - -For both the full entity and multipart parts, a Content-Disposition header may -be used to fill .name and .filename attributes on the request.body or the Part. -""" - -import re -import tempfile -from urllib import unquote_plus - -import cherrypy -from cherrypy.lib import httputil - - -# -------------------------------- Processors -------------------------------- # - -def process_urlencoded(entity): - """Read application/x-www-form-urlencoded data into entity.params.""" - qs = entity.fp.read() - for charset in entity.attempt_charsets: - try: - params = {} - for aparam in qs.split('&'): - for pair in aparam.split(';'): - if not pair: - continue - - atoms = pair.split('=', 1) - if len(atoms) == 1: - atoms.append('') - - key = unquote_plus(atoms[0]).decode(charset) - value = unquote_plus(atoms[1]).decode(charset) - - if key in params: - if not isinstance(params[key], list): - params[key] = [params[key]] - params[key].append(value) - else: - params[key] = value - except UnicodeDecodeError: - pass - else: - entity.charset = charset - break - else: - raise cherrypy.HTTPError( - 400, "The request entity could not be decoded. The following " - "charsets were attempted: %s" % repr(entity.attempt_charsets)) - - # Now that all values have been successfully parsed and decoded, - # apply them to the entity.params dict. - for key, value in params.items(): - if key in entity.params: - if not isinstance(entity.params[key], list): - entity.params[key] = [entity.params[key]] - entity.params[key].append(value) - else: - entity.params[key] = value - - -def process_multipart(entity): - """Read all multipart parts into entity.parts.""" - ib = u"" - if u'boundary' in entity.content_type.params: - # http://tools.ietf.org/html/rfc2046#section-5.1.1 - # "The grammar for parameters on the Content-type field is such that it - # is often necessary to enclose the boundary parameter values in quotes - # on the Content-type line" - ib = entity.content_type.params['boundary'].strip(u'"') - - if not re.match(u"^[ -~]{0,200}[!-~]$", ib): - raise ValueError(u'Invalid boundary in multipart form: %r' % (ib,)) - - ib = (u'--' + ib).encode('ascii') - - # Find the first marker - while True: - b = entity.readline() - if not b: - return - - b = b.strip() - if b == ib: - break - - # Read all parts - while True: - part = entity.part_class.from_fp(entity.fp, ib) - entity.parts.append(part) - part.process() - if part.fp.done: - break - -def process_multipart_form_data(entity): - """Read all multipart/form-data parts into entity.parts or entity.params.""" - process_multipart(entity) - - kept_parts = [] - for part in entity.parts: - if part.name is None: - kept_parts.append(part) - else: - if part.filename is None: - # It's a regular field - entity.params[part.name] = part.fullvalue() - else: - # It's a file upload. Retain the whole part so consumer code - # has access to its .file and .filename attributes. - entity.params[part.name] = part - - entity.parts = kept_parts - -def _old_process_multipart(entity): - """The behavior of 3.2 and lower. Deprecated and will be changed in 3.3.""" - process_multipart(entity) - - params = entity.params - - for part in entity.parts: - if part.name is None: - key = u'parts' - else: - key = part.name - - if part.filename is None: - # It's a regular field - value = part.fullvalue() - else: - # It's a file upload. Retain the whole part so consumer code - # has access to its .file and .filename attributes. - value = part - - if key in params: - if not isinstance(params[key], list): - params[key] = [params[key]] - params[key].append(value) - else: - params[key] = value - - - -# --------------------------------- Entities --------------------------------- # - - -class Entity(object): - """An HTTP request body, or MIME multipart body.""" - - __metaclass__ = cherrypy._AttributeDocstrings - - params = None - params__doc = u""" - If the request Content-Type is 'application/x-www-form-urlencoded' or - multipart, this will be a dict of the params pulled from the entity - body; that is, it will be the portion of request.params that come - from the message body (sometimes called "POST params", although they - can be sent with various HTTP method verbs). This value is set between - the 'before_request_body' and 'before_handler' hooks (assuming that - process_request_body is True).""" - - default_content_type = u'application/x-www-form-urlencoded' - # http://tools.ietf.org/html/rfc2046#section-4.1.2: - # "The default character set, which must be assumed in the - # absence of a charset parameter, is US-ASCII." - # However, many browsers send data in utf-8 with no charset. - attempt_charsets = [u'utf-8'] - processors = {u'application/x-www-form-urlencoded': process_urlencoded, - u'multipart/form-data': process_multipart_form_data, - u'multipart': process_multipart, - } - - def __init__(self, fp, headers, params=None, parts=None): - # Make an instance-specific copy of the class processors - # so Tools, etc. can replace them per-request. - self.processors = self.processors.copy() - - self.fp = fp - self.headers = headers - - if params is None: - params = {} - self.params = params - - if parts is None: - parts = [] - self.parts = parts - - # Content-Type - self.content_type = headers.elements(u'Content-Type') - if self.content_type: - self.content_type = self.content_type[0] - else: - self.content_type = httputil.HeaderElement.from_str( - self.default_content_type) - - # Copy the class 'attempt_charsets', prepending any Content-Type charset - dec = self.content_type.params.get(u"charset", None) - if dec: - dec = dec.decode('ISO-8859-1') - self.attempt_charsets = [dec] + [c for c in self.attempt_charsets - if c != dec] - else: - self.attempt_charsets = self.attempt_charsets[:] - - # Length - self.length = None - clen = headers.get(u'Content-Length', None) - # If Transfer-Encoding is 'chunked', ignore any Content-Length. - if clen is not None and 'chunked' not in headers.get(u'Transfer-Encoding', ''): - try: - self.length = int(clen) - except ValueError: - pass - - # Content-Disposition - self.name = None - self.filename = None - disp = headers.elements(u'Content-Disposition') - if disp: - disp = disp[0] - if 'name' in disp.params: - self.name = disp.params['name'] - if self.name.startswith(u'"') and self.name.endswith(u'"'): - self.name = self.name[1:-1] - if 'filename' in disp.params: - self.filename = disp.params['filename'] - if self.filename.startswith(u'"') and self.filename.endswith(u'"'): - self.filename = self.filename[1:-1] - - # The 'type' attribute is deprecated in 3.2; remove it in 3.3. - type = property(lambda self: self.content_type) - - def read(self, size=None, fp_out=None): - return self.fp.read(size, fp_out) - - def readline(self, size=None): - return self.fp.readline(size) - - def readlines(self, sizehint=None): - return self.fp.readlines(sizehint) - - def __iter__(self): - return self - - def next(self): - line = self.readline() - if not line: - raise StopIteration - return line - - def read_into_file(self, fp_out=None): - """Read the request body into fp_out (or make_file() if None). Return fp_out.""" - if fp_out is None: - fp_out = self.make_file() - self.read(fp_out=fp_out) - return fp_out - - def make_file(self): - """Return a file into which the request body will be read. - - By default, this will return a TemporaryFile. Override as needed.""" - return tempfile.TemporaryFile() - - def fullvalue(self): - """Return this entity as a string, whether stored in a file or not.""" - if self.file: - # It was stored in a tempfile. Read it. - self.file.seek(0) - value = self.file.read() - self.file.seek(0) - else: - value = self.value - return value - - def process(self): - """Execute the best-match processor for the given media type.""" - proc = None - ct = self.content_type.value - try: - proc = self.processors[ct] - except KeyError: - toptype = ct.split(u'/', 1)[0] - try: - proc = self.processors[toptype] - except KeyError: - pass - if proc is None: - self.default_proc() - else: - proc(self) - - def default_proc(self): - # Leave the fp alone for someone else to read. This works fine - # for request.body, but the Part subclasses need to override this - # so they can move on to the next part. - pass - - -class Part(Entity): - """A MIME part entity, part of a multipart entity.""" - - default_content_type = u'text/plain' - # "The default character set, which must be assumed in the absence of a - # charset parameter, is US-ASCII." - attempt_charsets = [u'us-ascii', u'utf-8'] - # This is the default in stdlib cgi. We may want to increase it. - maxrambytes = 1000 - - def __init__(self, fp, headers, boundary): - Entity.__init__(self, fp, headers) - self.boundary = boundary - self.file = None - self.value = None - - def from_fp(cls, fp, boundary): - headers = cls.read_headers(fp) - return cls(fp, headers, boundary) - from_fp = classmethod(from_fp) - - def read_headers(cls, fp): - headers = httputil.HeaderMap() - while True: - line = fp.readline() - if not line: - # No more data--illegal end of headers - raise EOFError(u"Illegal end of headers.") - - if line == '\r\n': - # Normal end of headers - break - if not line.endswith('\r\n'): - raise ValueError(u"MIME requires CRLF terminators: %r" % line) - - if line[0] in ' \t': - # It's a continuation line. - v = line.strip().decode(u'ISO-8859-1') - else: - k, v = line.split(":", 1) - k = k.strip().decode(u'ISO-8859-1') - v = v.strip().decode(u'ISO-8859-1') - - existing = headers.get(k) - if existing: - v = u", ".join((existing, v)) - headers[k] = v - - return headers - read_headers = classmethod(read_headers) - - def read_lines_to_boundary(self, fp_out=None): - """Read bytes from self.fp and return or write them to a file. - - If the 'fp_out' argument is None (the default), all bytes read are - returned in a single byte string. - - If the 'fp_out' argument is not None, it must be a file-like object that - supports the 'write' method; all bytes read will be written to the fp, - and that fp is returned. - """ - endmarker = self.boundary + "--" - delim = "" - prev_lf = True - lines = [] - seen = 0 - while True: - line = self.fp.readline(1 << 16) - if not line: - raise EOFError(u"Illegal end of multipart body.") - if line.startswith("--") and prev_lf: - strippedline = line.strip() - if strippedline == self.boundary: - break - if strippedline == endmarker: - self.fp.finish() - break - - line = delim + line - - if line.endswith("\r\n"): - delim = "\r\n" - line = line[:-2] - prev_lf = True - elif line.endswith("\n"): - delim = "\n" - line = line[:-1] - prev_lf = True - else: - delim = "" - prev_lf = False - - if fp_out is None: - lines.append(line) - seen += len(line) - if seen > self.maxrambytes: - fp_out = self.make_file() - for line in lines: - fp_out.write(line) - else: - fp_out.write(line) - - if fp_out is None: - result = ''.join(lines) - for charset in self.attempt_charsets: - try: - result = result.decode(charset) - except UnicodeDecodeError: - pass - else: - self.charset = charset - return result - else: - raise cherrypy.HTTPError( - 400, "The request entity could not be decoded. The following " - "charsets were attempted: %s" % repr(self.attempt_charsets)) - else: - fp_out.seek(0) - return fp_out - - def default_proc(self): - if self.filename: - # Always read into a file if a .filename was given. - self.file = self.read_into_file() - else: - result = self.read_lines_to_boundary() - if isinstance(result, basestring): - self.value = result - else: - self.file = result - - def read_into_file(self, fp_out=None): - """Read the request body into fp_out (or make_file() if None). Return fp_out.""" - if fp_out is None: - fp_out = self.make_file() - self.read_lines_to_boundary(fp_out=fp_out) - return fp_out - -Entity.part_class = Part - - -class Infinity(object): - def __cmp__(self, other): - return 1 - def __sub__(self, other): - return self -inf = Infinity() - - -comma_separated_headers = ['Accept', 'Accept-Charset', 'Accept-Encoding', - 'Accept-Language', 'Accept-Ranges', 'Allow', 'Cache-Control', 'Connection', - 'Content-Encoding', 'Content-Language', 'Expect', 'If-Match', - 'If-None-Match', 'Pragma', 'Proxy-Authenticate', 'Te', 'Trailer', - 'Transfer-Encoding', 'Upgrade', 'Vary', 'Via', 'Warning', 'Www-Authenticate'] - - -class SizedReader: - - def __init__(self, fp, length, maxbytes, bufsize=8192, has_trailers=False): - # Wrap our fp in a buffer so peek() works - self.fp = fp - self.length = length - self.maxbytes = maxbytes - self.buffer = '' - self.bufsize = bufsize - self.bytes_read = 0 - self.done = False - self.has_trailers = has_trailers - - def read(self, size=None, fp_out=None): - """Read bytes from the request body and return or write them to a file. - - A number of bytes less than or equal to the 'size' argument are read - off the socket. The actual number of bytes read are tracked in - self.bytes_read. The number may be smaller than 'size' when 1) the - client sends fewer bytes, 2) the 'Content-Length' request header - specifies fewer bytes than requested, or 3) the number of bytes read - exceeds self.maxbytes (in which case, 413 is raised). - - If the 'fp_out' argument is None (the default), all bytes read are - returned in a single byte string. - - If the 'fp_out' argument is not None, it must be a file-like object that - supports the 'write' method; all bytes read will be written to the fp, - and None is returned. - """ - - if self.length is None: - if size is None: - remaining = inf - else: - remaining = size - else: - remaining = self.length - self.bytes_read - if size and size < remaining: - remaining = size - if remaining == 0: - self.finish() - if fp_out is None: - return '' - else: - return None - - chunks = [] - - # Read bytes from the buffer. - if self.buffer: - if remaining is inf: - data = self.buffer - self.buffer = '' - else: - data = self.buffer[:remaining] - self.buffer = self.buffer[remaining:] - datalen = len(data) - remaining -= datalen - - # Check lengths. - self.bytes_read += datalen - if self.maxbytes and self.bytes_read > self.maxbytes: - raise cherrypy.HTTPError(413) - - # Store the data. - if fp_out is None: - chunks.append(data) - else: - fp_out.write(data) - - # Read bytes from the socket. - while remaining > 0: - chunksize = min(remaining, self.bufsize) - try: - data = self.fp.read(chunksize) - except Exception, e: - if e.__class__.__name__ == 'MaxSizeExceeded': - # Post data is too big - raise cherrypy.HTTPError( - 413, "Maximum request length: %r" % e.args[1]) - else: - raise - if not data: - self.finish() - break - datalen = len(data) - remaining -= datalen - - # Check lengths. - self.bytes_read += datalen - if self.maxbytes and self.bytes_read > self.maxbytes: - raise cherrypy.HTTPError(413) - - # Store the data. - if fp_out is None: - chunks.append(data) - else: - fp_out.write(data) - - if fp_out is None: - return ''.join(chunks) - - def readline(self, size=None): - """Read a line from the request body and return it.""" - chunks = [] - while size is None or size > 0: - chunksize = self.bufsize - if size is not None and size < self.bufsize: - chunksize = size - data = self.read(chunksize) - if not data: - break - pos = data.find('\n') + 1 - if pos: - chunks.append(data[:pos]) - remainder = data[pos:] - self.buffer += remainder - self.bytes_read -= len(remainder) - break - else: - chunks.append(data) - return ''.join(chunks) - - def readlines(self, sizehint=None): - """Read lines from the request body and return them.""" - if self.length is not None: - if sizehint is None: - sizehint = self.length - self.bytes_read - else: - sizehint = min(sizehint, self.length - self.bytes_read) - - lines = [] - seen = 0 - while True: - line = self.readline() - if not line: - break - lines.append(line) - seen += len(line) - if seen >= sizehint: - break - return lines - - def finish(self): - self.done = True - if self.has_trailers and hasattr(self.fp, 'read_trailer_lines'): - self.trailers = {} - - try: - for line in self.fp.read_trailer_lines(): - if line[0] in ' \t': - # It's a continuation line. - v = line.strip() - else: - try: - k, v = line.split(":", 1) - except ValueError: - raise ValueError("Illegal header line.") - k = k.strip().title() - v = v.strip() - - if k in comma_separated_headers: - existing = self.trailers.get(envname) - if existing: - v = ", ".join((existing, v)) - self.trailers[k] = v - except Exception, e: - if e.__class__.__name__ == 'MaxSizeExceeded': - # Post data is too big - raise cherrypy.HTTPError( - 413, "Maximum request length: %r" % e.args[1]) - else: - raise - - -class RequestBody(Entity): - - # Don't parse the request body at all if the client didn't provide - # a Content-Type header. See http://www.cherrypy.org/ticket/790 - default_content_type = u'' - - bufsize = 8 * 1024 - maxbytes = None - - def __init__(self, fp, headers, params=None, request_params=None): - Entity.__init__(self, fp, headers, params) - - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1 - # When no explicit charset parameter is provided by the - # sender, media subtypes of the "text" type are defined - # to have a default charset value of "ISO-8859-1" when - # received via HTTP. - if self.content_type.value.startswith('text/'): - for c in (u'ISO-8859-1', u'iso-8859-1', u'Latin-1', u'latin-1'): - if c in self.attempt_charsets: - break - else: - self.attempt_charsets.append(u'ISO-8859-1') - - # Temporary fix while deprecating passing .parts as .params. - self.processors[u'multipart'] = _old_process_multipart - - if request_params is None: - request_params = {} - self.request_params = request_params - - def process(self): - """Include body params in request params.""" - # "The presence of a message-body in a request is signaled by the - # inclusion of a Content-Length or Transfer-Encoding header field in - # the request's message-headers." - # It is possible to send a POST request with no body, for example; - # however, app developers are responsible in that case to set - # cherrypy.request.process_body to False so this method isn't called. - h = cherrypy.serving.request.headers - if u'Content-Length' not in h and u'Transfer-Encoding' not in h: - raise cherrypy.HTTPError(411) - - self.fp = SizedReader(self.fp, self.length, - self.maxbytes, bufsize=self.bufsize, - has_trailers='Trailer' in h) - super(RequestBody, self).process() - - # Body params should also be a part of the request_params - # add them in here. - request_params = self.request_params - for key, value in self.params.items(): - # Python 2 only: keyword arguments must be byte strings (type 'str'). - if isinstance(key, unicode): - key = key.encode('ISO-8859-1') - - if key in request_params: - if not isinstance(request_params[key], list): - request_params[key] = [request_params[key]] - request_params[key].append(value) - else: - request_params[key] = value - +"""Request body processing for CherryPy. + +When an HTTP request includes an entity body, it is often desirable to +provide that information to applications in a form other than the raw bytes. +Different content types demand different approaches. Examples: + + * For a GIF file, we want the raw bytes in a stream. + * An HTML form is better parsed into its component fields, and each text field + decoded from bytes to unicode. + * A JSON body should be deserialized into a Python dict or list. + +When the request contains a Content-Type header, the media type is used as a +key to look up a value in the 'request.body.processors' dict. If the full media +type is not found, then the major type is tried; for example, if no processor +is found for the 'image/jpeg' type, then we look for a processor for the 'image' +types altogether. If neither the full type nor the major type has a matching +processor, then a default processor is used (self.default_proc). For most +types, this means no processing is done, and the body is left unread as a +raw byte stream. Processors are configurable in an 'on_start_resource' hook. + +Some processors, especially those for the 'text' types, attempt to decode bytes +to unicode. If the Content-Type request header includes a 'charset' parameter, +this is used to decode the entity. Otherwise, one or more default charsets may +be attempted, although this decision is up to each processor. If a processor +successfully decodes an Entity or Part, it should set the 'charset' attribute +on the Entity or Part to the name of the successful charset, so that +applications can easily re-encode or transcode the value if they wish. + +If the Content-Type of the request entity is of major type 'multipart', then +the above parsing process, and possibly a decoding process, is performed for +each part. + +For both the full entity and multipart parts, a Content-Disposition header may +be used to fill .name and .filename attributes on the request.body or the Part. +""" + +import re +import tempfile +from urllib import unquote_plus + +import cherrypy +from cherrypy.lib import httputil + + +# -------------------------------- Processors -------------------------------- # + +def process_urlencoded(entity): + """Read application/x-www-form-urlencoded data into entity.params.""" + qs = entity.fp.read() + for charset in entity.attempt_charsets: + try: + params = {} + for aparam in qs.split('&'): + for pair in aparam.split(';'): + if not pair: + continue + + atoms = pair.split('=', 1) + if len(atoms) == 1: + atoms.append('') + + key = unquote_plus(atoms[0]).decode(charset) + value = unquote_plus(atoms[1]).decode(charset) + + if key in params: + if not isinstance(params[key], list): + params[key] = [params[key]] + params[key].append(value) + else: + params[key] = value + except UnicodeDecodeError: + pass + else: + entity.charset = charset + break + else: + raise cherrypy.HTTPError( + 400, "The request entity could not be decoded. The following " + "charsets were attempted: %s" % repr(entity.attempt_charsets)) + + # Now that all values have been successfully parsed and decoded, + # apply them to the entity.params dict. + for key, value in params.items(): + if key in entity.params: + if not isinstance(entity.params[key], list): + entity.params[key] = [entity.params[key]] + entity.params[key].append(value) + else: + entity.params[key] = value + + +def process_multipart(entity): + """Read all multipart parts into entity.parts.""" + ib = u"" + if u'boundary' in entity.content_type.params: + # http://tools.ietf.org/html/rfc2046#section-5.1.1 + # "The grammar for parameters on the Content-type field is such that it + # is often necessary to enclose the boundary parameter values in quotes + # on the Content-type line" + ib = entity.content_type.params['boundary'].strip(u'"') + + if not re.match(u"^[ -~]{0,200}[!-~]$", ib): + raise ValueError(u'Invalid boundary in multipart form: %r' % (ib,)) + + ib = (u'--' + ib).encode('ascii') + + # Find the first marker + while True: + b = entity.readline() + if not b: + return + + b = b.strip() + if b == ib: + break + + # Read all parts + while True: + part = entity.part_class.from_fp(entity.fp, ib) + entity.parts.append(part) + part.process() + if part.fp.done: + break + +def process_multipart_form_data(entity): + """Read all multipart/form-data parts into entity.parts or entity.params.""" + process_multipart(entity) + + kept_parts = [] + for part in entity.parts: + if part.name is None: + kept_parts.append(part) + else: + if part.filename is None: + # It's a regular field + entity.params[part.name] = part.fullvalue() + else: + # It's a file upload. Retain the whole part so consumer code + # has access to its .file and .filename attributes. + entity.params[part.name] = part + + entity.parts = kept_parts + +def _old_process_multipart(entity): + """The behavior of 3.2 and lower. Deprecated and will be changed in 3.3.""" + process_multipart(entity) + + params = entity.params + + for part in entity.parts: + if part.name is None: + key = u'parts' + else: + key = part.name + + if part.filename is None: + # It's a regular field + value = part.fullvalue() + else: + # It's a file upload. Retain the whole part so consumer code + # has access to its .file and .filename attributes. + value = part + + if key in params: + if not isinstance(params[key], list): + params[key] = [params[key]] + params[key].append(value) + else: + params[key] = value + + + +# --------------------------------- Entities --------------------------------- # + + +class Entity(object): + """An HTTP request body, or MIME multipart body.""" + + __metaclass__ = cherrypy._AttributeDocstrings + + params = None + params__doc = u""" + If the request Content-Type is 'application/x-www-form-urlencoded' or + multipart, this will be a dict of the params pulled from the entity + body; that is, it will be the portion of request.params that come + from the message body (sometimes called "POST params", although they + can be sent with various HTTP method verbs). This value is set between + the 'before_request_body' and 'before_handler' hooks (assuming that + process_request_body is True).""" + + default_content_type = u'application/x-www-form-urlencoded' + # http://tools.ietf.org/html/rfc2046#section-4.1.2: + # "The default character set, which must be assumed in the + # absence of a charset parameter, is US-ASCII." + # However, many browsers send data in utf-8 with no charset. + attempt_charsets = [u'utf-8'] + processors = {u'application/x-www-form-urlencoded': process_urlencoded, + u'multipart/form-data': process_multipart_form_data, + u'multipart': process_multipart, + } + + def __init__(self, fp, headers, params=None, parts=None): + # Make an instance-specific copy of the class processors + # so Tools, etc. can replace them per-request. + self.processors = self.processors.copy() + + self.fp = fp + self.headers = headers + + if params is None: + params = {} + self.params = params + + if parts is None: + parts = [] + self.parts = parts + + # Content-Type + self.content_type = headers.elements(u'Content-Type') + if self.content_type: + self.content_type = self.content_type[0] + else: + self.content_type = httputil.HeaderElement.from_str( + self.default_content_type) + + # Copy the class 'attempt_charsets', prepending any Content-Type charset + dec = self.content_type.params.get(u"charset", None) + if dec: + dec = dec.decode('ISO-8859-1') + self.attempt_charsets = [dec] + [c for c in self.attempt_charsets + if c != dec] + else: + self.attempt_charsets = self.attempt_charsets[:] + + # Length + self.length = None + clen = headers.get(u'Content-Length', None) + # If Transfer-Encoding is 'chunked', ignore any Content-Length. + if clen is not None and 'chunked' not in headers.get(u'Transfer-Encoding', ''): + try: + self.length = int(clen) + except ValueError: + pass + + # Content-Disposition + self.name = None + self.filename = None + disp = headers.elements(u'Content-Disposition') + if disp: + disp = disp[0] + if 'name' in disp.params: + self.name = disp.params['name'] + if self.name.startswith(u'"') and self.name.endswith(u'"'): + self.name = self.name[1:-1] + if 'filename' in disp.params: + self.filename = disp.params['filename'] + if self.filename.startswith(u'"') and self.filename.endswith(u'"'): + self.filename = self.filename[1:-1] + + # The 'type' attribute is deprecated in 3.2; remove it in 3.3. + type = property(lambda self: self.content_type) + + def read(self, size=None, fp_out=None): + return self.fp.read(size, fp_out) + + def readline(self, size=None): + return self.fp.readline(size) + + def readlines(self, sizehint=None): + return self.fp.readlines(sizehint) + + def __iter__(self): + return self + + def next(self): + line = self.readline() + if not line: + raise StopIteration + return line + + def read_into_file(self, fp_out=None): + """Read the request body into fp_out (or make_file() if None). Return fp_out.""" + if fp_out is None: + fp_out = self.make_file() + self.read(fp_out=fp_out) + return fp_out + + def make_file(self): + """Return a file into which the request body will be read. + + By default, this will return a TemporaryFile. Override as needed.""" + return tempfile.TemporaryFile() + + def fullvalue(self): + """Return this entity as a string, whether stored in a file or not.""" + if self.file: + # It was stored in a tempfile. Read it. + self.file.seek(0) + value = self.file.read() + self.file.seek(0) + else: + value = self.value + return value + + def process(self): + """Execute the best-match processor for the given media type.""" + proc = None + ct = self.content_type.value + try: + proc = self.processors[ct] + except KeyError: + toptype = ct.split(u'/', 1)[0] + try: + proc = self.processors[toptype] + except KeyError: + pass + if proc is None: + self.default_proc() + else: + proc(self) + + def default_proc(self): + # Leave the fp alone for someone else to read. This works fine + # for request.body, but the Part subclasses need to override this + # so they can move on to the next part. + pass + + +class Part(Entity): + """A MIME part entity, part of a multipart entity.""" + + default_content_type = u'text/plain' + # "The default character set, which must be assumed in the absence of a + # charset parameter, is US-ASCII." + attempt_charsets = [u'us-ascii', u'utf-8'] + # This is the default in stdlib cgi. We may want to increase it. + maxrambytes = 1000 + + def __init__(self, fp, headers, boundary): + Entity.__init__(self, fp, headers) + self.boundary = boundary + self.file = None + self.value = None + + def from_fp(cls, fp, boundary): + headers = cls.read_headers(fp) + return cls(fp, headers, boundary) + from_fp = classmethod(from_fp) + + def read_headers(cls, fp): + headers = httputil.HeaderMap() + while True: + line = fp.readline() + if not line: + # No more data--illegal end of headers + raise EOFError(u"Illegal end of headers.") + + if line == '\r\n': + # Normal end of headers + break + if not line.endswith('\r\n'): + raise ValueError(u"MIME requires CRLF terminators: %r" % line) + + if line[0] in ' \t': + # It's a continuation line. + v = line.strip().decode(u'ISO-8859-1') + else: + k, v = line.split(":", 1) + k = k.strip().decode(u'ISO-8859-1') + v = v.strip().decode(u'ISO-8859-1') + + existing = headers.get(k) + if existing: + v = u", ".join((existing, v)) + headers[k] = v + + return headers + read_headers = classmethod(read_headers) + + def read_lines_to_boundary(self, fp_out=None): + """Read bytes from self.fp and return or write them to a file. + + If the 'fp_out' argument is None (the default), all bytes read are + returned in a single byte string. + + If the 'fp_out' argument is not None, it must be a file-like object that + supports the 'write' method; all bytes read will be written to the fp, + and that fp is returned. + """ + endmarker = self.boundary + "--" + delim = "" + prev_lf = True + lines = [] + seen = 0 + while True: + line = self.fp.readline(1 << 16) + if not line: + raise EOFError(u"Illegal end of multipart body.") + if line.startswith("--") and prev_lf: + strippedline = line.strip() + if strippedline == self.boundary: + break + if strippedline == endmarker: + self.fp.finish() + break + + line = delim + line + + if line.endswith("\r\n"): + delim = "\r\n" + line = line[:-2] + prev_lf = True + elif line.endswith("\n"): + delim = "\n" + line = line[:-1] + prev_lf = True + else: + delim = "" + prev_lf = False + + if fp_out is None: + lines.append(line) + seen += len(line) + if seen > self.maxrambytes: + fp_out = self.make_file() + for line in lines: + fp_out.write(line) + else: + fp_out.write(line) + + if fp_out is None: + result = ''.join(lines) + for charset in self.attempt_charsets: + try: + result = result.decode(charset) + except UnicodeDecodeError: + pass + else: + self.charset = charset + return result + else: + raise cherrypy.HTTPError( + 400, "The request entity could not be decoded. The following " + "charsets were attempted: %s" % repr(self.attempt_charsets)) + else: + fp_out.seek(0) + return fp_out + + def default_proc(self): + if self.filename: + # Always read into a file if a .filename was given. + self.file = self.read_into_file() + else: + result = self.read_lines_to_boundary() + if isinstance(result, basestring): + self.value = result + else: + self.file = result + + def read_into_file(self, fp_out=None): + """Read the request body into fp_out (or make_file() if None). Return fp_out.""" + if fp_out is None: + fp_out = self.make_file() + self.read_lines_to_boundary(fp_out=fp_out) + return fp_out + +Entity.part_class = Part + + +class Infinity(object): + def __cmp__(self, other): + return 1 + def __sub__(self, other): + return self +inf = Infinity() + + +comma_separated_headers = ['Accept', 'Accept-Charset', 'Accept-Encoding', + 'Accept-Language', 'Accept-Ranges', 'Allow', 'Cache-Control', 'Connection', + 'Content-Encoding', 'Content-Language', 'Expect', 'If-Match', + 'If-None-Match', 'Pragma', 'Proxy-Authenticate', 'Te', 'Trailer', + 'Transfer-Encoding', 'Upgrade', 'Vary', 'Via', 'Warning', 'Www-Authenticate'] + + +class SizedReader: + + def __init__(self, fp, length, maxbytes, bufsize=8192, has_trailers=False): + # Wrap our fp in a buffer so peek() works + self.fp = fp + self.length = length + self.maxbytes = maxbytes + self.buffer = '' + self.bufsize = bufsize + self.bytes_read = 0 + self.done = False + self.has_trailers = has_trailers + + def read(self, size=None, fp_out=None): + """Read bytes from the request body and return or write them to a file. + + A number of bytes less than or equal to the 'size' argument are read + off the socket. The actual number of bytes read are tracked in + self.bytes_read. The number may be smaller than 'size' when 1) the + client sends fewer bytes, 2) the 'Content-Length' request header + specifies fewer bytes than requested, or 3) the number of bytes read + exceeds self.maxbytes (in which case, 413 is raised). + + If the 'fp_out' argument is None (the default), all bytes read are + returned in a single byte string. + + If the 'fp_out' argument is not None, it must be a file-like object that + supports the 'write' method; all bytes read will be written to the fp, + and None is returned. + """ + + if self.length is None: + if size is None: + remaining = inf + else: + remaining = size + else: + remaining = self.length - self.bytes_read + if size and size < remaining: + remaining = size + if remaining == 0: + self.finish() + if fp_out is None: + return '' + else: + return None + + chunks = [] + + # Read bytes from the buffer. + if self.buffer: + if remaining is inf: + data = self.buffer + self.buffer = '' + else: + data = self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + datalen = len(data) + remaining -= datalen + + # Check lengths. + self.bytes_read += datalen + if self.maxbytes and self.bytes_read > self.maxbytes: + raise cherrypy.HTTPError(413) + + # Store the data. + if fp_out is None: + chunks.append(data) + else: + fp_out.write(data) + + # Read bytes from the socket. + while remaining > 0: + chunksize = min(remaining, self.bufsize) + try: + data = self.fp.read(chunksize) + except Exception, e: + if e.__class__.__name__ == 'MaxSizeExceeded': + # Post data is too big + raise cherrypy.HTTPError( + 413, "Maximum request length: %r" % e.args[1]) + else: + raise + if not data: + self.finish() + break + datalen = len(data) + remaining -= datalen + + # Check lengths. + self.bytes_read += datalen + if self.maxbytes and self.bytes_read > self.maxbytes: + raise cherrypy.HTTPError(413) + + # Store the data. + if fp_out is None: + chunks.append(data) + else: + fp_out.write(data) + + if fp_out is None: + return ''.join(chunks) + + def readline(self, size=None): + """Read a line from the request body and return it.""" + chunks = [] + while size is None or size > 0: + chunksize = self.bufsize + if size is not None and size < self.bufsize: + chunksize = size + data = self.read(chunksize) + if not data: + break + pos = data.find('\n') + 1 + if pos: + chunks.append(data[:pos]) + remainder = data[pos:] + self.buffer += remainder + self.bytes_read -= len(remainder) + break + else: + chunks.append(data) + return ''.join(chunks) + + def readlines(self, sizehint=None): + """Read lines from the request body and return them.""" + if self.length is not None: + if sizehint is None: + sizehint = self.length - self.bytes_read + else: + sizehint = min(sizehint, self.length - self.bytes_read) + + lines = [] + seen = 0 + while True: + line = self.readline() + if not line: + break + lines.append(line) + seen += len(line) + if seen >= sizehint: + break + return lines + + def finish(self): + self.done = True + if self.has_trailers and hasattr(self.fp, 'read_trailer_lines'): + self.trailers = {} + + try: + for line in self.fp.read_trailer_lines(): + if line[0] in ' \t': + # It's a continuation line. + v = line.strip() + else: + try: + k, v = line.split(":", 1) + except ValueError: + raise ValueError("Illegal header line.") + k = k.strip().title() + v = v.strip() + + if k in comma_separated_headers: + existing = self.trailers.get(envname) + if existing: + v = ", ".join((existing, v)) + self.trailers[k] = v + except Exception, e: + if e.__class__.__name__ == 'MaxSizeExceeded': + # Post data is too big + raise cherrypy.HTTPError( + 413, "Maximum request length: %r" % e.args[1]) + else: + raise + + +class RequestBody(Entity): + + # Don't parse the request body at all if the client didn't provide + # a Content-Type header. See http://www.cherrypy.org/ticket/790 + default_content_type = u'' + + bufsize = 8 * 1024 + maxbytes = None + + def __init__(self, fp, headers, params=None, request_params=None): + Entity.__init__(self, fp, headers, params) + + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7.1 + # When no explicit charset parameter is provided by the + # sender, media subtypes of the "text" type are defined + # to have a default charset value of "ISO-8859-1" when + # received via HTTP. + if self.content_type.value.startswith('text/'): + for c in (u'ISO-8859-1', u'iso-8859-1', u'Latin-1', u'latin-1'): + if c in self.attempt_charsets: + break + else: + self.attempt_charsets.append(u'ISO-8859-1') + + # Temporary fix while deprecating passing .parts as .params. + self.processors[u'multipart'] = _old_process_multipart + + if request_params is None: + request_params = {} + self.request_params = request_params + + def process(self): + """Include body params in request params.""" + # "The presence of a message-body in a request is signaled by the + # inclusion of a Content-Length or Transfer-Encoding header field in + # the request's message-headers." + # It is possible to send a POST request with no body, for example; + # however, app developers are responsible in that case to set + # cherrypy.request.process_body to False so this method isn't called. + h = cherrypy.serving.request.headers + if u'Content-Length' not in h and u'Transfer-Encoding' not in h: + raise cherrypy.HTTPError(411) + + self.fp = SizedReader(self.fp, self.length, + self.maxbytes, bufsize=self.bufsize, + has_trailers='Trailer' in h) + super(RequestBody, self).process() + + # Body params should also be a part of the request_params + # add them in here. + request_params = self.request_params + for key, value in self.params.items(): + # Python 2 only: keyword arguments must be byte strings (type 'str'). + if isinstance(key, unicode): + key = key.encode('ISO-8859-1') + + if key in request_params: + if not isinstance(request_params[key], list): + request_params[key] = [request_params[key]] + request_params[key].append(value) + else: + request_params[key] = value + diff --git a/cherrypy/_cprequest.py b/cherrypy/_cprequest.py index 2b698e4934..f08c4a82a7 100644 --- a/cherrypy/_cprequest.py +++ b/cherrypy/_cprequest.py @@ -1,940 +1,940 @@ - -from Cookie import SimpleCookie, CookieError -import os -import sys -import time -import types -import warnings - -import cherrypy -from cherrypy import _cpreqbody, _cpconfig -from cherrypy._cperror import format_exc, bare_error -from cherrypy.lib import httputil, file_generator - - -class Hook(object): - """A callback and its metadata: failsafe, priority, and kwargs.""" - - __metaclass__ = cherrypy._AttributeDocstrings - - callback = None - callback__doc = """ - The bare callable that this Hook object is wrapping, which will - be called when the Hook is called.""" - - failsafe = False - failsafe__doc = """ - If True, the callback is guaranteed to run even if other callbacks - from the same call point raise exceptions.""" - - priority = 50 - priority__doc = """ - Defines the order of execution for a list of Hooks. Priority numbers - should be limited to the closed interval [0, 100], but values outside - this range are acceptable, as are fractional values.""" - - kwargs = {} - kwargs__doc = """ - A set of keyword arguments that will be passed to the - callable on each call.""" - - def __init__(self, callback, failsafe=None, priority=None, **kwargs): - self.callback = callback - - if failsafe is None: - failsafe = getattr(callback, "failsafe", False) - self.failsafe = failsafe - - if priority is None: - priority = getattr(callback, "priority", 50) - self.priority = priority - - self.kwargs = kwargs - - def __cmp__(self, other): - return cmp(self.priority, other.priority) - - def __call__(self): - """Run self.callback(**self.kwargs).""" - return self.callback(**self.kwargs) - - def __repr__(self): - cls = self.__class__ - return ("%s.%s(callback=%r, failsafe=%r, priority=%r, %s)" - % (cls.__module__, cls.__name__, self.callback, - self.failsafe, self.priority, - ", ".join(['%s=%r' % (k, v) - for k, v in self.kwargs.items()]))) - - -class HookMap(dict): - """A map of call points to lists of callbacks (Hook objects).""" - - def __new__(cls, points=None): - d = dict.__new__(cls) - for p in points or []: - d[p] = [] - return d - - def __init__(self, *a, **kw): - pass - - def attach(self, point, callback, failsafe=None, priority=None, **kwargs): - """Append a new Hook made from the supplied arguments.""" - self[point].append(Hook(callback, failsafe, priority, **kwargs)) - - def run(self, point): - """Execute all registered Hooks (callbacks) for the given point.""" - exc = None - hooks = self[point] - hooks.sort() - for hook in hooks: - # Some hooks are guaranteed to run even if others at - # the same hookpoint fail. We will still log the failure, - # but proceed on to the next hook. The only way - # to stop all processing from one of these hooks is - # to raise SystemExit and stop the whole server. - if exc is None or hook.failsafe: - try: - hook() - except (KeyboardInterrupt, SystemExit): - raise - except (cherrypy.HTTPError, cherrypy.HTTPRedirect, - cherrypy.InternalRedirect): - exc = sys.exc_info()[1] - except: - exc = sys.exc_info()[1] - cherrypy.log(traceback=True, severity=40) - if exc: - raise - - def __copy__(self): - newmap = self.__class__() - # We can't just use 'update' because we want copies of the - # mutable values (each is a list) as well. - for k, v in self.items(): - newmap[k] = v[:] - return newmap - copy = __copy__ - - def __repr__(self): - cls = self.__class__ - return "%s.%s(points=%r)" % (cls.__module__, cls.__name__, self.keys()) - - -# Config namespace handlers - -def hooks_namespace(k, v): - """Attach bare hooks declared in config.""" - # Use split again to allow multiple hooks for a single - # hookpoint per path (e.g. "hooks.before_handler.1"). - # Little-known fact you only get from reading source ;) - hookpoint = k.split(".", 1)[0] - if isinstance(v, basestring): - v = cherrypy.lib.attributes(v) - if not isinstance(v, Hook): - v = Hook(v) - cherrypy.serving.request.hooks[hookpoint].append(v) - -def request_namespace(k, v): - """Attach request attributes declared in config.""" - # Provides config entries to set request.body attrs (like attempt_charsets). - if k[:5] == 'body.': - setattr(cherrypy.serving.request.body, k[5:], v) - else: - setattr(cherrypy.serving.request, k, v) - -def response_namespace(k, v): - """Attach response attributes declared in config.""" - # Provides config entries to set default response headers - # http://cherrypy.org/ticket/889 - if k[:8] == 'headers.': - cherrypy.serving.response.headers[k.split('.', 1)[1]] = v - else: - setattr(cherrypy.serving.response, k, v) - -def error_page_namespace(k, v): - """Attach error pages declared in config.""" - if k != 'default': - k = int(k) - cherrypy.serving.request.error_page[k] = v - - -hookpoints = ['on_start_resource', 'before_request_body', - 'before_handler', 'before_finalize', - 'on_end_resource', 'on_end_request', - 'before_error_response', 'after_error_response'] - - -class Request(object): - """An HTTP request. - - This object represents the metadata of an HTTP request message; - that is, it contains attributes which describe the environment - in which the request URL, headers, and body were sent (if you - want tools to interpret the headers and body, those are elsewhere, - mostly in Tools). This 'metadata' consists of socket data, - transport characteristics, and the Request-Line. This object - also contains data regarding the configuration in effect for - the given URL, and the execution plan for generating a response. - """ - - __metaclass__ = cherrypy._AttributeDocstrings - - prev = None - prev__doc = """ - The previous Request object (if any). This should be None - unless we are processing an InternalRedirect.""" - - # Conversation/connection attributes - local = httputil.Host("127.0.0.1", 80) - local__doc = \ - "An httputil.Host(ip, port, hostname) object for the server socket." - - remote = httputil.Host("127.0.0.1", 1111) - remote__doc = \ - "An httputil.Host(ip, port, hostname) object for the client socket." - - scheme = "http" - scheme__doc = """ - The protocol used between client and server. In most cases, - this will be either 'http' or 'https'.""" - - server_protocol = "HTTP/1.1" - server_protocol__doc = """ - The HTTP version for which the HTTP server is at least - conditionally compliant.""" - - base = "" - base__doc = """The (scheme://host) portion of the requested URL. - In some cases (e.g. when proxying via mod_rewrite), this may contain - path segments which cherrypy.url uses when constructing url's, but - which otherwise are ignored by CherryPy. Regardless, this value - MUST NOT end in a slash.""" - - # Request-Line attributes - request_line = "" - request_line__doc = """ - The complete Request-Line received from the client. This is a - single string consisting of the request method, URI, and protocol - version (joined by spaces). Any final CRLF is removed.""" - - method = "GET" - method__doc = """ - Indicates the HTTP method to be performed on the resource identified - by the Request-URI. Common methods include GET, HEAD, POST, PUT, and - DELETE. CherryPy allows any extension method; however, various HTTP - servers and gateways may restrict the set of allowable methods. - CherryPy applications SHOULD restrict the set (on a per-URI basis).""" - - query_string = "" - query_string__doc = """ - The query component of the Request-URI, a string of information to be - interpreted by the resource. The query portion of a URI follows the - path component, and is separated by a '?'. For example, the URI - 'http://www.cherrypy.org/wiki?a=3&b=4' has the query component, - 'a=3&b=4'.""" - - query_string_encoding = 'utf8' - query_string_encoding__doc = """ - The encoding expected for query string arguments after % HEX HEX decoding). - If a query string is provided that cannot be decoded with this encoding, - 404 is raised (since technically it's a different URI). If you want - arbitrary encodings to not error, set this to 'Latin-1'; you can then - encode back to bytes and re-decode to whatever encoding you like later. - """ - - protocol = (1, 1) - protocol__doc = """The HTTP protocol version corresponding to the set - of features which should be allowed in the response. If BOTH - the client's request message AND the server's level of HTTP - compliance is HTTP/1.1, this attribute will be the tuple (1, 1). - If either is 1.0, this attribute will be the tuple (1, 0). - Lower HTTP protocol versions are not explicitly supported.""" - - params = {} - params__doc = """ - A dict which combines query string (GET) and request entity (POST) - variables. This is populated in two stages: GET params are added - before the 'on_start_resource' hook, and POST params are added - between the 'before_request_body' and 'before_handler' hooks.""" - - # Message attributes - header_list = [] - header_list__doc = """ - A list of the HTTP request headers as (name, value) tuples. - In general, you should use request.headers (a dict) instead.""" - - headers = httputil.HeaderMap() - headers__doc = """ - A dict-like object containing the request headers. Keys are header - names (in Title-Case format); however, you may get and set them in - a case-insensitive manner. That is, headers['Content-Type'] and - headers['content-type'] refer to the same value. Values are header - values (decoded according to RFC 2047 if necessary). See also: - httputil.HeaderMap, httputil.HeaderElement.""" - - cookie = SimpleCookie() - cookie__doc = """See help(Cookie).""" - - body = None - body__doc = """See help(cherrypy.request.body)""" - - rfile = None - rfile__doc = """ - If the request included an entity (body), it will be available - as a stream in this attribute. However, the rfile will normally - be read for you between the 'before_request_body' hook and the - 'before_handler' hook, and the resulting string is placed into - either request.params or the request.body attribute. - - You may disable the automatic consumption of the rfile by setting - request.process_request_body to False, either in config for the desired - path, or in an 'on_start_resource' or 'before_request_body' hook. - - WARNING: In almost every case, you should not attempt to read from the - rfile stream after CherryPy's automatic mechanism has read it. If you - turn off the automatic parsing of rfile, you should read exactly the - number of bytes specified in request.headers['Content-Length']. - Ignoring either of these warnings may result in a hung request thread - or in corruption of the next (pipelined) request. - """ - - process_request_body = True - process_request_body__doc = """ - If True, the rfile (if any) is automatically read and parsed, - and the result placed into request.params or request.body.""" - - methods_with_bodies = ("POST", "PUT") - methods_with_bodies__doc = """ - A sequence of HTTP methods for which CherryPy will automatically - attempt to read a body from the rfile.""" - - body = None - body__doc = """ - If the request Content-Type is 'application/x-www-form-urlencoded' - or multipart, this will be None. Otherwise, this will contain the - request entity body as an open file object (which you can .read()); - this value is set between the 'before_request_body' and 'before_handler' - hooks (assuming that process_request_body is True).""" - - body_params = None - body_params__doc = """ - If the request Content-Type is 'application/x-www-form-urlencoded' or - multipart, this will be a dict of the params pulled from the entity - body; that is, it will be the portion of request.params that come - from the message body (sometimes called "POST params", although they - can be sent with various HTTP method verbs). This value is set between - the 'before_request_body' and 'before_handler' hooks (assuming that - process_request_body is True).""" - - # Dispatch attributes - dispatch = cherrypy.dispatch.Dispatcher() - dispatch__doc = """ - The object which looks up the 'page handler' callable and collects - config for the current request based on the path_info, other - request attributes, and the application architecture. The core - calls the dispatcher as early as possible, passing it a 'path_info' - argument. - - The default dispatcher discovers the page handler by matching path_info - to a hierarchical arrangement of objects, starting at request.app.root. - See help(cherrypy.dispatch) for more information.""" - - script_name = "" - script_name__doc = """ - The 'mount point' of the application which is handling this request. - - This attribute MUST NOT end in a slash. If the script_name refers to - the root of the URI, it MUST be an empty string (not "/"). - """ - - path_info = "/" - path_info__doc = """ - The 'relative path' portion of the Request-URI. This is relative - to the script_name ('mount point') of the application which is - handling this request.""" - - login = None - login__doc = """ - When authentication is used during the request processing this is - set to 'False' if it failed and to the 'username' value if it succeeded. - The default 'None' implies that no authentication happened.""" - - # Note that cherrypy.url uses "if request.app:" to determine whether - # the call is during a real HTTP request or not. So leave this None. - app = None - app__doc = \ - """The cherrypy.Application object which is handling this request.""" - - handler = None - handler__doc = """ - The function, method, or other callable which CherryPy will call to - produce the response. The discovery of the handler and the arguments - it will receive are determined by the request.dispatch object. - By default, the handler is discovered by walking a tree of objects - starting at request.app.root, and is then passed all HTTP params - (from the query string and POST body) as keyword arguments.""" - - toolmaps = {} - toolmaps__doc = """ - A nested dict of all Toolboxes and Tools in effect for this request, - of the form: {Toolbox.namespace: {Tool.name: config dict}}.""" - - config = None - config__doc = """ - A flat dict of all configuration entries which apply to the - current request. These entries are collected from global config, - application config (based on request.path_info), and from handler - config (exactly how is governed by the request.dispatch object in - effect for this request; by default, handler config can be attached - anywhere in the tree between request.app.root and the final handler, - and inherits downward).""" - - is_index = None - is_index__doc = """ - This will be True if the current request is mapped to an 'index' - resource handler (also, a 'default' handler if path_info ends with - a slash). The value may be used to automatically redirect the - user-agent to a 'more canonical' URL which either adds or removes - the trailing slash. See cherrypy.tools.trailing_slash.""" - - hooks = HookMap(hookpoints) - hooks__doc = """ - A HookMap (dict-like object) of the form: {hookpoint: [hook, ...]}. - Each key is a str naming the hook point, and each value is a list - of hooks which will be called at that hook point during this request. - The list of hooks is generally populated as early as possible (mostly - from Tools specified in config), but may be extended at any time. - See also: _cprequest.Hook, _cprequest.HookMap, and cherrypy.tools.""" - - error_response = cherrypy.HTTPError(500).set_response - error_response__doc = """ - The no-arg callable which will handle unexpected, untrapped errors - during request processing. This is not used for expected exceptions - (like NotFound, HTTPError, or HTTPRedirect) which are raised in - response to expected conditions (those should be customized either - via request.error_page or by overriding HTTPError.set_response). - By default, error_response uses HTTPError(500) to return a generic - error response to the user-agent.""" - - error_page = {} - error_page__doc = """ - A dict of {error code: response filename or callable} pairs. - - The error code must be an int representing a given HTTP error code, - or the string 'default', which will be used if no matching entry - is found for a given numeric code. - - If a filename is provided, the file should contain a Python string- - formatting template, and can expect by default to receive format - values with the mapping keys %(status)s, %(message)s, %(traceback)s, - and %(version)s. The set of format mappings can be extended by - overriding HTTPError.set_response. - - If a callable is provided, it will be called by default with keyword - arguments 'status', 'message', 'traceback', and 'version', as for a - string-formatting template. The callable must return a string or iterable of - strings which will be set to response.body. It may also override headers or - perform any other processing. - - If no entry is given for an error code, and no 'default' entry exists, - a default template will be used. - """ - - show_tracebacks = True - show_tracebacks__doc = """ - If True, unexpected errors encountered during request processing will - include a traceback in the response body.""" - - show_mismatched_params = True - show_mismatched_params__doc = """ - If True, mismatched parameters encountered during PageHandler invocation - processing will be included in the response body.""" - - throws = (KeyboardInterrupt, SystemExit, cherrypy.InternalRedirect) - throws__doc = \ - """The sequence of exceptions which Request.run does not trap.""" - - throw_errors = False - throw_errors__doc = """ - If True, Request.run will not trap any errors (except HTTPRedirect and - HTTPError, which are more properly called 'exceptions', not errors).""" - - closed = False - closed__doc = """ - True once the close method has been called, False otherwise.""" - - stage = None - stage__doc = """ - A string containing the stage reached in the request-handling process. - This is useful when debugging a live server with hung requests.""" - - namespaces = _cpconfig.NamespaceSet( - **{"hooks": hooks_namespace, - "request": request_namespace, - "response": response_namespace, - "error_page": error_page_namespace, - "tools": cherrypy.tools, - }) - - def __init__(self, local_host, remote_host, scheme="http", - server_protocol="HTTP/1.1"): - """Populate a new Request object. - - local_host should be an httputil.Host object with the server info. - remote_host should be an httputil.Host object with the client info. - scheme should be a string, either "http" or "https". - """ - self.local = local_host - self.remote = remote_host - self.scheme = scheme - self.server_protocol = server_protocol - - self.closed = False - - # Put a *copy* of the class error_page into self. - self.error_page = self.error_page.copy() - - # Put a *copy* of the class namespaces into self. - self.namespaces = self.namespaces.copy() - - self.stage = None - - def close(self): - """Run cleanup code. (Core)""" - if not self.closed: - self.closed = True - self.stage = 'on_end_request' - self.hooks.run('on_end_request') - self.stage = 'close' - - def run(self, method, path, query_string, req_protocol, headers, rfile): - """Process the Request. (Core) - - method, path, query_string, and req_protocol should be pulled directly - from the Request-Line (e.g. "GET /path?key=val HTTP/1.0"). - path should be %XX-unquoted, but query_string should not be. - They both MUST be byte strings, not unicode strings. - headers should be a list of (name, value) tuples. - rfile should be a file-like object containing the HTTP request entity. - - When run() is done, the returned object should have 3 attributes: - status, e.g. "200 OK" - header_list, a list of (name, value) tuples - body, an iterable yielding strings - - Consumer code (HTTP servers) should then access these response - attributes to build the outbound stream. - - """ - response = cherrypy.serving.response - self.stage = 'run' - try: - self.error_response = cherrypy.HTTPError(500).set_response - - self.method = method - path = path or "/" - self.query_string = query_string or '' - self.params = {} - - # Compare request and server HTTP protocol versions, in case our - # server does not support the requested protocol. Limit our output - # to min(req, server). We want the following output: - # request server actual written supported response - # protocol protocol response protocol feature set - # a 1.0 1.0 1.0 1.0 - # b 1.0 1.1 1.1 1.0 - # c 1.1 1.0 1.0 1.0 - # d 1.1 1.1 1.1 1.1 - # Notice that, in (b), the response will be "HTTP/1.1" even though - # the client only understands 1.0. RFC 2616 10.5.6 says we should - # only return 505 if the _major_ version is different. - rp = int(req_protocol[5]), int(req_protocol[7]) - sp = int(self.server_protocol[5]), int(self.server_protocol[7]) - self.protocol = min(rp, sp) - response.headers.protocol = self.protocol - - # Rebuild first line of the request (e.g. "GET /path HTTP/1.0"). - url = path - if query_string: - url += '?' + query_string - self.request_line = '%s %s %s' % (method, url, req_protocol) - - self.header_list = list(headers) - self.headers = httputil.HeaderMap() - - self.rfile = rfile - self.body = None - - self.cookie = SimpleCookie() - self.handler = None - - # path_info should be the path from the - # app root (script_name) to the handler. - self.script_name = self.app.script_name - self.path_info = pi = path[len(self.script_name):] - - self.stage = 'respond' - self.respond(pi) - - except self.throws: - raise - except: - if self.throw_errors: - raise - else: - # Failure in setup, error handler or finalize. Bypass them. - # Can't use handle_error because we may not have hooks yet. - cherrypy.log(traceback=True, severity=40) - if self.show_tracebacks: - body = format_exc() - else: - body = "" - r = bare_error(body) - response.output_status, response.header_list, response.body = r - - if self.method == "HEAD": - # HEAD requests MUST NOT return a message-body in the response. - response.body = [] - - try: - cherrypy.log.access() - except: - cherrypy.log.error(traceback=True) - - if response.timed_out: - raise cherrypy.TimeoutError() - - return response - - # Uncomment for stage debugging - # stage = property(lambda self: self._stage, lambda self, v: print(v)) - - def respond(self, path_info): - """Generate a response for the resource at self.path_info. (Core)""" - response = cherrypy.serving.response - try: - try: - try: - if self.app is None: - raise cherrypy.NotFound() - - # Get the 'Host' header, so we can HTTPRedirect properly. - self.stage = 'process_headers' - self.process_headers() - - # Make a copy of the class hooks - self.hooks = self.__class__.hooks.copy() - self.toolmaps = {} - - self.stage = 'get_resource' - self.get_resource(path_info) - - self.body = _cpreqbody.RequestBody( - self.rfile, self.headers, request_params=self.params) - - self.namespaces(self.config) - - self.stage = 'on_start_resource' - self.hooks.run('on_start_resource') - - # Parse the querystring - self.stage = 'process_query_string' - self.process_query_string() - - # Process the body - if self.process_request_body: - if self.method not in self.methods_with_bodies: - self.process_request_body = False - self.stage = 'before_request_body' - self.hooks.run('before_request_body') - if self.process_request_body: - self.body.process() - - # Run the handler - self.stage = 'before_handler' - self.hooks.run('before_handler') - if self.handler: - self.stage = 'handler' - response.body = self.handler() - - # Finalize - self.stage = 'before_finalize' - self.hooks.run('before_finalize') - response.finalize() - except (cherrypy.HTTPRedirect, cherrypy.HTTPError), inst: - inst.set_response() - self.stage = 'before_finalize (HTTPError)' - self.hooks.run('before_finalize') - response.finalize() - finally: - self.stage = 'on_end_resource' - self.hooks.run('on_end_resource') - except self.throws: - raise - except: - if self.throw_errors: - raise - self.handle_error() - - def process_query_string(self): - """Parse the query string into Python structures. (Core)""" - try: - p = httputil.parse_query_string( - self.query_string, encoding=self.query_string_encoding) - except UnicodeDecodeError: - raise cherrypy.HTTPError( - 404, "The given query string could not be processed. Query " - "strings for this resource must be encoded with %r." % - self.query_string_encoding) - - # Python 2 only: keyword arguments must be byte strings (type 'str'). - for key, value in p.items(): - if isinstance(key, unicode): - del p[key] - p[key.encode(self.query_string_encoding)] = value - self.params.update(p) - - def process_headers(self): - """Parse HTTP header data into Python structures. (Core)""" - # Process the headers into self.headers - headers = self.headers - for name, value in self.header_list: - # Call title() now (and use dict.__method__(headers)) - # so title doesn't have to be called twice. - name = name.title() - value = value.strip() - - # Warning: if there is more than one header entry for cookies (AFAIK, - # only Konqueror does that), only the last one will remain in headers - # (but they will be correctly stored in request.cookie). - if "=?" in value: - dict.__setitem__(headers, name, httputil.decode_TEXT(value)) - else: - dict.__setitem__(headers, name, value) - - # Handle cookies differently because on Konqueror, multiple - # cookies come on different lines with the same key - if name == 'Cookie': - try: - self.cookie.load(value) - except CookieError: - msg = "Illegal cookie name %s" % value.split('=')[0] - raise cherrypy.HTTPError(400, msg) - - if not dict.__contains__(headers, 'Host'): - # All Internet-based HTTP/1.1 servers MUST respond with a 400 - # (Bad Request) status code to any HTTP/1.1 request message - # which lacks a Host header field. - if self.protocol >= (1, 1): - msg = "HTTP/1.1 requires a 'Host' request header." - raise cherrypy.HTTPError(400, msg) - host = dict.get(headers, 'Host') - if not host: - host = self.local.name or self.local.ip - self.base = "%s://%s" % (self.scheme, host) - - def get_resource(self, path): - """Call a dispatcher (which sets self.handler and .config). (Core)""" - # First, see if there is a custom dispatch at this URI. Custom - # dispatchers can only be specified in app.config, not in _cp_config - # (since custom dispatchers may not even have an app.root). - dispatch = self.app.find_config(path, "request.dispatch", self.dispatch) - - # dispatch() should set self.handler and self.config - dispatch(path) - - def handle_error(self): - """Handle the last unanticipated exception. (Core)""" - try: - self.hooks.run("before_error_response") - if self.error_response: - self.error_response() - self.hooks.run("after_error_response") - cherrypy.serving.response.finalize() - except cherrypy.HTTPRedirect, inst: - inst.set_response() - cherrypy.serving.response.finalize() - - # ------------------------- Properties ------------------------- # - - def _get_body_params(self): - warnings.warn( - "body_params is deprecated in CherryPy 3.2, will be removed in " - "CherryPy 3.3.", - DeprecationWarning - ) - return self.body.params - body_params = property(_get_body_params, - doc=""" - If the request Content-Type is 'application/x-www-form-urlencoded' or - multipart, this will be a dict of the params pulled from the entity - body; that is, it will be the portion of request.params that come - from the message body (sometimes called "POST params", although they - can be sent with various HTTP method verbs). This value is set between - the 'before_request_body' and 'before_handler' hooks (assuming that - process_request_body is True). - - Deprecated in 3.2, will be removed for 3.3""") - - -class ResponseBody(object): - """The body of the HTTP response (the response entity).""" - - def __get__(self, obj, objclass=None): - if obj is None: - # When calling on the class instead of an instance... - return self - else: - return obj._body - - def __set__(self, obj, value): - # Convert the given value to an iterable object. - if isinstance(value, basestring): - # strings get wrapped in a list because iterating over a single - # item list is much faster than iterating over every character - # in a long string. - if value: - value = [value] - else: - # [''] doesn't evaluate to False, so replace it with []. - value = [] - elif isinstance(value, types.FileType): - value = file_generator(value) - elif value is None: - value = [] - obj._body = value - - -class Response(object): - """An HTTP Response, including status, headers, and body. - - Application developers should use Response.headers (a dict) to - set or modify HTTP response headers. When the response is finalized, - Response.headers is transformed into Response.header_list as - (key, value) tuples. - """ - - __metaclass__ = cherrypy._AttributeDocstrings - - # Class attributes for dev-time introspection. - status = "" - status__doc = """The HTTP Status-Code and Reason-Phrase.""" - - header_list = [] - header_list__doc = """ - A list of the HTTP response headers as (name, value) tuples. - In general, you should use response.headers (a dict) instead.""" - - headers = httputil.HeaderMap() - headers__doc = """ - A dict-like object containing the response headers. Keys are header - names (in Title-Case format); however, you may get and set them in - a case-insensitive manner. That is, headers['Content-Type'] and - headers['content-type'] refer to the same value. Values are header - values (decoded according to RFC 2047 if necessary). See also: - httputil.HeaderMap, httputil.HeaderElement.""" - - cookie = SimpleCookie() - cookie__doc = """See help(Cookie).""" - - body = ResponseBody() - body__doc = """The body (entity) of the HTTP response.""" - - time = None - time__doc = """The value of time.time() when created. Use in HTTP dates.""" - - timeout = 300 - timeout__doc = """Seconds after which the response will be aborted.""" - - timed_out = False - timed_out__doc = """ - Flag to indicate the response should be aborted, because it has - exceeded its timeout.""" - - stream = False - stream__doc = """If False, buffer the response body.""" - - def __init__(self): - self.status = None - self.header_list = None - self._body = [] - self.time = time.time() - - self.headers = httputil.HeaderMap() - # Since we know all our keys are titled strings, we can - # bypass HeaderMap.update and get a big speed boost. - dict.update(self.headers, { - "Content-Type": 'text/html', - "Server": "CherryPy/" + cherrypy.__version__, - "Date": httputil.HTTPDate(self.time), - }) - self.cookie = SimpleCookie() - - def collapse_body(self): - """Collapse self.body to a single string; replace it and return it.""" - if isinstance(self.body, basestring): - return self.body - - newbody = ''.join([chunk for chunk in self.body]) - self.body = newbody - return newbody - - def finalize(self): - """Transform headers (and cookies) into self.header_list. (Core)""" - try: - code, reason, _ = httputil.valid_status(self.status) - except ValueError, x: - raise cherrypy.HTTPError(500, x.args[0]) - - headers = self.headers - - self.output_status = str(code) + " " + headers.encode(reason) - - if self.stream: - # The upshot: wsgiserver will chunk the response if - # you pop Content-Length (or set it explicitly to None). - # Note that lib.static sets C-L to the file's st_size. - if dict.get(headers, 'Content-Length') is None: - dict.pop(headers, 'Content-Length', None) - elif code < 200 or code in (204, 205, 304): - # "All 1xx (informational), 204 (no content), - # and 304 (not modified) responses MUST NOT - # include a message-body." - dict.pop(headers, 'Content-Length', None) - self.body = "" - else: - # Responses which are not streamed should have a Content-Length, - # but allow user code to set Content-Length if desired. - if dict.get(headers, 'Content-Length') is None: - content = self.collapse_body() - dict.__setitem__(headers, 'Content-Length', len(content)) - - # Transform our header dict into a list of tuples. - self.header_list = h = headers.output() - - cookie = self.cookie.output() - if cookie: - for line in cookie.split("\n"): - if line.endswith("\r"): - # Python 2.4 emits cookies joined by LF but 2.5+ by CRLF. - line = line[:-1] - name, value = line.split(": ", 1) - if isinstance(name, unicode): - name = name.encode("ISO-8859-1") - if isinstance(value, unicode): - value = headers.encode(value) - h.append((name, value)) - - def check_timeout(self): - """If now > self.time + self.timeout, set self.timed_out. - - This purposefully sets a flag, rather than raising an error, - so that a monitor thread can interrupt the Response thread. - """ - if time.time() > self.time + self.timeout: - self.timed_out = True - - - + +from Cookie import SimpleCookie, CookieError +import os +import sys +import time +import types +import warnings + +import cherrypy +from cherrypy import _cpreqbody, _cpconfig +from cherrypy._cperror import format_exc, bare_error +from cherrypy.lib import httputil, file_generator + + +class Hook(object): + """A callback and its metadata: failsafe, priority, and kwargs.""" + + __metaclass__ = cherrypy._AttributeDocstrings + + callback = None + callback__doc = """ + The bare callable that this Hook object is wrapping, which will + be called when the Hook is called.""" + + failsafe = False + failsafe__doc = """ + If True, the callback is guaranteed to run even if other callbacks + from the same call point raise exceptions.""" + + priority = 50 + priority__doc = """ + Defines the order of execution for a list of Hooks. Priority numbers + should be limited to the closed interval [0, 100], but values outside + this range are acceptable, as are fractional values.""" + + kwargs = {} + kwargs__doc = """ + A set of keyword arguments that will be passed to the + callable on each call.""" + + def __init__(self, callback, failsafe=None, priority=None, **kwargs): + self.callback = callback + + if failsafe is None: + failsafe = getattr(callback, "failsafe", False) + self.failsafe = failsafe + + if priority is None: + priority = getattr(callback, "priority", 50) + self.priority = priority + + self.kwargs = kwargs + + def __cmp__(self, other): + return cmp(self.priority, other.priority) + + def __call__(self): + """Run self.callback(**self.kwargs).""" + return self.callback(**self.kwargs) + + def __repr__(self): + cls = self.__class__ + return ("%s.%s(callback=%r, failsafe=%r, priority=%r, %s)" + % (cls.__module__, cls.__name__, self.callback, + self.failsafe, self.priority, + ", ".join(['%s=%r' % (k, v) + for k, v in self.kwargs.items()]))) + + +class HookMap(dict): + """A map of call points to lists of callbacks (Hook objects).""" + + def __new__(cls, points=None): + d = dict.__new__(cls) + for p in points or []: + d[p] = [] + return d + + def __init__(self, *a, **kw): + pass + + def attach(self, point, callback, failsafe=None, priority=None, **kwargs): + """Append a new Hook made from the supplied arguments.""" + self[point].append(Hook(callback, failsafe, priority, **kwargs)) + + def run(self, point): + """Execute all registered Hooks (callbacks) for the given point.""" + exc = None + hooks = self[point] + hooks.sort() + for hook in hooks: + # Some hooks are guaranteed to run even if others at + # the same hookpoint fail. We will still log the failure, + # but proceed on to the next hook. The only way + # to stop all processing from one of these hooks is + # to raise SystemExit and stop the whole server. + if exc is None or hook.failsafe: + try: + hook() + except (KeyboardInterrupt, SystemExit): + raise + except (cherrypy.HTTPError, cherrypy.HTTPRedirect, + cherrypy.InternalRedirect): + exc = sys.exc_info()[1] + except: + exc = sys.exc_info()[1] + cherrypy.log(traceback=True, severity=40) + if exc: + raise + + def __copy__(self): + newmap = self.__class__() + # We can't just use 'update' because we want copies of the + # mutable values (each is a list) as well. + for k, v in self.items(): + newmap[k] = v[:] + return newmap + copy = __copy__ + + def __repr__(self): + cls = self.__class__ + return "%s.%s(points=%r)" % (cls.__module__, cls.__name__, self.keys()) + + +# Config namespace handlers + +def hooks_namespace(k, v): + """Attach bare hooks declared in config.""" + # Use split again to allow multiple hooks for a single + # hookpoint per path (e.g. "hooks.before_handler.1"). + # Little-known fact you only get from reading source ;) + hookpoint = k.split(".", 1)[0] + if isinstance(v, basestring): + v = cherrypy.lib.attributes(v) + if not isinstance(v, Hook): + v = Hook(v) + cherrypy.serving.request.hooks[hookpoint].append(v) + +def request_namespace(k, v): + """Attach request attributes declared in config.""" + # Provides config entries to set request.body attrs (like attempt_charsets). + if k[:5] == 'body.': + setattr(cherrypy.serving.request.body, k[5:], v) + else: + setattr(cherrypy.serving.request, k, v) + +def response_namespace(k, v): + """Attach response attributes declared in config.""" + # Provides config entries to set default response headers + # http://cherrypy.org/ticket/889 + if k[:8] == 'headers.': + cherrypy.serving.response.headers[k.split('.', 1)[1]] = v + else: + setattr(cherrypy.serving.response, k, v) + +def error_page_namespace(k, v): + """Attach error pages declared in config.""" + if k != 'default': + k = int(k) + cherrypy.serving.request.error_page[k] = v + + +hookpoints = ['on_start_resource', 'before_request_body', + 'before_handler', 'before_finalize', + 'on_end_resource', 'on_end_request', + 'before_error_response', 'after_error_response'] + + +class Request(object): + """An HTTP request. + + This object represents the metadata of an HTTP request message; + that is, it contains attributes which describe the environment + in which the request URL, headers, and body were sent (if you + want tools to interpret the headers and body, those are elsewhere, + mostly in Tools). This 'metadata' consists of socket data, + transport characteristics, and the Request-Line. This object + also contains data regarding the configuration in effect for + the given URL, and the execution plan for generating a response. + """ + + __metaclass__ = cherrypy._AttributeDocstrings + + prev = None + prev__doc = """ + The previous Request object (if any). This should be None + unless we are processing an InternalRedirect.""" + + # Conversation/connection attributes + local = httputil.Host("127.0.0.1", 80) + local__doc = \ + "An httputil.Host(ip, port, hostname) object for the server socket." + + remote = httputil.Host("127.0.0.1", 1111) + remote__doc = \ + "An httputil.Host(ip, port, hostname) object for the client socket." + + scheme = "http" + scheme__doc = """ + The protocol used between client and server. In most cases, + this will be either 'http' or 'https'.""" + + server_protocol = "HTTP/1.1" + server_protocol__doc = """ + The HTTP version for which the HTTP server is at least + conditionally compliant.""" + + base = "" + base__doc = """The (scheme://host) portion of the requested URL. + In some cases (e.g. when proxying via mod_rewrite), this may contain + path segments which cherrypy.url uses when constructing url's, but + which otherwise are ignored by CherryPy. Regardless, this value + MUST NOT end in a slash.""" + + # Request-Line attributes + request_line = "" + request_line__doc = """ + The complete Request-Line received from the client. This is a + single string consisting of the request method, URI, and protocol + version (joined by spaces). Any final CRLF is removed.""" + + method = "GET" + method__doc = """ + Indicates the HTTP method to be performed on the resource identified + by the Request-URI. Common methods include GET, HEAD, POST, PUT, and + DELETE. CherryPy allows any extension method; however, various HTTP + servers and gateways may restrict the set of allowable methods. + CherryPy applications SHOULD restrict the set (on a per-URI basis).""" + + query_string = "" + query_string__doc = """ + The query component of the Request-URI, a string of information to be + interpreted by the resource. The query portion of a URI follows the + path component, and is separated by a '?'. For example, the URI + 'http://www.cherrypy.org/wiki?a=3&b=4' has the query component, + 'a=3&b=4'.""" + + query_string_encoding = 'utf8' + query_string_encoding__doc = """ + The encoding expected for query string arguments after % HEX HEX decoding). + If a query string is provided that cannot be decoded with this encoding, + 404 is raised (since technically it's a different URI). If you want + arbitrary encodings to not error, set this to 'Latin-1'; you can then + encode back to bytes and re-decode to whatever encoding you like later. + """ + + protocol = (1, 1) + protocol__doc = """The HTTP protocol version corresponding to the set + of features which should be allowed in the response. If BOTH + the client's request message AND the server's level of HTTP + compliance is HTTP/1.1, this attribute will be the tuple (1, 1). + If either is 1.0, this attribute will be the tuple (1, 0). + Lower HTTP protocol versions are not explicitly supported.""" + + params = {} + params__doc = """ + A dict which combines query string (GET) and request entity (POST) + variables. This is populated in two stages: GET params are added + before the 'on_start_resource' hook, and POST params are added + between the 'before_request_body' and 'before_handler' hooks.""" + + # Message attributes + header_list = [] + header_list__doc = """ + A list of the HTTP request headers as (name, value) tuples. + In general, you should use request.headers (a dict) instead.""" + + headers = httputil.HeaderMap() + headers__doc = """ + A dict-like object containing the request headers. Keys are header + names (in Title-Case format); however, you may get and set them in + a case-insensitive manner. That is, headers['Content-Type'] and + headers['content-type'] refer to the same value. Values are header + values (decoded according to RFC 2047 if necessary). See also: + httputil.HeaderMap, httputil.HeaderElement.""" + + cookie = SimpleCookie() + cookie__doc = """See help(Cookie).""" + + body = None + body__doc = """See help(cherrypy.request.body)""" + + rfile = None + rfile__doc = """ + If the request included an entity (body), it will be available + as a stream in this attribute. However, the rfile will normally + be read for you between the 'before_request_body' hook and the + 'before_handler' hook, and the resulting string is placed into + either request.params or the request.body attribute. + + You may disable the automatic consumption of the rfile by setting + request.process_request_body to False, either in config for the desired + path, or in an 'on_start_resource' or 'before_request_body' hook. + + WARNING: In almost every case, you should not attempt to read from the + rfile stream after CherryPy's automatic mechanism has read it. If you + turn off the automatic parsing of rfile, you should read exactly the + number of bytes specified in request.headers['Content-Length']. + Ignoring either of these warnings may result in a hung request thread + or in corruption of the next (pipelined) request. + """ + + process_request_body = True + process_request_body__doc = """ + If True, the rfile (if any) is automatically read and parsed, + and the result placed into request.params or request.body.""" + + methods_with_bodies = ("POST", "PUT") + methods_with_bodies__doc = """ + A sequence of HTTP methods for which CherryPy will automatically + attempt to read a body from the rfile.""" + + body = None + body__doc = """ + If the request Content-Type is 'application/x-www-form-urlencoded' + or multipart, this will be None. Otherwise, this will contain the + request entity body as an open file object (which you can .read()); + this value is set between the 'before_request_body' and 'before_handler' + hooks (assuming that process_request_body is True).""" + + body_params = None + body_params__doc = """ + If the request Content-Type is 'application/x-www-form-urlencoded' or + multipart, this will be a dict of the params pulled from the entity + body; that is, it will be the portion of request.params that come + from the message body (sometimes called "POST params", although they + can be sent with various HTTP method verbs). This value is set between + the 'before_request_body' and 'before_handler' hooks (assuming that + process_request_body is True).""" + + # Dispatch attributes + dispatch = cherrypy.dispatch.Dispatcher() + dispatch__doc = """ + The object which looks up the 'page handler' callable and collects + config for the current request based on the path_info, other + request attributes, and the application architecture. The core + calls the dispatcher as early as possible, passing it a 'path_info' + argument. + + The default dispatcher discovers the page handler by matching path_info + to a hierarchical arrangement of objects, starting at request.app.root. + See help(cherrypy.dispatch) for more information.""" + + script_name = "" + script_name__doc = """ + The 'mount point' of the application which is handling this request. + + This attribute MUST NOT end in a slash. If the script_name refers to + the root of the URI, it MUST be an empty string (not "/"). + """ + + path_info = "/" + path_info__doc = """ + The 'relative path' portion of the Request-URI. This is relative + to the script_name ('mount point') of the application which is + handling this request.""" + + login = None + login__doc = """ + When authentication is used during the request processing this is + set to 'False' if it failed and to the 'username' value if it succeeded. + The default 'None' implies that no authentication happened.""" + + # Note that cherrypy.url uses "if request.app:" to determine whether + # the call is during a real HTTP request or not. So leave this None. + app = None + app__doc = \ + """The cherrypy.Application object which is handling this request.""" + + handler = None + handler__doc = """ + The function, method, or other callable which CherryPy will call to + produce the response. The discovery of the handler and the arguments + it will receive are determined by the request.dispatch object. + By default, the handler is discovered by walking a tree of objects + starting at request.app.root, and is then passed all HTTP params + (from the query string and POST body) as keyword arguments.""" + + toolmaps = {} + toolmaps__doc = """ + A nested dict of all Toolboxes and Tools in effect for this request, + of the form: {Toolbox.namespace: {Tool.name: config dict}}.""" + + config = None + config__doc = """ + A flat dict of all configuration entries which apply to the + current request. These entries are collected from global config, + application config (based on request.path_info), and from handler + config (exactly how is governed by the request.dispatch object in + effect for this request; by default, handler config can be attached + anywhere in the tree between request.app.root and the final handler, + and inherits downward).""" + + is_index = None + is_index__doc = """ + This will be True if the current request is mapped to an 'index' + resource handler (also, a 'default' handler if path_info ends with + a slash). The value may be used to automatically redirect the + user-agent to a 'more canonical' URL which either adds or removes + the trailing slash. See cherrypy.tools.trailing_slash.""" + + hooks = HookMap(hookpoints) + hooks__doc = """ + A HookMap (dict-like object) of the form: {hookpoint: [hook, ...]}. + Each key is a str naming the hook point, and each value is a list + of hooks which will be called at that hook point during this request. + The list of hooks is generally populated as early as possible (mostly + from Tools specified in config), but may be extended at any time. + See also: _cprequest.Hook, _cprequest.HookMap, and cherrypy.tools.""" + + error_response = cherrypy.HTTPError(500).set_response + error_response__doc = """ + The no-arg callable which will handle unexpected, untrapped errors + during request processing. This is not used for expected exceptions + (like NotFound, HTTPError, or HTTPRedirect) which are raised in + response to expected conditions (those should be customized either + via request.error_page or by overriding HTTPError.set_response). + By default, error_response uses HTTPError(500) to return a generic + error response to the user-agent.""" + + error_page = {} + error_page__doc = """ + A dict of {error code: response filename or callable} pairs. + + The error code must be an int representing a given HTTP error code, + or the string 'default', which will be used if no matching entry + is found for a given numeric code. + + If a filename is provided, the file should contain a Python string- + formatting template, and can expect by default to receive format + values with the mapping keys %(status)s, %(message)s, %(traceback)s, + and %(version)s. The set of format mappings can be extended by + overriding HTTPError.set_response. + + If a callable is provided, it will be called by default with keyword + arguments 'status', 'message', 'traceback', and 'version', as for a + string-formatting template. The callable must return a string or iterable of + strings which will be set to response.body. It may also override headers or + perform any other processing. + + If no entry is given for an error code, and no 'default' entry exists, + a default template will be used. + """ + + show_tracebacks = True + show_tracebacks__doc = """ + If True, unexpected errors encountered during request processing will + include a traceback in the response body.""" + + show_mismatched_params = True + show_mismatched_params__doc = """ + If True, mismatched parameters encountered during PageHandler invocation + processing will be included in the response body.""" + + throws = (KeyboardInterrupt, SystemExit, cherrypy.InternalRedirect) + throws__doc = \ + """The sequence of exceptions which Request.run does not trap.""" + + throw_errors = False + throw_errors__doc = """ + If True, Request.run will not trap any errors (except HTTPRedirect and + HTTPError, which are more properly called 'exceptions', not errors).""" + + closed = False + closed__doc = """ + True once the close method has been called, False otherwise.""" + + stage = None + stage__doc = """ + A string containing the stage reached in the request-handling process. + This is useful when debugging a live server with hung requests.""" + + namespaces = _cpconfig.NamespaceSet( + **{"hooks": hooks_namespace, + "request": request_namespace, + "response": response_namespace, + "error_page": error_page_namespace, + "tools": cherrypy.tools, + }) + + def __init__(self, local_host, remote_host, scheme="http", + server_protocol="HTTP/1.1"): + """Populate a new Request object. + + local_host should be an httputil.Host object with the server info. + remote_host should be an httputil.Host object with the client info. + scheme should be a string, either "http" or "https". + """ + self.local = local_host + self.remote = remote_host + self.scheme = scheme + self.server_protocol = server_protocol + + self.closed = False + + # Put a *copy* of the class error_page into self. + self.error_page = self.error_page.copy() + + # Put a *copy* of the class namespaces into self. + self.namespaces = self.namespaces.copy() + + self.stage = None + + def close(self): + """Run cleanup code. (Core)""" + if not self.closed: + self.closed = True + self.stage = 'on_end_request' + self.hooks.run('on_end_request') + self.stage = 'close' + + def run(self, method, path, query_string, req_protocol, headers, rfile): + """Process the Request. (Core) + + method, path, query_string, and req_protocol should be pulled directly + from the Request-Line (e.g. "GET /path?key=val HTTP/1.0"). + path should be %XX-unquoted, but query_string should not be. + They both MUST be byte strings, not unicode strings. + headers should be a list of (name, value) tuples. + rfile should be a file-like object containing the HTTP request entity. + + When run() is done, the returned object should have 3 attributes: + status, e.g. "200 OK" + header_list, a list of (name, value) tuples + body, an iterable yielding strings + + Consumer code (HTTP servers) should then access these response + attributes to build the outbound stream. + + """ + response = cherrypy.serving.response + self.stage = 'run' + try: + self.error_response = cherrypy.HTTPError(500).set_response + + self.method = method + path = path or "/" + self.query_string = query_string or '' + self.params = {} + + # Compare request and server HTTP protocol versions, in case our + # server does not support the requested protocol. Limit our output + # to min(req, server). We want the following output: + # request server actual written supported response + # protocol protocol response protocol feature set + # a 1.0 1.0 1.0 1.0 + # b 1.0 1.1 1.1 1.0 + # c 1.1 1.0 1.0 1.0 + # d 1.1 1.1 1.1 1.1 + # Notice that, in (b), the response will be "HTTP/1.1" even though + # the client only understands 1.0. RFC 2616 10.5.6 says we should + # only return 505 if the _major_ version is different. + rp = int(req_protocol[5]), int(req_protocol[7]) + sp = int(self.server_protocol[5]), int(self.server_protocol[7]) + self.protocol = min(rp, sp) + response.headers.protocol = self.protocol + + # Rebuild first line of the request (e.g. "GET /path HTTP/1.0"). + url = path + if query_string: + url += '?' + query_string + self.request_line = '%s %s %s' % (method, url, req_protocol) + + self.header_list = list(headers) + self.headers = httputil.HeaderMap() + + self.rfile = rfile + self.body = None + + self.cookie = SimpleCookie() + self.handler = None + + # path_info should be the path from the + # app root (script_name) to the handler. + self.script_name = self.app.script_name + self.path_info = pi = path[len(self.script_name):] + + self.stage = 'respond' + self.respond(pi) + + except self.throws: + raise + except: + if self.throw_errors: + raise + else: + # Failure in setup, error handler or finalize. Bypass them. + # Can't use handle_error because we may not have hooks yet. + cherrypy.log(traceback=True, severity=40) + if self.show_tracebacks: + body = format_exc() + else: + body = "" + r = bare_error(body) + response.output_status, response.header_list, response.body = r + + if self.method == "HEAD": + # HEAD requests MUST NOT return a message-body in the response. + response.body = [] + + try: + cherrypy.log.access() + except: + cherrypy.log.error(traceback=True) + + if response.timed_out: + raise cherrypy.TimeoutError() + + return response + + # Uncomment for stage debugging + # stage = property(lambda self: self._stage, lambda self, v: print(v)) + + def respond(self, path_info): + """Generate a response for the resource at self.path_info. (Core)""" + response = cherrypy.serving.response + try: + try: + try: + if self.app is None: + raise cherrypy.NotFound() + + # Get the 'Host' header, so we can HTTPRedirect properly. + self.stage = 'process_headers' + self.process_headers() + + # Make a copy of the class hooks + self.hooks = self.__class__.hooks.copy() + self.toolmaps = {} + + self.stage = 'get_resource' + self.get_resource(path_info) + + self.body = _cpreqbody.RequestBody( + self.rfile, self.headers, request_params=self.params) + + self.namespaces(self.config) + + self.stage = 'on_start_resource' + self.hooks.run('on_start_resource') + + # Parse the querystring + self.stage = 'process_query_string' + self.process_query_string() + + # Process the body + if self.process_request_body: + if self.method not in self.methods_with_bodies: + self.process_request_body = False + self.stage = 'before_request_body' + self.hooks.run('before_request_body') + if self.process_request_body: + self.body.process() + + # Run the handler + self.stage = 'before_handler' + self.hooks.run('before_handler') + if self.handler: + self.stage = 'handler' + response.body = self.handler() + + # Finalize + self.stage = 'before_finalize' + self.hooks.run('before_finalize') + response.finalize() + except (cherrypy.HTTPRedirect, cherrypy.HTTPError), inst: + inst.set_response() + self.stage = 'before_finalize (HTTPError)' + self.hooks.run('before_finalize') + response.finalize() + finally: + self.stage = 'on_end_resource' + self.hooks.run('on_end_resource') + except self.throws: + raise + except: + if self.throw_errors: + raise + self.handle_error() + + def process_query_string(self): + """Parse the query string into Python structures. (Core)""" + try: + p = httputil.parse_query_string( + self.query_string, encoding=self.query_string_encoding) + except UnicodeDecodeError: + raise cherrypy.HTTPError( + 404, "The given query string could not be processed. Query " + "strings for this resource must be encoded with %r." % + self.query_string_encoding) + + # Python 2 only: keyword arguments must be byte strings (type 'str'). + for key, value in p.items(): + if isinstance(key, unicode): + del p[key] + p[key.encode(self.query_string_encoding)] = value + self.params.update(p) + + def process_headers(self): + """Parse HTTP header data into Python structures. (Core)""" + # Process the headers into self.headers + headers = self.headers + for name, value in self.header_list: + # Call title() now (and use dict.__method__(headers)) + # so title doesn't have to be called twice. + name = name.title() + value = value.strip() + + # Warning: if there is more than one header entry for cookies (AFAIK, + # only Konqueror does that), only the last one will remain in headers + # (but they will be correctly stored in request.cookie). + if "=?" in value: + dict.__setitem__(headers, name, httputil.decode_TEXT(value)) + else: + dict.__setitem__(headers, name, value) + + # Handle cookies differently because on Konqueror, multiple + # cookies come on different lines with the same key + if name == 'Cookie': + try: + self.cookie.load(value) + except CookieError: + msg = "Illegal cookie name %s" % value.split('=')[0] + raise cherrypy.HTTPError(400, msg) + + if not dict.__contains__(headers, 'Host'): + # All Internet-based HTTP/1.1 servers MUST respond with a 400 + # (Bad Request) status code to any HTTP/1.1 request message + # which lacks a Host header field. + if self.protocol >= (1, 1): + msg = "HTTP/1.1 requires a 'Host' request header." + raise cherrypy.HTTPError(400, msg) + host = dict.get(headers, 'Host') + if not host: + host = self.local.name or self.local.ip + self.base = "%s://%s" % (self.scheme, host) + + def get_resource(self, path): + """Call a dispatcher (which sets self.handler and .config). (Core)""" + # First, see if there is a custom dispatch at this URI. Custom + # dispatchers can only be specified in app.config, not in _cp_config + # (since custom dispatchers may not even have an app.root). + dispatch = self.app.find_config(path, "request.dispatch", self.dispatch) + + # dispatch() should set self.handler and self.config + dispatch(path) + + def handle_error(self): + """Handle the last unanticipated exception. (Core)""" + try: + self.hooks.run("before_error_response") + if self.error_response: + self.error_response() + self.hooks.run("after_error_response") + cherrypy.serving.response.finalize() + except cherrypy.HTTPRedirect, inst: + inst.set_response() + cherrypy.serving.response.finalize() + + # ------------------------- Properties ------------------------- # + + def _get_body_params(self): + warnings.warn( + "body_params is deprecated in CherryPy 3.2, will be removed in " + "CherryPy 3.3.", + DeprecationWarning + ) + return self.body.params + body_params = property(_get_body_params, + doc=""" + If the request Content-Type is 'application/x-www-form-urlencoded' or + multipart, this will be a dict of the params pulled from the entity + body; that is, it will be the portion of request.params that come + from the message body (sometimes called "POST params", although they + can be sent with various HTTP method verbs). This value is set between + the 'before_request_body' and 'before_handler' hooks (assuming that + process_request_body is True). + + Deprecated in 3.2, will be removed for 3.3""") + + +class ResponseBody(object): + """The body of the HTTP response (the response entity).""" + + def __get__(self, obj, objclass=None): + if obj is None: + # When calling on the class instead of an instance... + return self + else: + return obj._body + + def __set__(self, obj, value): + # Convert the given value to an iterable object. + if isinstance(value, basestring): + # strings get wrapped in a list because iterating over a single + # item list is much faster than iterating over every character + # in a long string. + if value: + value = [value] + else: + # [''] doesn't evaluate to False, so replace it with []. + value = [] + elif isinstance(value, types.FileType): + value = file_generator(value) + elif value is None: + value = [] + obj._body = value + + +class Response(object): + """An HTTP Response, including status, headers, and body. + + Application developers should use Response.headers (a dict) to + set or modify HTTP response headers. When the response is finalized, + Response.headers is transformed into Response.header_list as + (key, value) tuples. + """ + + __metaclass__ = cherrypy._AttributeDocstrings + + # Class attributes for dev-time introspection. + status = "" + status__doc = """The HTTP Status-Code and Reason-Phrase.""" + + header_list = [] + header_list__doc = """ + A list of the HTTP response headers as (name, value) tuples. + In general, you should use response.headers (a dict) instead.""" + + headers = httputil.HeaderMap() + headers__doc = """ + A dict-like object containing the response headers. Keys are header + names (in Title-Case format); however, you may get and set them in + a case-insensitive manner. That is, headers['Content-Type'] and + headers['content-type'] refer to the same value. Values are header + values (decoded according to RFC 2047 if necessary). See also: + httputil.HeaderMap, httputil.HeaderElement.""" + + cookie = SimpleCookie() + cookie__doc = """See help(Cookie).""" + + body = ResponseBody() + body__doc = """The body (entity) of the HTTP response.""" + + time = None + time__doc = """The value of time.time() when created. Use in HTTP dates.""" + + timeout = 300 + timeout__doc = """Seconds after which the response will be aborted.""" + + timed_out = False + timed_out__doc = """ + Flag to indicate the response should be aborted, because it has + exceeded its timeout.""" + + stream = False + stream__doc = """If False, buffer the response body.""" + + def __init__(self): + self.status = None + self.header_list = None + self._body = [] + self.time = time.time() + + self.headers = httputil.HeaderMap() + # Since we know all our keys are titled strings, we can + # bypass HeaderMap.update and get a big speed boost. + dict.update(self.headers, { + "Content-Type": 'text/html', + "Server": "CherryPy/" + cherrypy.__version__, + "Date": httputil.HTTPDate(self.time), + }) + self.cookie = SimpleCookie() + + def collapse_body(self): + """Collapse self.body to a single string; replace it and return it.""" + if isinstance(self.body, basestring): + return self.body + + newbody = ''.join([chunk for chunk in self.body]) + self.body = newbody + return newbody + + def finalize(self): + """Transform headers (and cookies) into self.header_list. (Core)""" + try: + code, reason, _ = httputil.valid_status(self.status) + except ValueError, x: + raise cherrypy.HTTPError(500, x.args[0]) + + headers = self.headers + + self.output_status = str(code) + " " + headers.encode(reason) + + if self.stream: + # The upshot: wsgiserver will chunk the response if + # you pop Content-Length (or set it explicitly to None). + # Note that lib.static sets C-L to the file's st_size. + if dict.get(headers, 'Content-Length') is None: + dict.pop(headers, 'Content-Length', None) + elif code < 200 or code in (204, 205, 304): + # "All 1xx (informational), 204 (no content), + # and 304 (not modified) responses MUST NOT + # include a message-body." + dict.pop(headers, 'Content-Length', None) + self.body = "" + else: + # Responses which are not streamed should have a Content-Length, + # but allow user code to set Content-Length if desired. + if dict.get(headers, 'Content-Length') is None: + content = self.collapse_body() + dict.__setitem__(headers, 'Content-Length', len(content)) + + # Transform our header dict into a list of tuples. + self.header_list = h = headers.output() + + cookie = self.cookie.output() + if cookie: + for line in cookie.split("\n"): + if line.endswith("\r"): + # Python 2.4 emits cookies joined by LF but 2.5+ by CRLF. + line = line[:-1] + name, value = line.split(": ", 1) + if isinstance(name, unicode): + name = name.encode("ISO-8859-1") + if isinstance(value, unicode): + value = headers.encode(value) + h.append((name, value)) + + def check_timeout(self): + """If now > self.time + self.timeout, set self.timed_out. + + This purposefully sets a flag, rather than raising an error, + so that a monitor thread can interrupt the Response thread. + """ + if time.time() > self.time + self.timeout: + self.timed_out = True + + + diff --git a/cherrypy/_cpserver.py b/cherrypy/_cpserver.py index 2dda250701..9fa63f0666 100644 --- a/cherrypy/_cpserver.py +++ b/cherrypy/_cpserver.py @@ -1,139 +1,139 @@ -"""Manage HTTP servers with CherryPy.""" - -import warnings - -import cherrypy -from cherrypy.lib import attributes - -# We import * because we want to export check_port -# et al as attributes of this module. -from cherrypy.process.servers import * - - -class Server(ServerAdapter): - """An adapter for an HTTP server. - - You can set attributes (like socket_host and socket_port) - on *this* object (which is probably cherrypy.server), and call - quickstart. For example: - - cherrypy.server.socket_port = 80 - cherrypy.quickstart() - """ - - socket_port = 8080 - - _socket_host = '127.0.0.1' - def _get_socket_host(self): - return self._socket_host - def _set_socket_host(self, value): - if value == '': - raise ValueError("The empty string ('') is not an allowed value. " - "Use '0.0.0.0' instead to listen on all active " - "interfaces (INADDR_ANY).") - self._socket_host = value - socket_host = property(_get_socket_host, _set_socket_host, - doc="""The hostname or IP address on which to listen for connections. - - Host values may be any IPv4 or IPv6 address, or any valid hostname. - The string 'localhost' is a synonym for '127.0.0.1' (or '::1', if - your hosts file prefers IPv6). The string '0.0.0.0' is a special - IPv4 entry meaning "any active interface" (INADDR_ANY), and '::' - is the similar IN6ADDR_ANY for IPv6. The empty string or None are - not allowed.""") - - socket_file = None - socket_queue_size = 5 - socket_timeout = 10 - shutdown_timeout = 5 - protocol_version = 'HTTP/1.1' - reverse_dns = False - thread_pool = 10 - thread_pool_max = -1 - max_request_header_size = 500 * 1024 - max_request_body_size = 100 * 1024 * 1024 - instance = None - ssl_context = None - ssl_certificate = None - ssl_certificate_chain = None - ssl_private_key = None - ssl_module = 'pyopenssl' - nodelay = True - wsgi_version = (1, 1) - - def __init__(self): - self.bus = cherrypy.engine - self.httpserver = None - self.interrupt = None - self.running = False - - def httpserver_from_self(self, httpserver=None): - """Return a (httpserver, bind_addr) pair based on self attributes.""" - if httpserver is None: - httpserver = self.instance - if httpserver is None: - from cherrypy import _cpwsgi_server - httpserver = _cpwsgi_server.CPWSGIServer(self) - if isinstance(httpserver, basestring): - # Is anyone using this? Can I add an arg? - httpserver = attributes(httpserver)(self) - return httpserver, self.bind_addr - - def start(self): - """Start the HTTP server.""" - if not self.httpserver: - self.httpserver, self.bind_addr = self.httpserver_from_self() - ServerAdapter.start(self) - start.priority = 75 - - def _get_bind_addr(self): - if self.socket_file: - return self.socket_file - if self.socket_host is None and self.socket_port is None: - return None - return (self.socket_host, self.socket_port) - def _set_bind_addr(self, value): - if value is None: - self.socket_file = None - self.socket_host = None - self.socket_port = None - elif isinstance(value, basestring): - self.socket_file = value - self.socket_host = None - self.socket_port = None - else: - try: - self.socket_host, self.socket_port = value - self.socket_file = None - except ValueError: - raise ValueError("bind_addr must be a (host, port) tuple " - "(for TCP sockets) or a string (for Unix " - "domain sockets), not %r" % value) - bind_addr = property(_get_bind_addr, _set_bind_addr) - - def base(self): - """Return the base (scheme://host[:port] or sock file) for this server.""" - if self.socket_file: - return self.socket_file - - host = self.socket_host - if host in ('0.0.0.0', '::'): - # 0.0.0.0 is INADDR_ANY and :: is IN6ADDR_ANY. - # Look up the host name, which should be the - # safest thing to spit out in a URL. - import socket - host = socket.gethostname() - - port = self.socket_port - - if self.ssl_certificate: - scheme = "https" - if port != 443: - host += ":%s" % port - else: - scheme = "http" - if port != 80: - host += ":%s" % port - - return "%s://%s" % (scheme, host) - +"""Manage HTTP servers with CherryPy.""" + +import warnings + +import cherrypy +from cherrypy.lib import attributes + +# We import * because we want to export check_port +# et al as attributes of this module. +from cherrypy.process.servers import * + + +class Server(ServerAdapter): + """An adapter for an HTTP server. + + You can set attributes (like socket_host and socket_port) + on *this* object (which is probably cherrypy.server), and call + quickstart. For example: + + cherrypy.server.socket_port = 80 + cherrypy.quickstart() + """ + + socket_port = 8080 + + _socket_host = '127.0.0.1' + def _get_socket_host(self): + return self._socket_host + def _set_socket_host(self, value): + if value == '': + raise ValueError("The empty string ('') is not an allowed value. " + "Use '0.0.0.0' instead to listen on all active " + "interfaces (INADDR_ANY).") + self._socket_host = value + socket_host = property(_get_socket_host, _set_socket_host, + doc="""The hostname or IP address on which to listen for connections. + + Host values may be any IPv4 or IPv6 address, or any valid hostname. + The string 'localhost' is a synonym for '127.0.0.1' (or '::1', if + your hosts file prefers IPv6). The string '0.0.0.0' is a special + IPv4 entry meaning "any active interface" (INADDR_ANY), and '::' + is the similar IN6ADDR_ANY for IPv6. The empty string or None are + not allowed.""") + + socket_file = None + socket_queue_size = 5 + socket_timeout = 10 + shutdown_timeout = 5 + protocol_version = 'HTTP/1.1' + reverse_dns = False + thread_pool = 10 + thread_pool_max = -1 + max_request_header_size = 500 * 1024 + max_request_body_size = 100 * 1024 * 1024 + instance = None + ssl_context = None + ssl_certificate = None + ssl_certificate_chain = None + ssl_private_key = None + ssl_module = 'pyopenssl' + nodelay = True + wsgi_version = (1, 1) + + def __init__(self): + self.bus = cherrypy.engine + self.httpserver = None + self.interrupt = None + self.running = False + + def httpserver_from_self(self, httpserver=None): + """Return a (httpserver, bind_addr) pair based on self attributes.""" + if httpserver is None: + httpserver = self.instance + if httpserver is None: + from cherrypy import _cpwsgi_server + httpserver = _cpwsgi_server.CPWSGIServer(self) + if isinstance(httpserver, basestring): + # Is anyone using this? Can I add an arg? + httpserver = attributes(httpserver)(self) + return httpserver, self.bind_addr + + def start(self): + """Start the HTTP server.""" + if not self.httpserver: + self.httpserver, self.bind_addr = self.httpserver_from_self() + ServerAdapter.start(self) + start.priority = 75 + + def _get_bind_addr(self): + if self.socket_file: + return self.socket_file + if self.socket_host is None and self.socket_port is None: + return None + return (self.socket_host, self.socket_port) + def _set_bind_addr(self, value): + if value is None: + self.socket_file = None + self.socket_host = None + self.socket_port = None + elif isinstance(value, basestring): + self.socket_file = value + self.socket_host = None + self.socket_port = None + else: + try: + self.socket_host, self.socket_port = value + self.socket_file = None + except ValueError: + raise ValueError("bind_addr must be a (host, port) tuple " + "(for TCP sockets) or a string (for Unix " + "domain sockets), not %r" % value) + bind_addr = property(_get_bind_addr, _set_bind_addr) + + def base(self): + """Return the base (scheme://host[:port] or sock file) for this server.""" + if self.socket_file: + return self.socket_file + + host = self.socket_host + if host in ('0.0.0.0', '::'): + # 0.0.0.0 is INADDR_ANY and :: is IN6ADDR_ANY. + # Look up the host name, which should be the + # safest thing to spit out in a URL. + import socket + host = socket.gethostname() + + port = self.socket_port + + if self.ssl_certificate: + scheme = "https" + if port != 443: + host += ":%s" % port + else: + scheme = "http" + if port != 80: + host += ":%s" % port + + return "%s://%s" % (scheme, host) + diff --git a/cherrypy/_cpthreadinglocal.py b/cherrypy/_cpthreadinglocal.py index e8893b504a..34c17ac41a 100644 --- a/cherrypy/_cpthreadinglocal.py +++ b/cherrypy/_cpthreadinglocal.py @@ -1,239 +1,239 @@ -# This is a backport of Python-2.4's threading.local() implementation - -"""Thread-local objects - -(Note that this module provides a Python version of thread - threading.local class. Depending on the version of Python you're - using, there may be a faster one available. You should always import - the local class from threading.) - -Thread-local objects support the management of thread-local data. -If you have data that you want to be local to a thread, simply create -a thread-local object and use its attributes: - - >>> mydata = local() - >>> mydata.number = 42 - >>> mydata.number - 42 - -You can also access the local-object's dictionary: - - >>> mydata.__dict__ - {'number': 42} - >>> mydata.__dict__.setdefault('widgets', []) - [] - >>> mydata.widgets - [] - -What's important about thread-local objects is that their data are -local to a thread. If we access the data in a different thread: - - >>> log = [] - >>> def f(): - ... items = mydata.__dict__.items() - ... items.sort() - ... log.append(items) - ... mydata.number = 11 - ... log.append(mydata.number) - - >>> import threading - >>> thread = threading.Thread(target=f) - >>> thread.start() - >>> thread.join() - >>> log - [[], 11] - -we get different data. Furthermore, changes made in the other thread -don't affect data seen in this thread: - - >>> mydata.number - 42 - -Of course, values you get from a local object, including a __dict__ -attribute, are for whatever thread was current at the time the -attribute was read. For that reason, you generally don't want to save -these values across threads, as they apply only to the thread they -came from. - -You can create custom local objects by subclassing the local class: - - >>> class MyLocal(local): - ... number = 2 - ... initialized = False - ... def __init__(self, **kw): - ... if self.initialized: - ... raise SystemError('__init__ called too many times') - ... self.initialized = True - ... self.__dict__.update(kw) - ... def squared(self): - ... return self.number ** 2 - -This can be useful to support default values, methods and -initialization. Note that if you define an __init__ method, it will be -called each time the local object is used in a separate thread. This -is necessary to initialize each thread's dictionary. - -Now if we create a local object: - - >>> mydata = MyLocal(color='red') - -Now we have a default number: - - >>> mydata.number - 2 - -an initial color: - - >>> mydata.color - 'red' - >>> del mydata.color - -And a method that operates on the data: - - >>> mydata.squared() - 4 - -As before, we can access the data in a separate thread: - - >>> log = [] - >>> thread = threading.Thread(target=f) - >>> thread.start() - >>> thread.join() - >>> log - [[('color', 'red'), ('initialized', True)], 11] - -without affecting this thread's data: - - >>> mydata.number - 2 - >>> mydata.color - Traceback (most recent call last): - ... - AttributeError: 'MyLocal' object has no attribute 'color' - -Note that subclasses can define slots, but they are not thread -local. They are shared across threads: - - >>> class MyLocal(local): - ... __slots__ = 'number' - - >>> mydata = MyLocal() - >>> mydata.number = 42 - >>> mydata.color = 'red' - -So, the separate thread: - - >>> thread = threading.Thread(target=f) - >>> thread.start() - >>> thread.join() - -affects what we see: - - >>> mydata.number - 11 - ->>> del mydata -""" - -# Threading import is at end - -class _localbase(object): - __slots__ = '_local__key', '_local__args', '_local__lock' - - def __new__(cls, *args, **kw): - self = object.__new__(cls) - key = 'thread.local.' + str(id(self)) - object.__setattr__(self, '_local__key', key) - object.__setattr__(self, '_local__args', (args, kw)) - object.__setattr__(self, '_local__lock', RLock()) - - if args or kw and (cls.__init__ is object.__init__): - raise TypeError("Initialization arguments are not supported") - - # We need to create the thread dict in anticipation of - # __init__ being called, to make sure we don't call it - # again ourselves. - dict = object.__getattribute__(self, '__dict__') - currentThread().__dict__[key] = dict - - return self - -def _patch(self): - key = object.__getattribute__(self, '_local__key') - d = currentThread().__dict__.get(key) - if d is None: - d = {} - currentThread().__dict__[key] = d - object.__setattr__(self, '__dict__', d) - - # we have a new instance dict, so call out __init__ if we have - # one - cls = type(self) - if cls.__init__ is not object.__init__: - args, kw = object.__getattribute__(self, '_local__args') - cls.__init__(self, *args, **kw) - else: - object.__setattr__(self, '__dict__', d) - -class local(_localbase): - - def __getattribute__(self, name): - lock = object.__getattribute__(self, '_local__lock') - lock.acquire() - try: - _patch(self) - return object.__getattribute__(self, name) - finally: - lock.release() - - def __setattr__(self, name, value): - lock = object.__getattribute__(self, '_local__lock') - lock.acquire() - try: - _patch(self) - return object.__setattr__(self, name, value) - finally: - lock.release() - - def __delattr__(self, name): - lock = object.__getattribute__(self, '_local__lock') - lock.acquire() - try: - _patch(self) - return object.__delattr__(self, name) - finally: - lock.release() - - - def __del__(): - threading_enumerate = enumerate - __getattribute__ = object.__getattribute__ - - def __del__(self): - key = __getattribute__(self, '_local__key') - - try: - threads = list(threading_enumerate()) - except: - # if enumerate fails, as it seems to do during - # shutdown, we'll skip cleanup under the assumption - # that there is nothing to clean up - return - - for thread in threads: - try: - __dict__ = thread.__dict__ - except AttributeError: - # Thread is dying, rest in peace - continue - - if key in __dict__: - try: - del __dict__[key] - except KeyError: - pass # didn't have anything in this thread - - return __del__ - __del__ = __del__() - -from threading import currentThread, enumerate, RLock +# This is a backport of Python-2.4's threading.local() implementation + +"""Thread-local objects + +(Note that this module provides a Python version of thread + threading.local class. Depending on the version of Python you're + using, there may be a faster one available. You should always import + the local class from threading.) + +Thread-local objects support the management of thread-local data. +If you have data that you want to be local to a thread, simply create +a thread-local object and use its attributes: + + >>> mydata = local() + >>> mydata.number = 42 + >>> mydata.number + 42 + +You can also access the local-object's dictionary: + + >>> mydata.__dict__ + {'number': 42} + >>> mydata.__dict__.setdefault('widgets', []) + [] + >>> mydata.widgets + [] + +What's important about thread-local objects is that their data are +local to a thread. If we access the data in a different thread: + + >>> log = [] + >>> def f(): + ... items = mydata.__dict__.items() + ... items.sort() + ... log.append(items) + ... mydata.number = 11 + ... log.append(mydata.number) + + >>> import threading + >>> thread = threading.Thread(target=f) + >>> thread.start() + >>> thread.join() + >>> log + [[], 11] + +we get different data. Furthermore, changes made in the other thread +don't affect data seen in this thread: + + >>> mydata.number + 42 + +Of course, values you get from a local object, including a __dict__ +attribute, are for whatever thread was current at the time the +attribute was read. For that reason, you generally don't want to save +these values across threads, as they apply only to the thread they +came from. + +You can create custom local objects by subclassing the local class: + + >>> class MyLocal(local): + ... number = 2 + ... initialized = False + ... def __init__(self, **kw): + ... if self.initialized: + ... raise SystemError('__init__ called too many times') + ... self.initialized = True + ... self.__dict__.update(kw) + ... def squared(self): + ... return self.number ** 2 + +This can be useful to support default values, methods and +initialization. Note that if you define an __init__ method, it will be +called each time the local object is used in a separate thread. This +is necessary to initialize each thread's dictionary. + +Now if we create a local object: + + >>> mydata = MyLocal(color='red') + +Now we have a default number: + + >>> mydata.number + 2 + +an initial color: + + >>> mydata.color + 'red' + >>> del mydata.color + +And a method that operates on the data: + + >>> mydata.squared() + 4 + +As before, we can access the data in a separate thread: + + >>> log = [] + >>> thread = threading.Thread(target=f) + >>> thread.start() + >>> thread.join() + >>> log + [[('color', 'red'), ('initialized', True)], 11] + +without affecting this thread's data: + + >>> mydata.number + 2 + >>> mydata.color + Traceback (most recent call last): + ... + AttributeError: 'MyLocal' object has no attribute 'color' + +Note that subclasses can define slots, but they are not thread +local. They are shared across threads: + + >>> class MyLocal(local): + ... __slots__ = 'number' + + >>> mydata = MyLocal() + >>> mydata.number = 42 + >>> mydata.color = 'red' + +So, the separate thread: + + >>> thread = threading.Thread(target=f) + >>> thread.start() + >>> thread.join() + +affects what we see: + + >>> mydata.number + 11 + +>>> del mydata +""" + +# Threading import is at end + +class _localbase(object): + __slots__ = '_local__key', '_local__args', '_local__lock' + + def __new__(cls, *args, **kw): + self = object.__new__(cls) + key = 'thread.local.' + str(id(self)) + object.__setattr__(self, '_local__key', key) + object.__setattr__(self, '_local__args', (args, kw)) + object.__setattr__(self, '_local__lock', RLock()) + + if args or kw and (cls.__init__ is object.__init__): + raise TypeError("Initialization arguments are not supported") + + # We need to create the thread dict in anticipation of + # __init__ being called, to make sure we don't call it + # again ourselves. + dict = object.__getattribute__(self, '__dict__') + currentThread().__dict__[key] = dict + + return self + +def _patch(self): + key = object.__getattribute__(self, '_local__key') + d = currentThread().__dict__.get(key) + if d is None: + d = {} + currentThread().__dict__[key] = d + object.__setattr__(self, '__dict__', d) + + # we have a new instance dict, so call out __init__ if we have + # one + cls = type(self) + if cls.__init__ is not object.__init__: + args, kw = object.__getattribute__(self, '_local__args') + cls.__init__(self, *args, **kw) + else: + object.__setattr__(self, '__dict__', d) + +class local(_localbase): + + def __getattribute__(self, name): + lock = object.__getattribute__(self, '_local__lock') + lock.acquire() + try: + _patch(self) + return object.__getattribute__(self, name) + finally: + lock.release() + + def __setattr__(self, name, value): + lock = object.__getattribute__(self, '_local__lock') + lock.acquire() + try: + _patch(self) + return object.__setattr__(self, name, value) + finally: + lock.release() + + def __delattr__(self, name): + lock = object.__getattribute__(self, '_local__lock') + lock.acquire() + try: + _patch(self) + return object.__delattr__(self, name) + finally: + lock.release() + + + def __del__(): + threading_enumerate = enumerate + __getattribute__ = object.__getattribute__ + + def __del__(self): + key = __getattribute__(self, '_local__key') + + try: + threads = list(threading_enumerate()) + except: + # if enumerate fails, as it seems to do during + # shutdown, we'll skip cleanup under the assumption + # that there is nothing to clean up + return + + for thread in threads: + try: + __dict__ = thread.__dict__ + except AttributeError: + # Thread is dying, rest in peace + continue + + if key in __dict__: + try: + del __dict__[key] + except KeyError: + pass # didn't have anything in this thread + + return __del__ + __del__ = __del__() + +from threading import currentThread, enumerate, RLock diff --git a/cherrypy/_cptools.py b/cherrypy/_cptools.py index 97afd48a73..2eb826fc69 100644 --- a/cherrypy/_cptools.py +++ b/cherrypy/_cptools.py @@ -1,498 +1,498 @@ -"""CherryPy tools. A "tool" is any helper, adapted to CP. - -Tools are usually designed to be used in a variety of ways (although some -may only offer one if they choose): - - Library calls: - All tools are callables that can be used wherever needed. - The arguments are straightforward and should be detailed within the - docstring. - - Function decorators: - All tools, when called, may be used as decorators which configure - individual CherryPy page handlers (methods on the CherryPy tree). - That is, "@tools.anytool()" should "turn on" the tool via the - decorated function's _cp_config attribute. - - CherryPy config: - If a tool exposes a "_setup" callable, it will be called - once per Request (if the feature is "turned on" via config). - -Tools may be implemented as any object with a namespace. The builtins -are generally either modules or instances of the tools.Tool class. -""" - -import cherrypy -import warnings - - -def _getargs(func): - """Return the names of all static arguments to the given function.""" - # Use this instead of importing inspect for less mem overhead. - import types - if isinstance(func, types.MethodType): - func = func.im_func - co = func.func_code - return co.co_varnames[:co.co_argcount] - - -_attr_error = ("CherryPy Tools cannot be turned on directly. Instead, turn them " - "on via config, or use them as decorators on your page handlers.") - -class Tool(object): - """A registered function for use with CherryPy request-processing hooks. - - help(tool.callable) should give you more information about this Tool. - """ - - namespace = "tools" - - def __init__(self, point, callable, name=None, priority=50): - self._point = point - self.callable = callable - self._name = name - self._priority = priority - self.__doc__ = self.callable.__doc__ - self._setargs() - - def _get_on(self): - raise AttributeError(_attr_error) - def _set_on(self, value): - raise AttributeError(_attr_error) - on = property(_get_on, _set_on) - - def _setargs(self): - """Copy func parameter names to obj attributes.""" - try: - for arg in _getargs(self.callable): - setattr(self, arg, None) - except (TypeError, AttributeError): - if hasattr(self.callable, "__call__"): - for arg in _getargs(self.callable.__call__): - setattr(self, arg, None) - # IronPython 1.0 raises NotImplementedError because - # inspect.getargspec tries to access Python bytecode - # in co_code attribute. - except NotImplementedError: - pass - # IronPython 1B1 may raise IndexError in some cases, - # but if we trap it here it doesn't prevent CP from working. - except IndexError: - pass - - def _merged_args(self, d=None): - """Return a dict of configuration entries for this Tool.""" - if d: - conf = d.copy() - else: - conf = {} - - tm = cherrypy.serving.request.toolmaps[self.namespace] - if self._name in tm: - conf.update(tm[self._name]) - - if "on" in conf: - del conf["on"] - - return conf - - def __call__(self, *args, **kwargs): - """Compile-time decorator (turn on the tool in config). - - For example: - - @tools.proxy() - def whats_my_base(self): - return cherrypy.request.base - whats_my_base.exposed = True - """ - if args: - raise TypeError("The %r Tool does not accept positional " - "arguments; you must use keyword arguments." - % self._name) - def tool_decorator(f): - if not hasattr(f, "_cp_config"): - f._cp_config = {} - subspace = self.namespace + "." + self._name + "." - f._cp_config[subspace + "on"] = True - for k, v in kwargs.items(): - f._cp_config[subspace + k] = v - return f - return tool_decorator - - def _setup(self): - """Hook this tool into cherrypy.request. - - The standard CherryPy request object will automatically call this - method when the tool is "turned on" in config. - """ - conf = self._merged_args() - p = conf.pop("priority", None) - if p is None: - p = getattr(self.callable, "priority", self._priority) - cherrypy.serving.request.hooks.attach(self._point, self.callable, - priority=p, **conf) - - -class HandlerTool(Tool): - """Tool which is called 'before main', that may skip normal handlers. - - If the tool successfully handles the request (by setting response.body), - if should return True. This will cause CherryPy to skip any 'normal' page - handler. If the tool did not handle the request, it should return False - to tell CherryPy to continue on and call the normal page handler. If the - tool is declared AS a page handler (see the 'handler' method), returning - False will raise NotFound. - """ - - def __init__(self, callable, name=None): - Tool.__init__(self, 'before_handler', callable, name) - - def handler(self, *args, **kwargs): - """Use this tool as a CherryPy page handler. - - For example: - class Root: - nav = tools.staticdir.handler(section="/nav", dir="nav", - root=absDir) - """ - def handle_func(*a, **kw): - handled = self.callable(*args, **self._merged_args(kwargs)) - if not handled: - raise cherrypy.NotFound() - return cherrypy.serving.response.body - handle_func.exposed = True - return handle_func - - def _wrapper(self, **kwargs): - if self.callable(**kwargs): - cherrypy.serving.request.handler = None - - def _setup(self): - """Hook this tool into cherrypy.request. - - The standard CherryPy request object will automatically call this - method when the tool is "turned on" in config. - """ - conf = self._merged_args() - p = conf.pop("priority", None) - if p is None: - p = getattr(self.callable, "priority", self._priority) - cherrypy.serving.request.hooks.attach(self._point, self._wrapper, - priority=p, **conf) - - -class HandlerWrapperTool(Tool): - """Tool which wraps request.handler in a provided wrapper function. - - The 'newhandler' arg must be a handler wrapper function that takes a - 'next_handler' argument, plus *args and **kwargs. Like all page handler - functions, it must return an iterable for use as cherrypy.response.body. - - For example, to allow your 'inner' page handlers to return dicts - which then get interpolated into a template: - - def interpolator(next_handler, *args, **kwargs): - filename = cherrypy.request.config.get('template') - cherrypy.response.template = env.get_template(filename) - response_dict = next_handler(*args, **kwargs) - return cherrypy.response.template.render(**response_dict) - cherrypy.tools.jinja = HandlerWrapperTool(interpolator) - """ - - def __init__(self, newhandler, point='before_handler', name=None, priority=50): - self.newhandler = newhandler - self._point = point - self._name = name - self._priority = priority - - def callable(self, debug=False): - innerfunc = cherrypy.serving.request.handler - def wrap(*args, **kwargs): - return self.newhandler(innerfunc, *args, **kwargs) - cherrypy.serving.request.handler = wrap - - -class ErrorTool(Tool): - """Tool which is used to replace the default request.error_response.""" - - def __init__(self, callable, name=None): - Tool.__init__(self, None, callable, name) - - def _wrapper(self): - self.callable(**self._merged_args()) - - def _setup(self): - """Hook this tool into cherrypy.request. - - The standard CherryPy request object will automatically call this - method when the tool is "turned on" in config. - """ - cherrypy.serving.request.error_response = self._wrapper - - -# Builtin tools # - -from cherrypy.lib import cptools, encoding, auth, static, jsontools -from cherrypy.lib import sessions as _sessions, xmlrpc as _xmlrpc -from cherrypy.lib import caching as _caching -from cherrypy.lib import auth_basic, auth_digest - - -class SessionTool(Tool): - """Session Tool for CherryPy. - - sessions.locking: - When 'implicit' (the default), the session will be locked for you, - just before running the page handler. - When 'early', the session will be locked before reading the request - body. This is off by default for safety reasons; for example, - a large upload would block the session, denying an AJAX - progress meter (see http://www.cherrypy.org/ticket/630). - When 'explicit' (or any other value), you need to call - cherrypy.session.acquire_lock() yourself before using - session data. - """ - - def __init__(self): - # _sessions.init must be bound after headers are read - Tool.__init__(self, 'before_request_body', _sessions.init) - - def _lock_session(self): - cherrypy.serving.session.acquire_lock() - - def _setup(self): - """Hook this tool into cherrypy.request. - - The standard CherryPy request object will automatically call this - method when the tool is "turned on" in config. - """ - hooks = cherrypy.serving.request.hooks - - conf = self._merged_args() - - p = conf.pop("priority", None) - if p is None: - p = getattr(self.callable, "priority", self._priority) - - hooks.attach(self._point, self.callable, priority=p, **conf) - - locking = conf.pop('locking', 'implicit') - if locking == 'implicit': - hooks.attach('before_handler', self._lock_session) - elif locking == 'early': - # Lock before the request body (but after _sessions.init runs!) - hooks.attach('before_request_body', self._lock_session, - priority=60) - else: - # Don't lock - pass - - hooks.attach('before_finalize', _sessions.save) - hooks.attach('on_end_request', _sessions.close) - - def regenerate(self): - """Drop the current session and make a new one (with a new id).""" - sess = cherrypy.serving.session - sess.regenerate() - - # Grab cookie-relevant tool args - conf = dict([(k, v) for k, v in self._merged_args().items() - if k in ('path', 'path_header', 'name', 'timeout', - 'domain', 'secure')]) - _sessions.set_response_cookie(**conf) - - - - -class XMLRPCController(object): - """A Controller (page handler collection) for XML-RPC. - - To use it, have your controllers subclass this base class (it will - turn on the tool for you). - - You can also supply the following optional config entries: - - tools.xmlrpc.encoding: 'utf-8' - tools.xmlrpc.allow_none: 0 - - XML-RPC is a rather discontinuous layer over HTTP; dispatching to the - appropriate handler must first be performed according to the URL, and - then a second dispatch step must take place according to the RPC method - specified in the request body. It also allows a superfluous "/RPC2" - prefix in the URL, supplies its own handler args in the body, and - requires a 200 OK "Fault" response instead of 404 when the desired - method is not found. - - Therefore, XML-RPC cannot be implemented for CherryPy via a Tool alone. - This Controller acts as the dispatch target for the first half (based - on the URL); it then reads the RPC method from the request body and - does its own second dispatch step based on that method. It also reads - body params, and returns a Fault on error. - - The XMLRPCDispatcher strips any /RPC2 prefix; if you aren't using /RPC2 - in your URL's, you can safely skip turning on the XMLRPCDispatcher. - Otherwise, you need to use declare it in config: - - request.dispatch: cherrypy.dispatch.XMLRPCDispatcher() - """ - - # Note we're hard-coding this into the 'tools' namespace. We could do - # a huge amount of work to make it relocatable, but the only reason why - # would be if someone actually disabled the default_toolbox. Meh. - _cp_config = {'tools.xmlrpc.on': True} - - def default(self, *vpath, **params): - rpcparams, rpcmethod = _xmlrpc.process_body() - - subhandler = self - for attr in str(rpcmethod).split('.'): - subhandler = getattr(subhandler, attr, None) - - if subhandler and getattr(subhandler, "exposed", False): - body = subhandler(*(vpath + rpcparams), **params) - - else: - # http://www.cherrypy.org/ticket/533 - # if a method is not found, an xmlrpclib.Fault should be returned - # raising an exception here will do that; see - # cherrypy.lib.xmlrpc.on_error - raise Exception('method "%s" is not supported' % attr) - - conf = cherrypy.serving.request.toolmaps['tools'].get("xmlrpc", {}) - _xmlrpc.respond(body, - conf.get('encoding', 'utf-8'), - conf.get('allow_none', 0)) - return cherrypy.serving.response.body - default.exposed = True - - -class SessionAuthTool(HandlerTool): - - def _setargs(self): - for name in dir(cptools.SessionAuth): - if not name.startswith("__"): - setattr(self, name, None) - - -class CachingTool(Tool): - """Caching Tool for CherryPy.""" - - def _wrapper(self, **kwargs): - request = cherrypy.serving.request - if _caching.get(**kwargs): - request.handler = None - else: - if request.cacheable: - # Note the devious technique here of adding hooks on the fly - request.hooks.attach('before_finalize', _caching.tee_output, - priority=90) - _wrapper.priority = 20 - - def _setup(self): - """Hook caching into cherrypy.request.""" - conf = self._merged_args() - - p = conf.pop("priority", None) - cherrypy.serving.request.hooks.attach('before_handler', self._wrapper, - priority=p, **conf) - - - -class Toolbox(object): - """A collection of Tools. - - This object also functions as a config namespace handler for itself. - Custom toolboxes should be added to each Application's toolboxes dict. - """ - - def __init__(self, namespace): - self.namespace = namespace - - def __setattr__(self, name, value): - # If the Tool._name is None, supply it from the attribute name. - if isinstance(value, Tool): - if value._name is None: - value._name = name - value.namespace = self.namespace - object.__setattr__(self, name, value) - - def __enter__(self): - """Populate request.toolmaps from tools specified in config.""" - cherrypy.serving.request.toolmaps[self.namespace] = map = {} - def populate(k, v): - toolname, arg = k.split(".", 1) - bucket = map.setdefault(toolname, {}) - bucket[arg] = v - return populate - - def __exit__(self, exc_type, exc_val, exc_tb): - """Run tool._setup() for each tool in our toolmap.""" - map = cherrypy.serving.request.toolmaps.get(self.namespace) - if map: - for name, settings in map.items(): - if settings.get("on", False): - tool = getattr(self, name) - tool._setup() - - -class DeprecatedTool(Tool): - - _name = None - warnmsg = "This Tool is deprecated." - - def __init__(self, point, warnmsg=None): - self.point = point - if warnmsg is not None: - self.warnmsg = warnmsg - - def __call__(self, *args, **kwargs): - warnings.warn(self.warnmsg) - def tool_decorator(f): - return f - return tool_decorator - - def _setup(self): - warnings.warn(self.warnmsg) - - -default_toolbox = _d = Toolbox("tools") -_d.session_auth = SessionAuthTool(cptools.session_auth) -_d.proxy = Tool('before_request_body', cptools.proxy, priority=30) -_d.response_headers = Tool('on_start_resource', cptools.response_headers) -_d.log_tracebacks = Tool('before_error_response', cptools.log_traceback) -_d.log_headers = Tool('before_error_response', cptools.log_request_headers) -_d.log_hooks = Tool('on_end_request', cptools.log_hooks, priority=100) -_d.err_redirect = ErrorTool(cptools.redirect) -_d.etags = Tool('before_finalize', cptools.validate_etags, priority=75) -_d.decode = Tool('before_request_body', encoding.decode) -# the order of encoding, gzip, caching is important -_d.encode = Tool('before_handler', encoding.ResponseEncoder, priority=70) -_d.gzip = Tool('before_finalize', encoding.gzip, priority=80) -_d.staticdir = HandlerTool(static.staticdir) -_d.staticfile = HandlerTool(static.staticfile) -_d.sessions = SessionTool() -_d.xmlrpc = ErrorTool(_xmlrpc.on_error) -_d.caching = CachingTool('before_handler', _caching.get, 'caching') -_d.expires = Tool('before_finalize', _caching.expires) -_d.tidy = DeprecatedTool('before_finalize', - "The tidy tool has been removed from the standard distribution of CherryPy. " - "The most recent version can be found at http://tools.cherrypy.org/browser.") -_d.nsgmls = DeprecatedTool('before_finalize', - "The nsgmls tool has been removed from the standard distribution of CherryPy. " - "The most recent version can be found at http://tools.cherrypy.org/browser.") -_d.ignore_headers = Tool('before_request_body', cptools.ignore_headers) -_d.referer = Tool('before_request_body', cptools.referer) -_d.basic_auth = Tool('on_start_resource', auth.basic_auth) -_d.digest_auth = Tool('on_start_resource', auth.digest_auth) -_d.trailing_slash = Tool('before_handler', cptools.trailing_slash, priority=60) -_d.flatten = Tool('before_finalize', cptools.flatten) -_d.accept = Tool('on_start_resource', cptools.accept) -_d.redirect = Tool('on_start_resource', cptools.redirect) -_d.autovary = Tool('on_start_resource', cptools.autovary, priority=0) -_d.json_in = Tool('before_request_body', jsontools.json_in, priority=30) -_d.json_out = Tool('before_handler', jsontools.json_out, priority=30) -_d.auth_basic = Tool('before_handler', auth_basic.basic_auth, priority=1) -_d.auth_digest = Tool('before_handler', auth_digest.digest_auth, priority=1) - -del _d, cptools, encoding, auth, static +"""CherryPy tools. A "tool" is any helper, adapted to CP. + +Tools are usually designed to be used in a variety of ways (although some +may only offer one if they choose): + + Library calls: + All tools are callables that can be used wherever needed. + The arguments are straightforward and should be detailed within the + docstring. + + Function decorators: + All tools, when called, may be used as decorators which configure + individual CherryPy page handlers (methods on the CherryPy tree). + That is, "@tools.anytool()" should "turn on" the tool via the + decorated function's _cp_config attribute. + + CherryPy config: + If a tool exposes a "_setup" callable, it will be called + once per Request (if the feature is "turned on" via config). + +Tools may be implemented as any object with a namespace. The builtins +are generally either modules or instances of the tools.Tool class. +""" + +import cherrypy +import warnings + + +def _getargs(func): + """Return the names of all static arguments to the given function.""" + # Use this instead of importing inspect for less mem overhead. + import types + if isinstance(func, types.MethodType): + func = func.im_func + co = func.func_code + return co.co_varnames[:co.co_argcount] + + +_attr_error = ("CherryPy Tools cannot be turned on directly. Instead, turn them " + "on via config, or use them as decorators on your page handlers.") + +class Tool(object): + """A registered function for use with CherryPy request-processing hooks. + + help(tool.callable) should give you more information about this Tool. + """ + + namespace = "tools" + + def __init__(self, point, callable, name=None, priority=50): + self._point = point + self.callable = callable + self._name = name + self._priority = priority + self.__doc__ = self.callable.__doc__ + self._setargs() + + def _get_on(self): + raise AttributeError(_attr_error) + def _set_on(self, value): + raise AttributeError(_attr_error) + on = property(_get_on, _set_on) + + def _setargs(self): + """Copy func parameter names to obj attributes.""" + try: + for arg in _getargs(self.callable): + setattr(self, arg, None) + except (TypeError, AttributeError): + if hasattr(self.callable, "__call__"): + for arg in _getargs(self.callable.__call__): + setattr(self, arg, None) + # IronPython 1.0 raises NotImplementedError because + # inspect.getargspec tries to access Python bytecode + # in co_code attribute. + except NotImplementedError: + pass + # IronPython 1B1 may raise IndexError in some cases, + # but if we trap it here it doesn't prevent CP from working. + except IndexError: + pass + + def _merged_args(self, d=None): + """Return a dict of configuration entries for this Tool.""" + if d: + conf = d.copy() + else: + conf = {} + + tm = cherrypy.serving.request.toolmaps[self.namespace] + if self._name in tm: + conf.update(tm[self._name]) + + if "on" in conf: + del conf["on"] + + return conf + + def __call__(self, *args, **kwargs): + """Compile-time decorator (turn on the tool in config). + + For example: + + @tools.proxy() + def whats_my_base(self): + return cherrypy.request.base + whats_my_base.exposed = True + """ + if args: + raise TypeError("The %r Tool does not accept positional " + "arguments; you must use keyword arguments." + % self._name) + def tool_decorator(f): + if not hasattr(f, "_cp_config"): + f._cp_config = {} + subspace = self.namespace + "." + self._name + "." + f._cp_config[subspace + "on"] = True + for k, v in kwargs.items(): + f._cp_config[subspace + k] = v + return f + return tool_decorator + + def _setup(self): + """Hook this tool into cherrypy.request. + + The standard CherryPy request object will automatically call this + method when the tool is "turned on" in config. + """ + conf = self._merged_args() + p = conf.pop("priority", None) + if p is None: + p = getattr(self.callable, "priority", self._priority) + cherrypy.serving.request.hooks.attach(self._point, self.callable, + priority=p, **conf) + + +class HandlerTool(Tool): + """Tool which is called 'before main', that may skip normal handlers. + + If the tool successfully handles the request (by setting response.body), + if should return True. This will cause CherryPy to skip any 'normal' page + handler. If the tool did not handle the request, it should return False + to tell CherryPy to continue on and call the normal page handler. If the + tool is declared AS a page handler (see the 'handler' method), returning + False will raise NotFound. + """ + + def __init__(self, callable, name=None): + Tool.__init__(self, 'before_handler', callable, name) + + def handler(self, *args, **kwargs): + """Use this tool as a CherryPy page handler. + + For example: + class Root: + nav = tools.staticdir.handler(section="/nav", dir="nav", + root=absDir) + """ + def handle_func(*a, **kw): + handled = self.callable(*args, **self._merged_args(kwargs)) + if not handled: + raise cherrypy.NotFound() + return cherrypy.serving.response.body + handle_func.exposed = True + return handle_func + + def _wrapper(self, **kwargs): + if self.callable(**kwargs): + cherrypy.serving.request.handler = None + + def _setup(self): + """Hook this tool into cherrypy.request. + + The standard CherryPy request object will automatically call this + method when the tool is "turned on" in config. + """ + conf = self._merged_args() + p = conf.pop("priority", None) + if p is None: + p = getattr(self.callable, "priority", self._priority) + cherrypy.serving.request.hooks.attach(self._point, self._wrapper, + priority=p, **conf) + + +class HandlerWrapperTool(Tool): + """Tool which wraps request.handler in a provided wrapper function. + + The 'newhandler' arg must be a handler wrapper function that takes a + 'next_handler' argument, plus *args and **kwargs. Like all page handler + functions, it must return an iterable for use as cherrypy.response.body. + + For example, to allow your 'inner' page handlers to return dicts + which then get interpolated into a template: + + def interpolator(next_handler, *args, **kwargs): + filename = cherrypy.request.config.get('template') + cherrypy.response.template = env.get_template(filename) + response_dict = next_handler(*args, **kwargs) + return cherrypy.response.template.render(**response_dict) + cherrypy.tools.jinja = HandlerWrapperTool(interpolator) + """ + + def __init__(self, newhandler, point='before_handler', name=None, priority=50): + self.newhandler = newhandler + self._point = point + self._name = name + self._priority = priority + + def callable(self, debug=False): + innerfunc = cherrypy.serving.request.handler + def wrap(*args, **kwargs): + return self.newhandler(innerfunc, *args, **kwargs) + cherrypy.serving.request.handler = wrap + + +class ErrorTool(Tool): + """Tool which is used to replace the default request.error_response.""" + + def __init__(self, callable, name=None): + Tool.__init__(self, None, callable, name) + + def _wrapper(self): + self.callable(**self._merged_args()) + + def _setup(self): + """Hook this tool into cherrypy.request. + + The standard CherryPy request object will automatically call this + method when the tool is "turned on" in config. + """ + cherrypy.serving.request.error_response = self._wrapper + + +# Builtin tools # + +from cherrypy.lib import cptools, encoding, auth, static, jsontools +from cherrypy.lib import sessions as _sessions, xmlrpc as _xmlrpc +from cherrypy.lib import caching as _caching +from cherrypy.lib import auth_basic, auth_digest + + +class SessionTool(Tool): + """Session Tool for CherryPy. + + sessions.locking: + When 'implicit' (the default), the session will be locked for you, + just before running the page handler. + When 'early', the session will be locked before reading the request + body. This is off by default for safety reasons; for example, + a large upload would block the session, denying an AJAX + progress meter (see http://www.cherrypy.org/ticket/630). + When 'explicit' (or any other value), you need to call + cherrypy.session.acquire_lock() yourself before using + session data. + """ + + def __init__(self): + # _sessions.init must be bound after headers are read + Tool.__init__(self, 'before_request_body', _sessions.init) + + def _lock_session(self): + cherrypy.serving.session.acquire_lock() + + def _setup(self): + """Hook this tool into cherrypy.request. + + The standard CherryPy request object will automatically call this + method when the tool is "turned on" in config. + """ + hooks = cherrypy.serving.request.hooks + + conf = self._merged_args() + + p = conf.pop("priority", None) + if p is None: + p = getattr(self.callable, "priority", self._priority) + + hooks.attach(self._point, self.callable, priority=p, **conf) + + locking = conf.pop('locking', 'implicit') + if locking == 'implicit': + hooks.attach('before_handler', self._lock_session) + elif locking == 'early': + # Lock before the request body (but after _sessions.init runs!) + hooks.attach('before_request_body', self._lock_session, + priority=60) + else: + # Don't lock + pass + + hooks.attach('before_finalize', _sessions.save) + hooks.attach('on_end_request', _sessions.close) + + def regenerate(self): + """Drop the current session and make a new one (with a new id).""" + sess = cherrypy.serving.session + sess.regenerate() + + # Grab cookie-relevant tool args + conf = dict([(k, v) for k, v in self._merged_args().items() + if k in ('path', 'path_header', 'name', 'timeout', + 'domain', 'secure')]) + _sessions.set_response_cookie(**conf) + + + + +class XMLRPCController(object): + """A Controller (page handler collection) for XML-RPC. + + To use it, have your controllers subclass this base class (it will + turn on the tool for you). + + You can also supply the following optional config entries: + + tools.xmlrpc.encoding: 'utf-8' + tools.xmlrpc.allow_none: 0 + + XML-RPC is a rather discontinuous layer over HTTP; dispatching to the + appropriate handler must first be performed according to the URL, and + then a second dispatch step must take place according to the RPC method + specified in the request body. It also allows a superfluous "/RPC2" + prefix in the URL, supplies its own handler args in the body, and + requires a 200 OK "Fault" response instead of 404 when the desired + method is not found. + + Therefore, XML-RPC cannot be implemented for CherryPy via a Tool alone. + This Controller acts as the dispatch target for the first half (based + on the URL); it then reads the RPC method from the request body and + does its own second dispatch step based on that method. It also reads + body params, and returns a Fault on error. + + The XMLRPCDispatcher strips any /RPC2 prefix; if you aren't using /RPC2 + in your URL's, you can safely skip turning on the XMLRPCDispatcher. + Otherwise, you need to use declare it in config: + + request.dispatch: cherrypy.dispatch.XMLRPCDispatcher() + """ + + # Note we're hard-coding this into the 'tools' namespace. We could do + # a huge amount of work to make it relocatable, but the only reason why + # would be if someone actually disabled the default_toolbox. Meh. + _cp_config = {'tools.xmlrpc.on': True} + + def default(self, *vpath, **params): + rpcparams, rpcmethod = _xmlrpc.process_body() + + subhandler = self + for attr in str(rpcmethod).split('.'): + subhandler = getattr(subhandler, attr, None) + + if subhandler and getattr(subhandler, "exposed", False): + body = subhandler(*(vpath + rpcparams), **params) + + else: + # http://www.cherrypy.org/ticket/533 + # if a method is not found, an xmlrpclib.Fault should be returned + # raising an exception here will do that; see + # cherrypy.lib.xmlrpc.on_error + raise Exception('method "%s" is not supported' % attr) + + conf = cherrypy.serving.request.toolmaps['tools'].get("xmlrpc", {}) + _xmlrpc.respond(body, + conf.get('encoding', 'utf-8'), + conf.get('allow_none', 0)) + return cherrypy.serving.response.body + default.exposed = True + + +class SessionAuthTool(HandlerTool): + + def _setargs(self): + for name in dir(cptools.SessionAuth): + if not name.startswith("__"): + setattr(self, name, None) + + +class CachingTool(Tool): + """Caching Tool for CherryPy.""" + + def _wrapper(self, **kwargs): + request = cherrypy.serving.request + if _caching.get(**kwargs): + request.handler = None + else: + if request.cacheable: + # Note the devious technique here of adding hooks on the fly + request.hooks.attach('before_finalize', _caching.tee_output, + priority=90) + _wrapper.priority = 20 + + def _setup(self): + """Hook caching into cherrypy.request.""" + conf = self._merged_args() + + p = conf.pop("priority", None) + cherrypy.serving.request.hooks.attach('before_handler', self._wrapper, + priority=p, **conf) + + + +class Toolbox(object): + """A collection of Tools. + + This object also functions as a config namespace handler for itself. + Custom toolboxes should be added to each Application's toolboxes dict. + """ + + def __init__(self, namespace): + self.namespace = namespace + + def __setattr__(self, name, value): + # If the Tool._name is None, supply it from the attribute name. + if isinstance(value, Tool): + if value._name is None: + value._name = name + value.namespace = self.namespace + object.__setattr__(self, name, value) + + def __enter__(self): + """Populate request.toolmaps from tools specified in config.""" + cherrypy.serving.request.toolmaps[self.namespace] = map = {} + def populate(k, v): + toolname, arg = k.split(".", 1) + bucket = map.setdefault(toolname, {}) + bucket[arg] = v + return populate + + def __exit__(self, exc_type, exc_val, exc_tb): + """Run tool._setup() for each tool in our toolmap.""" + map = cherrypy.serving.request.toolmaps.get(self.namespace) + if map: + for name, settings in map.items(): + if settings.get("on", False): + tool = getattr(self, name) + tool._setup() + + +class DeprecatedTool(Tool): + + _name = None + warnmsg = "This Tool is deprecated." + + def __init__(self, point, warnmsg=None): + self.point = point + if warnmsg is not None: + self.warnmsg = warnmsg + + def __call__(self, *args, **kwargs): + warnings.warn(self.warnmsg) + def tool_decorator(f): + return f + return tool_decorator + + def _setup(self): + warnings.warn(self.warnmsg) + + +default_toolbox = _d = Toolbox("tools") +_d.session_auth = SessionAuthTool(cptools.session_auth) +_d.proxy = Tool('before_request_body', cptools.proxy, priority=30) +_d.response_headers = Tool('on_start_resource', cptools.response_headers) +_d.log_tracebacks = Tool('before_error_response', cptools.log_traceback) +_d.log_headers = Tool('before_error_response', cptools.log_request_headers) +_d.log_hooks = Tool('on_end_request', cptools.log_hooks, priority=100) +_d.err_redirect = ErrorTool(cptools.redirect) +_d.etags = Tool('before_finalize', cptools.validate_etags, priority=75) +_d.decode = Tool('before_request_body', encoding.decode) +# the order of encoding, gzip, caching is important +_d.encode = Tool('before_handler', encoding.ResponseEncoder, priority=70) +_d.gzip = Tool('before_finalize', encoding.gzip, priority=80) +_d.staticdir = HandlerTool(static.staticdir) +_d.staticfile = HandlerTool(static.staticfile) +_d.sessions = SessionTool() +_d.xmlrpc = ErrorTool(_xmlrpc.on_error) +_d.caching = CachingTool('before_handler', _caching.get, 'caching') +_d.expires = Tool('before_finalize', _caching.expires) +_d.tidy = DeprecatedTool('before_finalize', + "The tidy tool has been removed from the standard distribution of CherryPy. " + "The most recent version can be found at http://tools.cherrypy.org/browser.") +_d.nsgmls = DeprecatedTool('before_finalize', + "The nsgmls tool has been removed from the standard distribution of CherryPy. " + "The most recent version can be found at http://tools.cherrypy.org/browser.") +_d.ignore_headers = Tool('before_request_body', cptools.ignore_headers) +_d.referer = Tool('before_request_body', cptools.referer) +_d.basic_auth = Tool('on_start_resource', auth.basic_auth) +_d.digest_auth = Tool('on_start_resource', auth.digest_auth) +_d.trailing_slash = Tool('before_handler', cptools.trailing_slash, priority=60) +_d.flatten = Tool('before_finalize', cptools.flatten) +_d.accept = Tool('on_start_resource', cptools.accept) +_d.redirect = Tool('on_start_resource', cptools.redirect) +_d.autovary = Tool('on_start_resource', cptools.autovary, priority=0) +_d.json_in = Tool('before_request_body', jsontools.json_in, priority=30) +_d.json_out = Tool('before_handler', jsontools.json_out, priority=30) +_d.auth_basic = Tool('before_handler', auth_basic.basic_auth, priority=1) +_d.auth_digest = Tool('before_handler', auth_digest.digest_auth, priority=1) + +del _d, cptools, encoding, auth, static diff --git a/cherrypy/_cptree.py b/cherrypy/_cptree.py index 22192b5f8e..9c89bdb861 100644 --- a/cherrypy/_cptree.py +++ b/cherrypy/_cptree.py @@ -1,278 +1,278 @@ -"""CherryPy Application and Tree objects.""" - -import os -import cherrypy -from cherrypy import _cpconfig, _cplogging, _cprequest, _cpwsgi, tools -from cherrypy.lib import httputil - - -class Application(object): - """A CherryPy Application. - - Servers and gateways should not instantiate Request objects directly. - Instead, they should ask an Application object for a request object. - - An instance of this class may also be used as a WSGI callable - (WSGI application object) for itself. - """ - - __metaclass__ = cherrypy._AttributeDocstrings - - root = None - root__doc = """ - The top-most container of page handlers for this app. Handlers should - be arranged in a hierarchy of attributes, matching the expected URI - hierarchy; the default dispatcher then searches this hierarchy for a - matching handler. When using a dispatcher other than the default, - this value may be None.""" - - config = {} - config__doc = """ - A dict of {path: pathconf} pairs, where 'pathconf' is itself a dict - of {key: value} pairs.""" - - namespaces = _cpconfig.NamespaceSet() - toolboxes = {'tools': cherrypy.tools} - - log = None - log__doc = """A LogManager instance. See _cplogging.""" - - wsgiapp = None - wsgiapp__doc = """A CPWSGIApp instance. See _cpwsgi.""" - - request_class = _cprequest.Request - response_class = _cprequest.Response - - relative_urls = False - - def __init__(self, root, script_name="", config=None): - self.log = _cplogging.LogManager(id(self), cherrypy.log.logger_root) - self.root = root - self.script_name = script_name - self.wsgiapp = _cpwsgi.CPWSGIApp(self) - - self.namespaces = self.namespaces.copy() - self.namespaces["log"] = lambda k, v: setattr(self.log, k, v) - self.namespaces["wsgi"] = self.wsgiapp.namespace_handler - - self.config = self.__class__.config.copy() - if config: - self.merge(config) - - def __repr__(self): - return "%s.%s(%r, %r)" % (self.__module__, self.__class__.__name__, - self.root, self.script_name) - - script_name__doc = """ - The URI "mount point" for this app. A mount point is that portion of - the URI which is constant for all URIs that are serviced by this - application; it does not include scheme, host, or proxy ("virtual host") - portions of the URI. - - For example, if script_name is "/my/cool/app", then the URL - "http://www.example.com/my/cool/app/page1" might be handled by a - "page1" method on the root object. - - The value of script_name MUST NOT end in a slash. If the script_name - refers to the root of the URI, it MUST be an empty string (not "/"). - - If script_name is explicitly set to None, then the script_name will be - provided for each call from request.wsgi_environ['SCRIPT_NAME']. - """ - def _get_script_name(self): - if self._script_name is None: - # None signals that the script name should be pulled from WSGI environ. - return cherrypy.serving.request.wsgi_environ['SCRIPT_NAME'].rstrip("/") - return self._script_name - def _set_script_name(self, value): - if value: - value = value.rstrip("/") - self._script_name = value - script_name = property(fget=_get_script_name, fset=_set_script_name, - doc=script_name__doc) - - def merge(self, config): - """Merge the given config into self.config.""" - _cpconfig.merge(self.config, config) - - # Handle namespaces specified in config. - self.namespaces(self.config.get("/", {})) - - def find_config(self, path, key, default=None): - """Return the most-specific value for key along path, or default.""" - trail = path or "/" - while trail: - nodeconf = self.config.get(trail, {}) - - if key in nodeconf: - return nodeconf[key] - - lastslash = trail.rfind("/") - if lastslash == -1: - break - elif lastslash == 0 and trail != "/": - trail = "/" - else: - trail = trail[:lastslash] - - return default - - def get_serving(self, local, remote, scheme, sproto): - """Create and return a Request and Response object.""" - req = self.request_class(local, remote, scheme, sproto) - req.app = self - - for name, toolbox in self.toolboxes.items(): - req.namespaces[name] = toolbox - - resp = self.response_class() - cherrypy.serving.load(req, resp) - cherrypy.engine.timeout_monitor.acquire() - cherrypy.engine.publish('acquire_thread') - - return req, resp - - def release_serving(self): - """Release the current serving (request and response).""" - req = cherrypy.serving.request - - cherrypy.engine.timeout_monitor.release() - - try: - req.close() - except: - cherrypy.log(traceback=True, severity=40) - - cherrypy.serving.clear() - - def __call__(self, environ, start_response): - return self.wsgiapp(environ, start_response) - - -class Tree(object): - """A registry of CherryPy applications, mounted at diverse points. - - An instance of this class may also be used as a WSGI callable - (WSGI application object), in which case it dispatches to all - mounted apps. - """ - - apps = {} - apps__doc = """ - A dict of the form {script name: application}, where "script name" - is a string declaring the URI mount point (no trailing slash), and - "application" is an instance of cherrypy.Application (or an arbitrary - WSGI callable if you happen to be using a WSGI server).""" - - def __init__(self): - self.apps = {} - - def mount(self, root, script_name="", config=None): - """Mount a new app from a root object, script_name, and config. - - root: an instance of a "controller class" (a collection of page - handler methods) which represents the root of the application. - This may also be an Application instance, or None if using - a dispatcher other than the default. - script_name: a string containing the "mount point" of the application. - This should start with a slash, and be the path portion of the - URL at which to mount the given root. For example, if root.index() - will handle requests to "http://www.example.com:8080/dept/app1/", - then the script_name argument would be "/dept/app1". - - It MUST NOT end in a slash. If the script_name refers to the - root of the URI, it MUST be an empty string (not "/"). - config: a file or dict containing application config. - """ - if script_name is None: - raise TypeError( - "The 'script_name' argument may not be None. Application " - "objects may, however, possess a script_name of None (in " - "order to inpect the WSGI environ for SCRIPT_NAME upon each " - "request). You cannot mount such Applications on this Tree; " - "you must pass them to a WSGI server interface directly.") - - # Next line both 1) strips trailing slash and 2) maps "/" -> "". - script_name = script_name.rstrip("/") - - if isinstance(root, Application): - app = root - if script_name != "" and script_name != app.script_name: - raise ValueError("Cannot specify a different script name and " - "pass an Application instance to cherrypy.mount") - script_name = app.script_name - else: - app = Application(root, script_name) - - # If mounted at "", add favicon.ico - if (script_name == "" and root is not None - and not hasattr(root, "favicon_ico")): - favicon = os.path.join(os.getcwd(), os.path.dirname(__file__), - "favicon.ico") - root.favicon_ico = tools.staticfile.handler(favicon) - - if config: - app.merge(config) - - self.apps[script_name] = app - - return app - - def graft(self, wsgi_callable, script_name=""): - """Mount a wsgi callable at the given script_name.""" - # Next line both 1) strips trailing slash and 2) maps "/" -> "". - script_name = script_name.rstrip("/") - self.apps[script_name] = wsgi_callable - - def script_name(self, path=None): - """The script_name of the app at the given path, or None. - - If path is None, cherrypy.request is used. - """ - if path is None: - try: - request = cherrypy.serving.request - path = httputil.urljoin(request.script_name, - request.path_info) - except AttributeError: - return None - - while True: - if path in self.apps: - return path - - if path == "": - return None - - # Move one node up the tree and try again. - path = path[:path.rfind("/")] - - def __call__(self, environ, start_response): - # If you're calling this, then you're probably setting SCRIPT_NAME - # to '' (some WSGI servers always set SCRIPT_NAME to ''). - # Try to look up the app using the full path. - env1x = environ - if environ.get(u'wsgi.version') == (u'u', 0): - env1x = _cpwsgi.downgrade_wsgi_ux_to_1x(environ) - path = httputil.urljoin(env1x.get('SCRIPT_NAME', ''), - env1x.get('PATH_INFO', '')) - sn = self.script_name(path or "/") - if sn is None: - start_response('404 Not Found', []) - return [] - - app = self.apps[sn] - - # Correct the SCRIPT_NAME and PATH_INFO environ entries. - environ = environ.copy() - if environ.get(u'wsgi.version') == (u'u', 0): - # Python 2/WSGI u.0: all strings MUST be of type unicode - enc = environ[u'wsgi.url_encoding'] - environ[u'SCRIPT_NAME'] = sn.decode(enc) - environ[u'PATH_INFO'] = path[len(sn.rstrip("/")):].decode(enc) - else: - # Python 2/WSGI 1.x: all strings MUST be of type str - environ['SCRIPT_NAME'] = sn - environ['PATH_INFO'] = path[len(sn.rstrip("/")):] - return app(environ, start_response) - +"""CherryPy Application and Tree objects.""" + +import os +import cherrypy +from cherrypy import _cpconfig, _cplogging, _cprequest, _cpwsgi, tools +from cherrypy.lib import httputil + + +class Application(object): + """A CherryPy Application. + + Servers and gateways should not instantiate Request objects directly. + Instead, they should ask an Application object for a request object. + + An instance of this class may also be used as a WSGI callable + (WSGI application object) for itself. + """ + + __metaclass__ = cherrypy._AttributeDocstrings + + root = None + root__doc = """ + The top-most container of page handlers for this app. Handlers should + be arranged in a hierarchy of attributes, matching the expected URI + hierarchy; the default dispatcher then searches this hierarchy for a + matching handler. When using a dispatcher other than the default, + this value may be None.""" + + config = {} + config__doc = """ + A dict of {path: pathconf} pairs, where 'pathconf' is itself a dict + of {key: value} pairs.""" + + namespaces = _cpconfig.NamespaceSet() + toolboxes = {'tools': cherrypy.tools} + + log = None + log__doc = """A LogManager instance. See _cplogging.""" + + wsgiapp = None + wsgiapp__doc = """A CPWSGIApp instance. See _cpwsgi.""" + + request_class = _cprequest.Request + response_class = _cprequest.Response + + relative_urls = False + + def __init__(self, root, script_name="", config=None): + self.log = _cplogging.LogManager(id(self), cherrypy.log.logger_root) + self.root = root + self.script_name = script_name + self.wsgiapp = _cpwsgi.CPWSGIApp(self) + + self.namespaces = self.namespaces.copy() + self.namespaces["log"] = lambda k, v: setattr(self.log, k, v) + self.namespaces["wsgi"] = self.wsgiapp.namespace_handler + + self.config = self.__class__.config.copy() + if config: + self.merge(config) + + def __repr__(self): + return "%s.%s(%r, %r)" % (self.__module__, self.__class__.__name__, + self.root, self.script_name) + + script_name__doc = """ + The URI "mount point" for this app. A mount point is that portion of + the URI which is constant for all URIs that are serviced by this + application; it does not include scheme, host, or proxy ("virtual host") + portions of the URI. + + For example, if script_name is "/my/cool/app", then the URL + "http://www.example.com/my/cool/app/page1" might be handled by a + "page1" method on the root object. + + The value of script_name MUST NOT end in a slash. If the script_name + refers to the root of the URI, it MUST be an empty string (not "/"). + + If script_name is explicitly set to None, then the script_name will be + provided for each call from request.wsgi_environ['SCRIPT_NAME']. + """ + def _get_script_name(self): + if self._script_name is None: + # None signals that the script name should be pulled from WSGI environ. + return cherrypy.serving.request.wsgi_environ['SCRIPT_NAME'].rstrip("/") + return self._script_name + def _set_script_name(self, value): + if value: + value = value.rstrip("/") + self._script_name = value + script_name = property(fget=_get_script_name, fset=_set_script_name, + doc=script_name__doc) + + def merge(self, config): + """Merge the given config into self.config.""" + _cpconfig.merge(self.config, config) + + # Handle namespaces specified in config. + self.namespaces(self.config.get("/", {})) + + def find_config(self, path, key, default=None): + """Return the most-specific value for key along path, or default.""" + trail = path or "/" + while trail: + nodeconf = self.config.get(trail, {}) + + if key in nodeconf: + return nodeconf[key] + + lastslash = trail.rfind("/") + if lastslash == -1: + break + elif lastslash == 0 and trail != "/": + trail = "/" + else: + trail = trail[:lastslash] + + return default + + def get_serving(self, local, remote, scheme, sproto): + """Create and return a Request and Response object.""" + req = self.request_class(local, remote, scheme, sproto) + req.app = self + + for name, toolbox in self.toolboxes.items(): + req.namespaces[name] = toolbox + + resp = self.response_class() + cherrypy.serving.load(req, resp) + cherrypy.engine.timeout_monitor.acquire() + cherrypy.engine.publish('acquire_thread') + + return req, resp + + def release_serving(self): + """Release the current serving (request and response).""" + req = cherrypy.serving.request + + cherrypy.engine.timeout_monitor.release() + + try: + req.close() + except: + cherrypy.log(traceback=True, severity=40) + + cherrypy.serving.clear() + + def __call__(self, environ, start_response): + return self.wsgiapp(environ, start_response) + + +class Tree(object): + """A registry of CherryPy applications, mounted at diverse points. + + An instance of this class may also be used as a WSGI callable + (WSGI application object), in which case it dispatches to all + mounted apps. + """ + + apps = {} + apps__doc = """ + A dict of the form {script name: application}, where "script name" + is a string declaring the URI mount point (no trailing slash), and + "application" is an instance of cherrypy.Application (or an arbitrary + WSGI callable if you happen to be using a WSGI server).""" + + def __init__(self): + self.apps = {} + + def mount(self, root, script_name="", config=None): + """Mount a new app from a root object, script_name, and config. + + root: an instance of a "controller class" (a collection of page + handler methods) which represents the root of the application. + This may also be an Application instance, or None if using + a dispatcher other than the default. + script_name: a string containing the "mount point" of the application. + This should start with a slash, and be the path portion of the + URL at which to mount the given root. For example, if root.index() + will handle requests to "http://www.example.com:8080/dept/app1/", + then the script_name argument would be "/dept/app1". + + It MUST NOT end in a slash. If the script_name refers to the + root of the URI, it MUST be an empty string (not "/"). + config: a file or dict containing application config. + """ + if script_name is None: + raise TypeError( + "The 'script_name' argument may not be None. Application " + "objects may, however, possess a script_name of None (in " + "order to inpect the WSGI environ for SCRIPT_NAME upon each " + "request). You cannot mount such Applications on this Tree; " + "you must pass them to a WSGI server interface directly.") + + # Next line both 1) strips trailing slash and 2) maps "/" -> "". + script_name = script_name.rstrip("/") + + if isinstance(root, Application): + app = root + if script_name != "" and script_name != app.script_name: + raise ValueError("Cannot specify a different script name and " + "pass an Application instance to cherrypy.mount") + script_name = app.script_name + else: + app = Application(root, script_name) + + # If mounted at "", add favicon.ico + if (script_name == "" and root is not None + and not hasattr(root, "favicon_ico")): + favicon = os.path.join(os.getcwd(), os.path.dirname(__file__), + "favicon.ico") + root.favicon_ico = tools.staticfile.handler(favicon) + + if config: + app.merge(config) + + self.apps[script_name] = app + + return app + + def graft(self, wsgi_callable, script_name=""): + """Mount a wsgi callable at the given script_name.""" + # Next line both 1) strips trailing slash and 2) maps "/" -> "". + script_name = script_name.rstrip("/") + self.apps[script_name] = wsgi_callable + + def script_name(self, path=None): + """The script_name of the app at the given path, or None. + + If path is None, cherrypy.request is used. + """ + if path is None: + try: + request = cherrypy.serving.request + path = httputil.urljoin(request.script_name, + request.path_info) + except AttributeError: + return None + + while True: + if path in self.apps: + return path + + if path == "": + return None + + # Move one node up the tree and try again. + path = path[:path.rfind("/")] + + def __call__(self, environ, start_response): + # If you're calling this, then you're probably setting SCRIPT_NAME + # to '' (some WSGI servers always set SCRIPT_NAME to ''). + # Try to look up the app using the full path. + env1x = environ + if environ.get(u'wsgi.version') == (u'u', 0): + env1x = _cpwsgi.downgrade_wsgi_ux_to_1x(environ) + path = httputil.urljoin(env1x.get('SCRIPT_NAME', ''), + env1x.get('PATH_INFO', '')) + sn = self.script_name(path or "/") + if sn is None: + start_response('404 Not Found', []) + return [] + + app = self.apps[sn] + + # Correct the SCRIPT_NAME and PATH_INFO environ entries. + environ = environ.copy() + if environ.get(u'wsgi.version') == (u'u', 0): + # Python 2/WSGI u.0: all strings MUST be of type unicode + enc = environ[u'wsgi.url_encoding'] + environ[u'SCRIPT_NAME'] = sn.decode(enc) + environ[u'PATH_INFO'] = path[len(sn.rstrip("/")):].decode(enc) + else: + # Python 2/WSGI 1.x: all strings MUST be of type str + environ['SCRIPT_NAME'] = sn + environ['PATH_INFO'] = path[len(sn.rstrip("/")):] + return app(environ, start_response) + diff --git a/cherrypy/_cpwsgi.py b/cherrypy/_cpwsgi.py index fa1557516b..104562ce14 100644 --- a/cherrypy/_cpwsgi.py +++ b/cherrypy/_cpwsgi.py @@ -1,340 +1,340 @@ -"""WSGI interface (see PEP 333).""" - -import sys as _sys - -import cherrypy as _cherrypy -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO -from cherrypy import _cperror -from cherrypy.lib import httputil - - -def downgrade_wsgi_ux_to_1x(environ): - """Return a new environ dict for WSGI 1.x from the given WSGI u.x environ.""" - env1x = {} - - url_encoding = environ[u'wsgi.url_encoding'] - for k, v in environ.items(): - if k in [u'PATH_INFO', u'SCRIPT_NAME', u'QUERY_STRING']: - v = v.encode(url_encoding) - elif isinstance(v, unicode): - v = v.encode('ISO-8859-1') - env1x[k.encode('ISO-8859-1')] = v - - return env1x - - -class VirtualHost(object): - """Select a different WSGI application based on the Host header. - - This can be useful when running multiple sites within one CP server. - It allows several domains to point to different applications. For example: - - root = Root() - RootApp = cherrypy.Application(root) - Domain2App = cherrypy.Application(root) - SecureApp = cherrypy.Application(Secure()) - - vhost = cherrypy._cpwsgi.VirtualHost(RootApp, - domains={'www.domain2.example': Domain2App, - 'www.domain2.example:443': SecureApp, - }) - - cherrypy.tree.graft(vhost) - - default: required. The default WSGI application. - - use_x_forwarded_host: if True (the default), any "X-Forwarded-Host" - request header will be used instead of the "Host" header. This - is commonly added by HTTP servers (such as Apache) when proxying. - - domains: a dict of {host header value: application} pairs. - The incoming "Host" request header is looked up in this dict, - and, if a match is found, the corresponding WSGI application - will be called instead of the default. Note that you often need - separate entries for "example.com" and "www.example.com". - In addition, "Host" headers may contain the port number. - """ - - def __init__(self, default, domains=None, use_x_forwarded_host=True): - self.default = default - self.domains = domains or {} - self.use_x_forwarded_host = use_x_forwarded_host - - def __call__(self, environ, start_response): - domain = environ.get('HTTP_HOST', '') - if self.use_x_forwarded_host: - domain = environ.get("HTTP_X_FORWARDED_HOST", domain) - - nextapp = self.domains.get(domain) - if nextapp is None: - nextapp = self.default - return nextapp(environ, start_response) - - -class InternalRedirector(object): - """WSGI middleware that handles raised cherrypy.InternalRedirect.""" - - def __init__(self, nextapp, recursive=False): - self.nextapp = nextapp - self.recursive = recursive - - def __call__(self, environ, start_response): - redirections = [] - while True: - environ = environ.copy() - try: - return self.nextapp(environ, start_response) - except _cherrypy.InternalRedirect, ir: - sn = environ.get('SCRIPT_NAME', '') - path = environ.get('PATH_INFO', '') - qs = environ.get('QUERY_STRING', '') - - # Add the *previous* path_info + qs to redirections. - old_uri = sn + path - if qs: - old_uri += "?" + qs - redirections.append(old_uri) - - if not self.recursive: - # Check to see if the new URI has been redirected to already - new_uri = sn + ir.path - if ir.query_string: - new_uri += "?" + ir.query_string - if new_uri in redirections: - ir.request.close() - raise RuntimeError("InternalRedirector visited the " - "same URL twice: %r" % new_uri) - - # Munge the environment and try again. - environ['REQUEST_METHOD'] = "GET" - environ['PATH_INFO'] = ir.path - environ['QUERY_STRING'] = ir.query_string - environ['wsgi.input'] = StringIO() - environ['CONTENT_LENGTH'] = "0" - environ['cherrypy.previous_request'] = ir.request - - -class ExceptionTrapper(object): - - def __init__(self, nextapp, throws=(KeyboardInterrupt, SystemExit)): - self.nextapp = nextapp - self.throws = throws - - def __call__(self, environ, start_response): - return _TrappedResponse(self.nextapp, environ, start_response, self.throws) - - -class _TrappedResponse(object): - - response = iter([]) - - def __init__(self, nextapp, environ, start_response, throws): - self.nextapp = nextapp - self.environ = environ - self.start_response = start_response - self.throws = throws - self.started_response = False - self.response = self.trap(self.nextapp, self.environ, self.start_response) - self.iter_response = iter(self.response) - - def __iter__(self): - self.started_response = True - return self - - def next(self): - return self.trap(self.iter_response.next) - - def close(self): - if hasattr(self.response, 'close'): - self.response.close() - - def trap(self, func, *args, **kwargs): - try: - return func(*args, **kwargs) - except self.throws: - raise - except StopIteration: - raise - except: - tb = _cperror.format_exc() - #print('trapped (started %s):' % self.started_response, tb) - _cherrypy.log(tb, severity=40) - if not _cherrypy.request.show_tracebacks: - tb = "" - s, h, b = _cperror.bare_error(tb) - if self.started_response: - # Empty our iterable (so future calls raise StopIteration) - self.iter_response = iter([]) - else: - self.iter_response = iter(b) - - try: - self.start_response(s, h, _sys.exc_info()) - except: - # "The application must not trap any exceptions raised by - # start_response, if it called start_response with exc_info. - # Instead, it should allow such exceptions to propagate - # back to the server or gateway." - # But we still log and call close() to clean up ourselves. - _cherrypy.log(traceback=True, severity=40) - raise - - if self.started_response: - return "".join(b) - else: - return b - - -# WSGI-to-CP Adapter # - - -class AppResponse(object): - """WSGI response iterable for CherryPy applications.""" - - def __init__(self, environ, start_response, cpapp): - if environ.get(u'wsgi.version') == (u'u', 0): - environ = downgrade_wsgi_ux_to_1x(environ) - self.environ = environ - self.cpapp = cpapp - try: - self.run() - except: - self.close() - raise - r = _cherrypy.serving.response - self.iter_response = iter(r.body) - self.write = start_response(r.output_status, r.header_list) - - def __iter__(self): - return self - - def next(self): - return self.iter_response.next() - - def close(self): - """Close and de-reference the current request and response. (Core)""" - self.cpapp.release_serving() - - def run(self): - """Create a Request object using environ.""" - env = self.environ.get - - local = httputil.Host('', int(env('SERVER_PORT', 80)), - env('SERVER_NAME', '')) - remote = httputil.Host(env('REMOTE_ADDR', ''), - int(env('REMOTE_PORT', -1)), - env('REMOTE_HOST', '')) - scheme = env('wsgi.url_scheme') - sproto = env('ACTUAL_SERVER_PROTOCOL', "HTTP/1.1") - request, resp = self.cpapp.get_serving(local, remote, scheme, sproto) - - # LOGON_USER is served by IIS, and is the name of the - # user after having been mapped to a local account. - # Both IIS and Apache set REMOTE_USER, when possible. - request.login = env('LOGON_USER') or env('REMOTE_USER') or None - request.multithread = self.environ['wsgi.multithread'] - request.multiprocess = self.environ['wsgi.multiprocess'] - request.wsgi_environ = self.environ - request.prev = env('cherrypy.previous_request', None) - - meth = self.environ['REQUEST_METHOD'] - - path = httputil.urljoin(self.environ.get('SCRIPT_NAME', ''), - self.environ.get('PATH_INFO', '')) - qs = self.environ.get('QUERY_STRING', '') - rproto = self.environ.get('SERVER_PROTOCOL') - headers = self.translate_headers(self.environ) - rfile = self.environ['wsgi.input'] - request.run(meth, path, qs, rproto, headers, rfile) - - headerNames = {'HTTP_CGI_AUTHORIZATION': 'Authorization', - 'CONTENT_LENGTH': 'Content-Length', - 'CONTENT_TYPE': 'Content-Type', - 'REMOTE_HOST': 'Remote-Host', - 'REMOTE_ADDR': 'Remote-Addr', - } - - def translate_headers(self, environ): - """Translate CGI-environ header names to HTTP header names.""" - for cgiName in environ: - # We assume all incoming header keys are uppercase already. - if cgiName in self.headerNames: - yield self.headerNames[cgiName], environ[cgiName] - elif cgiName[:5] == "HTTP_": - # Hackish attempt at recovering original header names. - translatedHeader = cgiName[5:].replace("_", "-") - yield translatedHeader, environ[cgiName] - - -class CPWSGIApp(object): - """A WSGI application object for a CherryPy Application. - - pipeline: a list of (name, wsgiapp) pairs. Each 'wsgiapp' MUST be a - constructor that takes an initial, positional 'nextapp' argument, - plus optional keyword arguments, and returns a WSGI application - (that takes environ and start_response arguments). The 'name' can - be any you choose, and will correspond to keys in self.config. - - head: rather than nest all apps in the pipeline on each call, it's only - done the first time, and the result is memoized into self.head. Set - this to None again if you change self.pipeline after calling self. - - config: a dict whose keys match names listed in the pipeline. Each - value is a further dict which will be passed to the corresponding - named WSGI callable (from the pipeline) as keyword arguments. - """ - - pipeline = [('ExceptionTrapper', ExceptionTrapper), - ('InternalRedirector', InternalRedirector), - ] - head = None - config = {} - - response_class = AppResponse - - def __init__(self, cpapp, pipeline=None): - self.cpapp = cpapp - self.pipeline = self.pipeline[:] - if pipeline: - self.pipeline.extend(pipeline) - self.config = self.config.copy() - - def tail(self, environ, start_response): - """WSGI application callable for the actual CherryPy application. - - You probably shouldn't call this; call self.__call__ instead, - so that any WSGI middleware in self.pipeline can run first. - """ - return self.response_class(environ, start_response, self.cpapp) - - def __call__(self, environ, start_response): - head = self.head - if head is None: - # Create and nest the WSGI apps in our pipeline (in reverse order). - # Then memoize the result in self.head. - head = self.tail - for name, callable in self.pipeline[::-1]: - conf = self.config.get(name, {}) - head = callable(head, **conf) - self.head = head - return head(environ, start_response) - - def namespace_handler(self, k, v): - """Config handler for the 'wsgi' namespace.""" - if k == "pipeline": - # Note this allows multiple 'wsgi.pipeline' config entries - # (but each entry will be processed in a 'random' order). - # It should also allow developers to set default middleware - # in code (passed to self.__init__) that deployers can add to - # (but not remove) via config. - self.pipeline.extend(v) - elif k == "response_class": - self.response_class = v - else: - name, arg = k.split(".", 1) - bucket = self.config.setdefault(name, {}) - bucket[arg] = v - +"""WSGI interface (see PEP 333).""" + +import sys as _sys + +import cherrypy as _cherrypy +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO +from cherrypy import _cperror +from cherrypy.lib import httputil + + +def downgrade_wsgi_ux_to_1x(environ): + """Return a new environ dict for WSGI 1.x from the given WSGI u.x environ.""" + env1x = {} + + url_encoding = environ[u'wsgi.url_encoding'] + for k, v in environ.items(): + if k in [u'PATH_INFO', u'SCRIPT_NAME', u'QUERY_STRING']: + v = v.encode(url_encoding) + elif isinstance(v, unicode): + v = v.encode('ISO-8859-1') + env1x[k.encode('ISO-8859-1')] = v + + return env1x + + +class VirtualHost(object): + """Select a different WSGI application based on the Host header. + + This can be useful when running multiple sites within one CP server. + It allows several domains to point to different applications. For example: + + root = Root() + RootApp = cherrypy.Application(root) + Domain2App = cherrypy.Application(root) + SecureApp = cherrypy.Application(Secure()) + + vhost = cherrypy._cpwsgi.VirtualHost(RootApp, + domains={'www.domain2.example': Domain2App, + 'www.domain2.example:443': SecureApp, + }) + + cherrypy.tree.graft(vhost) + + default: required. The default WSGI application. + + use_x_forwarded_host: if True (the default), any "X-Forwarded-Host" + request header will be used instead of the "Host" header. This + is commonly added by HTTP servers (such as Apache) when proxying. + + domains: a dict of {host header value: application} pairs. + The incoming "Host" request header is looked up in this dict, + and, if a match is found, the corresponding WSGI application + will be called instead of the default. Note that you often need + separate entries for "example.com" and "www.example.com". + In addition, "Host" headers may contain the port number. + """ + + def __init__(self, default, domains=None, use_x_forwarded_host=True): + self.default = default + self.domains = domains or {} + self.use_x_forwarded_host = use_x_forwarded_host + + def __call__(self, environ, start_response): + domain = environ.get('HTTP_HOST', '') + if self.use_x_forwarded_host: + domain = environ.get("HTTP_X_FORWARDED_HOST", domain) + + nextapp = self.domains.get(domain) + if nextapp is None: + nextapp = self.default + return nextapp(environ, start_response) + + +class InternalRedirector(object): + """WSGI middleware that handles raised cherrypy.InternalRedirect.""" + + def __init__(self, nextapp, recursive=False): + self.nextapp = nextapp + self.recursive = recursive + + def __call__(self, environ, start_response): + redirections = [] + while True: + environ = environ.copy() + try: + return self.nextapp(environ, start_response) + except _cherrypy.InternalRedirect, ir: + sn = environ.get('SCRIPT_NAME', '') + path = environ.get('PATH_INFO', '') + qs = environ.get('QUERY_STRING', '') + + # Add the *previous* path_info + qs to redirections. + old_uri = sn + path + if qs: + old_uri += "?" + qs + redirections.append(old_uri) + + if not self.recursive: + # Check to see if the new URI has been redirected to already + new_uri = sn + ir.path + if ir.query_string: + new_uri += "?" + ir.query_string + if new_uri in redirections: + ir.request.close() + raise RuntimeError("InternalRedirector visited the " + "same URL twice: %r" % new_uri) + + # Munge the environment and try again. + environ['REQUEST_METHOD'] = "GET" + environ['PATH_INFO'] = ir.path + environ['QUERY_STRING'] = ir.query_string + environ['wsgi.input'] = StringIO() + environ['CONTENT_LENGTH'] = "0" + environ['cherrypy.previous_request'] = ir.request + + +class ExceptionTrapper(object): + + def __init__(self, nextapp, throws=(KeyboardInterrupt, SystemExit)): + self.nextapp = nextapp + self.throws = throws + + def __call__(self, environ, start_response): + return _TrappedResponse(self.nextapp, environ, start_response, self.throws) + + +class _TrappedResponse(object): + + response = iter([]) + + def __init__(self, nextapp, environ, start_response, throws): + self.nextapp = nextapp + self.environ = environ + self.start_response = start_response + self.throws = throws + self.started_response = False + self.response = self.trap(self.nextapp, self.environ, self.start_response) + self.iter_response = iter(self.response) + + def __iter__(self): + self.started_response = True + return self + + def next(self): + return self.trap(self.iter_response.next) + + def close(self): + if hasattr(self.response, 'close'): + self.response.close() + + def trap(self, func, *args, **kwargs): + try: + return func(*args, **kwargs) + except self.throws: + raise + except StopIteration: + raise + except: + tb = _cperror.format_exc() + #print('trapped (started %s):' % self.started_response, tb) + _cherrypy.log(tb, severity=40) + if not _cherrypy.request.show_tracebacks: + tb = "" + s, h, b = _cperror.bare_error(tb) + if self.started_response: + # Empty our iterable (so future calls raise StopIteration) + self.iter_response = iter([]) + else: + self.iter_response = iter(b) + + try: + self.start_response(s, h, _sys.exc_info()) + except: + # "The application must not trap any exceptions raised by + # start_response, if it called start_response with exc_info. + # Instead, it should allow such exceptions to propagate + # back to the server or gateway." + # But we still log and call close() to clean up ourselves. + _cherrypy.log(traceback=True, severity=40) + raise + + if self.started_response: + return "".join(b) + else: + return b + + +# WSGI-to-CP Adapter # + + +class AppResponse(object): + """WSGI response iterable for CherryPy applications.""" + + def __init__(self, environ, start_response, cpapp): + if environ.get(u'wsgi.version') == (u'u', 0): + environ = downgrade_wsgi_ux_to_1x(environ) + self.environ = environ + self.cpapp = cpapp + try: + self.run() + except: + self.close() + raise + r = _cherrypy.serving.response + self.iter_response = iter(r.body) + self.write = start_response(r.output_status, r.header_list) + + def __iter__(self): + return self + + def next(self): + return self.iter_response.next() + + def close(self): + """Close and de-reference the current request and response. (Core)""" + self.cpapp.release_serving() + + def run(self): + """Create a Request object using environ.""" + env = self.environ.get + + local = httputil.Host('', int(env('SERVER_PORT', 80)), + env('SERVER_NAME', '')) + remote = httputil.Host(env('REMOTE_ADDR', ''), + int(env('REMOTE_PORT', -1)), + env('REMOTE_HOST', '')) + scheme = env('wsgi.url_scheme') + sproto = env('ACTUAL_SERVER_PROTOCOL', "HTTP/1.1") + request, resp = self.cpapp.get_serving(local, remote, scheme, sproto) + + # LOGON_USER is served by IIS, and is the name of the + # user after having been mapped to a local account. + # Both IIS and Apache set REMOTE_USER, when possible. + request.login = env('LOGON_USER') or env('REMOTE_USER') or None + request.multithread = self.environ['wsgi.multithread'] + request.multiprocess = self.environ['wsgi.multiprocess'] + request.wsgi_environ = self.environ + request.prev = env('cherrypy.previous_request', None) + + meth = self.environ['REQUEST_METHOD'] + + path = httputil.urljoin(self.environ.get('SCRIPT_NAME', ''), + self.environ.get('PATH_INFO', '')) + qs = self.environ.get('QUERY_STRING', '') + rproto = self.environ.get('SERVER_PROTOCOL') + headers = self.translate_headers(self.environ) + rfile = self.environ['wsgi.input'] + request.run(meth, path, qs, rproto, headers, rfile) + + headerNames = {'HTTP_CGI_AUTHORIZATION': 'Authorization', + 'CONTENT_LENGTH': 'Content-Length', + 'CONTENT_TYPE': 'Content-Type', + 'REMOTE_HOST': 'Remote-Host', + 'REMOTE_ADDR': 'Remote-Addr', + } + + def translate_headers(self, environ): + """Translate CGI-environ header names to HTTP header names.""" + for cgiName in environ: + # We assume all incoming header keys are uppercase already. + if cgiName in self.headerNames: + yield self.headerNames[cgiName], environ[cgiName] + elif cgiName[:5] == "HTTP_": + # Hackish attempt at recovering original header names. + translatedHeader = cgiName[5:].replace("_", "-") + yield translatedHeader, environ[cgiName] + + +class CPWSGIApp(object): + """A WSGI application object for a CherryPy Application. + + pipeline: a list of (name, wsgiapp) pairs. Each 'wsgiapp' MUST be a + constructor that takes an initial, positional 'nextapp' argument, + plus optional keyword arguments, and returns a WSGI application + (that takes environ and start_response arguments). The 'name' can + be any you choose, and will correspond to keys in self.config. + + head: rather than nest all apps in the pipeline on each call, it's only + done the first time, and the result is memoized into self.head. Set + this to None again if you change self.pipeline after calling self. + + config: a dict whose keys match names listed in the pipeline. Each + value is a further dict which will be passed to the corresponding + named WSGI callable (from the pipeline) as keyword arguments. + """ + + pipeline = [('ExceptionTrapper', ExceptionTrapper), + ('InternalRedirector', InternalRedirector), + ] + head = None + config = {} + + response_class = AppResponse + + def __init__(self, cpapp, pipeline=None): + self.cpapp = cpapp + self.pipeline = self.pipeline[:] + if pipeline: + self.pipeline.extend(pipeline) + self.config = self.config.copy() + + def tail(self, environ, start_response): + """WSGI application callable for the actual CherryPy application. + + You probably shouldn't call this; call self.__call__ instead, + so that any WSGI middleware in self.pipeline can run first. + """ + return self.response_class(environ, start_response, self.cpapp) + + def __call__(self, environ, start_response): + head = self.head + if head is None: + # Create and nest the WSGI apps in our pipeline (in reverse order). + # Then memoize the result in self.head. + head = self.tail + for name, callable in self.pipeline[::-1]: + conf = self.config.get(name, {}) + head = callable(head, **conf) + self.head = head + return head(environ, start_response) + + def namespace_handler(self, k, v): + """Config handler for the 'wsgi' namespace.""" + if k == "pipeline": + # Note this allows multiple 'wsgi.pipeline' config entries + # (but each entry will be processed in a 'random' order). + # It should also allow developers to set default middleware + # in code (passed to self.__init__) that deployers can add to + # (but not remove) via config. + self.pipeline.extend(v) + elif k == "response_class": + self.response_class = v + else: + name, arg = k.split(".", 1) + bucket = self.config.setdefault(name, {}) + bucket[arg] = v + diff --git a/cherrypy/_cpwsgi_server.py b/cherrypy/_cpwsgi_server.py index 2e2e543407..55c3a42a71 100644 --- a/cherrypy/_cpwsgi_server.py +++ b/cherrypy/_cpwsgi_server.py @@ -1,62 +1,62 @@ -"""WSGI server interface (see PEP 333). This adds some CP-specific bits to -the framework-agnostic wsgiserver package. -""" -import sys - -import cherrypy -from cherrypy import wsgiserver - - -class CPHTTPRequest(wsgiserver.HTTPRequest): - pass - - -class CPHTTPConnection(wsgiserver.HTTPConnection): - pass - - -class CPWSGIServer(wsgiserver.CherryPyWSGIServer): - """Wrapper for wsgiserver.CherryPyWSGIServer. - - wsgiserver has been designed to not reference CherryPy in any way, - so that it can be used in other frameworks and applications. Therefore, - we wrap it here, so we can set our own mount points from cherrypy.tree - and apply some attributes from config -> cherrypy.server -> wsgiserver. - """ - - def __init__(self, server_adapter=cherrypy.server): - self.server_adapter = server_adapter - self.max_request_header_size = self.server_adapter.max_request_header_size or 0 - self.max_request_body_size = self.server_adapter.max_request_body_size or 0 - - server_name = (self.server_adapter.socket_host or - self.server_adapter.socket_file or - None) - - self.wsgi_version = self.server_adapter.wsgi_version - s = wsgiserver.CherryPyWSGIServer - s.__init__(self, server_adapter.bind_addr, cherrypy.tree, - self.server_adapter.thread_pool, - server_name, - max=self.server_adapter.thread_pool_max, - request_queue_size=self.server_adapter.socket_queue_size, - timeout=self.server_adapter.socket_timeout, - shutdown_timeout=self.server_adapter.shutdown_timeout, - ) - self.protocol = self.server_adapter.protocol_version - self.nodelay = self.server_adapter.nodelay - - ssl_module = self.server_adapter.ssl_module or 'pyopenssl' - if self.server_adapter.ssl_context: - adapter_class = wsgiserver.get_ssl_adapter_class(ssl_module) - self.ssl_adapter = adapter_class( - self.server_adapter.ssl_certificate, - self.server_adapter.ssl_private_key, - self.server_adapter.ssl_certificate_chain) - self.ssl_adapter.context = self.server_adapter.ssl_context - elif self.server_adapter.ssl_certificate: - adapter_class = wsgiserver.get_ssl_adapter_class(ssl_module) - self.ssl_adapter = adapter_class( - self.server_adapter.ssl_certificate, - self.server_adapter.ssl_private_key, - self.server_adapter.ssl_certificate_chain) +"""WSGI server interface (see PEP 333). This adds some CP-specific bits to +the framework-agnostic wsgiserver package. +""" +import sys + +import cherrypy +from cherrypy import wsgiserver + + +class CPHTTPRequest(wsgiserver.HTTPRequest): + pass + + +class CPHTTPConnection(wsgiserver.HTTPConnection): + pass + + +class CPWSGIServer(wsgiserver.CherryPyWSGIServer): + """Wrapper for wsgiserver.CherryPyWSGIServer. + + wsgiserver has been designed to not reference CherryPy in any way, + so that it can be used in other frameworks and applications. Therefore, + we wrap it here, so we can set our own mount points from cherrypy.tree + and apply some attributes from config -> cherrypy.server -> wsgiserver. + """ + + def __init__(self, server_adapter=cherrypy.server): + self.server_adapter = server_adapter + self.max_request_header_size = self.server_adapter.max_request_header_size or 0 + self.max_request_body_size = self.server_adapter.max_request_body_size or 0 + + server_name = (self.server_adapter.socket_host or + self.server_adapter.socket_file or + None) + + self.wsgi_version = self.server_adapter.wsgi_version + s = wsgiserver.CherryPyWSGIServer + s.__init__(self, server_adapter.bind_addr, cherrypy.tree, + self.server_adapter.thread_pool, + server_name, + max=self.server_adapter.thread_pool_max, + request_queue_size=self.server_adapter.socket_queue_size, + timeout=self.server_adapter.socket_timeout, + shutdown_timeout=self.server_adapter.shutdown_timeout, + ) + self.protocol = self.server_adapter.protocol_version + self.nodelay = self.server_adapter.nodelay + + ssl_module = self.server_adapter.ssl_module or 'pyopenssl' + if self.server_adapter.ssl_context: + adapter_class = wsgiserver.get_ssl_adapter_class(ssl_module) + self.ssl_adapter = adapter_class( + self.server_adapter.ssl_certificate, + self.server_adapter.ssl_private_key, + self.server_adapter.ssl_certificate_chain) + self.ssl_adapter.context = self.server_adapter.ssl_context + elif self.server_adapter.ssl_certificate: + adapter_class = wsgiserver.get_ssl_adapter_class(ssl_module) + self.ssl_adapter = adapter_class( + self.server_adapter.ssl_certificate, + self.server_adapter.ssl_private_key, + self.server_adapter.ssl_certificate_chain) diff --git a/cherrypy/lib/__init__.py b/cherrypy/lib/__init__.py index e4d492d056..733101732f 100644 --- a/cherrypy/lib/__init__.py +++ b/cherrypy/lib/__init__.py @@ -1,44 +1,44 @@ -"""CherryPy Library""" - -# Deprecated in CherryPy 3.2 -- remove in CherryPy 3.3 -from cherrypy.lib.reprconf import _Builder, unrepr, modules, attributes - -class file_generator(object): - """Yield the given input (a file object) in chunks (default 64k). (Core)""" - - def __init__(self, input, chunkSize=65536): - self.input = input - self.chunkSize = chunkSize - - def __iter__(self): - return self - - def __next__(self): - chunk = self.input.read(self.chunkSize) - if chunk: - return chunk - else: - self.input.close() - raise StopIteration() - next = __next__ - -def file_generator_limited(fileobj, count, chunk_size=65536): - """Yield the given file object in chunks, stopping after `count` - bytes has been emitted. Default chunk size is 64kB. (Core) - """ - remaining = count - while remaining > 0: - chunk = fileobj.read(min(chunk_size, remaining)) - chunklen = len(chunk) - if chunklen == 0: - return - remaining -= chunklen - yield chunk - -def set_vary_header(response, header_name): - "Add a Vary header to a response" - varies = response.headers.get("Vary", "") - varies = [x.strip() for x in varies.split(",") if x.strip()] - if header_name not in varies: - varies.append(header_name) - response.headers['Vary'] = ", ".join(varies) +"""CherryPy Library""" + +# Deprecated in CherryPy 3.2 -- remove in CherryPy 3.3 +from cherrypy.lib.reprconf import _Builder, unrepr, modules, attributes + +class file_generator(object): + """Yield the given input (a file object) in chunks (default 64k). (Core)""" + + def __init__(self, input, chunkSize=65536): + self.input = input + self.chunkSize = chunkSize + + def __iter__(self): + return self + + def __next__(self): + chunk = self.input.read(self.chunkSize) + if chunk: + return chunk + else: + self.input.close() + raise StopIteration() + next = __next__ + +def file_generator_limited(fileobj, count, chunk_size=65536): + """Yield the given file object in chunks, stopping after `count` + bytes has been emitted. Default chunk size is 64kB. (Core) + """ + remaining = count + while remaining > 0: + chunk = fileobj.read(min(chunk_size, remaining)) + chunklen = len(chunk) + if chunklen == 0: + return + remaining -= chunklen + yield chunk + +def set_vary_header(response, header_name): + "Add a Vary header to a response" + varies = response.headers.get("Vary", "") + varies = [x.strip() for x in varies.split(",") if x.strip()] + if header_name not in varies: + varies.append(header_name) + response.headers['Vary'] = ", ".join(varies) diff --git a/cherrypy/lib/auth.py b/cherrypy/lib/auth.py index ab13760f43..36ac771020 100644 --- a/cherrypy/lib/auth.py +++ b/cherrypy/lib/auth.py @@ -1,79 +1,79 @@ -import cherrypy -from cherrypy.lib import httpauth - - -def check_auth(users, encrypt=None, realm=None): - """If an authorization header contains credentials, return True, else False.""" - request = cherrypy.serving.request - if 'authorization' in request.headers: - # make sure the provided credentials are correctly set - ah = httpauth.parseAuthorization(request.headers['authorization']) - if ah is None: - raise cherrypy.HTTPError(400, 'Bad Request') - - if not encrypt: - encrypt = httpauth.DIGEST_AUTH_ENCODERS[httpauth.MD5] - - if hasattr(users, '__call__'): - try: - # backward compatibility - users = users() # expect it to return a dictionary - - if not isinstance(users, dict): - raise ValueError("Authentication users must be a dictionary") - - # fetch the user password - password = users.get(ah["username"], None) - except TypeError: - # returns a password (encrypted or clear text) - password = users(ah["username"]) - else: - if not isinstance(users, dict): - raise ValueError("Authentication users must be a dictionary") - - # fetch the user password - password = users.get(ah["username"], None) - - # validate the authorization by re-computing it here - # and compare it with what the user-agent provided - if httpauth.checkResponse(ah, password, method=request.method, - encrypt=encrypt, realm=realm): - request.login = ah["username"] - return True - - request.login = False - return False - -def basic_auth(realm, users, encrypt=None, debug=False): - """If auth fails, raise 401 with a basic authentication header. - - realm: a string containing the authentication realm. - users: a dict of the form: {username: password} or a callable returning a dict. - encrypt: callable used to encrypt the password returned from the user-agent. - if None it defaults to a md5 encryption. - """ - if check_auth(users, encrypt): - if debug: - cherrypy.log('Auth successful', 'TOOLS.BASIC_AUTH') - return - - # inform the user-agent this path is protected - cherrypy.serving.response.headers['www-authenticate'] = httpauth.basicAuth(realm) - - raise cherrypy.HTTPError(401, "You are not authorized to access that resource") - -def digest_auth(realm, users, debug=False): - """If auth fails, raise 401 with a digest authentication header. - - realm: a string containing the authentication realm. - users: a dict of the form: {username: password} or a callable returning a dict. - """ - if check_auth(users, realm=realm): - if debug: - cherrypy.log('Auth successful', 'TOOLS.DIGEST_AUTH') - return - - # inform the user-agent this path is protected - cherrypy.serving.response.headers['www-authenticate'] = httpauth.digestAuth(realm) - - raise cherrypy.HTTPError(401, "You are not authorized to access that resource") +import cherrypy +from cherrypy.lib import httpauth + + +def check_auth(users, encrypt=None, realm=None): + """If an authorization header contains credentials, return True, else False.""" + request = cherrypy.serving.request + if 'authorization' in request.headers: + # make sure the provided credentials are correctly set + ah = httpauth.parseAuthorization(request.headers['authorization']) + if ah is None: + raise cherrypy.HTTPError(400, 'Bad Request') + + if not encrypt: + encrypt = httpauth.DIGEST_AUTH_ENCODERS[httpauth.MD5] + + if hasattr(users, '__call__'): + try: + # backward compatibility + users = users() # expect it to return a dictionary + + if not isinstance(users, dict): + raise ValueError("Authentication users must be a dictionary") + + # fetch the user password + password = users.get(ah["username"], None) + except TypeError: + # returns a password (encrypted or clear text) + password = users(ah["username"]) + else: + if not isinstance(users, dict): + raise ValueError("Authentication users must be a dictionary") + + # fetch the user password + password = users.get(ah["username"], None) + + # validate the authorization by re-computing it here + # and compare it with what the user-agent provided + if httpauth.checkResponse(ah, password, method=request.method, + encrypt=encrypt, realm=realm): + request.login = ah["username"] + return True + + request.login = False + return False + +def basic_auth(realm, users, encrypt=None, debug=False): + """If auth fails, raise 401 with a basic authentication header. + + realm: a string containing the authentication realm. + users: a dict of the form: {username: password} or a callable returning a dict. + encrypt: callable used to encrypt the password returned from the user-agent. + if None it defaults to a md5 encryption. + """ + if check_auth(users, encrypt): + if debug: + cherrypy.log('Auth successful', 'TOOLS.BASIC_AUTH') + return + + # inform the user-agent this path is protected + cherrypy.serving.response.headers['www-authenticate'] = httpauth.basicAuth(realm) + + raise cherrypy.HTTPError(401, "You are not authorized to access that resource") + +def digest_auth(realm, users, debug=False): + """If auth fails, raise 401 with a digest authentication header. + + realm: a string containing the authentication realm. + users: a dict of the form: {username: password} or a callable returning a dict. + """ + if check_auth(users, realm=realm): + if debug: + cherrypy.log('Auth successful', 'TOOLS.DIGEST_AUTH') + return + + # inform the user-agent this path is protected + cherrypy.serving.response.headers['www-authenticate'] = httpauth.digestAuth(realm) + + raise cherrypy.HTTPError(401, "You are not authorized to access that resource") diff --git a/cherrypy/lib/auth_basic.py b/cherrypy/lib/auth_basic.py index b3e671cd0a..7aa6d33c16 100644 --- a/cherrypy/lib/auth_basic.py +++ b/cherrypy/lib/auth_basic.py @@ -1,87 +1,87 @@ -# This file is part of CherryPy -# -*- coding: utf-8 -*- -# vim:ts=4:sw=4:expandtab:fileencoding=utf-8 - -__doc__ = """Module auth_basic.py provides a CherryPy 3.x tool which implements -the server-side of HTTP Basic Access Authentication, as described in RFC 2617. - -Example usage, using the built-in checkpassword_dict function which uses a dict -as the credentials store: - -userpassdict = {'bird' : 'bebop', 'ornette' : 'wayout'} -checkpassword = cherrypy.lib.auth_basic.checkpassword_dict(userpassdict) -basic_auth = {'tools.auth_basic.on': True, - 'tools.auth_basic.realm': 'earth', - 'tools.auth_basic.checkpassword': checkpassword, -} -app_config = { '/' : basic_auth } -""" - -__author__ = 'visteya' -__date__ = 'April 2009' - -import binascii -import base64 -import cherrypy - - -def checkpassword_dict(user_password_dict): - """Returns a checkpassword function which checks credentials - against a dictionary of the form: {username : password}. - - If you want a simple dictionary-based authentication scheme, use - checkpassword_dict(my_credentials_dict) as the value for the - checkpassword argument to basic_auth(). - """ - def checkpassword(realm, user, password): - p = user_password_dict.get(user) - return p and p == password or False - - return checkpassword - - -def basic_auth(realm, checkpassword, debug=False): - """basic_auth is a CherryPy tool which hooks at before_handler to perform - HTTP Basic Access Authentication, as specified in RFC 2617. - - If the request has an 'authorization' header with a 'Basic' scheme, this - tool attempts to authenticate the credentials supplied in that header. If - the request has no 'authorization' header, or if it does but the scheme is - not 'Basic', or if authentication fails, the tool sends a 401 response with - a 'WWW-Authenticate' Basic header. - - Arguments: - realm: a string containing the authentication realm. - - checkpassword: a callable which checks the authentication credentials. - Its signature is checkpassword(realm, username, password). where - username and password are the values obtained from the request's - 'authorization' header. If authentication succeeds, checkpassword - returns True, else it returns False. - """ - - if '"' in realm: - raise ValueError('Realm cannot contain the " (quote) character.') - request = cherrypy.serving.request - - auth_header = request.headers.get('authorization') - if auth_header is not None: - try: - scheme, params = auth_header.split(' ', 1) - if scheme.lower() == 'basic': - # since CherryPy claims compability with Python 2.3, we must use - # the legacy API of base64 - username_password = base64.decodestring(params) - username, password = username_password.split(':', 1) - if checkpassword(realm, username, password): - if debug: - cherrypy.log('Auth succeeded', 'TOOLS.AUTH_BASIC') - request.login = username - return # successful authentication - except (ValueError, binascii.Error): # split() error, base64.decodestring() error - raise cherrypy.HTTPError(400, 'Bad Request') - - # Respond with 401 status and a WWW-Authenticate header - cherrypy.serving.response.headers['www-authenticate'] = 'Basic realm="%s"' % realm - raise cherrypy.HTTPError(401, "You are not authorized to access that resource") - +# This file is part of CherryPy +# -*- coding: utf-8 -*- +# vim:ts=4:sw=4:expandtab:fileencoding=utf-8 + +__doc__ = """Module auth_basic.py provides a CherryPy 3.x tool which implements +the server-side of HTTP Basic Access Authentication, as described in RFC 2617. + +Example usage, using the built-in checkpassword_dict function which uses a dict +as the credentials store: + +userpassdict = {'bird' : 'bebop', 'ornette' : 'wayout'} +checkpassword = cherrypy.lib.auth_basic.checkpassword_dict(userpassdict) +basic_auth = {'tools.auth_basic.on': True, + 'tools.auth_basic.realm': 'earth', + 'tools.auth_basic.checkpassword': checkpassword, +} +app_config = { '/' : basic_auth } +""" + +__author__ = 'visteya' +__date__ = 'April 2009' + +import binascii +import base64 +import cherrypy + + +def checkpassword_dict(user_password_dict): + """Returns a checkpassword function which checks credentials + against a dictionary of the form: {username : password}. + + If you want a simple dictionary-based authentication scheme, use + checkpassword_dict(my_credentials_dict) as the value for the + checkpassword argument to basic_auth(). + """ + def checkpassword(realm, user, password): + p = user_password_dict.get(user) + return p and p == password or False + + return checkpassword + + +def basic_auth(realm, checkpassword, debug=False): + """basic_auth is a CherryPy tool which hooks at before_handler to perform + HTTP Basic Access Authentication, as specified in RFC 2617. + + If the request has an 'authorization' header with a 'Basic' scheme, this + tool attempts to authenticate the credentials supplied in that header. If + the request has no 'authorization' header, or if it does but the scheme is + not 'Basic', or if authentication fails, the tool sends a 401 response with + a 'WWW-Authenticate' Basic header. + + Arguments: + realm: a string containing the authentication realm. + + checkpassword: a callable which checks the authentication credentials. + Its signature is checkpassword(realm, username, password). where + username and password are the values obtained from the request's + 'authorization' header. If authentication succeeds, checkpassword + returns True, else it returns False. + """ + + if '"' in realm: + raise ValueError('Realm cannot contain the " (quote) character.') + request = cherrypy.serving.request + + auth_header = request.headers.get('authorization') + if auth_header is not None: + try: + scheme, params = auth_header.split(' ', 1) + if scheme.lower() == 'basic': + # since CherryPy claims compability with Python 2.3, we must use + # the legacy API of base64 + username_password = base64.decodestring(params) + username, password = username_password.split(':', 1) + if checkpassword(realm, username, password): + if debug: + cherrypy.log('Auth succeeded', 'TOOLS.AUTH_BASIC') + request.login = username + return # successful authentication + except (ValueError, binascii.Error): # split() error, base64.decodestring() error + raise cherrypy.HTTPError(400, 'Bad Request') + + # Respond with 401 status and a WWW-Authenticate header + cherrypy.serving.response.headers['www-authenticate'] = 'Basic realm="%s"' % realm + raise cherrypy.HTTPError(401, "You are not authorized to access that resource") + diff --git a/cherrypy/lib/auth_digest.py b/cherrypy/lib/auth_digest.py index 4ea281387e..ba64a59216 100644 --- a/cherrypy/lib/auth_digest.py +++ b/cherrypy/lib/auth_digest.py @@ -1,358 +1,358 @@ -# This file is part of CherryPy -# -*- coding: utf-8 -*- -# vim:ts=4:sw=4:expandtab:fileencoding=utf-8 - -__doc__ = """An implementation of the server-side of HTTP Digest Access -Authentication, which is described in RFC 2617. - -Example usage, using the built-in get_ha1_dict_plain function which uses a dict -of plaintext passwords as the credentials store: - -userpassdict = {'alice' : '4x5istwelve'} -get_ha1 = cherrypy.lib.auth_digest.get_ha1_dict_plain(userpassdict) -digest_auth = {'tools.auth_digest.on': True, - 'tools.auth_digest.realm': 'wonderland', - 'tools.auth_digest.get_ha1': get_ha1, - 'tools.auth_digest.key': 'a565c27146791cfb', -} -app_config = { '/' : digest_auth } -""" - -__author__ = 'visteya' -__date__ = 'April 2009' - - -try: - from hashlib import md5 -except ImportError: - # Python 2.4 and earlier - from md5 import new as md5 -md5_hex = lambda s: md5(s).hexdigest() - -import time -import base64 -from urllib2 import parse_http_list, parse_keqv_list - -import cherrypy - -qop_auth = 'auth' -qop_auth_int = 'auth-int' -valid_qops = (qop_auth, qop_auth_int) - -valid_algorithms = ('MD5', 'MD5-sess') - - -def TRACE(msg): - cherrypy.log(msg, context='TOOLS.AUTH_DIGEST') - -# Three helper functions for users of the tool, providing three variants -# of get_ha1() functions for three different kinds of credential stores. -def get_ha1_dict_plain(user_password_dict): - """Returns a get_ha1 function which obtains a plaintext password from a - dictionary of the form: {username : password}. - - If you want a simple dictionary-based authentication scheme, with plaintext - passwords, use get_ha1_dict_plain(my_userpass_dict) as the value for the - get_ha1 argument to digest_auth(). - """ - def get_ha1(realm, username): - password = user_password_dict.get(username) - if password: - return md5_hex('%s:%s:%s' % (username, realm, password)) - return None - - return get_ha1 - -def get_ha1_dict(user_ha1_dict): - """Returns a get_ha1 function which obtains a HA1 password hash from a - dictionary of the form: {username : HA1}. - - If you want a dictionary-based authentication scheme, but with - pre-computed HA1 hashes instead of plain-text passwords, use - get_ha1_dict(my_userha1_dict) as the value for the get_ha1 - argument to digest_auth(). - """ - def get_ha1(realm, username): - return user_ha1_dict.get(user) - - return get_ha1 - -def get_ha1_file_htdigest(filename): - """Returns a get_ha1 function which obtains a HA1 password hash from a - flat file with lines of the same format as that produced by the Apache - htdigest utility. For example, for realm 'wonderland', username 'alice', - and password '4x5istwelve', the htdigest line would be: - - alice:wonderland:3238cdfe91a8b2ed8e39646921a02d4c - - If you want to use an Apache htdigest file as the credentials store, - then use get_ha1_file_htdigest(my_htdigest_file) as the value for the - get_ha1 argument to digest_auth(). It is recommended that the filename - argument be an absolute path, to avoid problems. - """ - def get_ha1(realm, username): - result = None - f = open(filename, 'r') - for line in f: - u, r, ha1 = line.rstrip().split(':') - if u == username and r == realm: - result = ha1 - break - f.close() - return result - - return get_ha1 - - -def synthesize_nonce(s, key, timestamp=None): - """Synthesize a nonce value which resists spoofing and can be checked for staleness. - Returns a string suitable as the value for 'nonce' in the www-authenticate header. - - Args: - s: a string related to the resource, such as the hostname of the server. - key: a secret string known only to the server. - timestamp: an integer seconds-since-the-epoch timestamp - """ - if timestamp is None: - timestamp = int(time.time()) - h = md5_hex('%s:%s:%s' % (timestamp, s, key)) - nonce = '%s:%s' % (timestamp, h) - return nonce - - -def H(s): - """The hash function H""" - return md5_hex(s) - - -class HttpDigestAuthorization (object): - """Class to parse a Digest Authorization header and perform re-calculation - of the digest. - """ - - def errmsg(self, s): - return 'Digest Authorization header: %s' % s - - def __init__(self, auth_header, http_method, debug=False): - self.http_method = http_method - self.debug = debug - scheme, params = auth_header.split(" ", 1) - self.scheme = scheme.lower() - if self.scheme != 'digest': - raise ValueError('Authorization scheme is not "Digest"') - - self.auth_header = auth_header - - # make a dict of the params - items = parse_http_list(params) - paramsd = parse_keqv_list(items) - - self.realm = paramsd.get('realm') - self.username = paramsd.get('username') - self.nonce = paramsd.get('nonce') - self.uri = paramsd.get('uri') - self.method = paramsd.get('method') - self.response = paramsd.get('response') # the response digest - self.algorithm = paramsd.get('algorithm', 'MD5') - self.cnonce = paramsd.get('cnonce') - self.opaque = paramsd.get('opaque') - self.qop = paramsd.get('qop') # qop - self.nc = paramsd.get('nc') # nonce count - - # perform some correctness checks - if self.algorithm not in valid_algorithms: - raise ValueError(self.errmsg("Unsupported value for algorithm: '%s'" % self.algorithm)) - - has_reqd = self.username and \ - self.realm and \ - self.nonce and \ - self.uri and \ - self.response - if not has_reqd: - raise ValueError(self.errmsg("Not all required parameters are present.")) - - if self.qop: - if self.qop not in valid_qops: - raise ValueError(self.errmsg("Unsupported value for qop: '%s'" % self.qop)) - if not (self.cnonce and self.nc): - raise ValueError(self.errmsg("If qop is sent then cnonce and nc MUST be present")) - else: - if self.cnonce or self.nc: - raise ValueError(self.errmsg("If qop is not sent, neither cnonce nor nc can be present")) - - - def __str__(self): - return 'authorization : %s' % self.auth_header - - def validate_nonce(self, s, key): - """Validate the nonce. - Returns True if nonce was generated by synthesize_nonce() and the timestamp - is not spoofed, else returns False. - - Args: - s: a string related to the resource, such as the hostname of the server. - key: a secret string known only to the server. - Both s and key must be the same values which were used to synthesize the nonce - we are trying to validate. - """ - try: - timestamp, hashpart = self.nonce.split(':', 1) - s_timestamp, s_hashpart = synthesize_nonce(s, key, timestamp).split(':', 1) - is_valid = s_hashpart == hashpart - if self.debug: - TRACE('validate_nonce: %s' % is_valid) - return is_valid - except ValueError: # split() error - pass - return False - - - def is_nonce_stale(self, max_age_seconds=600): - """Returns True if a validated nonce is stale. The nonce contains a - timestamp in plaintext and also a secure hash of the timestamp. You should - first validate the nonce to ensure the plaintext timestamp is not spoofed. - """ - try: - timestamp, hashpart = self.nonce.split(':', 1) - if int(timestamp) + max_age_seconds > int(time.time()): - return False - except ValueError: # int() error - pass - if self.debug: - TRACE("nonce is stale") - return True - - - def HA2(self, entity_body=''): - """Returns the H(A2) string. See RFC 2617 3.2.2.3.""" - # RFC 2617 3.2.2.3 - # If the "qop" directive's value is "auth" or is unspecified, then A2 is: - # A2 = method ":" digest-uri-value - # - # If the "qop" value is "auth-int", then A2 is: - # A2 = method ":" digest-uri-value ":" H(entity-body) - if self.qop is None or self.qop == "auth": - a2 = '%s:%s' % (self.http_method, self.uri) - elif self.qop == "auth-int": - a2 = "%s:%s:%s" % (self.http_method, self.uri, H(entity_body)) - else: - # in theory, this should never happen, since I validate qop in __init__() - raise ValueError(self.errmsg("Unrecognized value for qop!")) - return H(a2) - - - def request_digest(self, ha1, entity_body=''): - """Calculates the Request-Digest. See RFC 2617 3.2.2.1. - Arguments: - - ha1 : the HA1 string obtained from the credentials store. - - entity_body : if 'qop' is set to 'auth-int', then A2 includes a hash - of the "entity body". The entity body is the part of the - message which follows the HTTP headers. See RFC 2617 section - 4.3. This refers to the entity the user agent sent in the request which - has the Authorization header. Typically GET requests don't have an entity, - and POST requests do. - """ - ha2 = self.HA2(entity_body) - # Request-Digest -- RFC 2617 3.2.2.1 - if self.qop: - req = "%s:%s:%s:%s:%s" % (self.nonce, self.nc, self.cnonce, self.qop, ha2) - else: - req = "%s:%s" % (self.nonce, ha2) - - # RFC 2617 3.2.2.2 - # - # If the "algorithm" directive's value is "MD5" or is unspecified, then A1 is: - # A1 = unq(username-value) ":" unq(realm-value) ":" passwd - # - # If the "algorithm" directive's value is "MD5-sess", then A1 is - # calculated only once - on the first request by the client following - # receipt of a WWW-Authenticate challenge from the server. - # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd ) - # ":" unq(nonce-value) ":" unq(cnonce-value) - if self.algorithm == 'MD5-sess': - ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce)) - - digest = H('%s:%s' % (ha1, req)) - return digest - - - -def www_authenticate(realm, key, algorithm='MD5', nonce=None, qop=qop_auth, stale=False): - """Constructs a WWW-Authenticate header for Digest authentication.""" - if qop not in valid_qops: - raise ValueError("Unsupported value for qop: '%s'" % qop) - if algorithm not in valid_algorithms: - raise ValueError("Unsupported value for algorithm: '%s'" % algorithm) - - if nonce is None: - nonce = synthesize_nonce(realm, key) - s = 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % ( - realm, nonce, algorithm, qop) - if stale: - s += ', stale="true"' - return s - - -def digest_auth(realm, get_ha1, key, debug=False): - """digest_auth is a CherryPy tool which hooks at before_handler to perform - HTTP Digest Access Authentication, as specified in RFC 2617. - - If the request has an 'authorization' header with a 'Digest' scheme, this - tool authenticates the credentials supplied in that header. If - the request has no 'authorization' header, or if it does but the scheme is - not "Digest", or if authentication fails, the tool sends a 401 response with - a 'WWW-Authenticate' Digest header. - - Arguments: - realm: a string containing the authentication realm. - - get_ha1: a callable which looks up a username in a credentials store - and returns the HA1 string, which is defined in the RFC to be - MD5(username : realm : password). The function's signature is: - get_ha1(realm, username) - where username is obtained from the request's 'authorization' header. - If username is not found in the credentials store, get_ha1() returns - None. - - key: a secret string known only to the server, used in the synthesis of nonces. - """ - request = cherrypy.serving.request - - auth_header = request.headers.get('authorization') - nonce_is_stale = False - if auth_header is not None: - try: - auth = HttpDigestAuthorization(auth_header, request.method, debug=debug) - except ValueError, e: - raise cherrypy.HTTPError(400, 'Bad Request: %s' % e) - - if debug: - TRACE(str(auth)) - - if auth.validate_nonce(realm, key): - ha1 = get_ha1(realm, auth.username) - if ha1 is not None: - # note that for request.body to be available we need to hook in at - # before_handler, not on_start_resource like 3.1.x digest_auth does. - digest = auth.request_digest(ha1, entity_body=request.body) - if digest == auth.response: # authenticated - if debug: - TRACE("digest matches auth.response") - # Now check if nonce is stale. - # The choice of ten minutes' lifetime for nonce is somewhat arbitrary - nonce_is_stale = auth.is_nonce_stale(max_age_seconds=600) - if not nonce_is_stale: - request.login = auth.username - if debug: - TRACE("authentication of %s successful" % auth.username) - return - - # Respond with 401 status and a WWW-Authenticate header - header = www_authenticate(realm, key, stale=nonce_is_stale) - if debug: - TRACE(header) - cherrypy.serving.response.headers['WWW-Authenticate'] = header - raise cherrypy.HTTPError(401, "You are not authorized to access that resource") - +# This file is part of CherryPy +# -*- coding: utf-8 -*- +# vim:ts=4:sw=4:expandtab:fileencoding=utf-8 + +__doc__ = """An implementation of the server-side of HTTP Digest Access +Authentication, which is described in RFC 2617. + +Example usage, using the built-in get_ha1_dict_plain function which uses a dict +of plaintext passwords as the credentials store: + +userpassdict = {'alice' : '4x5istwelve'} +get_ha1 = cherrypy.lib.auth_digest.get_ha1_dict_plain(userpassdict) +digest_auth = {'tools.auth_digest.on': True, + 'tools.auth_digest.realm': 'wonderland', + 'tools.auth_digest.get_ha1': get_ha1, + 'tools.auth_digest.key': 'a565c27146791cfb', +} +app_config = { '/' : digest_auth } +""" + +__author__ = 'visteya' +__date__ = 'April 2009' + + +try: + from hashlib import md5 +except ImportError: + # Python 2.4 and earlier + from md5 import new as md5 +md5_hex = lambda s: md5(s).hexdigest() + +import time +import base64 +from urllib2 import parse_http_list, parse_keqv_list + +import cherrypy + +qop_auth = 'auth' +qop_auth_int = 'auth-int' +valid_qops = (qop_auth, qop_auth_int) + +valid_algorithms = ('MD5', 'MD5-sess') + + +def TRACE(msg): + cherrypy.log(msg, context='TOOLS.AUTH_DIGEST') + +# Three helper functions for users of the tool, providing three variants +# of get_ha1() functions for three different kinds of credential stores. +def get_ha1_dict_plain(user_password_dict): + """Returns a get_ha1 function which obtains a plaintext password from a + dictionary of the form: {username : password}. + + If you want a simple dictionary-based authentication scheme, with plaintext + passwords, use get_ha1_dict_plain(my_userpass_dict) as the value for the + get_ha1 argument to digest_auth(). + """ + def get_ha1(realm, username): + password = user_password_dict.get(username) + if password: + return md5_hex('%s:%s:%s' % (username, realm, password)) + return None + + return get_ha1 + +def get_ha1_dict(user_ha1_dict): + """Returns a get_ha1 function which obtains a HA1 password hash from a + dictionary of the form: {username : HA1}. + + If you want a dictionary-based authentication scheme, but with + pre-computed HA1 hashes instead of plain-text passwords, use + get_ha1_dict(my_userha1_dict) as the value for the get_ha1 + argument to digest_auth(). + """ + def get_ha1(realm, username): + return user_ha1_dict.get(user) + + return get_ha1 + +def get_ha1_file_htdigest(filename): + """Returns a get_ha1 function which obtains a HA1 password hash from a + flat file with lines of the same format as that produced by the Apache + htdigest utility. For example, for realm 'wonderland', username 'alice', + and password '4x5istwelve', the htdigest line would be: + + alice:wonderland:3238cdfe91a8b2ed8e39646921a02d4c + + If you want to use an Apache htdigest file as the credentials store, + then use get_ha1_file_htdigest(my_htdigest_file) as the value for the + get_ha1 argument to digest_auth(). It is recommended that the filename + argument be an absolute path, to avoid problems. + """ + def get_ha1(realm, username): + result = None + f = open(filename, 'r') + for line in f: + u, r, ha1 = line.rstrip().split(':') + if u == username and r == realm: + result = ha1 + break + f.close() + return result + + return get_ha1 + + +def synthesize_nonce(s, key, timestamp=None): + """Synthesize a nonce value which resists spoofing and can be checked for staleness. + Returns a string suitable as the value for 'nonce' in the www-authenticate header. + + Args: + s: a string related to the resource, such as the hostname of the server. + key: a secret string known only to the server. + timestamp: an integer seconds-since-the-epoch timestamp + """ + if timestamp is None: + timestamp = int(time.time()) + h = md5_hex('%s:%s:%s' % (timestamp, s, key)) + nonce = '%s:%s' % (timestamp, h) + return nonce + + +def H(s): + """The hash function H""" + return md5_hex(s) + + +class HttpDigestAuthorization (object): + """Class to parse a Digest Authorization header and perform re-calculation + of the digest. + """ + + def errmsg(self, s): + return 'Digest Authorization header: %s' % s + + def __init__(self, auth_header, http_method, debug=False): + self.http_method = http_method + self.debug = debug + scheme, params = auth_header.split(" ", 1) + self.scheme = scheme.lower() + if self.scheme != 'digest': + raise ValueError('Authorization scheme is not "Digest"') + + self.auth_header = auth_header + + # make a dict of the params + items = parse_http_list(params) + paramsd = parse_keqv_list(items) + + self.realm = paramsd.get('realm') + self.username = paramsd.get('username') + self.nonce = paramsd.get('nonce') + self.uri = paramsd.get('uri') + self.method = paramsd.get('method') + self.response = paramsd.get('response') # the response digest + self.algorithm = paramsd.get('algorithm', 'MD5') + self.cnonce = paramsd.get('cnonce') + self.opaque = paramsd.get('opaque') + self.qop = paramsd.get('qop') # qop + self.nc = paramsd.get('nc') # nonce count + + # perform some correctness checks + if self.algorithm not in valid_algorithms: + raise ValueError(self.errmsg("Unsupported value for algorithm: '%s'" % self.algorithm)) + + has_reqd = self.username and \ + self.realm and \ + self.nonce and \ + self.uri and \ + self.response + if not has_reqd: + raise ValueError(self.errmsg("Not all required parameters are present.")) + + if self.qop: + if self.qop not in valid_qops: + raise ValueError(self.errmsg("Unsupported value for qop: '%s'" % self.qop)) + if not (self.cnonce and self.nc): + raise ValueError(self.errmsg("If qop is sent then cnonce and nc MUST be present")) + else: + if self.cnonce or self.nc: + raise ValueError(self.errmsg("If qop is not sent, neither cnonce nor nc can be present")) + + + def __str__(self): + return 'authorization : %s' % self.auth_header + + def validate_nonce(self, s, key): + """Validate the nonce. + Returns True if nonce was generated by synthesize_nonce() and the timestamp + is not spoofed, else returns False. + + Args: + s: a string related to the resource, such as the hostname of the server. + key: a secret string known only to the server. + Both s and key must be the same values which were used to synthesize the nonce + we are trying to validate. + """ + try: + timestamp, hashpart = self.nonce.split(':', 1) + s_timestamp, s_hashpart = synthesize_nonce(s, key, timestamp).split(':', 1) + is_valid = s_hashpart == hashpart + if self.debug: + TRACE('validate_nonce: %s' % is_valid) + return is_valid + except ValueError: # split() error + pass + return False + + + def is_nonce_stale(self, max_age_seconds=600): + """Returns True if a validated nonce is stale. The nonce contains a + timestamp in plaintext and also a secure hash of the timestamp. You should + first validate the nonce to ensure the plaintext timestamp is not spoofed. + """ + try: + timestamp, hashpart = self.nonce.split(':', 1) + if int(timestamp) + max_age_seconds > int(time.time()): + return False + except ValueError: # int() error + pass + if self.debug: + TRACE("nonce is stale") + return True + + + def HA2(self, entity_body=''): + """Returns the H(A2) string. See RFC 2617 3.2.2.3.""" + # RFC 2617 3.2.2.3 + # If the "qop" directive's value is "auth" or is unspecified, then A2 is: + # A2 = method ":" digest-uri-value + # + # If the "qop" value is "auth-int", then A2 is: + # A2 = method ":" digest-uri-value ":" H(entity-body) + if self.qop is None or self.qop == "auth": + a2 = '%s:%s' % (self.http_method, self.uri) + elif self.qop == "auth-int": + a2 = "%s:%s:%s" % (self.http_method, self.uri, H(entity_body)) + else: + # in theory, this should never happen, since I validate qop in __init__() + raise ValueError(self.errmsg("Unrecognized value for qop!")) + return H(a2) + + + def request_digest(self, ha1, entity_body=''): + """Calculates the Request-Digest. See RFC 2617 3.2.2.1. + Arguments: + + ha1 : the HA1 string obtained from the credentials store. + + entity_body : if 'qop' is set to 'auth-int', then A2 includes a hash + of the "entity body". The entity body is the part of the + message which follows the HTTP headers. See RFC 2617 section + 4.3. This refers to the entity the user agent sent in the request which + has the Authorization header. Typically GET requests don't have an entity, + and POST requests do. + """ + ha2 = self.HA2(entity_body) + # Request-Digest -- RFC 2617 3.2.2.1 + if self.qop: + req = "%s:%s:%s:%s:%s" % (self.nonce, self.nc, self.cnonce, self.qop, ha2) + else: + req = "%s:%s" % (self.nonce, ha2) + + # RFC 2617 3.2.2.2 + # + # If the "algorithm" directive's value is "MD5" or is unspecified, then A1 is: + # A1 = unq(username-value) ":" unq(realm-value) ":" passwd + # + # If the "algorithm" directive's value is "MD5-sess", then A1 is + # calculated only once - on the first request by the client following + # receipt of a WWW-Authenticate challenge from the server. + # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd ) + # ":" unq(nonce-value) ":" unq(cnonce-value) + if self.algorithm == 'MD5-sess': + ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce)) + + digest = H('%s:%s' % (ha1, req)) + return digest + + + +def www_authenticate(realm, key, algorithm='MD5', nonce=None, qop=qop_auth, stale=False): + """Constructs a WWW-Authenticate header for Digest authentication.""" + if qop not in valid_qops: + raise ValueError("Unsupported value for qop: '%s'" % qop) + if algorithm not in valid_algorithms: + raise ValueError("Unsupported value for algorithm: '%s'" % algorithm) + + if nonce is None: + nonce = synthesize_nonce(realm, key) + s = 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % ( + realm, nonce, algorithm, qop) + if stale: + s += ', stale="true"' + return s + + +def digest_auth(realm, get_ha1, key, debug=False): + """digest_auth is a CherryPy tool which hooks at before_handler to perform + HTTP Digest Access Authentication, as specified in RFC 2617. + + If the request has an 'authorization' header with a 'Digest' scheme, this + tool authenticates the credentials supplied in that header. If + the request has no 'authorization' header, or if it does but the scheme is + not "Digest", or if authentication fails, the tool sends a 401 response with + a 'WWW-Authenticate' Digest header. + + Arguments: + realm: a string containing the authentication realm. + + get_ha1: a callable which looks up a username in a credentials store + and returns the HA1 string, which is defined in the RFC to be + MD5(username : realm : password). The function's signature is: + get_ha1(realm, username) + where username is obtained from the request's 'authorization' header. + If username is not found in the credentials store, get_ha1() returns + None. + + key: a secret string known only to the server, used in the synthesis of nonces. + """ + request = cherrypy.serving.request + + auth_header = request.headers.get('authorization') + nonce_is_stale = False + if auth_header is not None: + try: + auth = HttpDigestAuthorization(auth_header, request.method, debug=debug) + except ValueError, e: + raise cherrypy.HTTPError(400, 'Bad Request: %s' % e) + + if debug: + TRACE(str(auth)) + + if auth.validate_nonce(realm, key): + ha1 = get_ha1(realm, auth.username) + if ha1 is not None: + # note that for request.body to be available we need to hook in at + # before_handler, not on_start_resource like 3.1.x digest_auth does. + digest = auth.request_digest(ha1, entity_body=request.body) + if digest == auth.response: # authenticated + if debug: + TRACE("digest matches auth.response") + # Now check if nonce is stale. + # The choice of ten minutes' lifetime for nonce is somewhat arbitrary + nonce_is_stale = auth.is_nonce_stale(max_age_seconds=600) + if not nonce_is_stale: + request.login = auth.username + if debug: + TRACE("authentication of %s successful" % auth.username) + return + + # Respond with 401 status and a WWW-Authenticate header + header = www_authenticate(realm, key, stale=nonce_is_stale) + if debug: + TRACE(header) + cherrypy.serving.response.headers['WWW-Authenticate'] = header + raise cherrypy.HTTPError(401, "You are not authorized to access that resource") + diff --git a/cherrypy/lib/caching.py b/cherrypy/lib/caching.py index 9dff62bc04..6a0320711c 100644 --- a/cherrypy/lib/caching.py +++ b/cherrypy/lib/caching.py @@ -1,401 +1,401 @@ -import datetime -import threading -import time - -import cherrypy -from cherrypy.lib import cptools, httputil - - -class Cache(object): - - def get(self): - raise NotImplemented - - def put(self, obj, size): - raise NotImplemented - - def delete(self): - raise NotImplemented - - def clear(self): - raise NotImplemented - - - -# ------------------------------- Memory Cache ------------------------------- # - - -class AntiStampedeCache(dict): - - def wait(self, key, timeout=5, debug=False): - """Return the cached value for the given key, or None. - - If timeout is not None (the default), and the value is already - being calculated by another thread, wait until the given timeout has - elapsed. If the value is available before the timeout expires, it is - returned. If not, None is returned, and a sentinel placed in the cache - to signal other threads to wait. - - If timeout is None, no waiting is performed nor sentinels used. - """ - value = self.get(key) - if isinstance(value, threading._Event): - if timeout is None: - # Ignore the other thread and recalc it ourselves. - if debug: - cherrypy.log('No timeout', 'TOOLS.CACHING') - return None - - # Wait until it's done or times out. - if debug: - cherrypy.log('Waiting up to %s seconds' % timeout, 'TOOLS.CACHING') - value.wait(timeout) - if value.result is not None: - # The other thread finished its calculation. Use it. - if debug: - cherrypy.log('Result!', 'TOOLS.CACHING') - return value.result - # Timed out. Stick an Event in the slot so other threads wait - # on this one to finish calculating the value. - if debug: - cherrypy.log('Timed out', 'TOOLS.CACHING') - e = threading.Event() - e.result = None - dict.__setitem__(self, key, e) - - return None - elif value is None: - # Stick an Event in the slot so other threads wait - # on this one to finish calculating the value. - if debug: - cherrypy.log('Timed out', 'TOOLS.CACHING') - e = threading.Event() - e.result = None - dict.__setitem__(self, key, e) - return value - - def __setitem__(self, key, value): - """Set the cached value for the given key.""" - existing = self.get(key) - dict.__setitem__(self, key, value) - if isinstance(existing, threading._Event): - # Set Event.result so other threads waiting on it have - # immediate access without needing to poll the cache again. - existing.result = value - existing.set() - - -class MemoryCache(Cache): - """An in-memory cache for varying response content. - - Each key in self.store is a URI, and each value is an AntiStampedeCache. - The response for any given URI may vary based on the values of - "selecting request headers"; that is, those named in the Vary - response header. We assume the list of header names to be constant - for each URI throughout the lifetime of the application, and store - that list in self.store[uri].selecting_headers. - - The items contained in self.store[uri] have keys which are tuples of request - header values (in the same order as the names in its selecting_headers), - and values which are the actual responses. - """ - - maxobjects = 1000 - maxobj_size = 100000 - maxsize = 10000000 - delay = 600 - antistampede_timeout = 5 - expire_freq = 0.1 - debug = False - - def __init__(self): - self.clear() - - # Run self.expire_cache in a separate daemon thread. - t = threading.Thread(target=self.expire_cache, name='expire_cache') - self.expiration_thread = t - if hasattr(threading.Thread, "daemon"): - # Python 2.6+ - t.daemon = True - else: - t.setDaemon(True) - t.start() - - def clear(self): - """Reset the cache to its initial, empty state.""" - self.store = {} - self.expirations = {} - self.tot_puts = 0 - self.tot_gets = 0 - self.tot_hist = 0 - self.tot_expires = 0 - self.tot_non_modified = 0 - self.cursize = 0 - - def expire_cache(self): - # expire_cache runs in a separate thread which the servers are - # not aware of. It's possible that "time" will be set to None - # arbitrarily, so we check "while time" to avoid exceptions. - # See tickets #99 and #180 for more information. - while time: - now = time.time() - # Must make a copy of expirations so it doesn't change size - # during iteration - for expiration_time, objects in self.expirations.items(): - if expiration_time <= now: - for obj_size, uri, sel_header_values in objects: - try: - del self.store[uri][sel_header_values] - self.tot_expires += 1 - self.cursize -= obj_size - except KeyError: - # the key may have been deleted elsewhere - pass - del self.expirations[expiration_time] - time.sleep(self.expire_freq) - - def get(self): - """Return the current variant if in the cache, else None.""" - request = cherrypy.serving.request - self.tot_gets += 1 - - uri = cherrypy.url(qs=request.query_string) - uricache = self.store.get(uri) - if uricache is None: - return None - - header_values = [request.headers.get(h, '') - for h in uricache.selecting_headers] - header_values.sort() - variant = uricache.wait(key=tuple(header_values), - timeout=self.antistampede_timeout, - debug=self.debug) - if variant is not None: - self.tot_hist += 1 - return variant - - def put(self, variant, size): - """Store the current variant in the cache.""" - request = cherrypy.serving.request - response = cherrypy.serving.response - - uri = cherrypy.url(qs=request.query_string) - uricache = self.store.get(uri) - if uricache is None: - uricache = AntiStampedeCache() - uricache.selecting_headers = [ - e.value for e in response.headers.elements('Vary')] - self.store[uri] = uricache - - if len(self.store) < self.maxobjects: - total_size = self.cursize + size - - # checks if there's space for the object - if (size < self.maxobj_size and total_size < self.maxsize): - # add to the expirations list - expiration_time = response.time + self.delay - bucket = self.expirations.setdefault(expiration_time, []) - bucket.append((size, uri, uricache.selecting_headers)) - - # add to the cache - header_values = [request.headers.get(h, '') - for h in uricache.selecting_headers] - header_values.sort() - uricache[tuple(header_values)] = variant - self.tot_puts += 1 - self.cursize = total_size - - def delete(self): - """Remove ALL cached variants of the current resource.""" - uri = cherrypy.url(qs=cherrypy.serving.request.query_string) - self.store.pop(uri, None) - - -def get(invalid_methods=("POST", "PUT", "DELETE"), debug=False, **kwargs): - """Try to obtain cached output. If fresh enough, raise HTTPError(304). - - If POST, PUT, or DELETE: - * invalidates (deletes) any cached response for this resource - * sets request.cached = False - * sets request.cacheable = False - - else if a cached copy exists: - * sets request.cached = True - * sets request.cacheable = False - * sets response.headers to the cached values - * checks the cached Last-Modified response header against the - current If-(Un)Modified-Since request headers; raises 304 - if necessary. - * sets response.status and response.body to the cached values - * returns True - - otherwise: - * sets request.cached = False - * sets request.cacheable = True - * returns False - """ - request = cherrypy.serving.request - response = cherrypy.serving.response - - if not hasattr(cherrypy, "_cache"): - # Make a process-wide Cache object. - cherrypy._cache = kwargs.pop("cache_class", MemoryCache)() - - # Take all remaining kwargs and set them on the Cache object. - for k, v in kwargs.items(): - setattr(cherrypy._cache, k, v) - cherrypy._cache.debug = debug - - # POST, PUT, DELETE should invalidate (delete) the cached copy. - # See http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.10. - if request.method in invalid_methods: - if debug: - cherrypy.log('request.method %r in invalid_methods %r' % - (request.method, invalid_methods), 'TOOLS.CACHING') - cherrypy._cache.delete() - request.cached = False - request.cacheable = False - return False - - if 'no-cache' in [e.value for e in request.headers.elements('Pragma')]: - request.cached = False - request.cacheable = True - return False - - cache_data = cherrypy._cache.get() - request.cached = bool(cache_data) - request.cacheable = not request.cached - if request.cached: - # Serve the cached copy. - max_age = cherrypy._cache.delay - for v in [e.value for e in request.headers.elements('Cache-Control')]: - atoms = v.split('=', 1) - directive = atoms.pop(0) - if directive == 'max-age': - if len(atoms) != 1 or not atoms[0].isdigit(): - raise cherrypy.HTTPError(400, "Invalid Cache-Control header") - max_age = int(atoms[0]) - break - elif directive == 'no-cache': - if debug: - cherrypy.log('Ignoring cache due to Cache-Control: no-cache', - 'TOOLS.CACHING') - request.cached = False - request.cacheable = True - return False - - if debug: - cherrypy.log('Reading response from cache', 'TOOLS.CACHING') - s, h, b, create_time = cache_data - age = int(response.time - create_time) - if (age > max_age): - if debug: - cherrypy.log('Ignoring cache due to age > %d' % max_age, - 'TOOLS.CACHING') - request.cached = False - request.cacheable = True - return False - - # Copy the response headers. See http://www.cherrypy.org/ticket/721. - response.headers = rh = httputil.HeaderMap() - for k in h: - dict.__setitem__(rh, k, dict.__getitem__(h, k)) - - # Add the required Age header - response.headers["Age"] = str(age) - - try: - # Note that validate_since depends on a Last-Modified header; - # this was put into the cached copy, and should have been - # resurrected just above (response.headers = cache_data[1]). - cptools.validate_since() - except cherrypy.HTTPRedirect, x: - if x.status == 304: - cherrypy._cache.tot_non_modified += 1 - raise - - # serve it & get out from the request - response.status = s - response.body = b - else: - if debug: - cherrypy.log('request is not cached', 'TOOLS.CACHING') - return request.cached - - -def tee_output(): - request = cherrypy.serving.request - if 'no-store' in request.headers.values('Cache-Control'): - return - - def tee(body): - """Tee response.body into a list.""" - if ('no-cache' in response.headers.values('Pragma') or - 'no-store' in response.headers.values('Cache-Control')): - for chunk in body: - yield chunk - return - - output = [] - for chunk in body: - output.append(chunk) - yield chunk - - # save the cache data - body = ''.join(output) - cherrypy._cache.put((response.status, response.headers or {}, - body, response.time), len(body)) - - response = cherrypy.serving.response - response.body = tee(response.body) - - -def expires(secs=0, force=False, debug=False): - """Tool for influencing cache mechanisms using the 'Expires' header. - - 'secs' must be either an int or a datetime.timedelta, and indicates the - number of seconds between response.time and when the response should - expire. The 'Expires' header will be set to (response.time + secs). - - If 'secs' is zero, the 'Expires' header is set one year in the past, and - the following "cache prevention" headers are also set: - 'Pragma': 'no-cache' - 'Cache-Control': 'no-cache, must-revalidate' - - If 'force' is False (the default), the following headers are checked: - 'Etag', 'Last-Modified', 'Age', 'Expires'. If any are already present, - none of the above response headers are set. - """ - - response = cherrypy.serving.response - headers = response.headers - - cacheable = False - if not force: - # some header names that indicate that the response can be cached - for indicator in ('Etag', 'Last-Modified', 'Age', 'Expires'): - if indicator in headers: - cacheable = True - break - - if not cacheable and not force: - if debug: - cherrypy.log('request is not cacheable', 'TOOLS.EXPIRES') - else: - if debug: - cherrypy.log('request is cacheable', 'TOOLS.EXPIRES') - if isinstance(secs, datetime.timedelta): - secs = (86400 * secs.days) + secs.seconds - - if secs == 0: - if force or ("Pragma" not in headers): - headers["Pragma"] = "no-cache" - if cherrypy.serving.request.protocol >= (1, 1): - if force or "Cache-Control" not in headers: - headers["Cache-Control"] = "no-cache, must-revalidate" - # Set an explicit Expires date in the past. - expiry = httputil.HTTPDate(1169942400.0) - else: - expiry = httputil.HTTPDate(response.time + secs) - if force or "Expires" not in headers: - headers["Expires"] = expiry +import datetime +import threading +import time + +import cherrypy +from cherrypy.lib import cptools, httputil + + +class Cache(object): + + def get(self): + raise NotImplemented + + def put(self, obj, size): + raise NotImplemented + + def delete(self): + raise NotImplemented + + def clear(self): + raise NotImplemented + + + +# ------------------------------- Memory Cache ------------------------------- # + + +class AntiStampedeCache(dict): + + def wait(self, key, timeout=5, debug=False): + """Return the cached value for the given key, or None. + + If timeout is not None (the default), and the value is already + being calculated by another thread, wait until the given timeout has + elapsed. If the value is available before the timeout expires, it is + returned. If not, None is returned, and a sentinel placed in the cache + to signal other threads to wait. + + If timeout is None, no waiting is performed nor sentinels used. + """ + value = self.get(key) + if isinstance(value, threading._Event): + if timeout is None: + # Ignore the other thread and recalc it ourselves. + if debug: + cherrypy.log('No timeout', 'TOOLS.CACHING') + return None + + # Wait until it's done or times out. + if debug: + cherrypy.log('Waiting up to %s seconds' % timeout, 'TOOLS.CACHING') + value.wait(timeout) + if value.result is not None: + # The other thread finished its calculation. Use it. + if debug: + cherrypy.log('Result!', 'TOOLS.CACHING') + return value.result + # Timed out. Stick an Event in the slot so other threads wait + # on this one to finish calculating the value. + if debug: + cherrypy.log('Timed out', 'TOOLS.CACHING') + e = threading.Event() + e.result = None + dict.__setitem__(self, key, e) + + return None + elif value is None: + # Stick an Event in the slot so other threads wait + # on this one to finish calculating the value. + if debug: + cherrypy.log('Timed out', 'TOOLS.CACHING') + e = threading.Event() + e.result = None + dict.__setitem__(self, key, e) + return value + + def __setitem__(self, key, value): + """Set the cached value for the given key.""" + existing = self.get(key) + dict.__setitem__(self, key, value) + if isinstance(existing, threading._Event): + # Set Event.result so other threads waiting on it have + # immediate access without needing to poll the cache again. + existing.result = value + existing.set() + + +class MemoryCache(Cache): + """An in-memory cache for varying response content. + + Each key in self.store is a URI, and each value is an AntiStampedeCache. + The response for any given URI may vary based on the values of + "selecting request headers"; that is, those named in the Vary + response header. We assume the list of header names to be constant + for each URI throughout the lifetime of the application, and store + that list in self.store[uri].selecting_headers. + + The items contained in self.store[uri] have keys which are tuples of request + header values (in the same order as the names in its selecting_headers), + and values which are the actual responses. + """ + + maxobjects = 1000 + maxobj_size = 100000 + maxsize = 10000000 + delay = 600 + antistampede_timeout = 5 + expire_freq = 0.1 + debug = False + + def __init__(self): + self.clear() + + # Run self.expire_cache in a separate daemon thread. + t = threading.Thread(target=self.expire_cache, name='expire_cache') + self.expiration_thread = t + if hasattr(threading.Thread, "daemon"): + # Python 2.6+ + t.daemon = True + else: + t.setDaemon(True) + t.start() + + def clear(self): + """Reset the cache to its initial, empty state.""" + self.store = {} + self.expirations = {} + self.tot_puts = 0 + self.tot_gets = 0 + self.tot_hist = 0 + self.tot_expires = 0 + self.tot_non_modified = 0 + self.cursize = 0 + + def expire_cache(self): + # expire_cache runs in a separate thread which the servers are + # not aware of. It's possible that "time" will be set to None + # arbitrarily, so we check "while time" to avoid exceptions. + # See tickets #99 and #180 for more information. + while time: + now = time.time() + # Must make a copy of expirations so it doesn't change size + # during iteration + for expiration_time, objects in self.expirations.items(): + if expiration_time <= now: + for obj_size, uri, sel_header_values in objects: + try: + del self.store[uri][sel_header_values] + self.tot_expires += 1 + self.cursize -= obj_size + except KeyError: + # the key may have been deleted elsewhere + pass + del self.expirations[expiration_time] + time.sleep(self.expire_freq) + + def get(self): + """Return the current variant if in the cache, else None.""" + request = cherrypy.serving.request + self.tot_gets += 1 + + uri = cherrypy.url(qs=request.query_string) + uricache = self.store.get(uri) + if uricache is None: + return None + + header_values = [request.headers.get(h, '') + for h in uricache.selecting_headers] + header_values.sort() + variant = uricache.wait(key=tuple(header_values), + timeout=self.antistampede_timeout, + debug=self.debug) + if variant is not None: + self.tot_hist += 1 + return variant + + def put(self, variant, size): + """Store the current variant in the cache.""" + request = cherrypy.serving.request + response = cherrypy.serving.response + + uri = cherrypy.url(qs=request.query_string) + uricache = self.store.get(uri) + if uricache is None: + uricache = AntiStampedeCache() + uricache.selecting_headers = [ + e.value for e in response.headers.elements('Vary')] + self.store[uri] = uricache + + if len(self.store) < self.maxobjects: + total_size = self.cursize + size + + # checks if there's space for the object + if (size < self.maxobj_size and total_size < self.maxsize): + # add to the expirations list + expiration_time = response.time + self.delay + bucket = self.expirations.setdefault(expiration_time, []) + bucket.append((size, uri, uricache.selecting_headers)) + + # add to the cache + header_values = [request.headers.get(h, '') + for h in uricache.selecting_headers] + header_values.sort() + uricache[tuple(header_values)] = variant + self.tot_puts += 1 + self.cursize = total_size + + def delete(self): + """Remove ALL cached variants of the current resource.""" + uri = cherrypy.url(qs=cherrypy.serving.request.query_string) + self.store.pop(uri, None) + + +def get(invalid_methods=("POST", "PUT", "DELETE"), debug=False, **kwargs): + """Try to obtain cached output. If fresh enough, raise HTTPError(304). + + If POST, PUT, or DELETE: + * invalidates (deletes) any cached response for this resource + * sets request.cached = False + * sets request.cacheable = False + + else if a cached copy exists: + * sets request.cached = True + * sets request.cacheable = False + * sets response.headers to the cached values + * checks the cached Last-Modified response header against the + current If-(Un)Modified-Since request headers; raises 304 + if necessary. + * sets response.status and response.body to the cached values + * returns True + + otherwise: + * sets request.cached = False + * sets request.cacheable = True + * returns False + """ + request = cherrypy.serving.request + response = cherrypy.serving.response + + if not hasattr(cherrypy, "_cache"): + # Make a process-wide Cache object. + cherrypy._cache = kwargs.pop("cache_class", MemoryCache)() + + # Take all remaining kwargs and set them on the Cache object. + for k, v in kwargs.items(): + setattr(cherrypy._cache, k, v) + cherrypy._cache.debug = debug + + # POST, PUT, DELETE should invalidate (delete) the cached copy. + # See http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.10. + if request.method in invalid_methods: + if debug: + cherrypy.log('request.method %r in invalid_methods %r' % + (request.method, invalid_methods), 'TOOLS.CACHING') + cherrypy._cache.delete() + request.cached = False + request.cacheable = False + return False + + if 'no-cache' in [e.value for e in request.headers.elements('Pragma')]: + request.cached = False + request.cacheable = True + return False + + cache_data = cherrypy._cache.get() + request.cached = bool(cache_data) + request.cacheable = not request.cached + if request.cached: + # Serve the cached copy. + max_age = cherrypy._cache.delay + for v in [e.value for e in request.headers.elements('Cache-Control')]: + atoms = v.split('=', 1) + directive = atoms.pop(0) + if directive == 'max-age': + if len(atoms) != 1 or not atoms[0].isdigit(): + raise cherrypy.HTTPError(400, "Invalid Cache-Control header") + max_age = int(atoms[0]) + break + elif directive == 'no-cache': + if debug: + cherrypy.log('Ignoring cache due to Cache-Control: no-cache', + 'TOOLS.CACHING') + request.cached = False + request.cacheable = True + return False + + if debug: + cherrypy.log('Reading response from cache', 'TOOLS.CACHING') + s, h, b, create_time = cache_data + age = int(response.time - create_time) + if (age > max_age): + if debug: + cherrypy.log('Ignoring cache due to age > %d' % max_age, + 'TOOLS.CACHING') + request.cached = False + request.cacheable = True + return False + + # Copy the response headers. See http://www.cherrypy.org/ticket/721. + response.headers = rh = httputil.HeaderMap() + for k in h: + dict.__setitem__(rh, k, dict.__getitem__(h, k)) + + # Add the required Age header + response.headers["Age"] = str(age) + + try: + # Note that validate_since depends on a Last-Modified header; + # this was put into the cached copy, and should have been + # resurrected just above (response.headers = cache_data[1]). + cptools.validate_since() + except cherrypy.HTTPRedirect, x: + if x.status == 304: + cherrypy._cache.tot_non_modified += 1 + raise + + # serve it & get out from the request + response.status = s + response.body = b + else: + if debug: + cherrypy.log('request is not cached', 'TOOLS.CACHING') + return request.cached + + +def tee_output(): + request = cherrypy.serving.request + if 'no-store' in request.headers.values('Cache-Control'): + return + + def tee(body): + """Tee response.body into a list.""" + if ('no-cache' in response.headers.values('Pragma') or + 'no-store' in response.headers.values('Cache-Control')): + for chunk in body: + yield chunk + return + + output = [] + for chunk in body: + output.append(chunk) + yield chunk + + # save the cache data + body = ''.join(output) + cherrypy._cache.put((response.status, response.headers or {}, + body, response.time), len(body)) + + response = cherrypy.serving.response + response.body = tee(response.body) + + +def expires(secs=0, force=False, debug=False): + """Tool for influencing cache mechanisms using the 'Expires' header. + + 'secs' must be either an int or a datetime.timedelta, and indicates the + number of seconds between response.time and when the response should + expire. The 'Expires' header will be set to (response.time + secs). + + If 'secs' is zero, the 'Expires' header is set one year in the past, and + the following "cache prevention" headers are also set: + 'Pragma': 'no-cache' + 'Cache-Control': 'no-cache, must-revalidate' + + If 'force' is False (the default), the following headers are checked: + 'Etag', 'Last-Modified', 'Age', 'Expires'. If any are already present, + none of the above response headers are set. + """ + + response = cherrypy.serving.response + headers = response.headers + + cacheable = False + if not force: + # some header names that indicate that the response can be cached + for indicator in ('Etag', 'Last-Modified', 'Age', 'Expires'): + if indicator in headers: + cacheable = True + break + + if not cacheable and not force: + if debug: + cherrypy.log('request is not cacheable', 'TOOLS.EXPIRES') + else: + if debug: + cherrypy.log('request is cacheable', 'TOOLS.EXPIRES') + if isinstance(secs, datetime.timedelta): + secs = (86400 * secs.days) + secs.seconds + + if secs == 0: + if force or ("Pragma" not in headers): + headers["Pragma"] = "no-cache" + if cherrypy.serving.request.protocol >= (1, 1): + if force or "Cache-Control" not in headers: + headers["Cache-Control"] = "no-cache, must-revalidate" + # Set an explicit Expires date in the past. + expiry = httputil.HTTPDate(1169942400.0) + else: + expiry = httputil.HTTPDate(response.time + secs) + if force or "Expires" not in headers: + headers["Expires"] = expiry diff --git a/cherrypy/lib/cptools.py b/cherrypy/lib/cptools.py index ac152c7c10..143b85d196 100644 --- a/cherrypy/lib/cptools.py +++ b/cherrypy/lib/cptools.py @@ -1,580 +1,580 @@ -"""Functions for builtin CherryPy tools.""" - -import logging -try: - # Python 2.5+ - from hashlib import md5 -except ImportError: - from md5 import new as md5 -import re - -try: - set -except NameError: - from sets import Set as set - -import cherrypy -from cherrypy.lib import httputil as _httputil - - -# Conditional HTTP request support # - -def validate_etags(autotags=False, debug=False): - """Validate the current ETag against If-Match, If-None-Match headers. - - If autotags is True, an ETag response-header value will be provided - from an MD5 hash of the response body (unless some other code has - already provided an ETag header). If False (the default), the ETag - will not be automatic. - - WARNING: the autotags feature is not designed for URL's which allow - methods other than GET. For example, if a POST to the same URL returns - no content, the automatic ETag will be incorrect, breaking a fundamental - use for entity tags in a possibly destructive fashion. Likewise, if you - raise 304 Not Modified, the response body will be empty, the ETag hash - will be incorrect, and your application will break. - See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.24 - """ - response = cherrypy.serving.response - - # Guard against being run twice. - if hasattr(response, "ETag"): - return - - status, reason, msg = _httputil.valid_status(response.status) - - etag = response.headers.get('ETag') - - # Automatic ETag generation. See warning in docstring. - if etag: - if debug: - cherrypy.log('ETag already set: %s' % etag, 'TOOLS.ETAGS') - elif not autotags: - if debug: - cherrypy.log('Autotags off', 'TOOLS.ETAGS') - elif status != 200: - if debug: - cherrypy.log('Status not 200', 'TOOLS.ETAGS') - else: - etag = response.collapse_body() - etag = '"%s"' % md5(etag).hexdigest() - if debug: - cherrypy.log('Setting ETag: %s' % etag, 'TOOLS.ETAGS') - response.headers['ETag'] = etag - - response.ETag = etag - - # "If the request would, without the If-Match header field, result in - # anything other than a 2xx or 412 status, then the If-Match header - # MUST be ignored." - if debug: - cherrypy.log('Status: %s' % status, 'TOOLS.ETAGS') - if status >= 200 and status <= 299: - request = cherrypy.serving.request - - conditions = request.headers.elements('If-Match') or [] - conditions = [str(x) for x in conditions] - if debug: - cherrypy.log('If-Match conditions: %s' % repr(conditions), - 'TOOLS.ETAGS') - if conditions and not (conditions == ["*"] or etag in conditions): - raise cherrypy.HTTPError(412, "If-Match failed: ETag %r did " - "not match %r" % (etag, conditions)) - - conditions = request.headers.elements('If-None-Match') or [] - conditions = [str(x) for x in conditions] - if debug: - cherrypy.log('If-None-Match conditions: %s' % repr(conditions), - 'TOOLS.ETAGS') - if conditions == ["*"] or etag in conditions: - if debug: - cherrypy.log('request.method: %s' % request.method, 'TOOLS.ETAGS') - if request.method in ("GET", "HEAD"): - raise cherrypy.HTTPRedirect([], 304) - else: - raise cherrypy.HTTPError(412, "If-None-Match failed: ETag %r " - "matched %r" % (etag, conditions)) - -def validate_since(): - """Validate the current Last-Modified against If-Modified-Since headers. - - If no code has set the Last-Modified response header, then no validation - will be performed. - """ - response = cherrypy.serving.response - lastmod = response.headers.get('Last-Modified') - if lastmod: - status, reason, msg = _httputil.valid_status(response.status) - - request = cherrypy.serving.request - - since = request.headers.get('If-Unmodified-Since') - if since and since != lastmod: - if (status >= 200 and status <= 299) or status == 412: - raise cherrypy.HTTPError(412) - - since = request.headers.get('If-Modified-Since') - if since and since == lastmod: - if (status >= 200 and status <= 299) or status == 304: - if request.method in ("GET", "HEAD"): - raise cherrypy.HTTPRedirect([], 304) - else: - raise cherrypy.HTTPError(412) - - -# Tool code # - -def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For', - scheme='X-Forwarded-Proto', debug=False): - """Change the base URL (scheme://host[:port][/path]). - - For running a CP server behind Apache, lighttpd, or other HTTP server. - - If you want the new request.base to include path info (not just the host), - you must explicitly set base to the full base path, and ALSO set 'local' - to '', so that the X-Forwarded-Host request header (which never includes - path info) does not override it. Regardless, the value for 'base' MUST - NOT end in a slash. - - cherrypy.request.remote.ip (the IP address of the client) will be - rewritten if the header specified by the 'remote' arg is valid. - By default, 'remote' is set to 'X-Forwarded-For'. If you do not - want to rewrite remote.ip, set the 'remote' arg to an empty string. - """ - - request = cherrypy.serving.request - - if scheme: - s = request.headers.get(scheme, None) - if debug: - cherrypy.log('Testing scheme %r:%r' % (scheme, s), 'TOOLS.PROXY') - if s == 'on' and 'ssl' in scheme.lower(): - # This handles e.g. webfaction's 'X-Forwarded-Ssl: on' header - scheme = 'https' - else: - # This is for lighttpd/pound/Mongrel's 'X-Forwarded-Proto: https' - scheme = s - if not scheme: - scheme = request.base[:request.base.find("://")] - - if local: - lbase = request.headers.get(local, None) - if debug: - cherrypy.log('Testing local %r:%r' % (local, lbase), 'TOOLS.PROXY') - if lbase is not None: - base = lbase.split(',')[0] - if not base: - port = request.local.port - if port == 80: - base = '127.0.0.1' - else: - base = '127.0.0.1:%s' % port - - if base.find("://") == -1: - # add http:// or https:// if needed - base = scheme + "://" + base - - request.base = base - - if remote: - xff = request.headers.get(remote) - if debug: - cherrypy.log('Testing remote %r:%r' % (remote, xff), 'TOOLS.PROXY') - if xff: - if remote == 'X-Forwarded-For': - # See http://bob.pythonmac.org/archives/2005/09/23/apache-x-forwarded-for-caveat/ - xff = xff.split(',')[-1].strip() - request.remote.ip = xff - - -def ignore_headers(headers=('Range',), debug=False): - """Delete request headers whose field names are included in 'headers'. - - This is a useful tool for working behind certain HTTP servers; - for example, Apache duplicates the work that CP does for 'Range' - headers, and will doubly-truncate the response. - """ - request = cherrypy.serving.request - for name in headers: - if name in request.headers: - if debug: - cherrypy.log('Ignoring request header %r' % name, - 'TOOLS.IGNORE_HEADERS') - del request.headers[name] - - -def response_headers(headers=None, debug=False): - """Set headers on the response.""" - if debug: - cherrypy.log('Setting response headers: %s' % repr(headers), - 'TOOLS.RESPONSE_HEADERS') - for name, value in (headers or []): - cherrypy.serving.response.headers[name] = value -response_headers.failsafe = True - - -def referer(pattern, accept=True, accept_missing=False, error=403, - message='Forbidden Referer header.', debug=False): - """Raise HTTPError if Referer header does/does not match the given pattern. - - pattern: a regular expression pattern to test against the Referer. - accept: if True, the Referer must match the pattern; if False, - the Referer must NOT match the pattern. - accept_missing: if True, permit requests with no Referer header. - error: the HTTP error code to return to the client on failure. - message: a string to include in the response body on failure. - """ - try: - ref = cherrypy.serving.request.headers['Referer'] - match = bool(re.match(pattern, ref)) - if debug: - cherrypy.log('Referer %r matches %r' % (ref, pattern), - 'TOOLS.REFERER') - if accept == match: - return - except KeyError: - if debug: - cherrypy.log('No Referer header', 'TOOLS.REFERER') - if accept_missing: - return - - raise cherrypy.HTTPError(error, message) - - -class SessionAuth(object): - """Assert that the user is logged in.""" - - session_key = "username" - debug = False - - def check_username_and_password(self, username, password): - pass - - def anonymous(self): - """Provide a temporary user name for anonymous users.""" - pass - - def on_login(self, username): - pass - - def on_logout(self, username): - pass - - def on_check(self, username): - pass - - def login_screen(self, from_page='..', username='', error_msg='', **kwargs): - return """ -Message: %(error_msg)s -
    - Login:
    - Password:
    -
    - -
    -""" % {'from_page': from_page, 'username': username, - 'error_msg': error_msg} - - def do_login(self, username, password, from_page='..', **kwargs): - """Login. May raise redirect, or return True if request handled.""" - response = cherrypy.serving.response - error_msg = self.check_username_and_password(username, password) - if error_msg: - body = self.login_screen(from_page, username, error_msg) - response.body = body - if "Content-Length" in response.headers: - # Delete Content-Length header so finalize() recalcs it. - del response.headers["Content-Length"] - return True - else: - cherrypy.serving.request.login = username - cherrypy.session[self.session_key] = username - self.on_login(username) - raise cherrypy.HTTPRedirect(from_page or "/") - - def do_logout(self, from_page='..', **kwargs): - """Logout. May raise redirect, or return True if request handled.""" - sess = cherrypy.session - username = sess.get(self.session_key) - sess[self.session_key] = None - if username: - cherrypy.serving.request.login = None - self.on_logout(username) - raise cherrypy.HTTPRedirect(from_page) - - def do_check(self): - """Assert username. May raise redirect, or return True if request handled.""" - sess = cherrypy.session - request = cherrypy.serving.request - response = cherrypy.serving.response - - username = sess.get(self.session_key) - if not username: - sess[self.session_key] = username = self.anonymous() - if self.debug: - cherrypy.log('No session[username], trying anonymous', 'TOOLS.SESSAUTH') - if not username: - url = cherrypy.url(qs=request.query_string) - if self.debug: - cherrypy.log('No username, routing to login_screen with ' - 'from_page %r' % url, 'TOOLS.SESSAUTH') - response.body = self.login_screen(url) - if "Content-Length" in response.headers: - # Delete Content-Length header so finalize() recalcs it. - del response.headers["Content-Length"] - return True - if self.debug: - cherrypy.log('Setting request.login to %r' % username, 'TOOLS.SESSAUTH') - request.login = username - self.on_check(username) - - def run(self): - request = cherrypy.serving.request - response = cherrypy.serving.response - - path = request.path_info - if path.endswith('login_screen'): - if self.debug: - cherrypy.log('routing %r to login_screen' % path, 'TOOLS.SESSAUTH') - return self.login_screen(**request.params) - elif path.endswith('do_login'): - if request.method != 'POST': - response.headers['Allow'] = "POST" - if self.debug: - cherrypy.log('do_login requires POST', 'TOOLS.SESSAUTH') - raise cherrypy.HTTPError(405) - if self.debug: - cherrypy.log('routing %r to do_login' % path, 'TOOLS.SESSAUTH') - return self.do_login(**request.params) - elif path.endswith('do_logout'): - if request.method != 'POST': - response.headers['Allow'] = "POST" - raise cherrypy.HTTPError(405) - if self.debug: - cherrypy.log('routing %r to do_logout' % path, 'TOOLS.SESSAUTH') - return self.do_logout(**request.params) - else: - if self.debug: - cherrypy.log('No special path, running do_check', 'TOOLS.SESSAUTH') - return self.do_check() - - -def session_auth(**kwargs): - sa = SessionAuth() - for k, v in kwargs.items(): - setattr(sa, k, v) - return sa.run() -session_auth.__doc__ = """Session authentication hook. - -Any attribute of the SessionAuth class may be overridden via a keyword arg -to this function: - -""" + "\n".join(["%s: %s" % (k, type(getattr(SessionAuth, k)).__name__) - for k in dir(SessionAuth) if not k.startswith("__")]) - - -def log_traceback(severity=logging.ERROR, debug=False): - """Write the last error's traceback to the cherrypy error log.""" - cherrypy.log("", "HTTP", severity=severity, traceback=True) - -def log_request_headers(debug=False): - """Write request headers to the cherrypy error log.""" - h = [" %s: %s" % (k, v) for k, v in cherrypy.serving.request.header_list] - cherrypy.log('\nRequest Headers:\n' + '\n'.join(h), "HTTP") - -def log_hooks(debug=False): - """Write request.hooks to the cherrypy error log.""" - request = cherrypy.serving.request - - msg = [] - # Sort by the standard points if possible. - from cherrypy import _cprequest - points = _cprequest.hookpoints - for k in request.hooks.keys(): - if k not in points: - points.append(k) - - for k in points: - msg.append(" %s:" % k) - v = request.hooks.get(k, []) - v.sort() - for h in v: - msg.append(" %r" % h) - cherrypy.log('\nRequest Hooks for ' + cherrypy.url() + - ':\n' + '\n'.join(msg), "HTTP") - -def redirect(url='', internal=True, debug=False): - """Raise InternalRedirect or HTTPRedirect to the given url.""" - if debug: - cherrypy.log('Redirecting %sto: %s' % - ({True: 'internal ', False: ''}[internal], url), - 'TOOLS.REDIRECT') - if internal: - raise cherrypy.InternalRedirect(url) - else: - raise cherrypy.HTTPRedirect(url) - -def trailing_slash(missing=True, extra=False, status=None, debug=False): - """Redirect if path_info has (missing|extra) trailing slash.""" - request = cherrypy.serving.request - pi = request.path_info - - if debug: - cherrypy.log('is_index: %r, missing: %r, extra: %r, path_info: %r' % - (request.is_index, missing, extra, pi), - 'TOOLS.TRAILING_SLASH') - if request.is_index is True: - if missing: - if not pi.endswith('/'): - new_url = cherrypy.url(pi + '/', request.query_string) - raise cherrypy.HTTPRedirect(new_url, status=status or 301) - elif request.is_index is False: - if extra: - # If pi == '/', don't redirect to ''! - if pi.endswith('/') and pi != '/': - new_url = cherrypy.url(pi[:-1], request.query_string) - raise cherrypy.HTTPRedirect(new_url, status=status or 301) - -def flatten(debug=False): - """Wrap response.body in a generator that recursively iterates over body. - - This allows cherrypy.response.body to consist of 'nested generators'; - that is, a set of generators that yield generators. - """ - import types - def flattener(input): - numchunks = 0 - for x in input: - if not isinstance(x, types.GeneratorType): - numchunks += 1 - yield x - else: - for y in flattener(x): - numchunks += 1 - yield y - if debug: - cherrypy.log('Flattened %d chunks' % numchunks, 'TOOLS.FLATTEN') - response = cherrypy.serving.response - response.body = flattener(response.body) - - -def accept(media=None, debug=False): - """Return the client's preferred media-type (from the given Content-Types). - - If 'media' is None (the default), no test will be performed. - - If 'media' is provided, it should be the Content-Type value (as a string) - or values (as a list or tuple of strings) which the current resource - can emit. The client's acceptable media ranges (as declared in the - Accept request header) will be matched in order to these Content-Type - values; the first such string is returned. That is, the return value - will always be one of the strings provided in the 'media' arg (or None - if 'media' is None). - - If no match is found, then HTTPError 406 (Not Acceptable) is raised. - Note that most web browsers send */* as a (low-quality) acceptable - media range, which should match any Content-Type. In addition, "...if - no Accept header field is present, then it is assumed that the client - accepts all media types." - - Matching types are checked in order of client preference first, - and then in the order of the given 'media' values. - - Note that this function does not honor accept-params (other than "q"). - """ - if not media: - return - if isinstance(media, basestring): - media = [media] - request = cherrypy.serving.request - - # Parse the Accept request header, and try to match one - # of the requested media-ranges (in order of preference). - ranges = request.headers.elements('Accept') - if not ranges: - # Any media type is acceptable. - if debug: - cherrypy.log('No Accept header elements', 'TOOLS.ACCEPT') - return media[0] - else: - # Note that 'ranges' is sorted in order of preference - for element in ranges: - if element.qvalue > 0: - if element.value == "*/*": - # Matches any type or subtype - if debug: - cherrypy.log('Match due to */*', 'TOOLS.ACCEPT') - return media[0] - elif element.value.endswith("/*"): - # Matches any subtype - mtype = element.value[:-1] # Keep the slash - for m in media: - if m.startswith(mtype): - if debug: - cherrypy.log('Match due to %s' % element.value, - 'TOOLS.ACCEPT') - return m - else: - # Matches exact value - if element.value in media: - if debug: - cherrypy.log('Match due to %s' % element.value, - 'TOOLS.ACCEPT') - return element.value - - # No suitable media-range found. - ah = request.headers.get('Accept') - if ah is None: - msg = "Your client did not send an Accept header." - else: - msg = "Your client sent this Accept header: %s." % ah - msg += (" But this resource only emits these media types: %s." % - ", ".join(media)) - raise cherrypy.HTTPError(406, msg) - - -class MonitoredHeaderMap(_httputil.HeaderMap): - - def __init__(self): - self.accessed_headers = set() - - def __getitem__(self, key): - self.accessed_headers.add(key) - return _httputil.HeaderMap.__getitem__(self, key) - - def __contains__(self, key): - self.accessed_headers.add(key) - return _httputil.HeaderMap.__contains__(self, key) - - def get(self, key, default=None): - self.accessed_headers.add(key) - return _httputil.HeaderMap.get(self, key, default=default) - - def has_key(self, key): - self.accessed_headers.add(key) - return _httputil.HeaderMap.has_key(self, key) - - -def autovary(ignore=None, debug=False): - """Auto-populate the Vary response header based on request.header access.""" - request = cherrypy.serving.request - - req_h = request.headers - request.headers = MonitoredHeaderMap() - request.headers.update(req_h) - if ignore is None: - ignore = set(['Content-Disposition', 'Content-Length', 'Content-Type']) - - def set_response_header(): - resp_h = cherrypy.serving.response.headers - v = set([e.value for e in resp_h.elements('Vary')]) - if debug: - cherrypy.log('Accessed headers: %s' % request.headers.accessed_headers, - 'TOOLS.AUTOVARY') - v = v.union(request.headers.accessed_headers) - v = v.difference(ignore) - v = list(v) - v.sort() - resp_h['Vary'] = ', '.join(v) - request.hooks.attach('before_finalize', set_response_header, 95) - +"""Functions for builtin CherryPy tools.""" + +import logging +try: + # Python 2.5+ + from hashlib import md5 +except ImportError: + from md5 import new as md5 +import re + +try: + set +except NameError: + from sets import Set as set + +import cherrypy +from cherrypy.lib import httputil as _httputil + + +# Conditional HTTP request support # + +def validate_etags(autotags=False, debug=False): + """Validate the current ETag against If-Match, If-None-Match headers. + + If autotags is True, an ETag response-header value will be provided + from an MD5 hash of the response body (unless some other code has + already provided an ETag header). If False (the default), the ETag + will not be automatic. + + WARNING: the autotags feature is not designed for URL's which allow + methods other than GET. For example, if a POST to the same URL returns + no content, the automatic ETag will be incorrect, breaking a fundamental + use for entity tags in a possibly destructive fashion. Likewise, if you + raise 304 Not Modified, the response body will be empty, the ETag hash + will be incorrect, and your application will break. + See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.24 + """ + response = cherrypy.serving.response + + # Guard against being run twice. + if hasattr(response, "ETag"): + return + + status, reason, msg = _httputil.valid_status(response.status) + + etag = response.headers.get('ETag') + + # Automatic ETag generation. See warning in docstring. + if etag: + if debug: + cherrypy.log('ETag already set: %s' % etag, 'TOOLS.ETAGS') + elif not autotags: + if debug: + cherrypy.log('Autotags off', 'TOOLS.ETAGS') + elif status != 200: + if debug: + cherrypy.log('Status not 200', 'TOOLS.ETAGS') + else: + etag = response.collapse_body() + etag = '"%s"' % md5(etag).hexdigest() + if debug: + cherrypy.log('Setting ETag: %s' % etag, 'TOOLS.ETAGS') + response.headers['ETag'] = etag + + response.ETag = etag + + # "If the request would, without the If-Match header field, result in + # anything other than a 2xx or 412 status, then the If-Match header + # MUST be ignored." + if debug: + cherrypy.log('Status: %s' % status, 'TOOLS.ETAGS') + if status >= 200 and status <= 299: + request = cherrypy.serving.request + + conditions = request.headers.elements('If-Match') or [] + conditions = [str(x) for x in conditions] + if debug: + cherrypy.log('If-Match conditions: %s' % repr(conditions), + 'TOOLS.ETAGS') + if conditions and not (conditions == ["*"] or etag in conditions): + raise cherrypy.HTTPError(412, "If-Match failed: ETag %r did " + "not match %r" % (etag, conditions)) + + conditions = request.headers.elements('If-None-Match') or [] + conditions = [str(x) for x in conditions] + if debug: + cherrypy.log('If-None-Match conditions: %s' % repr(conditions), + 'TOOLS.ETAGS') + if conditions == ["*"] or etag in conditions: + if debug: + cherrypy.log('request.method: %s' % request.method, 'TOOLS.ETAGS') + if request.method in ("GET", "HEAD"): + raise cherrypy.HTTPRedirect([], 304) + else: + raise cherrypy.HTTPError(412, "If-None-Match failed: ETag %r " + "matched %r" % (etag, conditions)) + +def validate_since(): + """Validate the current Last-Modified against If-Modified-Since headers. + + If no code has set the Last-Modified response header, then no validation + will be performed. + """ + response = cherrypy.serving.response + lastmod = response.headers.get('Last-Modified') + if lastmod: + status, reason, msg = _httputil.valid_status(response.status) + + request = cherrypy.serving.request + + since = request.headers.get('If-Unmodified-Since') + if since and since != lastmod: + if (status >= 200 and status <= 299) or status == 412: + raise cherrypy.HTTPError(412) + + since = request.headers.get('If-Modified-Since') + if since and since == lastmod: + if (status >= 200 and status <= 299) or status == 304: + if request.method in ("GET", "HEAD"): + raise cherrypy.HTTPRedirect([], 304) + else: + raise cherrypy.HTTPError(412) + + +# Tool code # + +def proxy(base=None, local='X-Forwarded-Host', remote='X-Forwarded-For', + scheme='X-Forwarded-Proto', debug=False): + """Change the base URL (scheme://host[:port][/path]). + + For running a CP server behind Apache, lighttpd, or other HTTP server. + + If you want the new request.base to include path info (not just the host), + you must explicitly set base to the full base path, and ALSO set 'local' + to '', so that the X-Forwarded-Host request header (which never includes + path info) does not override it. Regardless, the value for 'base' MUST + NOT end in a slash. + + cherrypy.request.remote.ip (the IP address of the client) will be + rewritten if the header specified by the 'remote' arg is valid. + By default, 'remote' is set to 'X-Forwarded-For'. If you do not + want to rewrite remote.ip, set the 'remote' arg to an empty string. + """ + + request = cherrypy.serving.request + + if scheme: + s = request.headers.get(scheme, None) + if debug: + cherrypy.log('Testing scheme %r:%r' % (scheme, s), 'TOOLS.PROXY') + if s == 'on' and 'ssl' in scheme.lower(): + # This handles e.g. webfaction's 'X-Forwarded-Ssl: on' header + scheme = 'https' + else: + # This is for lighttpd/pound/Mongrel's 'X-Forwarded-Proto: https' + scheme = s + if not scheme: + scheme = request.base[:request.base.find("://")] + + if local: + lbase = request.headers.get(local, None) + if debug: + cherrypy.log('Testing local %r:%r' % (local, lbase), 'TOOLS.PROXY') + if lbase is not None: + base = lbase.split(',')[0] + if not base: + port = request.local.port + if port == 80: + base = '127.0.0.1' + else: + base = '127.0.0.1:%s' % port + + if base.find("://") == -1: + # add http:// or https:// if needed + base = scheme + "://" + base + + request.base = base + + if remote: + xff = request.headers.get(remote) + if debug: + cherrypy.log('Testing remote %r:%r' % (remote, xff), 'TOOLS.PROXY') + if xff: + if remote == 'X-Forwarded-For': + # See http://bob.pythonmac.org/archives/2005/09/23/apache-x-forwarded-for-caveat/ + xff = xff.split(',')[-1].strip() + request.remote.ip = xff + + +def ignore_headers(headers=('Range',), debug=False): + """Delete request headers whose field names are included in 'headers'. + + This is a useful tool for working behind certain HTTP servers; + for example, Apache duplicates the work that CP does for 'Range' + headers, and will doubly-truncate the response. + """ + request = cherrypy.serving.request + for name in headers: + if name in request.headers: + if debug: + cherrypy.log('Ignoring request header %r' % name, + 'TOOLS.IGNORE_HEADERS') + del request.headers[name] + + +def response_headers(headers=None, debug=False): + """Set headers on the response.""" + if debug: + cherrypy.log('Setting response headers: %s' % repr(headers), + 'TOOLS.RESPONSE_HEADERS') + for name, value in (headers or []): + cherrypy.serving.response.headers[name] = value +response_headers.failsafe = True + + +def referer(pattern, accept=True, accept_missing=False, error=403, + message='Forbidden Referer header.', debug=False): + """Raise HTTPError if Referer header does/does not match the given pattern. + + pattern: a regular expression pattern to test against the Referer. + accept: if True, the Referer must match the pattern; if False, + the Referer must NOT match the pattern. + accept_missing: if True, permit requests with no Referer header. + error: the HTTP error code to return to the client on failure. + message: a string to include in the response body on failure. + """ + try: + ref = cherrypy.serving.request.headers['Referer'] + match = bool(re.match(pattern, ref)) + if debug: + cherrypy.log('Referer %r matches %r' % (ref, pattern), + 'TOOLS.REFERER') + if accept == match: + return + except KeyError: + if debug: + cherrypy.log('No Referer header', 'TOOLS.REFERER') + if accept_missing: + return + + raise cherrypy.HTTPError(error, message) + + +class SessionAuth(object): + """Assert that the user is logged in.""" + + session_key = "username" + debug = False + + def check_username_and_password(self, username, password): + pass + + def anonymous(self): + """Provide a temporary user name for anonymous users.""" + pass + + def on_login(self, username): + pass + + def on_logout(self, username): + pass + + def on_check(self, username): + pass + + def login_screen(self, from_page='..', username='', error_msg='', **kwargs): + return """ +Message: %(error_msg)s +
    + Login:
    + Password:
    +
    + +
    +""" % {'from_page': from_page, 'username': username, + 'error_msg': error_msg} + + def do_login(self, username, password, from_page='..', **kwargs): + """Login. May raise redirect, or return True if request handled.""" + response = cherrypy.serving.response + error_msg = self.check_username_and_password(username, password) + if error_msg: + body = self.login_screen(from_page, username, error_msg) + response.body = body + if "Content-Length" in response.headers: + # Delete Content-Length header so finalize() recalcs it. + del response.headers["Content-Length"] + return True + else: + cherrypy.serving.request.login = username + cherrypy.session[self.session_key] = username + self.on_login(username) + raise cherrypy.HTTPRedirect(from_page or "/") + + def do_logout(self, from_page='..', **kwargs): + """Logout. May raise redirect, or return True if request handled.""" + sess = cherrypy.session + username = sess.get(self.session_key) + sess[self.session_key] = None + if username: + cherrypy.serving.request.login = None + self.on_logout(username) + raise cherrypy.HTTPRedirect(from_page) + + def do_check(self): + """Assert username. May raise redirect, or return True if request handled.""" + sess = cherrypy.session + request = cherrypy.serving.request + response = cherrypy.serving.response + + username = sess.get(self.session_key) + if not username: + sess[self.session_key] = username = self.anonymous() + if self.debug: + cherrypy.log('No session[username], trying anonymous', 'TOOLS.SESSAUTH') + if not username: + url = cherrypy.url(qs=request.query_string) + if self.debug: + cherrypy.log('No username, routing to login_screen with ' + 'from_page %r' % url, 'TOOLS.SESSAUTH') + response.body = self.login_screen(url) + if "Content-Length" in response.headers: + # Delete Content-Length header so finalize() recalcs it. + del response.headers["Content-Length"] + return True + if self.debug: + cherrypy.log('Setting request.login to %r' % username, 'TOOLS.SESSAUTH') + request.login = username + self.on_check(username) + + def run(self): + request = cherrypy.serving.request + response = cherrypy.serving.response + + path = request.path_info + if path.endswith('login_screen'): + if self.debug: + cherrypy.log('routing %r to login_screen' % path, 'TOOLS.SESSAUTH') + return self.login_screen(**request.params) + elif path.endswith('do_login'): + if request.method != 'POST': + response.headers['Allow'] = "POST" + if self.debug: + cherrypy.log('do_login requires POST', 'TOOLS.SESSAUTH') + raise cherrypy.HTTPError(405) + if self.debug: + cherrypy.log('routing %r to do_login' % path, 'TOOLS.SESSAUTH') + return self.do_login(**request.params) + elif path.endswith('do_logout'): + if request.method != 'POST': + response.headers['Allow'] = "POST" + raise cherrypy.HTTPError(405) + if self.debug: + cherrypy.log('routing %r to do_logout' % path, 'TOOLS.SESSAUTH') + return self.do_logout(**request.params) + else: + if self.debug: + cherrypy.log('No special path, running do_check', 'TOOLS.SESSAUTH') + return self.do_check() + + +def session_auth(**kwargs): + sa = SessionAuth() + for k, v in kwargs.items(): + setattr(sa, k, v) + return sa.run() +session_auth.__doc__ = """Session authentication hook. + +Any attribute of the SessionAuth class may be overridden via a keyword arg +to this function: + +""" + "\n".join(["%s: %s" % (k, type(getattr(SessionAuth, k)).__name__) + for k in dir(SessionAuth) if not k.startswith("__")]) + + +def log_traceback(severity=logging.ERROR, debug=False): + """Write the last error's traceback to the cherrypy error log.""" + cherrypy.log("", "HTTP", severity=severity, traceback=True) + +def log_request_headers(debug=False): + """Write request headers to the cherrypy error log.""" + h = [" %s: %s" % (k, v) for k, v in cherrypy.serving.request.header_list] + cherrypy.log('\nRequest Headers:\n' + '\n'.join(h), "HTTP") + +def log_hooks(debug=False): + """Write request.hooks to the cherrypy error log.""" + request = cherrypy.serving.request + + msg = [] + # Sort by the standard points if possible. + from cherrypy import _cprequest + points = _cprequest.hookpoints + for k in request.hooks.keys(): + if k not in points: + points.append(k) + + for k in points: + msg.append(" %s:" % k) + v = request.hooks.get(k, []) + v.sort() + for h in v: + msg.append(" %r" % h) + cherrypy.log('\nRequest Hooks for ' + cherrypy.url() + + ':\n' + '\n'.join(msg), "HTTP") + +def redirect(url='', internal=True, debug=False): + """Raise InternalRedirect or HTTPRedirect to the given url.""" + if debug: + cherrypy.log('Redirecting %sto: %s' % + ({True: 'internal ', False: ''}[internal], url), + 'TOOLS.REDIRECT') + if internal: + raise cherrypy.InternalRedirect(url) + else: + raise cherrypy.HTTPRedirect(url) + +def trailing_slash(missing=True, extra=False, status=None, debug=False): + """Redirect if path_info has (missing|extra) trailing slash.""" + request = cherrypy.serving.request + pi = request.path_info + + if debug: + cherrypy.log('is_index: %r, missing: %r, extra: %r, path_info: %r' % + (request.is_index, missing, extra, pi), + 'TOOLS.TRAILING_SLASH') + if request.is_index is True: + if missing: + if not pi.endswith('/'): + new_url = cherrypy.url(pi + '/', request.query_string) + raise cherrypy.HTTPRedirect(new_url, status=status or 301) + elif request.is_index is False: + if extra: + # If pi == '/', don't redirect to ''! + if pi.endswith('/') and pi != '/': + new_url = cherrypy.url(pi[:-1], request.query_string) + raise cherrypy.HTTPRedirect(new_url, status=status or 301) + +def flatten(debug=False): + """Wrap response.body in a generator that recursively iterates over body. + + This allows cherrypy.response.body to consist of 'nested generators'; + that is, a set of generators that yield generators. + """ + import types + def flattener(input): + numchunks = 0 + for x in input: + if not isinstance(x, types.GeneratorType): + numchunks += 1 + yield x + else: + for y in flattener(x): + numchunks += 1 + yield y + if debug: + cherrypy.log('Flattened %d chunks' % numchunks, 'TOOLS.FLATTEN') + response = cherrypy.serving.response + response.body = flattener(response.body) + + +def accept(media=None, debug=False): + """Return the client's preferred media-type (from the given Content-Types). + + If 'media' is None (the default), no test will be performed. + + If 'media' is provided, it should be the Content-Type value (as a string) + or values (as a list or tuple of strings) which the current resource + can emit. The client's acceptable media ranges (as declared in the + Accept request header) will be matched in order to these Content-Type + values; the first such string is returned. That is, the return value + will always be one of the strings provided in the 'media' arg (or None + if 'media' is None). + + If no match is found, then HTTPError 406 (Not Acceptable) is raised. + Note that most web browsers send */* as a (low-quality) acceptable + media range, which should match any Content-Type. In addition, "...if + no Accept header field is present, then it is assumed that the client + accepts all media types." + + Matching types are checked in order of client preference first, + and then in the order of the given 'media' values. + + Note that this function does not honor accept-params (other than "q"). + """ + if not media: + return + if isinstance(media, basestring): + media = [media] + request = cherrypy.serving.request + + # Parse the Accept request header, and try to match one + # of the requested media-ranges (in order of preference). + ranges = request.headers.elements('Accept') + if not ranges: + # Any media type is acceptable. + if debug: + cherrypy.log('No Accept header elements', 'TOOLS.ACCEPT') + return media[0] + else: + # Note that 'ranges' is sorted in order of preference + for element in ranges: + if element.qvalue > 0: + if element.value == "*/*": + # Matches any type or subtype + if debug: + cherrypy.log('Match due to */*', 'TOOLS.ACCEPT') + return media[0] + elif element.value.endswith("/*"): + # Matches any subtype + mtype = element.value[:-1] # Keep the slash + for m in media: + if m.startswith(mtype): + if debug: + cherrypy.log('Match due to %s' % element.value, + 'TOOLS.ACCEPT') + return m + else: + # Matches exact value + if element.value in media: + if debug: + cherrypy.log('Match due to %s' % element.value, + 'TOOLS.ACCEPT') + return element.value + + # No suitable media-range found. + ah = request.headers.get('Accept') + if ah is None: + msg = "Your client did not send an Accept header." + else: + msg = "Your client sent this Accept header: %s." % ah + msg += (" But this resource only emits these media types: %s." % + ", ".join(media)) + raise cherrypy.HTTPError(406, msg) + + +class MonitoredHeaderMap(_httputil.HeaderMap): + + def __init__(self): + self.accessed_headers = set() + + def __getitem__(self, key): + self.accessed_headers.add(key) + return _httputil.HeaderMap.__getitem__(self, key) + + def __contains__(self, key): + self.accessed_headers.add(key) + return _httputil.HeaderMap.__contains__(self, key) + + def get(self, key, default=None): + self.accessed_headers.add(key) + return _httputil.HeaderMap.get(self, key, default=default) + + def has_key(self, key): + self.accessed_headers.add(key) + return _httputil.HeaderMap.has_key(self, key) + + +def autovary(ignore=None, debug=False): + """Auto-populate the Vary response header based on request.header access.""" + request = cherrypy.serving.request + + req_h = request.headers + request.headers = MonitoredHeaderMap() + request.headers.update(req_h) + if ignore is None: + ignore = set(['Content-Disposition', 'Content-Length', 'Content-Type']) + + def set_response_header(): + resp_h = cherrypy.serving.response.headers + v = set([e.value for e in resp_h.elements('Vary')]) + if debug: + cherrypy.log('Accessed headers: %s' % request.headers.accessed_headers, + 'TOOLS.AUTOVARY') + v = v.union(request.headers.accessed_headers) + v = v.difference(ignore) + v = list(v) + v.sort() + resp_h['Vary'] = ', '.join(v) + request.hooks.attach('before_finalize', set_response_header, 95) + diff --git a/cherrypy/lib/encoding.py b/cherrypy/lib/encoding.py index 48d119fa1b..0e5ec622b6 100644 --- a/cherrypy/lib/encoding.py +++ b/cherrypy/lib/encoding.py @@ -1,362 +1,362 @@ -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO -try: - set -except NameError: - from sets import Set as set -import struct -import time -import types - -import cherrypy -from cherrypy.lib import file_generator -from cherrypy.lib import set_vary_header - - -def decode(encoding=None, default_encoding='utf-8'): - """Replace or extend the list of charsets used to decode a request entity. - - Either argument may be a single string or a list of strings. - - encoding: If not None, restricts the set of charsets attempted while decoding - a request entity to the given set (even if a different charset is given in - the Content-Type request header). - - default_encoding: Only in effect if the 'encoding' argument is not given. - If given, the set of charsets attempted while decoding a request entity is - *extended* with the given value(s). - """ - body = cherrypy.request.body - if encoding is not None: - if not isinstance(encoding, list): - encoding = [encoding] - body.attempt_charsets = encoding - elif default_encoding: - if not isinstance(default_encoding, list): - default_encoding = [default_encoding] - body.attempt_charsets = body.attempt_charsets + default_encoding - - -class ResponseEncoder: - - default_encoding = 'utf-8' - failmsg = "Response body could not be encoded with %r." - encoding = None - errors = 'strict' - text_only = True - add_charset = True - debug = False - - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) - - self.attempted_charsets = set() - request = cherrypy.serving.request - if request.handler is not None: - # Replace request.handler with self - if self.debug: - cherrypy.log('Replacing request.handler', 'TOOLS.ENCODE') - self.oldhandler = request.handler - request.handler = self - - def encode_stream(self, encoding): - """Encode a streaming response body. - - Use a generator wrapper, and just pray it works as the stream is - being written out. - """ - if encoding in self.attempted_charsets: - return False - self.attempted_charsets.add(encoding) - - def encoder(body): - for chunk in body: - if isinstance(chunk, unicode): - chunk = chunk.encode(encoding, self.errors) - yield chunk - self.body = encoder(self.body) - return True - - def encode_string(self, encoding): - """Encode a buffered response body.""" - if encoding in self.attempted_charsets: - return False - self.attempted_charsets.add(encoding) - - try: - body = [] - for chunk in self.body: - if isinstance(chunk, unicode): - chunk = chunk.encode(encoding, self.errors) - body.append(chunk) - self.body = body - except (LookupError, UnicodeError): - return False - else: - return True - - def find_acceptable_charset(self): - request = cherrypy.serving.request - response = cherrypy.serving.response - - if self.debug: - cherrypy.log('response.stream %r' % response.stream, 'TOOLS.ENCODE') - if response.stream: - encoder = self.encode_stream - else: - encoder = self.encode_string - if "Content-Length" in response.headers: - # Delete Content-Length header so finalize() recalcs it. - # Encoded strings may be of different lengths from their - # unicode equivalents, and even from each other. For example: - # >>> t = u"\u7007\u3040" - # >>> len(t) - # 2 - # >>> len(t.encode("UTF-8")) - # 6 - # >>> len(t.encode("utf7")) - # 8 - del response.headers["Content-Length"] - - # Parse the Accept-Charset request header, and try to provide one - # of the requested charsets (in order of user preference). - encs = request.headers.elements('Accept-Charset') - charsets = [enc.value.lower() for enc in encs] - if self.debug: - cherrypy.log('charsets %s' % repr(charsets), 'TOOLS.ENCODE') - - if self.encoding is not None: - # If specified, force this encoding to be used, or fail. - encoding = self.encoding.lower() - if self.debug: - cherrypy.log('Specified encoding %r' % encoding, 'TOOLS.ENCODE') - if (not charsets) or "*" in charsets or encoding in charsets: - if self.debug: - cherrypy.log('Attempting encoding %r' % encoding, 'TOOLS.ENCODE') - if encoder(encoding): - return encoding - else: - if not encs: - if self.debug: - cherrypy.log('Attempting default encoding %r' % - self.default_encoding, 'TOOLS.ENCODE') - # Any character-set is acceptable. - if encoder(self.default_encoding): - return self.default_encoding - else: - raise cherrypy.HTTPError(500, self.failmsg % self.default_encoding) - else: - if "*" not in charsets: - # If no "*" is present in an Accept-Charset field, then all - # character sets not explicitly mentioned get a quality - # value of 0, except for ISO-8859-1, which gets a quality - # value of 1 if not explicitly mentioned. - iso = 'iso-8859-1' - if iso not in charsets: - if self.debug: - cherrypy.log('Attempting ISO-8859-1 encoding', - 'TOOLS.ENCODE') - if encoder(iso): - return iso - - for element in encs: - if element.qvalue > 0: - if element.value == "*": - # Matches any charset. Try our default. - if self.debug: - cherrypy.log('Attempting default encoding due ' - 'to %r' % element, 'TOOLS.ENCODE') - if encoder(self.default_encoding): - return self.default_encoding - else: - encoding = element.value - if self.debug: - cherrypy.log('Attempting encoding %r (qvalue >' - '0)' % element, 'TOOLS.ENCODE') - if encoder(encoding): - return encoding - - # No suitable encoding found. - ac = request.headers.get('Accept-Charset') - if ac is None: - msg = "Your client did not send an Accept-Charset header." - else: - msg = "Your client sent this Accept-Charset header: %s." % ac - msg += " We tried these charsets: %s." % ", ".join(self.attempted_charsets) - raise cherrypy.HTTPError(406, msg) - - def __call__(self, *args, **kwargs): - response = cherrypy.serving.response - self.body = self.oldhandler(*args, **kwargs) - - if isinstance(self.body, basestring): - # strings get wrapped in a list because iterating over a single - # item list is much faster than iterating over every character - # in a long string. - if self.body: - self.body = [self.body] - else: - # [''] doesn't evaluate to False, so replace it with []. - self.body = [] - elif isinstance(self.body, types.FileType): - self.body = file_generator(self.body) - elif self.body is None: - self.body = [] - - ct = response.headers.elements("Content-Type") - if self.debug: - cherrypy.log('Content-Type: %r' % ct, 'TOOLS.ENCODE') - if ct: - if self.text_only: - ct = ct[0] - if ct.value.lower().startswith("text/"): - if self.debug: - cherrypy.log('Content-Type %r starts with "text/"' % ct, - 'TOOLS.ENCODE') - do_find = True - else: - if self.debug: - cherrypy.log('Not finding because Content-Type %r does ' - 'not start with "text/"' % ct, - 'TOOLS.ENCODE') - do_find = False - else: - if self.debug: - cherrypy.log('Finding because not text_only', 'TOOLS.ENCODE') - do_find = True - - if do_find: - # Set "charset=..." param on response Content-Type header - ct.params['charset'] = self.find_acceptable_charset() - if self.add_charset: - if self.debug: - cherrypy.log('Setting Content-Type %r' % ct, - 'TOOLS.ENCODE') - response.headers["Content-Type"] = str(ct) - - return self.body - -# GZIP - -def compress(body, compress_level): - """Compress 'body' at the given compress_level.""" - import zlib - - # See http://www.gzip.org/zlib/rfc-gzip.html - yield '\x1f\x8b' # ID1 and ID2: gzip marker - yield '\x08' # CM: compression method - yield '\x00' # FLG: none set - # MTIME: 4 bytes - yield struct.pack(" 0 is present - * The 'identity' value is given with a qvalue > 0. - """ - request = cherrypy.serving.request - response = cherrypy.serving.response - - set_vary_header(response, "Accept-Encoding") - - if not response.body: - # Response body is empty (might be a 304 for instance) - if debug: - cherrypy.log('No response body', context='TOOLS.GZIP') - return - - # If returning cached content (which should already have been gzipped), - # don't re-zip. - if getattr(request, "cached", False): - if debug: - cherrypy.log('Not gzipping cached response', context='TOOLS.GZIP') - return - - acceptable = request.headers.elements('Accept-Encoding') - if not acceptable: - # If no Accept-Encoding field is present in a request, - # the server MAY assume that the client will accept any - # content coding. In this case, if "identity" is one of - # the available content-codings, then the server SHOULD use - # the "identity" content-coding, unless it has additional - # information that a different content-coding is meaningful - # to the client. - if debug: - cherrypy.log('No Accept-Encoding', context='TOOLS.GZIP') - return - - ct = response.headers.get('Content-Type', '').split(';')[0] - for coding in acceptable: - if coding.value == 'identity' and coding.qvalue != 0: - if debug: - cherrypy.log('Non-zero identity qvalue: %r' % coding, - context='TOOLS.GZIP') - return - if coding.value in ('gzip', 'x-gzip'): - if coding.qvalue == 0: - if debug: - cherrypy.log('Zero gzip qvalue: %r' % coding, - context='TOOLS.GZIP') - return - - if ct not in mime_types: - if debug: - cherrypy.log('Content-Type %r not in mime_types %r' % - (ct, mime_types), context='TOOLS.GZIP') - return - - if debug: - cherrypy.log('Gzipping', context='TOOLS.GZIP') - # Return a generator that compresses the page - response.headers['Content-Encoding'] = 'gzip' - response.body = compress(response.body, compress_level) - if "Content-Length" in response.headers: - # Delete Content-Length header so finalize() recalcs it. - del response.headers["Content-Length"] - - return - - if debug: - cherrypy.log('No acceptable encoding found.', context='GZIP') - cherrypy.HTTPError(406, "identity, gzip").set_response() - +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO +try: + set +except NameError: + from sets import Set as set +import struct +import time +import types + +import cherrypy +from cherrypy.lib import file_generator +from cherrypy.lib import set_vary_header + + +def decode(encoding=None, default_encoding='utf-8'): + """Replace or extend the list of charsets used to decode a request entity. + + Either argument may be a single string or a list of strings. + + encoding: If not None, restricts the set of charsets attempted while decoding + a request entity to the given set (even if a different charset is given in + the Content-Type request header). + + default_encoding: Only in effect if the 'encoding' argument is not given. + If given, the set of charsets attempted while decoding a request entity is + *extended* with the given value(s). + """ + body = cherrypy.request.body + if encoding is not None: + if not isinstance(encoding, list): + encoding = [encoding] + body.attempt_charsets = encoding + elif default_encoding: + if not isinstance(default_encoding, list): + default_encoding = [default_encoding] + body.attempt_charsets = body.attempt_charsets + default_encoding + + +class ResponseEncoder: + + default_encoding = 'utf-8' + failmsg = "Response body could not be encoded with %r." + encoding = None + errors = 'strict' + text_only = True + add_charset = True + debug = False + + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + self.attempted_charsets = set() + request = cherrypy.serving.request + if request.handler is not None: + # Replace request.handler with self + if self.debug: + cherrypy.log('Replacing request.handler', 'TOOLS.ENCODE') + self.oldhandler = request.handler + request.handler = self + + def encode_stream(self, encoding): + """Encode a streaming response body. + + Use a generator wrapper, and just pray it works as the stream is + being written out. + """ + if encoding in self.attempted_charsets: + return False + self.attempted_charsets.add(encoding) + + def encoder(body): + for chunk in body: + if isinstance(chunk, unicode): + chunk = chunk.encode(encoding, self.errors) + yield chunk + self.body = encoder(self.body) + return True + + def encode_string(self, encoding): + """Encode a buffered response body.""" + if encoding in self.attempted_charsets: + return False + self.attempted_charsets.add(encoding) + + try: + body = [] + for chunk in self.body: + if isinstance(chunk, unicode): + chunk = chunk.encode(encoding, self.errors) + body.append(chunk) + self.body = body + except (LookupError, UnicodeError): + return False + else: + return True + + def find_acceptable_charset(self): + request = cherrypy.serving.request + response = cherrypy.serving.response + + if self.debug: + cherrypy.log('response.stream %r' % response.stream, 'TOOLS.ENCODE') + if response.stream: + encoder = self.encode_stream + else: + encoder = self.encode_string + if "Content-Length" in response.headers: + # Delete Content-Length header so finalize() recalcs it. + # Encoded strings may be of different lengths from their + # unicode equivalents, and even from each other. For example: + # >>> t = u"\u7007\u3040" + # >>> len(t) + # 2 + # >>> len(t.encode("UTF-8")) + # 6 + # >>> len(t.encode("utf7")) + # 8 + del response.headers["Content-Length"] + + # Parse the Accept-Charset request header, and try to provide one + # of the requested charsets (in order of user preference). + encs = request.headers.elements('Accept-Charset') + charsets = [enc.value.lower() for enc in encs] + if self.debug: + cherrypy.log('charsets %s' % repr(charsets), 'TOOLS.ENCODE') + + if self.encoding is not None: + # If specified, force this encoding to be used, or fail. + encoding = self.encoding.lower() + if self.debug: + cherrypy.log('Specified encoding %r' % encoding, 'TOOLS.ENCODE') + if (not charsets) or "*" in charsets or encoding in charsets: + if self.debug: + cherrypy.log('Attempting encoding %r' % encoding, 'TOOLS.ENCODE') + if encoder(encoding): + return encoding + else: + if not encs: + if self.debug: + cherrypy.log('Attempting default encoding %r' % + self.default_encoding, 'TOOLS.ENCODE') + # Any character-set is acceptable. + if encoder(self.default_encoding): + return self.default_encoding + else: + raise cherrypy.HTTPError(500, self.failmsg % self.default_encoding) + else: + if "*" not in charsets: + # If no "*" is present in an Accept-Charset field, then all + # character sets not explicitly mentioned get a quality + # value of 0, except for ISO-8859-1, which gets a quality + # value of 1 if not explicitly mentioned. + iso = 'iso-8859-1' + if iso not in charsets: + if self.debug: + cherrypy.log('Attempting ISO-8859-1 encoding', + 'TOOLS.ENCODE') + if encoder(iso): + return iso + + for element in encs: + if element.qvalue > 0: + if element.value == "*": + # Matches any charset. Try our default. + if self.debug: + cherrypy.log('Attempting default encoding due ' + 'to %r' % element, 'TOOLS.ENCODE') + if encoder(self.default_encoding): + return self.default_encoding + else: + encoding = element.value + if self.debug: + cherrypy.log('Attempting encoding %r (qvalue >' + '0)' % element, 'TOOLS.ENCODE') + if encoder(encoding): + return encoding + + # No suitable encoding found. + ac = request.headers.get('Accept-Charset') + if ac is None: + msg = "Your client did not send an Accept-Charset header." + else: + msg = "Your client sent this Accept-Charset header: %s." % ac + msg += " We tried these charsets: %s." % ", ".join(self.attempted_charsets) + raise cherrypy.HTTPError(406, msg) + + def __call__(self, *args, **kwargs): + response = cherrypy.serving.response + self.body = self.oldhandler(*args, **kwargs) + + if isinstance(self.body, basestring): + # strings get wrapped in a list because iterating over a single + # item list is much faster than iterating over every character + # in a long string. + if self.body: + self.body = [self.body] + else: + # [''] doesn't evaluate to False, so replace it with []. + self.body = [] + elif isinstance(self.body, types.FileType): + self.body = file_generator(self.body) + elif self.body is None: + self.body = [] + + ct = response.headers.elements("Content-Type") + if self.debug: + cherrypy.log('Content-Type: %r' % ct, 'TOOLS.ENCODE') + if ct: + if self.text_only: + ct = ct[0] + if ct.value.lower().startswith("text/"): + if self.debug: + cherrypy.log('Content-Type %r starts with "text/"' % ct, + 'TOOLS.ENCODE') + do_find = True + else: + if self.debug: + cherrypy.log('Not finding because Content-Type %r does ' + 'not start with "text/"' % ct, + 'TOOLS.ENCODE') + do_find = False + else: + if self.debug: + cherrypy.log('Finding because not text_only', 'TOOLS.ENCODE') + do_find = True + + if do_find: + # Set "charset=..." param on response Content-Type header + ct.params['charset'] = self.find_acceptable_charset() + if self.add_charset: + if self.debug: + cherrypy.log('Setting Content-Type %r' % ct, + 'TOOLS.ENCODE') + response.headers["Content-Type"] = str(ct) + + return self.body + +# GZIP + +def compress(body, compress_level): + """Compress 'body' at the given compress_level.""" + import zlib + + # See http://www.gzip.org/zlib/rfc-gzip.html + yield '\x1f\x8b' # ID1 and ID2: gzip marker + yield '\x08' # CM: compression method + yield '\x00' # FLG: none set + # MTIME: 4 bytes + yield struct.pack(" 0 is present + * The 'identity' value is given with a qvalue > 0. + """ + request = cherrypy.serving.request + response = cherrypy.serving.response + + set_vary_header(response, "Accept-Encoding") + + if not response.body: + # Response body is empty (might be a 304 for instance) + if debug: + cherrypy.log('No response body', context='TOOLS.GZIP') + return + + # If returning cached content (which should already have been gzipped), + # don't re-zip. + if getattr(request, "cached", False): + if debug: + cherrypy.log('Not gzipping cached response', context='TOOLS.GZIP') + return + + acceptable = request.headers.elements('Accept-Encoding') + if not acceptable: + # If no Accept-Encoding field is present in a request, + # the server MAY assume that the client will accept any + # content coding. In this case, if "identity" is one of + # the available content-codings, then the server SHOULD use + # the "identity" content-coding, unless it has additional + # information that a different content-coding is meaningful + # to the client. + if debug: + cherrypy.log('No Accept-Encoding', context='TOOLS.GZIP') + return + + ct = response.headers.get('Content-Type', '').split(';')[0] + for coding in acceptable: + if coding.value == 'identity' and coding.qvalue != 0: + if debug: + cherrypy.log('Non-zero identity qvalue: %r' % coding, + context='TOOLS.GZIP') + return + if coding.value in ('gzip', 'x-gzip'): + if coding.qvalue == 0: + if debug: + cherrypy.log('Zero gzip qvalue: %r' % coding, + context='TOOLS.GZIP') + return + + if ct not in mime_types: + if debug: + cherrypy.log('Content-Type %r not in mime_types %r' % + (ct, mime_types), context='TOOLS.GZIP') + return + + if debug: + cherrypy.log('Gzipping', context='TOOLS.GZIP') + # Return a generator that compresses the page + response.headers['Content-Encoding'] = 'gzip' + response.body = compress(response.body, compress_level) + if "Content-Length" in response.headers: + # Delete Content-Length header so finalize() recalcs it. + del response.headers["Content-Length"] + + return + + if debug: + cherrypy.log('No acceptable encoding found.', context='GZIP') + cherrypy.HTTPError(406, "identity, gzip").set_response() + diff --git a/cherrypy/lib/http.py b/cherrypy/lib/http.py index 6fd1d5597e..4661d69e28 100644 --- a/cherrypy/lib/http.py +++ b/cherrypy/lib/http.py @@ -1,7 +1,7 @@ -import warnings -warnings.warn('cherrypy.lib.http has been deprecated and will be removed ' - 'in CherryPy 3.3 use cherrypy.lib.httputil instead.', - DeprecationWarning) - -from cherrypy.lib.httputil import * - +import warnings +warnings.warn('cherrypy.lib.http has been deprecated and will be removed ' + 'in CherryPy 3.3 use cherrypy.lib.httputil instead.', + DeprecationWarning) + +from cherrypy.lib.httputil import * + diff --git a/cherrypy/lib/httputil.py b/cherrypy/lib/httputil.py index 01ec16bf7e..38432470d1 100644 --- a/cherrypy/lib/httputil.py +++ b/cherrypy/lib/httputil.py @@ -1,446 +1,446 @@ -"""HTTP library functions.""" - -# This module contains functions for building an HTTP application -# framework: any one, not just one whose name starts with "Ch". ;) If you -# reference any modules from some popular framework inside *this* module, -# FuManChu will personally hang you up by your thumbs and submit you -# to a public caning. - -from binascii import b2a_base64 -from BaseHTTPServer import BaseHTTPRequestHandler -response_codes = BaseHTTPRequestHandler.responses.copy() - -# From http://www.cherrypy.org/ticket/361 -response_codes[500] = ('Internal Server Error', - 'The server encountered an unexpected condition ' - 'which prevented it from fulfilling the request.') -response_codes[503] = ('Service Unavailable', - 'The server is currently unable to handle the ' - 'request due to a temporary overloading or ' - 'maintenance of the server.') - -import re -import urllib - -from rfc822 import formatdate as HTTPDate - - -def urljoin(*atoms): - """Return the given path *atoms, joined into a single URL. - - This will correctly join a SCRIPT_NAME and PATH_INFO into the - original URL, even if either atom is blank. - """ - url = "/".join([x for x in atoms if x]) - while "//" in url: - url = url.replace("//", "/") - # Special-case the final url of "", and return "/" instead. - return url or "/" - -def protocol_from_http(protocol_str): - """Return a protocol tuple from the given 'HTTP/x.y' string.""" - return int(protocol_str[5]), int(protocol_str[7]) - -def get_ranges(headervalue, content_length): - """Return a list of (start, stop) indices from a Range header, or None. - - Each (start, stop) tuple will be composed of two ints, which are suitable - for use in a slicing operation. That is, the header "Range: bytes=3-6", - if applied against a Python string, is requesting resource[3:7]. This - function will return the list [(3, 7)]. - - If this function returns an empty list, you should return HTTP 416. - """ - - if not headervalue: - return None - - result = [] - bytesunit, byteranges = headervalue.split("=", 1) - for brange in byteranges.split(","): - start, stop = [x.strip() for x in brange.split("-", 1)] - if start: - if not stop: - stop = content_length - 1 - start, stop = int(start), int(stop) - if start >= content_length: - # From rfc 2616 sec 14.16: - # "If the server receives a request (other than one - # including an If-Range request-header field) with an - # unsatisfiable Range request-header field (that is, - # all of whose byte-range-spec values have a first-byte-pos - # value greater than the current length of the selected - # resource), it SHOULD return a response code of 416 - # (Requested range not satisfiable)." - continue - if stop < start: - # From rfc 2616 sec 14.16: - # "If the server ignores a byte-range-spec because it - # is syntactically invalid, the server SHOULD treat - # the request as if the invalid Range header field - # did not exist. (Normally, this means return a 200 - # response containing the full entity)." - return None - result.append((start, stop + 1)) - else: - if not stop: - # See rfc quote above. - return None - # Negative subscript (last N bytes) - result.append((content_length - int(stop), content_length)) - - return result - - -class HeaderElement(object): - """An element (with parameters) from an HTTP header's element list.""" - - def __init__(self, value, params=None): - self.value = value - if params is None: - params = {} - self.params = params - - def __cmp__(self, other): - return cmp(self.value, other.value) - - def __unicode__(self): - p = [";%s=%s" % (k, v) for k, v in self.params.iteritems()] - return u"%s%s" % (self.value, "".join(p)) - - def __str__(self): - return str(self.__unicode__()) - - def parse(elementstr): - """Transform 'token;key=val' to ('token', {'key': 'val'}).""" - # Split the element into a value and parameters. The 'value' may - # be of the form, "token=token", but we don't split that here. - atoms = [x.strip() for x in elementstr.split(";") if x.strip()] - if not atoms: - initial_value = '' - else: - initial_value = atoms.pop(0).strip() - params = {} - for atom in atoms: - atom = [x.strip() for x in atom.split("=", 1) if x.strip()] - key = atom.pop(0) - if atom: - val = atom[0] - else: - val = "" - params[key] = val - return initial_value, params - parse = staticmethod(parse) - - def from_str(cls, elementstr): - """Construct an instance from a string of the form 'token;key=val'.""" - ival, params = cls.parse(elementstr) - return cls(ival, params) - from_str = classmethod(from_str) - - -q_separator = re.compile(r'; *q *=') - -class AcceptElement(HeaderElement): - """An element (with parameters) from an Accept* header's element list. - - AcceptElement objects are comparable; the more-preferred object will be - "less than" the less-preferred object. They are also therefore sortable; - if you sort a list of AcceptElement objects, they will be listed in - priority order; the most preferred value will be first. Yes, it should - have been the other way around, but it's too late to fix now. - """ - - def from_str(cls, elementstr): - qvalue = None - # The first "q" parameter (if any) separates the initial - # media-range parameter(s) (if any) from the accept-params. - atoms = q_separator.split(elementstr, 1) - media_range = atoms.pop(0).strip() - if atoms: - # The qvalue for an Accept header can have extensions. The other - # headers cannot, but it's easier to parse them as if they did. - qvalue = HeaderElement.from_str(atoms[0].strip()) - - media_type, params = cls.parse(media_range) - if qvalue is not None: - params["q"] = qvalue - return cls(media_type, params) - from_str = classmethod(from_str) - - def qvalue(self): - val = self.params.get("q", "1") - if isinstance(val, HeaderElement): - val = val.value - return float(val) - qvalue = property(qvalue, doc="The qvalue, or priority, of this value.") - - def __cmp__(self, other): - diff = cmp(self.qvalue, other.qvalue) - if diff == 0: - diff = cmp(str(self), str(other)) - return diff - - -def header_elements(fieldname, fieldvalue): - """Return a sorted HeaderElement list from a comma-separated header str.""" - if not fieldvalue: - return [] - - result = [] - for element in fieldvalue.split(","): - if fieldname.startswith("Accept") or fieldname == 'TE': - hv = AcceptElement.from_str(element) - else: - hv = HeaderElement.from_str(element) - result.append(hv) - result.sort() - result.reverse() - return result - -def decode_TEXT(value): - """Decode RFC-2047 TEXT (e.g. "=?utf-8?q?f=C3=BCr?=" -> u"f\xfcr").""" - from email.Header import decode_header - atoms = decode_header(value) - decodedvalue = "" - for atom, charset in atoms: - if charset is not None: - atom = atom.decode(charset) - decodedvalue += atom - return decodedvalue - -def valid_status(status): - """Return legal HTTP status Code, Reason-phrase and Message. - - The status arg must be an int, or a str that begins with an int. - - If status is an int, or a str and no reason-phrase is supplied, - a default reason-phrase will be provided. - """ - - if not status: - status = 200 - - status = str(status) - parts = status.split(" ", 1) - if len(parts) == 1: - # No reason supplied. - code, = parts - reason = None - else: - code, reason = parts - reason = reason.strip() - - try: - code = int(code) - except ValueError: - raise ValueError("Illegal response status from server " - "(%s is non-numeric)." % repr(code)) - - if code < 100 or code > 599: - raise ValueError("Illegal response status from server " - "(%s is out of range)." % repr(code)) - - if code not in response_codes: - # code is unknown but not illegal - default_reason, message = "", "" - else: - default_reason, message = response_codes[code] - - if reason is None: - reason = default_reason - - return code, reason, message - - -def _parse_qs(qs, keep_blank_values=0, strict_parsing=0, encoding='utf-8'): - """Parse a query given as a string argument. - - Arguments: - - qs: URL-encoded query string to be parsed - - keep_blank_values: flag indicating whether blank values in - URL encoded queries should be treated as blank strings. A - true value indicates that blanks should be retained as blank - strings. The default false value indicates that blank values - are to be ignored and treated as if they were not included. - - strict_parsing: flag indicating what to do with parsing errors. If - false (the default), errors are silently ignored. If true, - errors raise a ValueError exception. - - Returns a dict, as G-d intended. - """ - pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')] - d = {} - for name_value in pairs: - if not name_value and not strict_parsing: - continue - nv = name_value.split('=', 1) - if len(nv) != 2: - if strict_parsing: - raise ValueError("bad query field: %r" % (name_value,)) - # Handle case of a control-name with no equal sign - if keep_blank_values: - nv.append('') - else: - continue - if len(nv[1]) or keep_blank_values: - name = urllib.unquote(nv[0].replace('+', ' ')) - name = name.decode(encoding, 'strict') - value = urllib.unquote(nv[1].replace('+', ' ')) - value = value.decode(encoding, 'strict') - if name in d: - if not isinstance(d[name], list): - d[name] = [d[name]] - d[name].append(value) - else: - d[name] = value - return d - - -image_map_pattern = re.compile(r"[0-9]+,[0-9]+") - -def parse_query_string(query_string, keep_blank_values=True, encoding='utf-8'): - """Build a params dictionary from a query_string. - - Duplicate key/value pairs in the provided query_string will be - returned as {'key': [val1, val2, ...]}. Single key/values will - be returned as strings: {'key': 'value'}. - """ - if image_map_pattern.match(query_string): - # Server-side image map. Map the coords to 'x' and 'y' - # (like CGI::Request does). - pm = query_string.split(",") - pm = {'x': int(pm[0]), 'y': int(pm[1])} - else: - pm = _parse_qs(query_string, keep_blank_values, encoding=encoding) - return pm - - -class CaseInsensitiveDict(dict): - """A case-insensitive dict subclass. - - Each key is changed on entry to str(key).title(). - """ - - def __getitem__(self, key): - return dict.__getitem__(self, str(key).title()) - - def __setitem__(self, key, value): - dict.__setitem__(self, str(key).title(), value) - - def __delitem__(self, key): - dict.__delitem__(self, str(key).title()) - - def __contains__(self, key): - return dict.__contains__(self, str(key).title()) - - def get(self, key, default=None): - return dict.get(self, str(key).title(), default) - - def has_key(self, key): - return dict.has_key(self, str(key).title()) - - def update(self, E): - for k in E.keys(): - self[str(k).title()] = E[k] - - def fromkeys(cls, seq, value=None): - newdict = cls() - for k in seq: - newdict[str(k).title()] = value - return newdict - fromkeys = classmethod(fromkeys) - - def setdefault(self, key, x=None): - key = str(key).title() - try: - return self[key] - except KeyError: - self[key] = x - return x - - def pop(self, key, default): - return dict.pop(self, str(key).title(), default) - - -class HeaderMap(CaseInsensitiveDict): - """A dict subclass for HTTP request and response headers. - - Each key is changed on entry to str(key).title(). This allows headers - to be case-insensitive and avoid duplicates. - - Values are header values (decoded according to RFC 2047 if necessary). - """ - - protocol = (1, 1) - - def elements(self, key): - """Return a sorted list of HeaderElements for the given header.""" - key = str(key).title() - value = self.get(key) - return header_elements(key, value) - - def values(self, key): - """Return a sorted list of HeaderElement.value for the given header.""" - return [e.value for e in self.elements(key)] - - def output(self): - """Transform self into a list of (name, value) tuples.""" - header_list = [] - for k, v in self.items(): - if isinstance(k, unicode): - k = k.encode("ISO-8859-1") - - if not isinstance(v, basestring): - v = str(v) - - if isinstance(v, unicode): - v = self.encode(v) - header_list.append((k, v)) - return header_list - - def encode(self, v): - """Return the given header value, encoded for HTTP output.""" - # HTTP/1.0 says, "Words of *TEXT may contain octets - # from character sets other than US-ASCII." and - # "Recipients of header field TEXT containing octets - # outside the US-ASCII character set may assume that - # they represent ISO-8859-1 characters." - try: - v = v.encode("ISO-8859-1") - except UnicodeEncodeError: - if self.protocol == (1, 1): - # Encode RFC-2047 TEXT - # (e.g. u"\u8200" -> "=?utf-8?b?6IiA?="). - # We do our own here instead of using the email module - # because we never want to fold lines--folding has - # been deprecated by the HTTP working group. - v = b2a_base64(v.encode('utf-8')) - v = ('=?utf-8?b?' + v.strip('\n') + '?=') - else: - raise - return v - -class Host(object): - """An internet address. - - name should be the client's host name. If not available (because no DNS - lookup is performed), the IP address should be used instead. - """ - - ip = "0.0.0.0" - port = 80 - name = "unknown.tld" - - def __init__(self, ip, port, name=None): - self.ip = ip - self.port = port - if name is None: - name = ip - self.name = name - - def __repr__(self): - return "httputil.Host(%r, %r, %r)" % (self.ip, self.port, self.name) +"""HTTP library functions.""" + +# This module contains functions for building an HTTP application +# framework: any one, not just one whose name starts with "Ch". ;) If you +# reference any modules from some popular framework inside *this* module, +# FuManChu will personally hang you up by your thumbs and submit you +# to a public caning. + +from binascii import b2a_base64 +from BaseHTTPServer import BaseHTTPRequestHandler +response_codes = BaseHTTPRequestHandler.responses.copy() + +# From http://www.cherrypy.org/ticket/361 +response_codes[500] = ('Internal Server Error', + 'The server encountered an unexpected condition ' + 'which prevented it from fulfilling the request.') +response_codes[503] = ('Service Unavailable', + 'The server is currently unable to handle the ' + 'request due to a temporary overloading or ' + 'maintenance of the server.') + +import re +import urllib + +from rfc822 import formatdate as HTTPDate + + +def urljoin(*atoms): + """Return the given path *atoms, joined into a single URL. + + This will correctly join a SCRIPT_NAME and PATH_INFO into the + original URL, even if either atom is blank. + """ + url = "/".join([x for x in atoms if x]) + while "//" in url: + url = url.replace("//", "/") + # Special-case the final url of "", and return "/" instead. + return url or "/" + +def protocol_from_http(protocol_str): + """Return a protocol tuple from the given 'HTTP/x.y' string.""" + return int(protocol_str[5]), int(protocol_str[7]) + +def get_ranges(headervalue, content_length): + """Return a list of (start, stop) indices from a Range header, or None. + + Each (start, stop) tuple will be composed of two ints, which are suitable + for use in a slicing operation. That is, the header "Range: bytes=3-6", + if applied against a Python string, is requesting resource[3:7]. This + function will return the list [(3, 7)]. + + If this function returns an empty list, you should return HTTP 416. + """ + + if not headervalue: + return None + + result = [] + bytesunit, byteranges = headervalue.split("=", 1) + for brange in byteranges.split(","): + start, stop = [x.strip() for x in brange.split("-", 1)] + if start: + if not stop: + stop = content_length - 1 + start, stop = int(start), int(stop) + if start >= content_length: + # From rfc 2616 sec 14.16: + # "If the server receives a request (other than one + # including an If-Range request-header field) with an + # unsatisfiable Range request-header field (that is, + # all of whose byte-range-spec values have a first-byte-pos + # value greater than the current length of the selected + # resource), it SHOULD return a response code of 416 + # (Requested range not satisfiable)." + continue + if stop < start: + # From rfc 2616 sec 14.16: + # "If the server ignores a byte-range-spec because it + # is syntactically invalid, the server SHOULD treat + # the request as if the invalid Range header field + # did not exist. (Normally, this means return a 200 + # response containing the full entity)." + return None + result.append((start, stop + 1)) + else: + if not stop: + # See rfc quote above. + return None + # Negative subscript (last N bytes) + result.append((content_length - int(stop), content_length)) + + return result + + +class HeaderElement(object): + """An element (with parameters) from an HTTP header's element list.""" + + def __init__(self, value, params=None): + self.value = value + if params is None: + params = {} + self.params = params + + def __cmp__(self, other): + return cmp(self.value, other.value) + + def __unicode__(self): + p = [";%s=%s" % (k, v) for k, v in self.params.iteritems()] + return u"%s%s" % (self.value, "".join(p)) + + def __str__(self): + return str(self.__unicode__()) + + def parse(elementstr): + """Transform 'token;key=val' to ('token', {'key': 'val'}).""" + # Split the element into a value and parameters. The 'value' may + # be of the form, "token=token", but we don't split that here. + atoms = [x.strip() for x in elementstr.split(";") if x.strip()] + if not atoms: + initial_value = '' + else: + initial_value = atoms.pop(0).strip() + params = {} + for atom in atoms: + atom = [x.strip() for x in atom.split("=", 1) if x.strip()] + key = atom.pop(0) + if atom: + val = atom[0] + else: + val = "" + params[key] = val + return initial_value, params + parse = staticmethod(parse) + + def from_str(cls, elementstr): + """Construct an instance from a string of the form 'token;key=val'.""" + ival, params = cls.parse(elementstr) + return cls(ival, params) + from_str = classmethod(from_str) + + +q_separator = re.compile(r'; *q *=') + +class AcceptElement(HeaderElement): + """An element (with parameters) from an Accept* header's element list. + + AcceptElement objects are comparable; the more-preferred object will be + "less than" the less-preferred object. They are also therefore sortable; + if you sort a list of AcceptElement objects, they will be listed in + priority order; the most preferred value will be first. Yes, it should + have been the other way around, but it's too late to fix now. + """ + + def from_str(cls, elementstr): + qvalue = None + # The first "q" parameter (if any) separates the initial + # media-range parameter(s) (if any) from the accept-params. + atoms = q_separator.split(elementstr, 1) + media_range = atoms.pop(0).strip() + if atoms: + # The qvalue for an Accept header can have extensions. The other + # headers cannot, but it's easier to parse them as if they did. + qvalue = HeaderElement.from_str(atoms[0].strip()) + + media_type, params = cls.parse(media_range) + if qvalue is not None: + params["q"] = qvalue + return cls(media_type, params) + from_str = classmethod(from_str) + + def qvalue(self): + val = self.params.get("q", "1") + if isinstance(val, HeaderElement): + val = val.value + return float(val) + qvalue = property(qvalue, doc="The qvalue, or priority, of this value.") + + def __cmp__(self, other): + diff = cmp(self.qvalue, other.qvalue) + if diff == 0: + diff = cmp(str(self), str(other)) + return diff + + +def header_elements(fieldname, fieldvalue): + """Return a sorted HeaderElement list from a comma-separated header str.""" + if not fieldvalue: + return [] + + result = [] + for element in fieldvalue.split(","): + if fieldname.startswith("Accept") or fieldname == 'TE': + hv = AcceptElement.from_str(element) + else: + hv = HeaderElement.from_str(element) + result.append(hv) + result.sort() + result.reverse() + return result + +def decode_TEXT(value): + """Decode RFC-2047 TEXT (e.g. "=?utf-8?q?f=C3=BCr?=" -> u"f\xfcr").""" + from email.Header import decode_header + atoms = decode_header(value) + decodedvalue = "" + for atom, charset in atoms: + if charset is not None: + atom = atom.decode(charset) + decodedvalue += atom + return decodedvalue + +def valid_status(status): + """Return legal HTTP status Code, Reason-phrase and Message. + + The status arg must be an int, or a str that begins with an int. + + If status is an int, or a str and no reason-phrase is supplied, + a default reason-phrase will be provided. + """ + + if not status: + status = 200 + + status = str(status) + parts = status.split(" ", 1) + if len(parts) == 1: + # No reason supplied. + code, = parts + reason = None + else: + code, reason = parts + reason = reason.strip() + + try: + code = int(code) + except ValueError: + raise ValueError("Illegal response status from server " + "(%s is non-numeric)." % repr(code)) + + if code < 100 or code > 599: + raise ValueError("Illegal response status from server " + "(%s is out of range)." % repr(code)) + + if code not in response_codes: + # code is unknown but not illegal + default_reason, message = "", "" + else: + default_reason, message = response_codes[code] + + if reason is None: + reason = default_reason + + return code, reason, message + + +def _parse_qs(qs, keep_blank_values=0, strict_parsing=0, encoding='utf-8'): + """Parse a query given as a string argument. + + Arguments: + + qs: URL-encoded query string to be parsed + + keep_blank_values: flag indicating whether blank values in + URL encoded queries should be treated as blank strings. A + true value indicates that blanks should be retained as blank + strings. The default false value indicates that blank values + are to be ignored and treated as if they were not included. + + strict_parsing: flag indicating what to do with parsing errors. If + false (the default), errors are silently ignored. If true, + errors raise a ValueError exception. + + Returns a dict, as G-d intended. + """ + pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')] + d = {} + for name_value in pairs: + if not name_value and not strict_parsing: + continue + nv = name_value.split('=', 1) + if len(nv) != 2: + if strict_parsing: + raise ValueError("bad query field: %r" % (name_value,)) + # Handle case of a control-name with no equal sign + if keep_blank_values: + nv.append('') + else: + continue + if len(nv[1]) or keep_blank_values: + name = urllib.unquote(nv[0].replace('+', ' ')) + name = name.decode(encoding, 'strict') + value = urllib.unquote(nv[1].replace('+', ' ')) + value = value.decode(encoding, 'strict') + if name in d: + if not isinstance(d[name], list): + d[name] = [d[name]] + d[name].append(value) + else: + d[name] = value + return d + + +image_map_pattern = re.compile(r"[0-9]+,[0-9]+") + +def parse_query_string(query_string, keep_blank_values=True, encoding='utf-8'): + """Build a params dictionary from a query_string. + + Duplicate key/value pairs in the provided query_string will be + returned as {'key': [val1, val2, ...]}. Single key/values will + be returned as strings: {'key': 'value'}. + """ + if image_map_pattern.match(query_string): + # Server-side image map. Map the coords to 'x' and 'y' + # (like CGI::Request does). + pm = query_string.split(",") + pm = {'x': int(pm[0]), 'y': int(pm[1])} + else: + pm = _parse_qs(query_string, keep_blank_values, encoding=encoding) + return pm + + +class CaseInsensitiveDict(dict): + """A case-insensitive dict subclass. + + Each key is changed on entry to str(key).title(). + """ + + def __getitem__(self, key): + return dict.__getitem__(self, str(key).title()) + + def __setitem__(self, key, value): + dict.__setitem__(self, str(key).title(), value) + + def __delitem__(self, key): + dict.__delitem__(self, str(key).title()) + + def __contains__(self, key): + return dict.__contains__(self, str(key).title()) + + def get(self, key, default=None): + return dict.get(self, str(key).title(), default) + + def has_key(self, key): + return dict.has_key(self, str(key).title()) + + def update(self, E): + for k in E.keys(): + self[str(k).title()] = E[k] + + def fromkeys(cls, seq, value=None): + newdict = cls() + for k in seq: + newdict[str(k).title()] = value + return newdict + fromkeys = classmethod(fromkeys) + + def setdefault(self, key, x=None): + key = str(key).title() + try: + return self[key] + except KeyError: + self[key] = x + return x + + def pop(self, key, default): + return dict.pop(self, str(key).title(), default) + + +class HeaderMap(CaseInsensitiveDict): + """A dict subclass for HTTP request and response headers. + + Each key is changed on entry to str(key).title(). This allows headers + to be case-insensitive and avoid duplicates. + + Values are header values (decoded according to RFC 2047 if necessary). + """ + + protocol = (1, 1) + + def elements(self, key): + """Return a sorted list of HeaderElements for the given header.""" + key = str(key).title() + value = self.get(key) + return header_elements(key, value) + + def values(self, key): + """Return a sorted list of HeaderElement.value for the given header.""" + return [e.value for e in self.elements(key)] + + def output(self): + """Transform self into a list of (name, value) tuples.""" + header_list = [] + for k, v in self.items(): + if isinstance(k, unicode): + k = k.encode("ISO-8859-1") + + if not isinstance(v, basestring): + v = str(v) + + if isinstance(v, unicode): + v = self.encode(v) + header_list.append((k, v)) + return header_list + + def encode(self, v): + """Return the given header value, encoded for HTTP output.""" + # HTTP/1.0 says, "Words of *TEXT may contain octets + # from character sets other than US-ASCII." and + # "Recipients of header field TEXT containing octets + # outside the US-ASCII character set may assume that + # they represent ISO-8859-1 characters." + try: + v = v.encode("ISO-8859-1") + except UnicodeEncodeError: + if self.protocol == (1, 1): + # Encode RFC-2047 TEXT + # (e.g. u"\u8200" -> "=?utf-8?b?6IiA?="). + # We do our own here instead of using the email module + # because we never want to fold lines--folding has + # been deprecated by the HTTP working group. + v = b2a_base64(v.encode('utf-8')) + v = ('=?utf-8?b?' + v.strip('\n') + '?=') + else: + raise + return v + +class Host(object): + """An internet address. + + name should be the client's host name. If not available (because no DNS + lookup is performed), the IP address should be used instead. + """ + + ip = "0.0.0.0" + port = 80 + name = "unknown.tld" + + def __init__(self, ip, port, name=None): + self.ip = ip + self.port = port + if name is None: + name = ip + self.name = name + + def __repr__(self): + return "httputil.Host(%r, %r, %r)" % (self.ip, self.port, self.name) diff --git a/cherrypy/lib/profiler.py b/cherrypy/lib/profiler.py index fac8ecf3fd..17500cdbf8 100644 --- a/cherrypy/lib/profiler.py +++ b/cherrypy/lib/profiler.py @@ -1,205 +1,205 @@ -"""Profiler tools for CherryPy. - -CherryPy users -============== - -You can profile any of your pages as follows: - - from cherrypy.lib import profiler - - class Root: - p = profile.Profiler("/path/to/profile/dir") - - def index(self): - self.p.run(self._index) - index.exposed = True - - def _index(self): - return "Hello, world!" - - cherrypy.tree.mount(Root()) - - -You can also turn on profiling for all requests -using the make_app function as WSGI middleware. - - -CherryPy developers -=================== - -This module can be used whenever you make changes to CherryPy, -to get a quick sanity-check on overall CP performance. Use the -"--profile" flag when running the test suite. Then, use the serve() -function to browse the results in a web browser. If you run this -module from the command line, it will call serve() for you. - -""" - - -# Make profiler output more readable by adding __init__ modules' parents. -def new_func_strip_path(func_name): - filename, line, name = func_name - if filename.endswith("__init__.py"): - return os.path.basename(filename[:-12]) + filename[-12:], line, name - return os.path.basename(filename), line, name - -try: - import profile - import pstats - pstats.func_strip_path = new_func_strip_path -except ImportError: - profile = None - pstats = None - -import os, os.path -import sys -import warnings - -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - -_count = 0 - -class Profiler(object): - - def __init__(self, path=None): - if not path: - path = os.path.join(os.path.dirname(__file__), "profile") - self.path = path - if not os.path.exists(path): - os.makedirs(path) - - def run(self, func, *args, **params): - """Dump profile data into self.path.""" - global _count - c = _count = _count + 1 - path = os.path.join(self.path, "cp_%04d.prof" % c) - prof = profile.Profile() - result = prof.runcall(func, *args, **params) - prof.dump_stats(path) - return result - - def statfiles(self): - """statfiles() -> list of available profiles.""" - return [f for f in os.listdir(self.path) - if f.startswith("cp_") and f.endswith(".prof")] - - def stats(self, filename, sortby='cumulative'): - """stats(index) -> output of print_stats() for the given profile.""" - sio = StringIO() - if sys.version_info >= (2, 5): - s = pstats.Stats(os.path.join(self.path, filename), stream=sio) - s.strip_dirs() - s.sort_stats(sortby) - s.print_stats() - else: - # pstats.Stats before Python 2.5 didn't take a 'stream' arg, - # but just printed to stdout. So re-route stdout. - s = pstats.Stats(os.path.join(self.path, filename)) - s.strip_dirs() - s.sort_stats(sortby) - oldout = sys.stdout - try: - sys.stdout = sio - s.print_stats() - finally: - sys.stdout = oldout - response = sio.getvalue() - sio.close() - return response - - def index(self): - return """ - CherryPy profile data - - - - - - """ - index.exposed = True - - def menu(self): - yield "

    Profiling runs

    " - yield "

    Click on one of the runs below to see profiling data.

    " - runs = self.statfiles() - runs.sort() - for i in runs: - yield "%s
    " % (i, i) - menu.exposed = True - - def report(self, filename): - import cherrypy - cherrypy.response.headers['Content-Type'] = 'text/plain' - return self.stats(filename) - report.exposed = True - - -class ProfileAggregator(Profiler): - - def __init__(self, path=None): - Profiler.__init__(self, path) - global _count - self.count = _count = _count + 1 - self.profiler = profile.Profile() - - def run(self, func, *args): - path = os.path.join(self.path, "cp_%04d.prof" % self.count) - result = self.profiler.runcall(func, *args) - self.profiler.dump_stats(path) - return result - - -class make_app: - def __init__(self, nextapp, path=None, aggregate=False): - """Make a WSGI middleware app which wraps 'nextapp' with profiling. - - nextapp: the WSGI application to wrap, usually an instance of - cherrypy.Application. - path: where to dump the profiling output. - aggregate: if True, profile data for all HTTP requests will go in - a single file. If False (the default), each HTTP request will - dump its profile data into a separate file. - """ - if profile is None or pstats is None: - msg = ("Your installation of Python does not have a profile module. " - "If you're on Debian, try `sudo apt-get install python-profiler`. " - "See http://www.cherrypy.org/wiki/ProfilingOnDebian for details.") - warnings.warn(msg) - - self.nextapp = nextapp - self.aggregate = aggregate - if aggregate: - self.profiler = ProfileAggregator(path) - else: - self.profiler = Profiler(path) - - def __call__(self, environ, start_response): - def gather(): - result = [] - for line in self.nextapp(environ, start_response): - result.append(line) - return result - return self.profiler.run(gather) - - -def serve(path=None, port=8080): - if profile is None or pstats is None: - msg = ("Your installation of Python does not have a profile module. " - "If you're on Debian, try `sudo apt-get install python-profiler`. " - "See http://www.cherrypy.org/wiki/ProfilingOnDebian for details.") - warnings.warn(msg) - - import cherrypy - cherrypy.config.update({'server.socket_port': int(port), - 'server.thread_pool': 10, - 'environment': "production", - }) - cherrypy.quickstart(Profiler(path)) - - -if __name__ == "__main__": - serve(*tuple(sys.argv[1:])) - +"""Profiler tools for CherryPy. + +CherryPy users +============== + +You can profile any of your pages as follows: + + from cherrypy.lib import profiler + + class Root: + p = profile.Profiler("/path/to/profile/dir") + + def index(self): + self.p.run(self._index) + index.exposed = True + + def _index(self): + return "Hello, world!" + + cherrypy.tree.mount(Root()) + + +You can also turn on profiling for all requests +using the make_app function as WSGI middleware. + + +CherryPy developers +=================== + +This module can be used whenever you make changes to CherryPy, +to get a quick sanity-check on overall CP performance. Use the +"--profile" flag when running the test suite. Then, use the serve() +function to browse the results in a web browser. If you run this +module from the command line, it will call serve() for you. + +""" + + +# Make profiler output more readable by adding __init__ modules' parents. +def new_func_strip_path(func_name): + filename, line, name = func_name + if filename.endswith("__init__.py"): + return os.path.basename(filename[:-12]) + filename[-12:], line, name + return os.path.basename(filename), line, name + +try: + import profile + import pstats + pstats.func_strip_path = new_func_strip_path +except ImportError: + profile = None + pstats = None + +import os, os.path +import sys +import warnings + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +_count = 0 + +class Profiler(object): + + def __init__(self, path=None): + if not path: + path = os.path.join(os.path.dirname(__file__), "profile") + self.path = path + if not os.path.exists(path): + os.makedirs(path) + + def run(self, func, *args, **params): + """Dump profile data into self.path.""" + global _count + c = _count = _count + 1 + path = os.path.join(self.path, "cp_%04d.prof" % c) + prof = profile.Profile() + result = prof.runcall(func, *args, **params) + prof.dump_stats(path) + return result + + def statfiles(self): + """statfiles() -> list of available profiles.""" + return [f for f in os.listdir(self.path) + if f.startswith("cp_") and f.endswith(".prof")] + + def stats(self, filename, sortby='cumulative'): + """stats(index) -> output of print_stats() for the given profile.""" + sio = StringIO() + if sys.version_info >= (2, 5): + s = pstats.Stats(os.path.join(self.path, filename), stream=sio) + s.strip_dirs() + s.sort_stats(sortby) + s.print_stats() + else: + # pstats.Stats before Python 2.5 didn't take a 'stream' arg, + # but just printed to stdout. So re-route stdout. + s = pstats.Stats(os.path.join(self.path, filename)) + s.strip_dirs() + s.sort_stats(sortby) + oldout = sys.stdout + try: + sys.stdout = sio + s.print_stats() + finally: + sys.stdout = oldout + response = sio.getvalue() + sio.close() + return response + + def index(self): + return """ + CherryPy profile data + + + + + + """ + index.exposed = True + + def menu(self): + yield "

    Profiling runs

    " + yield "

    Click on one of the runs below to see profiling data.

    " + runs = self.statfiles() + runs.sort() + for i in runs: + yield "%s
    " % (i, i) + menu.exposed = True + + def report(self, filename): + import cherrypy + cherrypy.response.headers['Content-Type'] = 'text/plain' + return self.stats(filename) + report.exposed = True + + +class ProfileAggregator(Profiler): + + def __init__(self, path=None): + Profiler.__init__(self, path) + global _count + self.count = _count = _count + 1 + self.profiler = profile.Profile() + + def run(self, func, *args): + path = os.path.join(self.path, "cp_%04d.prof" % self.count) + result = self.profiler.runcall(func, *args) + self.profiler.dump_stats(path) + return result + + +class make_app: + def __init__(self, nextapp, path=None, aggregate=False): + """Make a WSGI middleware app which wraps 'nextapp' with profiling. + + nextapp: the WSGI application to wrap, usually an instance of + cherrypy.Application. + path: where to dump the profiling output. + aggregate: if True, profile data for all HTTP requests will go in + a single file. If False (the default), each HTTP request will + dump its profile data into a separate file. + """ + if profile is None or pstats is None: + msg = ("Your installation of Python does not have a profile module. " + "If you're on Debian, try `sudo apt-get install python-profiler`. " + "See http://www.cherrypy.org/wiki/ProfilingOnDebian for details.") + warnings.warn(msg) + + self.nextapp = nextapp + self.aggregate = aggregate + if aggregate: + self.profiler = ProfileAggregator(path) + else: + self.profiler = Profiler(path) + + def __call__(self, environ, start_response): + def gather(): + result = [] + for line in self.nextapp(environ, start_response): + result.append(line) + return result + return self.profiler.run(gather) + + +def serve(path=None, port=8080): + if profile is None or pstats is None: + msg = ("Your installation of Python does not have a profile module. " + "If you're on Debian, try `sudo apt-get install python-profiler`. " + "See http://www.cherrypy.org/wiki/ProfilingOnDebian for details.") + warnings.warn(msg) + + import cherrypy + cherrypy.config.update({'server.socket_port': int(port), + 'server.thread_pool': 10, + 'environment': "production", + }) + cherrypy.quickstart(Profiler(path)) + + +if __name__ == "__main__": + serve(*tuple(sys.argv[1:])) + diff --git a/cherrypy/lib/sessions.py b/cherrypy/lib/sessions.py index cd3c0c3624..ed114f46c7 100644 --- a/cherrypy/lib/sessions.py +++ b/cherrypy/lib/sessions.py @@ -1,741 +1,741 @@ -"""Session implementation for CherryPy. - -We use cherrypy.request to store some convenient variables as -well as data about the session for the current request. Instead of -polluting cherrypy.request we use a Session object bound to -cherrypy.session to store these variables. -""" - -import datetime -import os -try: - import cPickle as pickle -except ImportError: - import pickle -import random -try: - # Python 2.5+ - from hashlib import sha1 as sha -except ImportError: - from sha import new as sha -import time -import threading -import types -from warnings import warn - -import cherrypy -from cherrypy.lib import httputil - - -missing = object() - -class Session(object): - """A CherryPy dict-like Session object (one per request).""" - - __metaclass__ = cherrypy._AttributeDocstrings - - _id = None - id_observers = None - id_observers__doc = "A list of callbacks to which to pass new id's." - - id__doc = "The current session ID." - def _get_id(self): - return self._id - def _set_id(self, value): - self._id = value - for o in self.id_observers: - o(value) - id = property(_get_id, _set_id, doc=id__doc) - - timeout = 60 - timeout__doc = "Number of minutes after which to delete session data." - - locked = False - locked__doc = """ - If True, this session instance has exclusive read/write access - to session data.""" - - loaded = False - loaded__doc = """ - If True, data has been retrieved from storage. This should happen - automatically on the first attempt to access session data.""" - - clean_thread = None - clean_thread__doc = "Class-level Monitor which calls self.clean_up." - - clean_freq = 5 - clean_freq__doc = "The poll rate for expired session cleanup in minutes." - - originalid = None - originalid__doc = "The session id passed by the client. May be missing or unsafe." - - missing = False - missing__doc = "True if the session requested by the client did not exist." - - regenerated = False - regenerated__doc = """ - True if the application called session.regenerate(). This is not set by - internal calls to regenerate the session id.""" - - debug = False - - def __init__(self, id=None, **kwargs): - self.id_observers = [] - self._data = {} - - for k, v in kwargs.items(): - setattr(self, k, v) - - self.originalid = id - self.missing = False - if id is None: - if self.debug: - cherrypy.log('No id given; making a new one', 'TOOLS.SESSIONS') - self._regenerate() - else: - self.id = id - if not self._exists(): - if self.debug: - cherrypy.log('Expired or malicious session %r; ' - 'making a new one' % id, 'TOOLS.SESSIONS') - # Expired or malicious session. Make a new one. - # See http://www.cherrypy.org/ticket/709. - self.id = None - self.missing = True - self._regenerate() - - def regenerate(self): - """Replace the current session (with a new id).""" - self.regenerated = True - self._regenerate() - - def _regenerate(self): - if self.id is not None: - self.delete() - - old_session_was_locked = self.locked - if old_session_was_locked: - self.release_lock() - - self.id = None - while self.id is None: - self.id = self.generate_id() - # Assert that the generated id is not already stored. - if self._exists(): - self.id = None - - if old_session_was_locked: - self.acquire_lock() - - def clean_up(self): - """Clean up expired sessions.""" - pass - - try: - os.urandom(20) - except (AttributeError, NotImplementedError): - # os.urandom not available until Python 2.4. Fall back to random.random. - def generate_id(self): - """Return a new session id.""" - return sha('%s' % random.random()).hexdigest() - else: - def generate_id(self): - """Return a new session id.""" - return os.urandom(20).encode('hex') - - def save(self): - """Save session data.""" - try: - # If session data has never been loaded then it's never been - # accessed: no need to save it - if self.loaded: - t = datetime.timedelta(seconds=self.timeout * 60) - expiration_time = datetime.datetime.now() + t - if self.debug: - cherrypy.log('Saving with expiry %s' % expiration_time, - 'TOOLS.SESSIONS') - self._save(expiration_time) - - finally: - if self.locked: - # Always release the lock if the user didn't release it - self.release_lock() - - def load(self): - """Copy stored session data into this session instance.""" - data = self._load() - # data is either None or a tuple (session_data, expiration_time) - if data is None or data[1] < datetime.datetime.now(): - if self.debug: - cherrypy.log('Expired session, flushing data', 'TOOLS.SESSIONS') - self._data = {} - else: - self._data = data[0] - self.loaded = True - - # Stick the clean_thread in the class, not the instance. - # The instances are created and destroyed per-request. - cls = self.__class__ - if self.clean_freq and not cls.clean_thread: - # clean_up is in instancemethod and not a classmethod, - # so that tool config can be accessed inside the method. - t = cherrypy.process.plugins.Monitor( - cherrypy.engine, self.clean_up, self.clean_freq * 60, - name='Session cleanup') - t.subscribe() - cls.clean_thread = t - t.start() - - def delete(self): - """Delete stored session data.""" - self._delete() - - def __getitem__(self, key): - if not self.loaded: self.load() - return self._data[key] - - def __setitem__(self, key, value): - if not self.loaded: self.load() - self._data[key] = value - - def __delitem__(self, key): - if not self.loaded: self.load() - del self._data[key] - - def pop(self, key, default=missing): - """Remove the specified key and return the corresponding value. - If key is not found, default is returned if given, - otherwise KeyError is raised. - """ - if not self.loaded: self.load() - if default is missing: - return self._data.pop(key) - else: - return self._data.pop(key, default) - - def __contains__(self, key): - if not self.loaded: self.load() - return key in self._data - - def has_key(self, key): - """D.has_key(k) -> True if D has a key k, else False.""" - if not self.loaded: self.load() - return key in self._data - - def get(self, key, default=None): - """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.""" - if not self.loaded: self.load() - return self._data.get(key, default) - - def update(self, d): - """D.update(E) -> None. Update D from E: for k in E: D[k] = E[k].""" - if not self.loaded: self.load() - self._data.update(d) - - def setdefault(self, key, default=None): - """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D.""" - if not self.loaded: self.load() - return self._data.setdefault(key, default) - - def clear(self): - """D.clear() -> None. Remove all items from D.""" - if not self.loaded: self.load() - self._data.clear() - - def keys(self): - """D.keys() -> list of D's keys.""" - if not self.loaded: self.load() - return self._data.keys() - - def items(self): - """D.items() -> list of D's (key, value) pairs, as 2-tuples.""" - if not self.loaded: self.load() - return self._data.items() - - def values(self): - """D.values() -> list of D's values.""" - if not self.loaded: self.load() - return self._data.values() - - -class RamSession(Session): - - # Class-level objects. Don't rebind these! - cache = {} - locks = {} - - def clean_up(self): - """Clean up expired sessions.""" - now = datetime.datetime.now() - for id, (data, expiration_time) in self.cache.items(): - if expiration_time <= now: - try: - del self.cache[id] - except KeyError: - pass - try: - del self.locks[id] - except KeyError: - pass - - def _exists(self): - return self.id in self.cache - - def _load(self): - return self.cache.get(self.id) - - def _save(self, expiration_time): - self.cache[self.id] = (self._data, expiration_time) - - def _delete(self): - self.cache.pop(self.id, None) - - def acquire_lock(self): - """Acquire an exclusive lock on the currently-loaded session data.""" - self.locked = True - self.locks.setdefault(self.id, threading.RLock()).acquire() - - def release_lock(self): - """Release the lock on the currently-loaded session data.""" - self.locks[self.id].release() - self.locked = False - - def __len__(self): - """Return the number of active sessions.""" - return len(self.cache) - - -class FileSession(Session): - """Implementation of the File backend for sessions - - storage_path: the folder where session data will be saved. Each session - will be saved as pickle.dump(data, expiration_time) in its own file; - the filename will be self.SESSION_PREFIX + self.id. - """ - - SESSION_PREFIX = 'session-' - LOCK_SUFFIX = '.lock' - pickle_protocol = pickle.HIGHEST_PROTOCOL - - def __init__(self, id=None, **kwargs): - # The 'storage_path' arg is required for file-based sessions. - kwargs['storage_path'] = os.path.abspath(kwargs['storage_path']) - Session.__init__(self, id=id, **kwargs) - - def setup(cls, **kwargs): - """Set up the storage system for file-based sessions. - - This should only be called once per process; this will be done - automatically when using sessions.init (as the built-in Tool does). - """ - # The 'storage_path' arg is required for file-based sessions. - kwargs['storage_path'] = os.path.abspath(kwargs['storage_path']) - - for k, v in kwargs.items(): - setattr(cls, k, v) - - # Warn if any lock files exist at startup. - lockfiles = [fname for fname in os.listdir(cls.storage_path) - if (fname.startswith(cls.SESSION_PREFIX) - and fname.endswith(cls.LOCK_SUFFIX))] - if lockfiles: - plural = ('', 's')[len(lockfiles) > 1] - warn("%s session lockfile%s found at startup. If you are " - "only running one process, then you may need to " - "manually delete the lockfiles found at %r." - % (len(lockfiles), plural, cls.storage_path)) - setup = classmethod(setup) - - def _get_file_path(self): - f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id) - if not os.path.abspath(f).startswith(self.storage_path): - raise cherrypy.HTTPError(400, "Invalid session id in cookie.") - return f - - def _exists(self): - path = self._get_file_path() - return os.path.exists(path) - - def _load(self, path=None): - if path is None: - path = self._get_file_path() - try: - f = open(path, "rb") - try: - return pickle.load(f) - finally: - f.close() - except (IOError, EOFError): - return None - - def _save(self, expiration_time): - f = open(self._get_file_path(), "wb") - try: - pickle.dump((self._data, expiration_time), f, self.pickle_protocol) - finally: - f.close() - - def _delete(self): - try: - os.unlink(self._get_file_path()) - except OSError: - pass - - def acquire_lock(self, path=None): - """Acquire an exclusive lock on the currently-loaded session data.""" - if path is None: - path = self._get_file_path() - path += self.LOCK_SUFFIX - while True: - try: - lockfd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_EXCL) - except OSError: - time.sleep(0.1) - else: - os.close(lockfd) - break - self.locked = True - - def release_lock(self, path=None): - """Release the lock on the currently-loaded session data.""" - if path is None: - path = self._get_file_path() - os.unlink(path + self.LOCK_SUFFIX) - self.locked = False - - def clean_up(self): - """Clean up expired sessions.""" - now = datetime.datetime.now() - # Iterate over all session files in self.storage_path - for fname in os.listdir(self.storage_path): - if (fname.startswith(self.SESSION_PREFIX) - and not fname.endswith(self.LOCK_SUFFIX)): - # We have a session file: lock and load it and check - # if it's expired. If it fails, nevermind. - path = os.path.join(self.storage_path, fname) - self.acquire_lock(path) - try: - contents = self._load(path) - # _load returns None on IOError - if contents is not None: - data, expiration_time = contents - if expiration_time < now: - # Session expired: deleting it - os.unlink(path) - finally: - self.release_lock(path) - - def __len__(self): - """Return the number of active sessions.""" - return len([fname for fname in os.listdir(self.storage_path) - if (fname.startswith(self.SESSION_PREFIX) - and not fname.endswith(self.LOCK_SUFFIX))]) - - -class PostgresqlSession(Session): - """ Implementation of the PostgreSQL backend for sessions. It assumes - a table like this: - - create table session ( - id varchar(40), - data text, - expiration_time timestamp - ) - - You must provide your own get_db function. - """ - - pickle_protocol = pickle.HIGHEST_PROTOCOL - - def __init__(self, id=None, **kwargs): - Session.__init__(self, id, **kwargs) - self.cursor = self.db.cursor() - - def setup(cls, **kwargs): - """Set up the storage system for Postgres-based sessions. - - This should only be called once per process; this will be done - automatically when using sessions.init (as the built-in Tool does). - """ - for k, v in kwargs.items(): - setattr(cls, k, v) - - self.db = self.get_db() - setup = classmethod(setup) - - def __del__(self): - if self.cursor: - self.cursor.close() - self.db.commit() - - def _exists(self): - # Select session data from table - self.cursor.execute('select data, expiration_time from session ' - 'where id=%s', (self.id,)) - rows = self.cursor.fetchall() - return bool(rows) - - def _load(self): - # Select session data from table - self.cursor.execute('select data, expiration_time from session ' - 'where id=%s', (self.id,)) - rows = self.cursor.fetchall() - if not rows: - return None - - pickled_data, expiration_time = rows[0] - data = pickle.loads(pickled_data) - return data, expiration_time - - def _save(self, expiration_time): - pickled_data = pickle.dumps(self._data, self.pickle_protocol) - self.cursor.execute('update session set data = %s, ' - 'expiration_time = %s where id = %s', - (pickled_data, expiration_time, self.id)) - - def _delete(self): - self.cursor.execute('delete from session where id=%s', (self.id,)) - - def acquire_lock(self): - """Acquire an exclusive lock on the currently-loaded session data.""" - # We use the "for update" clause to lock the row - self.locked = True - self.cursor.execute('select id from session where id=%s for update', - (self.id,)) - - def release_lock(self): - """Release the lock on the currently-loaded session data.""" - # We just close the cursor and that will remove the lock - # introduced by the "for update" clause - self.cursor.close() - self.locked = False - - def clean_up(self): - """Clean up expired sessions.""" - self.cursor.execute('delete from session where expiration_time < %s', - (datetime.datetime.now(),)) - - -class MemcachedSession(Session): - - # The most popular memcached client for Python isn't thread-safe. - # Wrap all .get and .set operations in a single lock. - mc_lock = threading.RLock() - - # This is a seperate set of locks per session id. - locks = {} - - servers = ['127.0.0.1:11211'] - - def setup(cls, **kwargs): - """Set up the storage system for memcached-based sessions. - - This should only be called once per process; this will be done - automatically when using sessions.init (as the built-in Tool does). - """ - for k, v in kwargs.items(): - setattr(cls, k, v) - - import memcache - cls.cache = memcache.Client(cls.servers) - setup = classmethod(setup) - - def _exists(self): - self.mc_lock.acquire() - try: - return bool(self.cache.get(self.id)) - finally: - self.mc_lock.release() - - def _load(self): - self.mc_lock.acquire() - try: - return self.cache.get(self.id) - finally: - self.mc_lock.release() - - def _save(self, expiration_time): - # Send the expiration time as "Unix time" (seconds since 1/1/1970) - td = int(time.mktime(expiration_time.timetuple())) - self.mc_lock.acquire() - try: - if not self.cache.set(self.id, (self._data, expiration_time), td): - raise AssertionError("Session data for id %r not set." % self.id) - finally: - self.mc_lock.release() - - def _delete(self): - self.cache.delete(self.id) - - def acquire_lock(self): - """Acquire an exclusive lock on the currently-loaded session data.""" - self.locked = True - self.locks.setdefault(self.id, threading.RLock()).acquire() - - def release_lock(self): - """Release the lock on the currently-loaded session data.""" - self.locks[self.id].release() - self.locked = False - - def __len__(self): - """Return the number of active sessions.""" - raise NotImplementedError - - -# Hook functions (for CherryPy tools) - -def save(): - """Save any changed session data.""" - - if not hasattr(cherrypy.serving, "session"): - return - request = cherrypy.serving.request - response = cherrypy.serving.response - - # Guard against running twice - if hasattr(request, "_sessionsaved"): - return - request._sessionsaved = True - - if response.stream: - # If the body is being streamed, we have to save the data - # *after* the response has been written out - request.hooks.attach('on_end_request', cherrypy.session.save) - else: - # If the body is not being streamed, we save the data now - # (so we can release the lock). - if isinstance(response.body, types.GeneratorType): - response.collapse_body() - cherrypy.session.save() -save.failsafe = True - -def close(): - """Close the session object for this request.""" - sess = getattr(cherrypy.serving, "session", None) - if getattr(sess, "locked", False): - # If the session is still locked we release the lock - sess.release_lock() -close.failsafe = True -close.priority = 90 - - -def init(storage_type='ram', path=None, path_header=None, name='session_id', - timeout=60, domain=None, secure=False, clean_freq=5, - persistent=True, debug=False, **kwargs): - """Initialize session object (using cookies). - - storage_type: one of 'ram', 'file', 'postgresql'. This will be used - to look up the corresponding class in cherrypy.lib.sessions - globals. For example, 'file' will use the FileSession class. - path: the 'path' value to stick in the response cookie metadata. - path_header: if 'path' is None (the default), then the response - cookie 'path' will be pulled from request.headers[path_header]. - name: the name of the cookie. - timeout: the expiration timeout (in minutes) for the stored session data. - If 'persistent' is True (the default), this is also the timeout - for the cookie. - domain: the cookie domain. - secure: if False (the default) the cookie 'secure' value will not - be set. If True, the cookie 'secure' value will be set (to 1). - clean_freq (minutes): the poll rate for expired session cleanup. - persistent: if True (the default), the 'timeout' argument will be used - to expire the cookie. If False, the cookie will not have an expiry, - and the cookie will be a "session cookie" which expires when the - browser is closed. - - Any additional kwargs will be bound to the new Session instance, - and may be specific to the storage type. See the subclass of Session - you're using for more information. - """ - - request = cherrypy.serving.request - - # Guard against running twice - if hasattr(request, "_session_init_flag"): - return - request._session_init_flag = True - - # Check if request came with a session ID - id = None - if name in request.cookie: - id = request.cookie[name].value - if debug: - cherrypy.log('ID obtained from request.cookie: %r' % id, - 'TOOLS.SESSIONS') - - # Find the storage class and call setup (first time only). - storage_class = storage_type.title() + 'Session' - storage_class = globals()[storage_class] - if not hasattr(cherrypy, "session"): - if hasattr(storage_class, "setup"): - storage_class.setup(**kwargs) - - # Create and attach a new Session instance to cherrypy.serving. - # It will possess a reference to (and lock, and lazily load) - # the requested session data. - kwargs['timeout'] = timeout - kwargs['clean_freq'] = clean_freq - cherrypy.serving.session = sess = storage_class(id, **kwargs) - sess.debug = debug - def update_cookie(id): - """Update the cookie every time the session id changes.""" - cherrypy.serving.response.cookie[name] = id - sess.id_observers.append(update_cookie) - - # Create cherrypy.session which will proxy to cherrypy.serving.session - if not hasattr(cherrypy, "session"): - cherrypy.session = cherrypy._ThreadLocalProxy('session') - - if persistent: - cookie_timeout = timeout - else: - # See http://support.microsoft.com/kb/223799/EN-US/ - # and http://support.mozilla.com/en-US/kb/Cookies - cookie_timeout = None - set_response_cookie(path=path, path_header=path_header, name=name, - timeout=cookie_timeout, domain=domain, secure=secure) - - -def set_response_cookie(path=None, path_header=None, name='session_id', - timeout=60, domain=None, secure=False): - """Set a response cookie for the client. - - path: the 'path' value to stick in the response cookie metadata. - path_header: if 'path' is None (the default), then the response - cookie 'path' will be pulled from request.headers[path_header]. - name: the name of the cookie. - timeout: the expiration timeout for the cookie. If 0 or other boolean - False, no 'expires' param will be set, and the cookie will be a - "session cookie" which expires when the browser is closed. - domain: the cookie domain. - secure: if False (the default) the cookie 'secure' value will not - be set. If True, the cookie 'secure' value will be set (to 1). - """ - # Set response cookie - cookie = cherrypy.serving.response.cookie - cookie[name] = cherrypy.serving.session.id - cookie[name]['path'] = (path or cherrypy.serving.request.headers.get(path_header) - or '/') - - # We'd like to use the "max-age" param as indicated in - # http://www.faqs.org/rfcs/rfc2109.html but IE doesn't - # save it to disk and the session is lost if people close - # the browser. So we have to use the old "expires" ... sigh ... -## cookie[name]['max-age'] = timeout * 60 - if timeout: - e = time.time() + (timeout * 60) - cookie[name]['expires'] = httputil.HTTPDate(e) - if domain is not None: - cookie[name]['domain'] = domain - if secure: - cookie[name]['secure'] = 1 - - -def expire(): - """Expire the current session cookie.""" - name = cherrypy.serving.request.config.get('tools.sessions.name', 'session_id') - one_year = 60 * 60 * 24 * 365 - e = time.time() - one_year - cherrypy.serving.response.cookie[name]['expires'] = httputil.HTTPDate(e) - - +"""Session implementation for CherryPy. + +We use cherrypy.request to store some convenient variables as +well as data about the session for the current request. Instead of +polluting cherrypy.request we use a Session object bound to +cherrypy.session to store these variables. +""" + +import datetime +import os +try: + import cPickle as pickle +except ImportError: + import pickle +import random +try: + # Python 2.5+ + from hashlib import sha1 as sha +except ImportError: + from sha import new as sha +import time +import threading +import types +from warnings import warn + +import cherrypy +from cherrypy.lib import httputil + + +missing = object() + +class Session(object): + """A CherryPy dict-like Session object (one per request).""" + + __metaclass__ = cherrypy._AttributeDocstrings + + _id = None + id_observers = None + id_observers__doc = "A list of callbacks to which to pass new id's." + + id__doc = "The current session ID." + def _get_id(self): + return self._id + def _set_id(self, value): + self._id = value + for o in self.id_observers: + o(value) + id = property(_get_id, _set_id, doc=id__doc) + + timeout = 60 + timeout__doc = "Number of minutes after which to delete session data." + + locked = False + locked__doc = """ + If True, this session instance has exclusive read/write access + to session data.""" + + loaded = False + loaded__doc = """ + If True, data has been retrieved from storage. This should happen + automatically on the first attempt to access session data.""" + + clean_thread = None + clean_thread__doc = "Class-level Monitor which calls self.clean_up." + + clean_freq = 5 + clean_freq__doc = "The poll rate for expired session cleanup in minutes." + + originalid = None + originalid__doc = "The session id passed by the client. May be missing or unsafe." + + missing = False + missing__doc = "True if the session requested by the client did not exist." + + regenerated = False + regenerated__doc = """ + True if the application called session.regenerate(). This is not set by + internal calls to regenerate the session id.""" + + debug = False + + def __init__(self, id=None, **kwargs): + self.id_observers = [] + self._data = {} + + for k, v in kwargs.items(): + setattr(self, k, v) + + self.originalid = id + self.missing = False + if id is None: + if self.debug: + cherrypy.log('No id given; making a new one', 'TOOLS.SESSIONS') + self._regenerate() + else: + self.id = id + if not self._exists(): + if self.debug: + cherrypy.log('Expired or malicious session %r; ' + 'making a new one' % id, 'TOOLS.SESSIONS') + # Expired or malicious session. Make a new one. + # See http://www.cherrypy.org/ticket/709. + self.id = None + self.missing = True + self._regenerate() + + def regenerate(self): + """Replace the current session (with a new id).""" + self.regenerated = True + self._regenerate() + + def _regenerate(self): + if self.id is not None: + self.delete() + + old_session_was_locked = self.locked + if old_session_was_locked: + self.release_lock() + + self.id = None + while self.id is None: + self.id = self.generate_id() + # Assert that the generated id is not already stored. + if self._exists(): + self.id = None + + if old_session_was_locked: + self.acquire_lock() + + def clean_up(self): + """Clean up expired sessions.""" + pass + + try: + os.urandom(20) + except (AttributeError, NotImplementedError): + # os.urandom not available until Python 2.4. Fall back to random.random. + def generate_id(self): + """Return a new session id.""" + return sha('%s' % random.random()).hexdigest() + else: + def generate_id(self): + """Return a new session id.""" + return os.urandom(20).encode('hex') + + def save(self): + """Save session data.""" + try: + # If session data has never been loaded then it's never been + # accessed: no need to save it + if self.loaded: + t = datetime.timedelta(seconds=self.timeout * 60) + expiration_time = datetime.datetime.now() + t + if self.debug: + cherrypy.log('Saving with expiry %s' % expiration_time, + 'TOOLS.SESSIONS') + self._save(expiration_time) + + finally: + if self.locked: + # Always release the lock if the user didn't release it + self.release_lock() + + def load(self): + """Copy stored session data into this session instance.""" + data = self._load() + # data is either None or a tuple (session_data, expiration_time) + if data is None or data[1] < datetime.datetime.now(): + if self.debug: + cherrypy.log('Expired session, flushing data', 'TOOLS.SESSIONS') + self._data = {} + else: + self._data = data[0] + self.loaded = True + + # Stick the clean_thread in the class, not the instance. + # The instances are created and destroyed per-request. + cls = self.__class__ + if self.clean_freq and not cls.clean_thread: + # clean_up is in instancemethod and not a classmethod, + # so that tool config can be accessed inside the method. + t = cherrypy.process.plugins.Monitor( + cherrypy.engine, self.clean_up, self.clean_freq * 60, + name='Session cleanup') + t.subscribe() + cls.clean_thread = t + t.start() + + def delete(self): + """Delete stored session data.""" + self._delete() + + def __getitem__(self, key): + if not self.loaded: self.load() + return self._data[key] + + def __setitem__(self, key, value): + if not self.loaded: self.load() + self._data[key] = value + + def __delitem__(self, key): + if not self.loaded: self.load() + del self._data[key] + + def pop(self, key, default=missing): + """Remove the specified key and return the corresponding value. + If key is not found, default is returned if given, + otherwise KeyError is raised. + """ + if not self.loaded: self.load() + if default is missing: + return self._data.pop(key) + else: + return self._data.pop(key, default) + + def __contains__(self, key): + if not self.loaded: self.load() + return key in self._data + + def has_key(self, key): + """D.has_key(k) -> True if D has a key k, else False.""" + if not self.loaded: self.load() + return key in self._data + + def get(self, key, default=None): + """D.get(k[,d]) -> D[k] if k in D, else d. d defaults to None.""" + if not self.loaded: self.load() + return self._data.get(key, default) + + def update(self, d): + """D.update(E) -> None. Update D from E: for k in E: D[k] = E[k].""" + if not self.loaded: self.load() + self._data.update(d) + + def setdefault(self, key, default=None): + """D.setdefault(k[,d]) -> D.get(k,d), also set D[k]=d if k not in D.""" + if not self.loaded: self.load() + return self._data.setdefault(key, default) + + def clear(self): + """D.clear() -> None. Remove all items from D.""" + if not self.loaded: self.load() + self._data.clear() + + def keys(self): + """D.keys() -> list of D's keys.""" + if not self.loaded: self.load() + return self._data.keys() + + def items(self): + """D.items() -> list of D's (key, value) pairs, as 2-tuples.""" + if not self.loaded: self.load() + return self._data.items() + + def values(self): + """D.values() -> list of D's values.""" + if not self.loaded: self.load() + return self._data.values() + + +class RamSession(Session): + + # Class-level objects. Don't rebind these! + cache = {} + locks = {} + + def clean_up(self): + """Clean up expired sessions.""" + now = datetime.datetime.now() + for id, (data, expiration_time) in self.cache.items(): + if expiration_time <= now: + try: + del self.cache[id] + except KeyError: + pass + try: + del self.locks[id] + except KeyError: + pass + + def _exists(self): + return self.id in self.cache + + def _load(self): + return self.cache.get(self.id) + + def _save(self, expiration_time): + self.cache[self.id] = (self._data, expiration_time) + + def _delete(self): + self.cache.pop(self.id, None) + + def acquire_lock(self): + """Acquire an exclusive lock on the currently-loaded session data.""" + self.locked = True + self.locks.setdefault(self.id, threading.RLock()).acquire() + + def release_lock(self): + """Release the lock on the currently-loaded session data.""" + self.locks[self.id].release() + self.locked = False + + def __len__(self): + """Return the number of active sessions.""" + return len(self.cache) + + +class FileSession(Session): + """Implementation of the File backend for sessions + + storage_path: the folder where session data will be saved. Each session + will be saved as pickle.dump(data, expiration_time) in its own file; + the filename will be self.SESSION_PREFIX + self.id. + """ + + SESSION_PREFIX = 'session-' + LOCK_SUFFIX = '.lock' + pickle_protocol = pickle.HIGHEST_PROTOCOL + + def __init__(self, id=None, **kwargs): + # The 'storage_path' arg is required for file-based sessions. + kwargs['storage_path'] = os.path.abspath(kwargs['storage_path']) + Session.__init__(self, id=id, **kwargs) + + def setup(cls, **kwargs): + """Set up the storage system for file-based sessions. + + This should only be called once per process; this will be done + automatically when using sessions.init (as the built-in Tool does). + """ + # The 'storage_path' arg is required for file-based sessions. + kwargs['storage_path'] = os.path.abspath(kwargs['storage_path']) + + for k, v in kwargs.items(): + setattr(cls, k, v) + + # Warn if any lock files exist at startup. + lockfiles = [fname for fname in os.listdir(cls.storage_path) + if (fname.startswith(cls.SESSION_PREFIX) + and fname.endswith(cls.LOCK_SUFFIX))] + if lockfiles: + plural = ('', 's')[len(lockfiles) > 1] + warn("%s session lockfile%s found at startup. If you are " + "only running one process, then you may need to " + "manually delete the lockfiles found at %r." + % (len(lockfiles), plural, cls.storage_path)) + setup = classmethod(setup) + + def _get_file_path(self): + f = os.path.join(self.storage_path, self.SESSION_PREFIX + self.id) + if not os.path.abspath(f).startswith(self.storage_path): + raise cherrypy.HTTPError(400, "Invalid session id in cookie.") + return f + + def _exists(self): + path = self._get_file_path() + return os.path.exists(path) + + def _load(self, path=None): + if path is None: + path = self._get_file_path() + try: + f = open(path, "rb") + try: + return pickle.load(f) + finally: + f.close() + except (IOError, EOFError): + return None + + def _save(self, expiration_time): + f = open(self._get_file_path(), "wb") + try: + pickle.dump((self._data, expiration_time), f, self.pickle_protocol) + finally: + f.close() + + def _delete(self): + try: + os.unlink(self._get_file_path()) + except OSError: + pass + + def acquire_lock(self, path=None): + """Acquire an exclusive lock on the currently-loaded session data.""" + if path is None: + path = self._get_file_path() + path += self.LOCK_SUFFIX + while True: + try: + lockfd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_EXCL) + except OSError: + time.sleep(0.1) + else: + os.close(lockfd) + break + self.locked = True + + def release_lock(self, path=None): + """Release the lock on the currently-loaded session data.""" + if path is None: + path = self._get_file_path() + os.unlink(path + self.LOCK_SUFFIX) + self.locked = False + + def clean_up(self): + """Clean up expired sessions.""" + now = datetime.datetime.now() + # Iterate over all session files in self.storage_path + for fname in os.listdir(self.storage_path): + if (fname.startswith(self.SESSION_PREFIX) + and not fname.endswith(self.LOCK_SUFFIX)): + # We have a session file: lock and load it and check + # if it's expired. If it fails, nevermind. + path = os.path.join(self.storage_path, fname) + self.acquire_lock(path) + try: + contents = self._load(path) + # _load returns None on IOError + if contents is not None: + data, expiration_time = contents + if expiration_time < now: + # Session expired: deleting it + os.unlink(path) + finally: + self.release_lock(path) + + def __len__(self): + """Return the number of active sessions.""" + return len([fname for fname in os.listdir(self.storage_path) + if (fname.startswith(self.SESSION_PREFIX) + and not fname.endswith(self.LOCK_SUFFIX))]) + + +class PostgresqlSession(Session): + """ Implementation of the PostgreSQL backend for sessions. It assumes + a table like this: + + create table session ( + id varchar(40), + data text, + expiration_time timestamp + ) + + You must provide your own get_db function. + """ + + pickle_protocol = pickle.HIGHEST_PROTOCOL + + def __init__(self, id=None, **kwargs): + Session.__init__(self, id, **kwargs) + self.cursor = self.db.cursor() + + def setup(cls, **kwargs): + """Set up the storage system for Postgres-based sessions. + + This should only be called once per process; this will be done + automatically when using sessions.init (as the built-in Tool does). + """ + for k, v in kwargs.items(): + setattr(cls, k, v) + + self.db = self.get_db() + setup = classmethod(setup) + + def __del__(self): + if self.cursor: + self.cursor.close() + self.db.commit() + + def _exists(self): + # Select session data from table + self.cursor.execute('select data, expiration_time from session ' + 'where id=%s', (self.id,)) + rows = self.cursor.fetchall() + return bool(rows) + + def _load(self): + # Select session data from table + self.cursor.execute('select data, expiration_time from session ' + 'where id=%s', (self.id,)) + rows = self.cursor.fetchall() + if not rows: + return None + + pickled_data, expiration_time = rows[0] + data = pickle.loads(pickled_data) + return data, expiration_time + + def _save(self, expiration_time): + pickled_data = pickle.dumps(self._data, self.pickle_protocol) + self.cursor.execute('update session set data = %s, ' + 'expiration_time = %s where id = %s', + (pickled_data, expiration_time, self.id)) + + def _delete(self): + self.cursor.execute('delete from session where id=%s', (self.id,)) + + def acquire_lock(self): + """Acquire an exclusive lock on the currently-loaded session data.""" + # We use the "for update" clause to lock the row + self.locked = True + self.cursor.execute('select id from session where id=%s for update', + (self.id,)) + + def release_lock(self): + """Release the lock on the currently-loaded session data.""" + # We just close the cursor and that will remove the lock + # introduced by the "for update" clause + self.cursor.close() + self.locked = False + + def clean_up(self): + """Clean up expired sessions.""" + self.cursor.execute('delete from session where expiration_time < %s', + (datetime.datetime.now(),)) + + +class MemcachedSession(Session): + + # The most popular memcached client for Python isn't thread-safe. + # Wrap all .get and .set operations in a single lock. + mc_lock = threading.RLock() + + # This is a seperate set of locks per session id. + locks = {} + + servers = ['127.0.0.1:11211'] + + def setup(cls, **kwargs): + """Set up the storage system for memcached-based sessions. + + This should only be called once per process; this will be done + automatically when using sessions.init (as the built-in Tool does). + """ + for k, v in kwargs.items(): + setattr(cls, k, v) + + import memcache + cls.cache = memcache.Client(cls.servers) + setup = classmethod(setup) + + def _exists(self): + self.mc_lock.acquire() + try: + return bool(self.cache.get(self.id)) + finally: + self.mc_lock.release() + + def _load(self): + self.mc_lock.acquire() + try: + return self.cache.get(self.id) + finally: + self.mc_lock.release() + + def _save(self, expiration_time): + # Send the expiration time as "Unix time" (seconds since 1/1/1970) + td = int(time.mktime(expiration_time.timetuple())) + self.mc_lock.acquire() + try: + if not self.cache.set(self.id, (self._data, expiration_time), td): + raise AssertionError("Session data for id %r not set." % self.id) + finally: + self.mc_lock.release() + + def _delete(self): + self.cache.delete(self.id) + + def acquire_lock(self): + """Acquire an exclusive lock on the currently-loaded session data.""" + self.locked = True + self.locks.setdefault(self.id, threading.RLock()).acquire() + + def release_lock(self): + """Release the lock on the currently-loaded session data.""" + self.locks[self.id].release() + self.locked = False + + def __len__(self): + """Return the number of active sessions.""" + raise NotImplementedError + + +# Hook functions (for CherryPy tools) + +def save(): + """Save any changed session data.""" + + if not hasattr(cherrypy.serving, "session"): + return + request = cherrypy.serving.request + response = cherrypy.serving.response + + # Guard against running twice + if hasattr(request, "_sessionsaved"): + return + request._sessionsaved = True + + if response.stream: + # If the body is being streamed, we have to save the data + # *after* the response has been written out + request.hooks.attach('on_end_request', cherrypy.session.save) + else: + # If the body is not being streamed, we save the data now + # (so we can release the lock). + if isinstance(response.body, types.GeneratorType): + response.collapse_body() + cherrypy.session.save() +save.failsafe = True + +def close(): + """Close the session object for this request.""" + sess = getattr(cherrypy.serving, "session", None) + if getattr(sess, "locked", False): + # If the session is still locked we release the lock + sess.release_lock() +close.failsafe = True +close.priority = 90 + + +def init(storage_type='ram', path=None, path_header=None, name='session_id', + timeout=60, domain=None, secure=False, clean_freq=5, + persistent=True, debug=False, **kwargs): + """Initialize session object (using cookies). + + storage_type: one of 'ram', 'file', 'postgresql'. This will be used + to look up the corresponding class in cherrypy.lib.sessions + globals. For example, 'file' will use the FileSession class. + path: the 'path' value to stick in the response cookie metadata. + path_header: if 'path' is None (the default), then the response + cookie 'path' will be pulled from request.headers[path_header]. + name: the name of the cookie. + timeout: the expiration timeout (in minutes) for the stored session data. + If 'persistent' is True (the default), this is also the timeout + for the cookie. + domain: the cookie domain. + secure: if False (the default) the cookie 'secure' value will not + be set. If True, the cookie 'secure' value will be set (to 1). + clean_freq (minutes): the poll rate for expired session cleanup. + persistent: if True (the default), the 'timeout' argument will be used + to expire the cookie. If False, the cookie will not have an expiry, + and the cookie will be a "session cookie" which expires when the + browser is closed. + + Any additional kwargs will be bound to the new Session instance, + and may be specific to the storage type. See the subclass of Session + you're using for more information. + """ + + request = cherrypy.serving.request + + # Guard against running twice + if hasattr(request, "_session_init_flag"): + return + request._session_init_flag = True + + # Check if request came with a session ID + id = None + if name in request.cookie: + id = request.cookie[name].value + if debug: + cherrypy.log('ID obtained from request.cookie: %r' % id, + 'TOOLS.SESSIONS') + + # Find the storage class and call setup (first time only). + storage_class = storage_type.title() + 'Session' + storage_class = globals()[storage_class] + if not hasattr(cherrypy, "session"): + if hasattr(storage_class, "setup"): + storage_class.setup(**kwargs) + + # Create and attach a new Session instance to cherrypy.serving. + # It will possess a reference to (and lock, and lazily load) + # the requested session data. + kwargs['timeout'] = timeout + kwargs['clean_freq'] = clean_freq + cherrypy.serving.session = sess = storage_class(id, **kwargs) + sess.debug = debug + def update_cookie(id): + """Update the cookie every time the session id changes.""" + cherrypy.serving.response.cookie[name] = id + sess.id_observers.append(update_cookie) + + # Create cherrypy.session which will proxy to cherrypy.serving.session + if not hasattr(cherrypy, "session"): + cherrypy.session = cherrypy._ThreadLocalProxy('session') + + if persistent: + cookie_timeout = timeout + else: + # See http://support.microsoft.com/kb/223799/EN-US/ + # and http://support.mozilla.com/en-US/kb/Cookies + cookie_timeout = None + set_response_cookie(path=path, path_header=path_header, name=name, + timeout=cookie_timeout, domain=domain, secure=secure) + + +def set_response_cookie(path=None, path_header=None, name='session_id', + timeout=60, domain=None, secure=False): + """Set a response cookie for the client. + + path: the 'path' value to stick in the response cookie metadata. + path_header: if 'path' is None (the default), then the response + cookie 'path' will be pulled from request.headers[path_header]. + name: the name of the cookie. + timeout: the expiration timeout for the cookie. If 0 or other boolean + False, no 'expires' param will be set, and the cookie will be a + "session cookie" which expires when the browser is closed. + domain: the cookie domain. + secure: if False (the default) the cookie 'secure' value will not + be set. If True, the cookie 'secure' value will be set (to 1). + """ + # Set response cookie + cookie = cherrypy.serving.response.cookie + cookie[name] = cherrypy.serving.session.id + cookie[name]['path'] = (path or cherrypy.serving.request.headers.get(path_header) + or '/') + + # We'd like to use the "max-age" param as indicated in + # http://www.faqs.org/rfcs/rfc2109.html but IE doesn't + # save it to disk and the session is lost if people close + # the browser. So we have to use the old "expires" ... sigh ... +## cookie[name]['max-age'] = timeout * 60 + if timeout: + e = time.time() + (timeout * 60) + cookie[name]['expires'] = httputil.HTTPDate(e) + if domain is not None: + cookie[name]['domain'] = domain + if secure: + cookie[name]['secure'] = 1 + + +def expire(): + """Expire the current session cookie.""" + name = cherrypy.serving.request.config.get('tools.sessions.name', 'session_id') + one_year = 60 * 60 * 24 * 365 + e = time.time() - one_year + cherrypy.serving.response.cookie[name]['expires'] = httputil.HTTPDate(e) + + diff --git a/cherrypy/lib/static.py b/cherrypy/lib/static.py index 31dd4b2245..61c207c2dd 100644 --- a/cherrypy/lib/static.py +++ b/cherrypy/lib/static.py @@ -1,346 +1,346 @@ -import logging -import mimetypes -mimetypes.init() -mimetypes.types_map['.dwg'] = 'image/x-dwg' -mimetypes.types_map['.ico'] = 'image/x-icon' -mimetypes.types_map['.bz2'] = 'application/x-bzip2' -mimetypes.types_map['.gz'] = 'application/x-gzip' - -import os -import re -import stat -import time -from urllib import unquote - -import cherrypy -from cherrypy.lib import cptools, httputil, file_generator_limited - - -def serve_file(path, content_type=None, disposition=None, name=None, debug=False): - """Set status, headers, and body in order to serve the given path. - - The Content-Type header will be set to the content_type arg, if provided. - If not provided, the Content-Type will be guessed by the file extension - of the 'path' argument. - - If disposition is not None, the Content-Disposition header will be set - to "; filename=". If name is None, it will be set - to the basename of path. If disposition is None, no Content-Disposition - header will be written. - """ - - response = cherrypy.serving.response - - # If path is relative, users should fix it by making path absolute. - # That is, CherryPy should not guess where the application root is. - # It certainly should *not* use cwd (since CP may be invoked from a - # variety of paths). If using tools.staticdir, you can make your relative - # paths become absolute by supplying a value for "tools.staticdir.root". - if not os.path.isabs(path): - msg = "'%s' is not an absolute path." % path - if debug: - cherrypy.log(msg, 'TOOLS.STATICFILE') - raise ValueError(msg) - - try: - st = os.stat(path) - except OSError: - if debug: - cherrypy.log('os.stat(%r) failed' % path, 'TOOLS.STATIC') - raise cherrypy.NotFound() - - # Check if path is a directory. - if stat.S_ISDIR(st.st_mode): - # Let the caller deal with it as they like. - if debug: - cherrypy.log('%r is a directory' % path, 'TOOLS.STATIC') - raise cherrypy.NotFound() - - # Set the Last-Modified response header, so that - # modified-since validation code can work. - response.headers['Last-Modified'] = httputil.HTTPDate(st.st_mtime) - cptools.validate_since() - - if content_type is None: - # Set content-type based on filename extension - ext = "" - i = path.rfind('.') - if i != -1: - ext = path[i:].lower() - content_type = mimetypes.types_map.get(ext, None) - if content_type is not None: - response.headers['Content-Type'] = content_type - if debug: - cherrypy.log('Content-Type: %r' % content_type, 'TOOLS.STATIC') - - cd = None - if disposition is not None: - if name is None: - name = os.path.basename(path) - cd = '%s; filename="%s"' % (disposition, name) - response.headers["Content-Disposition"] = cd - if debug: - cherrypy.log('Content-Disposition: %r' % cd, 'TOOLS.STATIC') - - # Set Content-Length and use an iterable (file object) - # this way CP won't load the whole file in memory - content_length = st.st_size - fileobj = open(path, 'rb') - return _serve_fileobj(fileobj, content_type, content_length, debug=debug) - -def serve_fileobj(fileobj, content_type=None, disposition=None, name=None, - debug=False): - """Set status, headers, and body in order to serve the given file object. - - The Content-Type header will be set to the content_type arg, if provided. - - If disposition is not None, the Content-Disposition header will be set - to "; filename=". If name is None, 'filename' will - not be set. If disposition is None, no Content-Disposition header will - be written. - - CAUTION: If the request contains a 'Range' header, one or more seek()s will - be performed on the file object. This may cause undesired behavior if - the file object is not seekable. It could also produce undesired results - if the caller set the read position of the file object prior to calling - serve_fileobj(), expecting that the data would be served starting from that - position. - """ - - response = cherrypy.serving.response - - try: - st = os.fstat(fileobj.fileno()) - except AttributeError: - if debug: - cherrypy.log('os has no fstat attribute', 'TOOLS.STATIC') - content_length = None - else: - # Set the Last-Modified response header, so that - # modified-since validation code can work. - response.headers['Last-Modified'] = httputil.HTTPDate(st.st_mtime) - cptools.validate_since() - content_length = st.st_size - - if content_type is not None: - response.headers['Content-Type'] = content_type - if debug: - cherrypy.log('Content-Type: %r' % content_type, 'TOOLS.STATIC') - - cd = None - if disposition is not None: - if name is None: - cd = disposition - else: - cd = '%s; filename="%s"' % (disposition, name) - response.headers["Content-Disposition"] = cd - if debug: - cherrypy.log('Content-Disposition: %r' % cd, 'TOOLS.STATIC') - - return _serve_fileobj(fileobj, content_type, content_length, debug=debug) - -def _serve_fileobj(fileobj, content_type, content_length, debug=False): - """Internal. Set response.body to the given file object, perhaps ranged.""" - response = cherrypy.serving.response - - # HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code - request = cherrypy.serving.request - if request.protocol >= (1, 1): - response.headers["Accept-Ranges"] = "bytes" - r = httputil.get_ranges(request.headers.get('Range'), content_length) - if r == []: - response.headers['Content-Range'] = "bytes */%s" % content_length - message = "Invalid Range (first-byte-pos greater than Content-Length)" - if debug: - cherrypy.log(message, 'TOOLS.STATIC') - raise cherrypy.HTTPError(416, message) - - if r: - if len(r) == 1: - # Return a single-part response. - start, stop = r[0] - if stop > content_length: - stop = content_length - r_len = stop - start - if debug: - cherrypy.log('Single part; start: %r, stop: %r' % (start, stop), - 'TOOLS.STATIC') - response.status = "206 Partial Content" - response.headers['Content-Range'] = ( - "bytes %s-%s/%s" % (start, stop - 1, content_length)) - response.headers['Content-Length'] = r_len - fileobj.seek(start) - response.body = file_generator_limited(fileobj, r_len) - else: - # Return a multipart/byteranges response. - response.status = "206 Partial Content" - import mimetools - boundary = mimetools.choose_boundary() - ct = "multipart/byteranges; boundary=%s" % boundary - response.headers['Content-Type'] = ct - if "Content-Length" in response.headers: - # Delete Content-Length header so finalize() recalcs it. - del response.headers["Content-Length"] - - def file_ranges(): - # Apache compatibility: - yield "\r\n" - - for start, stop in r: - if debug: - cherrypy.log('Multipart; start: %r, stop: %r' % (start, stop), - 'TOOLS.STATIC') - yield "--" + boundary - yield "\r\nContent-type: %s" % content_type - yield ("\r\nContent-range: bytes %s-%s/%s\r\n\r\n" - % (start, stop - 1, content_length)) - fileobj.seek(start) - for chunk in file_generator_limited(fileobj, stop - start): - yield chunk - yield "\r\n" - # Final boundary - yield "--" + boundary + "--" - - # Apache compatibility: - yield "\r\n" - response.body = file_ranges() - return response.body - else: - if debug: - cherrypy.log('No byteranges requested', 'TOOLS.STATIC') - - # Set Content-Length and use an iterable (file object) - # this way CP won't load the whole file in memory - response.headers['Content-Length'] = content_length - response.body = fileobj - return response.body - -def serve_download(path, name=None): - """Serve 'path' as an application/x-download attachment.""" - # This is such a common idiom I felt it deserved its own wrapper. - return serve_file(path, "application/x-download", "attachment", name) - - -def _attempt(filename, content_types, debug=False): - if debug: - cherrypy.log('Attempting %r (content_types %r)' % - (filename, content_types), 'TOOLS.STATICDIR') - try: - # you can set the content types for a - # complete directory per extension - content_type = None - if content_types: - r, ext = os.path.splitext(filename) - content_type = content_types.get(ext[1:], None) - serve_file(filename, content_type=content_type, debug=debug) - return True - except cherrypy.NotFound: - # If we didn't find the static file, continue handling the - # request. We might find a dynamic handler instead. - if debug: - cherrypy.log('NotFound', 'TOOLS.STATICFILE') - return False - -def staticdir(section, dir, root="", match="", content_types=None, index="", - debug=False): - """Serve a static resource from the given (root +) dir. - - If 'match' is given, request.path_info will be searched for the given - regular expression before attempting to serve static content. - - If content_types is given, it should be a Python dictionary of - {file-extension: content-type} pairs, where 'file-extension' is - a string (e.g. "gif") and 'content-type' is the value to write - out in the Content-Type response header (e.g. "image/gif"). - - If 'index' is provided, it should be the (relative) name of a file to - serve for directory requests. For example, if the dir argument is - '/home/me', the Request-URI is 'myapp', and the index arg is - 'index.html', the file '/home/me/myapp/index.html' will be sought. - """ - request = cherrypy.serving.request - if request.method not in ('GET', 'HEAD'): - if debug: - cherrypy.log('request.method not GET or HEAD', 'TOOLS.STATICDIR') - return False - - if match and not re.search(match, request.path_info): - if debug: - cherrypy.log('request.path_info %r does not match pattern %r' % - (request.path_info, match), 'TOOLS.STATICDIR') - return False - - # Allow the use of '~' to refer to a user's home directory. - dir = os.path.expanduser(dir) - - # If dir is relative, make absolute using "root". - if not os.path.isabs(dir): - if not root: - msg = "Static dir requires an absolute dir (or root)." - if debug: - cherrypy.log(msg, 'TOOLS.STATICDIR') - raise ValueError(msg) - dir = os.path.join(root, dir) - - # Determine where we are in the object tree relative to 'section' - # (where the static tool was defined). - if section == 'global': - section = "/" - section = section.rstrip(r"\/") - branch = request.path_info[len(section) + 1:] - branch = unquote(branch.lstrip(r"\/")) - - # If branch is "", filename will end in a slash - filename = os.path.join(dir, branch) - if debug: - cherrypy.log('Checking file %r to fulfill %r' % - (filename, request.path_info), 'TOOLS.STATICDIR') - - # There's a chance that the branch pulled from the URL might - # have ".." or similar uplevel attacks in it. Check that the final - # filename is a child of dir. - if not os.path.normpath(filename).startswith(os.path.normpath(dir)): - raise cherrypy.HTTPError(403) # Forbidden - - handled = _attempt(filename, content_types) - if not handled: - # Check for an index file if a folder was requested. - if index: - handled = _attempt(os.path.join(filename, index), content_types) - if handled: - request.is_index = filename[-1] in (r"\/") - return handled - -def staticfile(filename, root=None, match="", content_types=None, debug=False): - """Serve a static resource from the given (root +) filename. - - If 'match' is given, request.path_info will be searched for the given - regular expression before attempting to serve static content. - - If content_types is given, it should be a Python dictionary of - {file-extension: content-type} pairs, where 'file-extension' is - a string (e.g. "gif") and 'content-type' is the value to write - out in the Content-Type response header (e.g. "image/gif"). - """ - request = cherrypy.serving.request - if request.method not in ('GET', 'HEAD'): - if debug: - cherrypy.log('request.method not GET or HEAD', 'TOOLS.STATICFILE') - return False - - if match and not re.search(match, request.path_info): - if debug: - cherrypy.log('request.path_info %r does not match pattern %r' % - (request.path_info, match), 'TOOLS.STATICFILE') - return False - - # If filename is relative, make absolute using "root". - if not os.path.isabs(filename): - if not root: - msg = "Static tool requires an absolute filename (got '%s')." % filename - if debug: - cherrypy.log(msg, 'TOOLS.STATICFILE') - raise ValueError(msg) - filename = os.path.join(root, filename) - - return _attempt(filename, content_types, debug=debug) +import logging +import mimetypes +mimetypes.init() +mimetypes.types_map['.dwg'] = 'image/x-dwg' +mimetypes.types_map['.ico'] = 'image/x-icon' +mimetypes.types_map['.bz2'] = 'application/x-bzip2' +mimetypes.types_map['.gz'] = 'application/x-gzip' + +import os +import re +import stat +import time +from urllib import unquote + +import cherrypy +from cherrypy.lib import cptools, httputil, file_generator_limited + + +def serve_file(path, content_type=None, disposition=None, name=None, debug=False): + """Set status, headers, and body in order to serve the given path. + + The Content-Type header will be set to the content_type arg, if provided. + If not provided, the Content-Type will be guessed by the file extension + of the 'path' argument. + + If disposition is not None, the Content-Disposition header will be set + to "; filename=". If name is None, it will be set + to the basename of path. If disposition is None, no Content-Disposition + header will be written. + """ + + response = cherrypy.serving.response + + # If path is relative, users should fix it by making path absolute. + # That is, CherryPy should not guess where the application root is. + # It certainly should *not* use cwd (since CP may be invoked from a + # variety of paths). If using tools.staticdir, you can make your relative + # paths become absolute by supplying a value for "tools.staticdir.root". + if not os.path.isabs(path): + msg = "'%s' is not an absolute path." % path + if debug: + cherrypy.log(msg, 'TOOLS.STATICFILE') + raise ValueError(msg) + + try: + st = os.stat(path) + except OSError: + if debug: + cherrypy.log('os.stat(%r) failed' % path, 'TOOLS.STATIC') + raise cherrypy.NotFound() + + # Check if path is a directory. + if stat.S_ISDIR(st.st_mode): + # Let the caller deal with it as they like. + if debug: + cherrypy.log('%r is a directory' % path, 'TOOLS.STATIC') + raise cherrypy.NotFound() + + # Set the Last-Modified response header, so that + # modified-since validation code can work. + response.headers['Last-Modified'] = httputil.HTTPDate(st.st_mtime) + cptools.validate_since() + + if content_type is None: + # Set content-type based on filename extension + ext = "" + i = path.rfind('.') + if i != -1: + ext = path[i:].lower() + content_type = mimetypes.types_map.get(ext, None) + if content_type is not None: + response.headers['Content-Type'] = content_type + if debug: + cherrypy.log('Content-Type: %r' % content_type, 'TOOLS.STATIC') + + cd = None + if disposition is not None: + if name is None: + name = os.path.basename(path) + cd = '%s; filename="%s"' % (disposition, name) + response.headers["Content-Disposition"] = cd + if debug: + cherrypy.log('Content-Disposition: %r' % cd, 'TOOLS.STATIC') + + # Set Content-Length and use an iterable (file object) + # this way CP won't load the whole file in memory + content_length = st.st_size + fileobj = open(path, 'rb') + return _serve_fileobj(fileobj, content_type, content_length, debug=debug) + +def serve_fileobj(fileobj, content_type=None, disposition=None, name=None, + debug=False): + """Set status, headers, and body in order to serve the given file object. + + The Content-Type header will be set to the content_type arg, if provided. + + If disposition is not None, the Content-Disposition header will be set + to "; filename=". If name is None, 'filename' will + not be set. If disposition is None, no Content-Disposition header will + be written. + + CAUTION: If the request contains a 'Range' header, one or more seek()s will + be performed on the file object. This may cause undesired behavior if + the file object is not seekable. It could also produce undesired results + if the caller set the read position of the file object prior to calling + serve_fileobj(), expecting that the data would be served starting from that + position. + """ + + response = cherrypy.serving.response + + try: + st = os.fstat(fileobj.fileno()) + except AttributeError: + if debug: + cherrypy.log('os has no fstat attribute', 'TOOLS.STATIC') + content_length = None + else: + # Set the Last-Modified response header, so that + # modified-since validation code can work. + response.headers['Last-Modified'] = httputil.HTTPDate(st.st_mtime) + cptools.validate_since() + content_length = st.st_size + + if content_type is not None: + response.headers['Content-Type'] = content_type + if debug: + cherrypy.log('Content-Type: %r' % content_type, 'TOOLS.STATIC') + + cd = None + if disposition is not None: + if name is None: + cd = disposition + else: + cd = '%s; filename="%s"' % (disposition, name) + response.headers["Content-Disposition"] = cd + if debug: + cherrypy.log('Content-Disposition: %r' % cd, 'TOOLS.STATIC') + + return _serve_fileobj(fileobj, content_type, content_length, debug=debug) + +def _serve_fileobj(fileobj, content_type, content_length, debug=False): + """Internal. Set response.body to the given file object, perhaps ranged.""" + response = cherrypy.serving.response + + # HTTP/1.0 didn't have Range/Accept-Ranges headers, or the 206 code + request = cherrypy.serving.request + if request.protocol >= (1, 1): + response.headers["Accept-Ranges"] = "bytes" + r = httputil.get_ranges(request.headers.get('Range'), content_length) + if r == []: + response.headers['Content-Range'] = "bytes */%s" % content_length + message = "Invalid Range (first-byte-pos greater than Content-Length)" + if debug: + cherrypy.log(message, 'TOOLS.STATIC') + raise cherrypy.HTTPError(416, message) + + if r: + if len(r) == 1: + # Return a single-part response. + start, stop = r[0] + if stop > content_length: + stop = content_length + r_len = stop - start + if debug: + cherrypy.log('Single part; start: %r, stop: %r' % (start, stop), + 'TOOLS.STATIC') + response.status = "206 Partial Content" + response.headers['Content-Range'] = ( + "bytes %s-%s/%s" % (start, stop - 1, content_length)) + response.headers['Content-Length'] = r_len + fileobj.seek(start) + response.body = file_generator_limited(fileobj, r_len) + else: + # Return a multipart/byteranges response. + response.status = "206 Partial Content" + import mimetools + boundary = mimetools.choose_boundary() + ct = "multipart/byteranges; boundary=%s" % boundary + response.headers['Content-Type'] = ct + if "Content-Length" in response.headers: + # Delete Content-Length header so finalize() recalcs it. + del response.headers["Content-Length"] + + def file_ranges(): + # Apache compatibility: + yield "\r\n" + + for start, stop in r: + if debug: + cherrypy.log('Multipart; start: %r, stop: %r' % (start, stop), + 'TOOLS.STATIC') + yield "--" + boundary + yield "\r\nContent-type: %s" % content_type + yield ("\r\nContent-range: bytes %s-%s/%s\r\n\r\n" + % (start, stop - 1, content_length)) + fileobj.seek(start) + for chunk in file_generator_limited(fileobj, stop - start): + yield chunk + yield "\r\n" + # Final boundary + yield "--" + boundary + "--" + + # Apache compatibility: + yield "\r\n" + response.body = file_ranges() + return response.body + else: + if debug: + cherrypy.log('No byteranges requested', 'TOOLS.STATIC') + + # Set Content-Length and use an iterable (file object) + # this way CP won't load the whole file in memory + response.headers['Content-Length'] = content_length + response.body = fileobj + return response.body + +def serve_download(path, name=None): + """Serve 'path' as an application/x-download attachment.""" + # This is such a common idiom I felt it deserved its own wrapper. + return serve_file(path, "application/x-download", "attachment", name) + + +def _attempt(filename, content_types, debug=False): + if debug: + cherrypy.log('Attempting %r (content_types %r)' % + (filename, content_types), 'TOOLS.STATICDIR') + try: + # you can set the content types for a + # complete directory per extension + content_type = None + if content_types: + r, ext = os.path.splitext(filename) + content_type = content_types.get(ext[1:], None) + serve_file(filename, content_type=content_type, debug=debug) + return True + except cherrypy.NotFound: + # If we didn't find the static file, continue handling the + # request. We might find a dynamic handler instead. + if debug: + cherrypy.log('NotFound', 'TOOLS.STATICFILE') + return False + +def staticdir(section, dir, root="", match="", content_types=None, index="", + debug=False): + """Serve a static resource from the given (root +) dir. + + If 'match' is given, request.path_info will be searched for the given + regular expression before attempting to serve static content. + + If content_types is given, it should be a Python dictionary of + {file-extension: content-type} pairs, where 'file-extension' is + a string (e.g. "gif") and 'content-type' is the value to write + out in the Content-Type response header (e.g. "image/gif"). + + If 'index' is provided, it should be the (relative) name of a file to + serve for directory requests. For example, if the dir argument is + '/home/me', the Request-URI is 'myapp', and the index arg is + 'index.html', the file '/home/me/myapp/index.html' will be sought. + """ + request = cherrypy.serving.request + if request.method not in ('GET', 'HEAD'): + if debug: + cherrypy.log('request.method not GET or HEAD', 'TOOLS.STATICDIR') + return False + + if match and not re.search(match, request.path_info): + if debug: + cherrypy.log('request.path_info %r does not match pattern %r' % + (request.path_info, match), 'TOOLS.STATICDIR') + return False + + # Allow the use of '~' to refer to a user's home directory. + dir = os.path.expanduser(dir) + + # If dir is relative, make absolute using "root". + if not os.path.isabs(dir): + if not root: + msg = "Static dir requires an absolute dir (or root)." + if debug: + cherrypy.log(msg, 'TOOLS.STATICDIR') + raise ValueError(msg) + dir = os.path.join(root, dir) + + # Determine where we are in the object tree relative to 'section' + # (where the static tool was defined). + if section == 'global': + section = "/" + section = section.rstrip(r"\/") + branch = request.path_info[len(section) + 1:] + branch = unquote(branch.lstrip(r"\/")) + + # If branch is "", filename will end in a slash + filename = os.path.join(dir, branch) + if debug: + cherrypy.log('Checking file %r to fulfill %r' % + (filename, request.path_info), 'TOOLS.STATICDIR') + + # There's a chance that the branch pulled from the URL might + # have ".." or similar uplevel attacks in it. Check that the final + # filename is a child of dir. + if not os.path.normpath(filename).startswith(os.path.normpath(dir)): + raise cherrypy.HTTPError(403) # Forbidden + + handled = _attempt(filename, content_types) + if not handled: + # Check for an index file if a folder was requested. + if index: + handled = _attempt(os.path.join(filename, index), content_types) + if handled: + request.is_index = filename[-1] in (r"\/") + return handled + +def staticfile(filename, root=None, match="", content_types=None, debug=False): + """Serve a static resource from the given (root +) filename. + + If 'match' is given, request.path_info will be searched for the given + regular expression before attempting to serve static content. + + If content_types is given, it should be a Python dictionary of + {file-extension: content-type} pairs, where 'file-extension' is + a string (e.g. "gif") and 'content-type' is the value to write + out in the Content-Type response header (e.g. "image/gif"). + """ + request = cherrypy.serving.request + if request.method not in ('GET', 'HEAD'): + if debug: + cherrypy.log('request.method not GET or HEAD', 'TOOLS.STATICFILE') + return False + + if match and not re.search(match, request.path_info): + if debug: + cherrypy.log('request.path_info %r does not match pattern %r' % + (request.path_info, match), 'TOOLS.STATICFILE') + return False + + # If filename is relative, make absolute using "root". + if not os.path.isabs(filename): + if not root: + msg = "Static tool requires an absolute filename (got '%s')." % filename + if debug: + cherrypy.log(msg, 'TOOLS.STATICFILE') + raise ValueError(msg) + filename = os.path.join(root, filename) + + return _attempt(filename, content_types, debug=debug) diff --git a/cherrypy/lib/xmlrpc.py b/cherrypy/lib/xmlrpc.py index 6dde5475ed..7585d573c7 100644 --- a/cherrypy/lib/xmlrpc.py +++ b/cherrypy/lib/xmlrpc.py @@ -1,49 +1,49 @@ -import sys - -import cherrypy - - -def process_body(): - """Return (params, method) from request body.""" - try: - import xmlrpclib - return xmlrpclib.loads(cherrypy.request.body.read()) - except Exception: - return ('ERROR PARAMS',), 'ERRORMETHOD' - - -def patched_path(path): - """Return 'path', doctored for RPC.""" - if not path.endswith('/'): - path += '/' - if path.startswith('/RPC2/'): - # strip the first /rpc2 - path = path[5:] - return path - - -def _set_response(body): - # The XML-RPC spec (http://www.xmlrpc.com/spec) says: - # "Unless there's a lower-level error, always return 200 OK." - # Since Python's xmlrpclib interprets a non-200 response - # as a "Protocol Error", we'll just return 200 every time. - response = cherrypy.response - response.status = '200 OK' - response.body = body - response.headers['Content-Type'] = 'text/xml' - response.headers['Content-Length'] = len(body) - - -def respond(body, encoding='utf-8', allow_none=0): - from xmlrpclib import Fault, dumps - if not isinstance(body, Fault): - body = (body,) - _set_response(dumps(body, methodresponse=1, - encoding=encoding, - allow_none=allow_none)) - -def on_error(*args, **kwargs): - body = str(sys.exc_info()[1]) - from xmlrpclib import Fault, dumps - _set_response(dumps(Fault(1, body))) - +import sys + +import cherrypy + + +def process_body(): + """Return (params, method) from request body.""" + try: + import xmlrpclib + return xmlrpclib.loads(cherrypy.request.body.read()) + except Exception: + return ('ERROR PARAMS',), 'ERRORMETHOD' + + +def patched_path(path): + """Return 'path', doctored for RPC.""" + if not path.endswith('/'): + path += '/' + if path.startswith('/RPC2/'): + # strip the first /rpc2 + path = path[5:] + return path + + +def _set_response(body): + # The XML-RPC spec (http://www.xmlrpc.com/spec) says: + # "Unless there's a lower-level error, always return 200 OK." + # Since Python's xmlrpclib interprets a non-200 response + # as a "Protocol Error", we'll just return 200 every time. + response = cherrypy.response + response.status = '200 OK' + response.body = body + response.headers['Content-Type'] = 'text/xml' + response.headers['Content-Length'] = len(body) + + +def respond(body, encoding='utf-8', allow_none=0): + from xmlrpclib import Fault, dumps + if not isinstance(body, Fault): + body = (body,) + _set_response(dumps(body, methodresponse=1, + encoding=encoding, + allow_none=allow_none)) + +def on_error(*args, **kwargs): + body = str(sys.exc_info()[1]) + from xmlrpclib import Fault, dumps + _set_response(dumps(Fault(1, body))) + diff --git a/cherrypy/process/__init__.py b/cherrypy/process/__init__.py index 2ed60a2901..f15b12370a 100644 --- a/cherrypy/process/__init__.py +++ b/cherrypy/process/__init__.py @@ -1,14 +1,14 @@ -"""Site container for an HTTP server. - -A Web Site Process Bus object is used to connect applications, servers, -and frameworks with site-wide services such as daemonization, process -reload, signal handling, drop privileges, PID file management, logging -for all of these, and many more. - -The 'plugins' module defines a few abstract and concrete services for -use with the bus. Some use tool-specific channels; see the documentation -for each class. -""" - -from cherrypy.process.wspbus import bus -from cherrypy.process import plugins, servers +"""Site container for an HTTP server. + +A Web Site Process Bus object is used to connect applications, servers, +and frameworks with site-wide services such as daemonization, process +reload, signal handling, drop privileges, PID file management, logging +for all of these, and many more. + +The 'plugins' module defines a few abstract and concrete services for +use with the bus. Some use tool-specific channels; see the documentation +for each class. +""" + +from cherrypy.process.wspbus import bus +from cherrypy.process import plugins, servers diff --git a/cherrypy/process/plugins.py b/cherrypy/process/plugins.py index d11136713d..ddeefb2c2b 100644 --- a/cherrypy/process/plugins.py +++ b/cherrypy/process/plugins.py @@ -1,562 +1,562 @@ -"""Site services for use with a Web Site Process Bus.""" - -import os -import re -try: - set -except NameError: - from sets import Set as set -import signal as _signal -import sys -import time -import thread -import threading - -# _module__file__base is used by Autoreload to make -# absolute any filenames retrieved from sys.modules which are not -# already absolute paths. This is to work around Python's quirk -# of importing the startup script and using a relative filename -# for it in sys.modules. -# -# Autoreload examines sys.modules afresh every time it runs. If an application -# changes the current directory by executing os.chdir(), then the next time -# Autoreload runs, it will not be able to find any filenames which are -# not absolute paths, because the current directory is not the same as when the -# module was first imported. Autoreload will then wrongly conclude the file has -# "changed", and initiate the shutdown/re-exec sequence. -# See ticket #917. -# For this workaround to have a decent probability of success, this module -# needs to be imported as early as possible, before the app has much chance -# to change the working directory. -_module__file__base = os.getcwd() - - -class SimplePlugin(object): - """Plugin base class which auto-subscribes methods for known channels.""" - - def __init__(self, bus): - self.bus = bus - - def subscribe(self): - """Register this object as a (multi-channel) listener on the bus.""" - for channel in self.bus.listeners: - # Subscribe self.start, self.exit, etc. if present. - method = getattr(self, channel, None) - if method is not None: - self.bus.subscribe(channel, method) - - def unsubscribe(self): - """Unregister this object as a listener on the bus.""" - for channel in self.bus.listeners: - # Unsubscribe self.start, self.exit, etc. if present. - method = getattr(self, channel, None) - if method is not None: - self.bus.unsubscribe(channel, method) - - - -class SignalHandler(object): - """Register bus channels (and listeners) for system signals. - - By default, instantiating this object subscribes the following signals - and listeners: - - TERM: bus.exit - HUP : bus.restart - USR1: bus.graceful - """ - - # Map from signal numbers to names - signals = {} - for k, v in vars(_signal).items(): - if k.startswith('SIG') and not k.startswith('SIG_'): - signals[v] = k - del k, v - - def __init__(self, bus): - self.bus = bus - # Set default handlers - self.handlers = {'SIGTERM': self.bus.exit, - 'SIGHUP': self.handle_SIGHUP, - 'SIGUSR1': self.bus.graceful, - } - - self._previous_handlers = {} - - def subscribe(self): - for sig, func in self.handlers.items(): - try: - self.set_handler(sig, func) - except ValueError: - pass - - def unsubscribe(self): - for signum, handler in self._previous_handlers.items(): - signame = self.signals[signum] - - if handler is None: - self.bus.log("Restoring %s handler to SIG_DFL." % signame) - handler = _signal.SIG_DFL - else: - self.bus.log("Restoring %s handler %r." % (signame, handler)) - - try: - our_handler = _signal.signal(signum, handler) - if our_handler is None: - self.bus.log("Restored old %s handler %r, but our " - "handler was not registered." % - (signame, handler), level=30) - except ValueError: - self.bus.log("Unable to restore %s handler %r." % - (signame, handler), level=40, traceback=True) - - def set_handler(self, signal, listener=None): - """Subscribe a handler for the given signal (number or name). - - If the optional 'listener' argument is provided, it will be - subscribed as a listener for the given signal's channel. - - If the given signal name or number is not available on the current - platform, ValueError is raised. - """ - if isinstance(signal, basestring): - signum = getattr(_signal, signal, None) - if signum is None: - raise ValueError("No such signal: %r" % signal) - signame = signal - else: - try: - signame = self.signals[signal] - except KeyError: - raise ValueError("No such signal: %r" % signal) - signum = signal - - prev = _signal.signal(signum, self._handle_signal) - self._previous_handlers[signum] = prev - - if listener is not None: - self.bus.log("Listening for %s." % signame) - self.bus.subscribe(signame, listener) - - def _handle_signal(self, signum=None, frame=None): - """Python signal handler (self.set_handler subscribes it for you).""" - signame = self.signals[signum] - self.bus.log("Caught signal %s." % signame) - self.bus.publish(signame) - - def handle_SIGHUP(self): - if os.isatty(sys.stdin.fileno()): - # not daemonized (may be foreground or background) - self.bus.log("SIGHUP caught but not daemonized. Exiting.") - self.bus.exit() - else: - self.bus.log("SIGHUP caught while daemonized. Restarting.") - self.bus.restart() - - -try: - import pwd, grp -except ImportError: - pwd, grp = None, None - - -class DropPrivileges(SimplePlugin): - """Drop privileges. uid/gid arguments not available on Windows. - - Special thanks to Gavin Baker: http://antonym.org/node/100. - """ - - def __init__(self, bus, umask=None, uid=None, gid=None): - SimplePlugin.__init__(self, bus) - self.finalized = False - self.uid = uid - self.gid = gid - self.umask = umask - - def _get_uid(self): - return self._uid - def _set_uid(self, val): - if val is not None: - if pwd is None: - self.bus.log("pwd module not available; ignoring uid.", - level=30) - val = None - elif isinstance(val, basestring): - val = pwd.getpwnam(val)[2] - self._uid = val - uid = property(_get_uid, _set_uid, doc="The uid under which to run.") - - def _get_gid(self): - return self._gid - def _set_gid(self, val): - if val is not None: - if grp is None: - self.bus.log("grp module not available; ignoring gid.", - level=30) - val = None - elif isinstance(val, basestring): - val = grp.getgrnam(val)[2] - self._gid = val - gid = property(_get_gid, _set_gid, doc="The gid under which to run.") - - def _get_umask(self): - return self._umask - def _set_umask(self, val): - if val is not None: - try: - os.umask - except AttributeError: - self.bus.log("umask function not available; ignoring umask.", - level=30) - val = None - self._umask = val - umask = property(_get_umask, _set_umask, doc="The umask under which to run.") - - def start(self): - # uid/gid - def current_ids(): - """Return the current (uid, gid) if available.""" - name, group = None, None - if pwd: - name = pwd.getpwuid(os.getuid())[0] - if grp: - group = grp.getgrgid(os.getgid())[0] - return name, group - - if self.finalized: - if not (self.uid is None and self.gid is None): - self.bus.log('Already running as uid: %r gid: %r' % - current_ids()) - else: - if self.uid is None and self.gid is None: - if pwd or grp: - self.bus.log('uid/gid not set', level=30) - else: - self.bus.log('Started as uid: %r gid: %r' % current_ids()) - if self.gid is not None: - os.setgid(self.gid) - if self.uid is not None: - os.setuid(self.uid) - self.bus.log('Running as uid: %r gid: %r' % current_ids()) - - # umask - if self.finalized: - if self.umask is not None: - self.bus.log('umask already set to: %03o' % self.umask) - else: - if self.umask is None: - self.bus.log('umask not set', level=30) - else: - old_umask = os.umask(self.umask) - self.bus.log('umask old: %03o, new: %03o' % - (old_umask, self.umask)) - - self.finalized = True - # This is slightly higher than the priority for server.start - # in order to facilitate the most common use: starting on a low - # port (which requires root) and then dropping to another user. - start.priority = 77 - - -class Daemonizer(SimplePlugin): - """Daemonize the running script. - - Use this with a Web Site Process Bus via: - - Daemonizer(bus).subscribe() - - When this component finishes, the process is completely decoupled from - the parent environment. Please note that when this component is used, - the return code from the parent process will still be 0 if a startup - error occurs in the forked children. Errors in the initial daemonizing - process still return proper exit codes. Therefore, if you use this - plugin to daemonize, don't use the return code as an accurate indicator - of whether the process fully started. In fact, that return code only - indicates if the process succesfully finished the first fork. - """ - - def __init__(self, bus, stdin='/dev/null', stdout='/dev/null', - stderr='/dev/null'): - SimplePlugin.__init__(self, bus) - self.stdin = stdin - self.stdout = stdout - self.stderr = stderr - self.finalized = False - - def start(self): - if self.finalized: - self.bus.log('Already deamonized.') - - # forking has issues with threads: - # http://www.opengroup.org/onlinepubs/000095399/functions/fork.html - # "The general problem with making fork() work in a multi-threaded - # world is what to do with all of the threads..." - # So we check for active threads: - if threading.activeCount() != 1: - self.bus.log('There are %r active threads. ' - 'Daemonizing now may cause strange failures.' % - threading.enumerate(), level=30) - - # See http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 - # (or http://www.faqs.org/faqs/unix-faq/programmer/faq/ section 1.7) - # and http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012 - - # Finish up with the current stdout/stderr - sys.stdout.flush() - sys.stderr.flush() - - # Do first fork. - try: - pid = os.fork() - if pid == 0: - # This is the child process. Continue. - pass - else: - # This is the first parent. Exit, now that we've forked. - self.bus.log('Forking once.') - os._exit(0) - except OSError, exc: - # Python raises OSError rather than returning negative numbers. - sys.exit("%s: fork #1 failed: (%d) %s\n" - % (sys.argv[0], exc.errno, exc.strerror)) - - os.setsid() - - # Do second fork - try: - pid = os.fork() - if pid > 0: - self.bus.log('Forking twice.') - os._exit(0) # Exit second parent - except OSError, exc: - sys.exit("%s: fork #2 failed: (%d) %s\n" - % (sys.argv[0], exc.errno, exc.strerror)) - - os.chdir("/") - os.umask(0) - - si = open(self.stdin, "r") - so = open(self.stdout, "a+") - se = open(self.stderr, "a+") - - # os.dup2(fd, fd2) will close fd2 if necessary, - # so we don't explicitly close stdin/out/err. - # See http://docs.python.org/lib/os-fd-ops.html - os.dup2(si.fileno(), sys.stdin.fileno()) - os.dup2(so.fileno(), sys.stdout.fileno()) - os.dup2(se.fileno(), sys.stderr.fileno()) - - self.bus.log('Daemonized to PID: %s' % os.getpid()) - self.finalized = True - start.priority = 65 - - -class PIDFile(SimplePlugin): - """Maintain a PID file via a WSPBus.""" - - def __init__(self, bus, pidfile): - SimplePlugin.__init__(self, bus) - self.pidfile = pidfile - self.finalized = False - - def start(self): - pid = os.getpid() - if self.finalized: - self.bus.log('PID %r already written to %r.' % (pid, self.pidfile)) - else: - open(self.pidfile, "wb").write(str(pid)) - self.bus.log('PID %r written to %r.' % (pid, self.pidfile)) - self.finalized = True - start.priority = 70 - - def exit(self): - try: - os.remove(self.pidfile) - self.bus.log('PID file removed: %r.' % self.pidfile) - except (KeyboardInterrupt, SystemExit): - raise - except: - pass - - -class PerpetualTimer(threading._Timer): - """A subclass of threading._Timer whose run() method repeats.""" - - def run(self): - while True: - self.finished.wait(self.interval) - if self.finished.isSet(): - return - try: - self.function(*self.args, **self.kwargs) - except Exception, x: - self.bus.log("Error in perpetual timer thread function %r." % - self.function, level=40, traceback=True) - # Quit on first error to avoid massive logs. - raise - - -class Monitor(SimplePlugin): - """WSPBus listener to periodically run a callback in its own thread. - - bus: a Web Site Process Bus object. - callback: the function to call at intervals. - frequency: the time in seconds between callback runs. - """ - - frequency = 60 - - def __init__(self, bus, callback, frequency=60, name=None): - SimplePlugin.__init__(self, bus) - self.callback = callback - self.frequency = frequency - self.thread = None - self.name = name - - def start(self): - """Start our callback in its own perpetual timer thread.""" - if self.frequency > 0: - threadname = self.name or self.__class__.__name__ - if self.thread is None: - self.thread = PerpetualTimer(self.frequency, self.callback) - self.thread.bus = self.bus - self.thread.setName(threadname) - self.thread.start() - self.bus.log("Started monitor thread %r." % threadname) - else: - self.bus.log("Monitor thread %r already started." % threadname) - start.priority = 70 - - def stop(self): - """Stop our callback's perpetual timer thread.""" - if self.thread is None: - self.bus.log("No thread running for %s." % self.name or self.__class__.__name__) - else: - if self.thread is not threading.currentThread(): - name = self.thread.getName() - self.thread.cancel() - self.thread.join() - self.bus.log("Stopped thread %r." % name) - self.thread = None - - def graceful(self): - """Stop the callback's perpetual timer thread and restart it.""" - self.stop() - self.start() - - -class Autoreloader(Monitor): - """Monitor which re-executes the process when files change.""" - - frequency = 1 - match = '.*' - - def __init__(self, bus, frequency=1, match='.*'): - self.mtimes = {} - self.files = set() - self.match = match - Monitor.__init__(self, bus, self.run, frequency) - - def start(self): - """Start our own perpetual timer thread for self.run.""" - if self.thread is None: - self.mtimes = {} - Monitor.start(self) - start.priority = 70 - - def sysfiles(self): - """Return a Set of filenames which the Autoreloader will monitor.""" - files = set() - for k, m in sys.modules.items(): - if re.match(self.match, k): - if hasattr(m, '__loader__') and hasattr(m.__loader__, 'archive'): - f = m.__loader__.archive - else: - f = getattr(m, '__file__', None) - if f is not None and not os.path.isabs(f): - # ensure absolute paths so a os.chdir() in the app doesn't break me - f = os.path.normpath(os.path.join(_module__file__base, f)) - files.add(f) - return files - - def run(self): - """Reload the process if registered files have been modified.""" - for filename in self.sysfiles() | self.files: - if filename: - if filename.endswith('.pyc'): - filename = filename[:-1] - - oldtime = self.mtimes.get(filename, 0) - if oldtime is None: - # Module with no .py file. Skip it. - continue - - try: - mtime = os.stat(filename).st_mtime - except OSError: - # Either a module with no .py file, or it's been deleted. - mtime = None - - if filename not in self.mtimes: - # If a module has no .py file, this will be None. - self.mtimes[filename] = mtime - else: - if mtime is None or mtime > oldtime: - # The file has been deleted or modified. - self.bus.log("Restarting because %s changed." % filename) - self.thread.cancel() - self.bus.log("Stopped thread %r." % self.thread.getName()) - self.bus.restart() - return - - -class ThreadManager(SimplePlugin): - """Manager for HTTP request threads. - - If you have control over thread creation and destruction, publish to - the 'acquire_thread' and 'release_thread' channels (for each thread). - This will register/unregister the current thread and publish to - 'start_thread' and 'stop_thread' listeners in the bus as needed. - - If threads are created and destroyed by code you do not control - (e.g., Apache), then, at the beginning of every HTTP request, - publish to 'acquire_thread' only. You should not publish to - 'release_thread' in this case, since you do not know whether - the thread will be re-used or not. The bus will call - 'stop_thread' listeners for you when it stops. - """ - - def __init__(self, bus): - self.threads = {} - SimplePlugin.__init__(self, bus) - self.bus.listeners.setdefault('acquire_thread', set()) - self.bus.listeners.setdefault('release_thread', set()) - - def acquire_thread(self): - """Run 'start_thread' listeners for the current thread. - - If the current thread has already been seen, any 'start_thread' - listeners will not be run again. - """ - thread_ident = thread.get_ident() - if thread_ident not in self.threads: - # We can't just use _get_ident as the thread ID - # because some platforms reuse thread ID's. - i = len(self.threads) + 1 - self.threads[thread_ident] = i - self.bus.publish('start_thread', i) - - def release_thread(self): - """Release the current thread and run 'stop_thread' listeners.""" - thread_ident = threading._get_ident() - i = self.threads.pop(thread_ident, None) - if i is not None: - self.bus.publish('stop_thread', i) - - def stop(self): - """Release all threads and run all 'stop_thread' listeners.""" - for thread_ident, i in self.threads.items(): - self.bus.publish('stop_thread', i) - self.threads.clear() - graceful = stop - +"""Site services for use with a Web Site Process Bus.""" + +import os +import re +try: + set +except NameError: + from sets import Set as set +import signal as _signal +import sys +import time +import thread +import threading + +# _module__file__base is used by Autoreload to make +# absolute any filenames retrieved from sys.modules which are not +# already absolute paths. This is to work around Python's quirk +# of importing the startup script and using a relative filename +# for it in sys.modules. +# +# Autoreload examines sys.modules afresh every time it runs. If an application +# changes the current directory by executing os.chdir(), then the next time +# Autoreload runs, it will not be able to find any filenames which are +# not absolute paths, because the current directory is not the same as when the +# module was first imported. Autoreload will then wrongly conclude the file has +# "changed", and initiate the shutdown/re-exec sequence. +# See ticket #917. +# For this workaround to have a decent probability of success, this module +# needs to be imported as early as possible, before the app has much chance +# to change the working directory. +_module__file__base = os.getcwd() + + +class SimplePlugin(object): + """Plugin base class which auto-subscribes methods for known channels.""" + + def __init__(self, bus): + self.bus = bus + + def subscribe(self): + """Register this object as a (multi-channel) listener on the bus.""" + for channel in self.bus.listeners: + # Subscribe self.start, self.exit, etc. if present. + method = getattr(self, channel, None) + if method is not None: + self.bus.subscribe(channel, method) + + def unsubscribe(self): + """Unregister this object as a listener on the bus.""" + for channel in self.bus.listeners: + # Unsubscribe self.start, self.exit, etc. if present. + method = getattr(self, channel, None) + if method is not None: + self.bus.unsubscribe(channel, method) + + + +class SignalHandler(object): + """Register bus channels (and listeners) for system signals. + + By default, instantiating this object subscribes the following signals + and listeners: + + TERM: bus.exit + HUP : bus.restart + USR1: bus.graceful + """ + + # Map from signal numbers to names + signals = {} + for k, v in vars(_signal).items(): + if k.startswith('SIG') and not k.startswith('SIG_'): + signals[v] = k + del k, v + + def __init__(self, bus): + self.bus = bus + # Set default handlers + self.handlers = {'SIGTERM': self.bus.exit, + 'SIGHUP': self.handle_SIGHUP, + 'SIGUSR1': self.bus.graceful, + } + + self._previous_handlers = {} + + def subscribe(self): + for sig, func in self.handlers.items(): + try: + self.set_handler(sig, func) + except ValueError: + pass + + def unsubscribe(self): + for signum, handler in self._previous_handlers.items(): + signame = self.signals[signum] + + if handler is None: + self.bus.log("Restoring %s handler to SIG_DFL." % signame) + handler = _signal.SIG_DFL + else: + self.bus.log("Restoring %s handler %r." % (signame, handler)) + + try: + our_handler = _signal.signal(signum, handler) + if our_handler is None: + self.bus.log("Restored old %s handler %r, but our " + "handler was not registered." % + (signame, handler), level=30) + except ValueError: + self.bus.log("Unable to restore %s handler %r." % + (signame, handler), level=40, traceback=True) + + def set_handler(self, signal, listener=None): + """Subscribe a handler for the given signal (number or name). + + If the optional 'listener' argument is provided, it will be + subscribed as a listener for the given signal's channel. + + If the given signal name or number is not available on the current + platform, ValueError is raised. + """ + if isinstance(signal, basestring): + signum = getattr(_signal, signal, None) + if signum is None: + raise ValueError("No such signal: %r" % signal) + signame = signal + else: + try: + signame = self.signals[signal] + except KeyError: + raise ValueError("No such signal: %r" % signal) + signum = signal + + prev = _signal.signal(signum, self._handle_signal) + self._previous_handlers[signum] = prev + + if listener is not None: + self.bus.log("Listening for %s." % signame) + self.bus.subscribe(signame, listener) + + def _handle_signal(self, signum=None, frame=None): + """Python signal handler (self.set_handler subscribes it for you).""" + signame = self.signals[signum] + self.bus.log("Caught signal %s." % signame) + self.bus.publish(signame) + + def handle_SIGHUP(self): + if os.isatty(sys.stdin.fileno()): + # not daemonized (may be foreground or background) + self.bus.log("SIGHUP caught but not daemonized. Exiting.") + self.bus.exit() + else: + self.bus.log("SIGHUP caught while daemonized. Restarting.") + self.bus.restart() + + +try: + import pwd, grp +except ImportError: + pwd, grp = None, None + + +class DropPrivileges(SimplePlugin): + """Drop privileges. uid/gid arguments not available on Windows. + + Special thanks to Gavin Baker: http://antonym.org/node/100. + """ + + def __init__(self, bus, umask=None, uid=None, gid=None): + SimplePlugin.__init__(self, bus) + self.finalized = False + self.uid = uid + self.gid = gid + self.umask = umask + + def _get_uid(self): + return self._uid + def _set_uid(self, val): + if val is not None: + if pwd is None: + self.bus.log("pwd module not available; ignoring uid.", + level=30) + val = None + elif isinstance(val, basestring): + val = pwd.getpwnam(val)[2] + self._uid = val + uid = property(_get_uid, _set_uid, doc="The uid under which to run.") + + def _get_gid(self): + return self._gid + def _set_gid(self, val): + if val is not None: + if grp is None: + self.bus.log("grp module not available; ignoring gid.", + level=30) + val = None + elif isinstance(val, basestring): + val = grp.getgrnam(val)[2] + self._gid = val + gid = property(_get_gid, _set_gid, doc="The gid under which to run.") + + def _get_umask(self): + return self._umask + def _set_umask(self, val): + if val is not None: + try: + os.umask + except AttributeError: + self.bus.log("umask function not available; ignoring umask.", + level=30) + val = None + self._umask = val + umask = property(_get_umask, _set_umask, doc="The umask under which to run.") + + def start(self): + # uid/gid + def current_ids(): + """Return the current (uid, gid) if available.""" + name, group = None, None + if pwd: + name = pwd.getpwuid(os.getuid())[0] + if grp: + group = grp.getgrgid(os.getgid())[0] + return name, group + + if self.finalized: + if not (self.uid is None and self.gid is None): + self.bus.log('Already running as uid: %r gid: %r' % + current_ids()) + else: + if self.uid is None and self.gid is None: + if pwd or grp: + self.bus.log('uid/gid not set', level=30) + else: + self.bus.log('Started as uid: %r gid: %r' % current_ids()) + if self.gid is not None: + os.setgid(self.gid) + if self.uid is not None: + os.setuid(self.uid) + self.bus.log('Running as uid: %r gid: %r' % current_ids()) + + # umask + if self.finalized: + if self.umask is not None: + self.bus.log('umask already set to: %03o' % self.umask) + else: + if self.umask is None: + self.bus.log('umask not set', level=30) + else: + old_umask = os.umask(self.umask) + self.bus.log('umask old: %03o, new: %03o' % + (old_umask, self.umask)) + + self.finalized = True + # This is slightly higher than the priority for server.start + # in order to facilitate the most common use: starting on a low + # port (which requires root) and then dropping to another user. + start.priority = 77 + + +class Daemonizer(SimplePlugin): + """Daemonize the running script. + + Use this with a Web Site Process Bus via: + + Daemonizer(bus).subscribe() + + When this component finishes, the process is completely decoupled from + the parent environment. Please note that when this component is used, + the return code from the parent process will still be 0 if a startup + error occurs in the forked children. Errors in the initial daemonizing + process still return proper exit codes. Therefore, if you use this + plugin to daemonize, don't use the return code as an accurate indicator + of whether the process fully started. In fact, that return code only + indicates if the process succesfully finished the first fork. + """ + + def __init__(self, bus, stdin='/dev/null', stdout='/dev/null', + stderr='/dev/null'): + SimplePlugin.__init__(self, bus) + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + self.finalized = False + + def start(self): + if self.finalized: + self.bus.log('Already deamonized.') + + # forking has issues with threads: + # http://www.opengroup.org/onlinepubs/000095399/functions/fork.html + # "The general problem with making fork() work in a multi-threaded + # world is what to do with all of the threads..." + # So we check for active threads: + if threading.activeCount() != 1: + self.bus.log('There are %r active threads. ' + 'Daemonizing now may cause strange failures.' % + threading.enumerate(), level=30) + + # See http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16 + # (or http://www.faqs.org/faqs/unix-faq/programmer/faq/ section 1.7) + # and http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/66012 + + # Finish up with the current stdout/stderr + sys.stdout.flush() + sys.stderr.flush() + + # Do first fork. + try: + pid = os.fork() + if pid == 0: + # This is the child process. Continue. + pass + else: + # This is the first parent. Exit, now that we've forked. + self.bus.log('Forking once.') + os._exit(0) + except OSError, exc: + # Python raises OSError rather than returning negative numbers. + sys.exit("%s: fork #1 failed: (%d) %s\n" + % (sys.argv[0], exc.errno, exc.strerror)) + + os.setsid() + + # Do second fork + try: + pid = os.fork() + if pid > 0: + self.bus.log('Forking twice.') + os._exit(0) # Exit second parent + except OSError, exc: + sys.exit("%s: fork #2 failed: (%d) %s\n" + % (sys.argv[0], exc.errno, exc.strerror)) + + os.chdir("/") + os.umask(0) + + si = open(self.stdin, "r") + so = open(self.stdout, "a+") + se = open(self.stderr, "a+") + + # os.dup2(fd, fd2) will close fd2 if necessary, + # so we don't explicitly close stdin/out/err. + # See http://docs.python.org/lib/os-fd-ops.html + os.dup2(si.fileno(), sys.stdin.fileno()) + os.dup2(so.fileno(), sys.stdout.fileno()) + os.dup2(se.fileno(), sys.stderr.fileno()) + + self.bus.log('Daemonized to PID: %s' % os.getpid()) + self.finalized = True + start.priority = 65 + + +class PIDFile(SimplePlugin): + """Maintain a PID file via a WSPBus.""" + + def __init__(self, bus, pidfile): + SimplePlugin.__init__(self, bus) + self.pidfile = pidfile + self.finalized = False + + def start(self): + pid = os.getpid() + if self.finalized: + self.bus.log('PID %r already written to %r.' % (pid, self.pidfile)) + else: + open(self.pidfile, "wb").write(str(pid)) + self.bus.log('PID %r written to %r.' % (pid, self.pidfile)) + self.finalized = True + start.priority = 70 + + def exit(self): + try: + os.remove(self.pidfile) + self.bus.log('PID file removed: %r.' % self.pidfile) + except (KeyboardInterrupt, SystemExit): + raise + except: + pass + + +class PerpetualTimer(threading._Timer): + """A subclass of threading._Timer whose run() method repeats.""" + + def run(self): + while True: + self.finished.wait(self.interval) + if self.finished.isSet(): + return + try: + self.function(*self.args, **self.kwargs) + except Exception, x: + self.bus.log("Error in perpetual timer thread function %r." % + self.function, level=40, traceback=True) + # Quit on first error to avoid massive logs. + raise + + +class Monitor(SimplePlugin): + """WSPBus listener to periodically run a callback in its own thread. + + bus: a Web Site Process Bus object. + callback: the function to call at intervals. + frequency: the time in seconds between callback runs. + """ + + frequency = 60 + + def __init__(self, bus, callback, frequency=60, name=None): + SimplePlugin.__init__(self, bus) + self.callback = callback + self.frequency = frequency + self.thread = None + self.name = name + + def start(self): + """Start our callback in its own perpetual timer thread.""" + if self.frequency > 0: + threadname = self.name or self.__class__.__name__ + if self.thread is None: + self.thread = PerpetualTimer(self.frequency, self.callback) + self.thread.bus = self.bus + self.thread.setName(threadname) + self.thread.start() + self.bus.log("Started monitor thread %r." % threadname) + else: + self.bus.log("Monitor thread %r already started." % threadname) + start.priority = 70 + + def stop(self): + """Stop our callback's perpetual timer thread.""" + if self.thread is None: + self.bus.log("No thread running for %s." % self.name or self.__class__.__name__) + else: + if self.thread is not threading.currentThread(): + name = self.thread.getName() + self.thread.cancel() + self.thread.join() + self.bus.log("Stopped thread %r." % name) + self.thread = None + + def graceful(self): + """Stop the callback's perpetual timer thread and restart it.""" + self.stop() + self.start() + + +class Autoreloader(Monitor): + """Monitor which re-executes the process when files change.""" + + frequency = 1 + match = '.*' + + def __init__(self, bus, frequency=1, match='.*'): + self.mtimes = {} + self.files = set() + self.match = match + Monitor.__init__(self, bus, self.run, frequency) + + def start(self): + """Start our own perpetual timer thread for self.run.""" + if self.thread is None: + self.mtimes = {} + Monitor.start(self) + start.priority = 70 + + def sysfiles(self): + """Return a Set of filenames which the Autoreloader will monitor.""" + files = set() + for k, m in sys.modules.items(): + if re.match(self.match, k): + if hasattr(m, '__loader__') and hasattr(m.__loader__, 'archive'): + f = m.__loader__.archive + else: + f = getattr(m, '__file__', None) + if f is not None and not os.path.isabs(f): + # ensure absolute paths so a os.chdir() in the app doesn't break me + f = os.path.normpath(os.path.join(_module__file__base, f)) + files.add(f) + return files + + def run(self): + """Reload the process if registered files have been modified.""" + for filename in self.sysfiles() | self.files: + if filename: + if filename.endswith('.pyc'): + filename = filename[:-1] + + oldtime = self.mtimes.get(filename, 0) + if oldtime is None: + # Module with no .py file. Skip it. + continue + + try: + mtime = os.stat(filename).st_mtime + except OSError: + # Either a module with no .py file, or it's been deleted. + mtime = None + + if filename not in self.mtimes: + # If a module has no .py file, this will be None. + self.mtimes[filename] = mtime + else: + if mtime is None or mtime > oldtime: + # The file has been deleted or modified. + self.bus.log("Restarting because %s changed." % filename) + self.thread.cancel() + self.bus.log("Stopped thread %r." % self.thread.getName()) + self.bus.restart() + return + + +class ThreadManager(SimplePlugin): + """Manager for HTTP request threads. + + If you have control over thread creation and destruction, publish to + the 'acquire_thread' and 'release_thread' channels (for each thread). + This will register/unregister the current thread and publish to + 'start_thread' and 'stop_thread' listeners in the bus as needed. + + If threads are created and destroyed by code you do not control + (e.g., Apache), then, at the beginning of every HTTP request, + publish to 'acquire_thread' only. You should not publish to + 'release_thread' in this case, since you do not know whether + the thread will be re-used or not. The bus will call + 'stop_thread' listeners for you when it stops. + """ + + def __init__(self, bus): + self.threads = {} + SimplePlugin.__init__(self, bus) + self.bus.listeners.setdefault('acquire_thread', set()) + self.bus.listeners.setdefault('release_thread', set()) + + def acquire_thread(self): + """Run 'start_thread' listeners for the current thread. + + If the current thread has already been seen, any 'start_thread' + listeners will not be run again. + """ + thread_ident = thread.get_ident() + if thread_ident not in self.threads: + # We can't just use _get_ident as the thread ID + # because some platforms reuse thread ID's. + i = len(self.threads) + 1 + self.threads[thread_ident] = i + self.bus.publish('start_thread', i) + + def release_thread(self): + """Release the current thread and run 'stop_thread' listeners.""" + thread_ident = threading._get_ident() + i = self.threads.pop(thread_ident, None) + if i is not None: + self.bus.publish('stop_thread', i) + + def stop(self): + """Release all threads and run all 'stop_thread' listeners.""" + for thread_ident, i in self.threads.items(): + self.bus.publish('stop_thread', i) + self.threads.clear() + graceful = stop + diff --git a/cherrypy/process/servers.py b/cherrypy/process/servers.py index efe874bec8..93e523e514 100644 --- a/cherrypy/process/servers.py +++ b/cherrypy/process/servers.py @@ -1,283 +1,283 @@ -"""Adapt an HTTP server.""" - -import time - - -class ServerAdapter(object): - """Adapter for an HTTP server. - - If you need to start more than one HTTP server (to serve on multiple - ports, or protocols, etc.), you can manually register each one and then - start them all with bus.start: - - s1 = ServerAdapter(bus, MyWSGIServer(host='0.0.0.0', port=80)) - s2 = ServerAdapter(bus, another.HTTPServer(host='127.0.0.1', SSL=True)) - s1.subscribe() - s2.subscribe() - bus.start() - """ - - def __init__(self, bus, httpserver=None, bind_addr=None): - self.bus = bus - self.httpserver = httpserver - self.bind_addr = bind_addr - self.interrupt = None - self.running = False - - def subscribe(self): - self.bus.subscribe('start', self.start) - self.bus.subscribe('stop', self.stop) - - def unsubscribe(self): - self.bus.unsubscribe('start', self.start) - self.bus.unsubscribe('stop', self.stop) - - def start(self): - """Start the HTTP server.""" - if self.bind_addr is None: - on_what = "unknown interface (dynamic?)" - elif isinstance(self.bind_addr, tuple): - host, port = self.bind_addr - on_what = "%s:%s" % (host, port) - else: - on_what = "socket file: %s" % self.bind_addr - - if self.running: - self.bus.log("Already serving on %s" % on_what) - return - - self.interrupt = None - if not self.httpserver: - raise ValueError("No HTTP server has been created.") - - # Start the httpserver in a new thread. - if isinstance(self.bind_addr, tuple): - wait_for_free_port(*self.bind_addr) - - import threading - t = threading.Thread(target=self._start_http_thread) - t.setName("HTTPServer " + t.getName()) - t.start() - - self.wait() - self.running = True - self.bus.log("Serving on %s" % on_what) - start.priority = 75 - - def _start_http_thread(self): - """HTTP servers MUST be running in new threads, so that the - main thread persists to receive KeyboardInterrupt's. If an - exception is raised in the httpserver's thread then it's - trapped here, and the bus (and therefore our httpserver) - are shut down. - """ - try: - self.httpserver.start() - except KeyboardInterrupt, exc: - self.bus.log(" hit: shutting down HTTP server") - self.interrupt = exc - self.bus.exit() - except SystemExit, exc: - self.bus.log("SystemExit raised: shutting down HTTP server") - self.interrupt = exc - self.bus.exit() - raise - except: - import sys - self.interrupt = sys.exc_info()[1] - self.bus.log("Error in HTTP server: shutting down", - traceback=True, level=40) - self.bus.exit() - raise - - def wait(self): - """Wait until the HTTP server is ready to receive requests.""" - while not getattr(self.httpserver, "ready", False): - if self.interrupt: - raise self.interrupt - time.sleep(.1) - - # Wait for port to be occupied - if isinstance(self.bind_addr, tuple): - host, port = self.bind_addr - wait_for_occupied_port(host, port) - - def stop(self): - """Stop the HTTP server.""" - if self.running: - # stop() MUST block until the server is *truly* stopped. - self.httpserver.stop() - # Wait for the socket to be truly freed. - if isinstance(self.bind_addr, tuple): - wait_for_free_port(*self.bind_addr) - self.running = False - self.bus.log("HTTP Server %s shut down" % self.httpserver) - else: - self.bus.log("HTTP Server %s already shut down" % self.httpserver) - stop.priority = 25 - - def restart(self): - """Restart the HTTP server.""" - self.stop() - self.start() - - -class FlupFCGIServer(object): - """Adapter for a flup.server.fcgi.WSGIServer.""" - - def __init__(self, *args, **kwargs): - if kwargs.get('bindAddress', None) is None: - import socket - if not hasattr(socket.socket, 'fromfd'): - raise ValueError( - 'Dynamic FCGI server not available on this platform. ' - 'You must use a static or external one by providing a ' - 'legal bindAddress.') - self.args = args - self.kwargs = kwargs - self.ready = False - - def start(self): - """Start the FCGI server.""" - # We have to instantiate the server class here because its __init__ - # starts a threadpool. If we do it too early, daemonize won't work. - from flup.server.fcgi import WSGIServer - self.fcgiserver = WSGIServer(*self.args, **self.kwargs) - # TODO: report this bug upstream to flup. - # If we don't set _oldSIGs on Windows, we get: - # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", - # line 108, in run - # self._restoreSignalHandlers() - # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", - # line 156, in _restoreSignalHandlers - # for signum,handler in self._oldSIGs: - # AttributeError: 'WSGIServer' object has no attribute '_oldSIGs' - self.fcgiserver._installSignalHandlers = lambda: None - self.fcgiserver._oldSIGs = [] - self.ready = True - self.fcgiserver.run() - - def stop(self): - """Stop the HTTP server.""" - # Forcibly stop the fcgi server main event loop. - self.fcgiserver._keepGoing = False - # Force all worker threads to die off. - self.fcgiserver._threadPool.maxSpare = self.fcgiserver._threadPool._idleCount - self.ready = False - - -class FlupSCGIServer(object): - """Adapter for a flup.server.scgi.WSGIServer.""" - - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - self.ready = False - - def start(self): - """Start the SCGI server.""" - # We have to instantiate the server class here because its __init__ - # starts a threadpool. If we do it too early, daemonize won't work. - from flup.server.scgi import WSGIServer - self.scgiserver = WSGIServer(*self.args, **self.kwargs) - # TODO: report this bug upstream to flup. - # If we don't set _oldSIGs on Windows, we get: - # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", - # line 108, in run - # self._restoreSignalHandlers() - # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", - # line 156, in _restoreSignalHandlers - # for signum,handler in self._oldSIGs: - # AttributeError: 'WSGIServer' object has no attribute '_oldSIGs' - self.scgiserver._installSignalHandlers = lambda: None - self.scgiserver._oldSIGs = [] - self.ready = True - self.scgiserver.run() - - def stop(self): - """Stop the HTTP server.""" - self.ready = False - # Forcibly stop the scgi server main event loop. - self.scgiserver._keepGoing = False - # Force all worker threads to die off. - self.scgiserver._threadPool.maxSpare = 0 - - -def client_host(server_host): - """Return the host on which a client can connect to the given listener.""" - if server_host == '0.0.0.0': - # 0.0.0.0 is INADDR_ANY, which should answer on localhost. - return '127.0.0.1' - if server_host == '::': - # :: is IN6ADDR_ANY, which should answer on localhost. - return '::1' - return server_host - -def check_port(host, port, timeout=1.0): - """Raise an error if the given port is not free on the given host.""" - if not host: - raise ValueError("Host values of '' or None are not allowed.") - host = client_host(host) - port = int(port) - - import socket - - # AF_INET or AF_INET6 socket - # Get the correct address family for our host (allows IPv6 addresses) - try: - info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, - socket.SOCK_STREAM) - except socket.gaierror: - if ':' in host: - info = [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", (host, port, 0, 0))] - else: - info = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (host, port))] - - for res in info: - af, socktype, proto, canonname, sa = res - s = None - try: - s = socket.socket(af, socktype, proto) - # See http://groups.google.com/group/cherrypy-users/ - # browse_frm/thread/bbfe5eb39c904fe0 - s.settimeout(timeout) - s.connect((host, port)) - s.close() - raise IOError("Port %s is in use on %s; perhaps the previous " - "httpserver did not shut down properly." % - (repr(port), repr(host))) - except socket.error: - if s: - s.close() - -def wait_for_free_port(host, port): - """Wait for the specified port to become free (drop requests).""" - if not host: - raise ValueError("Host values of '' or None are not allowed.") - - for trial in range(50): - try: - # we are expecting a free port, so reduce the timeout - check_port(host, port, timeout=0.1) - except IOError: - # Give the old server thread time to free the port. - time.sleep(0.1) - else: - return - - raise IOError("Port %r not free on %r" % (port, host)) - -def wait_for_occupied_port(host, port): - """Wait for the specified port to become active (receive requests).""" - if not host: - raise ValueError("Host values of '' or None are not allowed.") - - for trial in range(50): - try: - check_port(host, port) - except IOError: - return - else: - time.sleep(.1) - - raise IOError("Port %r not bound on %r" % (port, host)) +"""Adapt an HTTP server.""" + +import time + + +class ServerAdapter(object): + """Adapter for an HTTP server. + + If you need to start more than one HTTP server (to serve on multiple + ports, or protocols, etc.), you can manually register each one and then + start them all with bus.start: + + s1 = ServerAdapter(bus, MyWSGIServer(host='0.0.0.0', port=80)) + s2 = ServerAdapter(bus, another.HTTPServer(host='127.0.0.1', SSL=True)) + s1.subscribe() + s2.subscribe() + bus.start() + """ + + def __init__(self, bus, httpserver=None, bind_addr=None): + self.bus = bus + self.httpserver = httpserver + self.bind_addr = bind_addr + self.interrupt = None + self.running = False + + def subscribe(self): + self.bus.subscribe('start', self.start) + self.bus.subscribe('stop', self.stop) + + def unsubscribe(self): + self.bus.unsubscribe('start', self.start) + self.bus.unsubscribe('stop', self.stop) + + def start(self): + """Start the HTTP server.""" + if self.bind_addr is None: + on_what = "unknown interface (dynamic?)" + elif isinstance(self.bind_addr, tuple): + host, port = self.bind_addr + on_what = "%s:%s" % (host, port) + else: + on_what = "socket file: %s" % self.bind_addr + + if self.running: + self.bus.log("Already serving on %s" % on_what) + return + + self.interrupt = None + if not self.httpserver: + raise ValueError("No HTTP server has been created.") + + # Start the httpserver in a new thread. + if isinstance(self.bind_addr, tuple): + wait_for_free_port(*self.bind_addr) + + import threading + t = threading.Thread(target=self._start_http_thread) + t.setName("HTTPServer " + t.getName()) + t.start() + + self.wait() + self.running = True + self.bus.log("Serving on %s" % on_what) + start.priority = 75 + + def _start_http_thread(self): + """HTTP servers MUST be running in new threads, so that the + main thread persists to receive KeyboardInterrupt's. If an + exception is raised in the httpserver's thread then it's + trapped here, and the bus (and therefore our httpserver) + are shut down. + """ + try: + self.httpserver.start() + except KeyboardInterrupt, exc: + self.bus.log(" hit: shutting down HTTP server") + self.interrupt = exc + self.bus.exit() + except SystemExit, exc: + self.bus.log("SystemExit raised: shutting down HTTP server") + self.interrupt = exc + self.bus.exit() + raise + except: + import sys + self.interrupt = sys.exc_info()[1] + self.bus.log("Error in HTTP server: shutting down", + traceback=True, level=40) + self.bus.exit() + raise + + def wait(self): + """Wait until the HTTP server is ready to receive requests.""" + while not getattr(self.httpserver, "ready", False): + if self.interrupt: + raise self.interrupt + time.sleep(.1) + + # Wait for port to be occupied + if isinstance(self.bind_addr, tuple): + host, port = self.bind_addr + wait_for_occupied_port(host, port) + + def stop(self): + """Stop the HTTP server.""" + if self.running: + # stop() MUST block until the server is *truly* stopped. + self.httpserver.stop() + # Wait for the socket to be truly freed. + if isinstance(self.bind_addr, tuple): + wait_for_free_port(*self.bind_addr) + self.running = False + self.bus.log("HTTP Server %s shut down" % self.httpserver) + else: + self.bus.log("HTTP Server %s already shut down" % self.httpserver) + stop.priority = 25 + + def restart(self): + """Restart the HTTP server.""" + self.stop() + self.start() + + +class FlupFCGIServer(object): + """Adapter for a flup.server.fcgi.WSGIServer.""" + + def __init__(self, *args, **kwargs): + if kwargs.get('bindAddress', None) is None: + import socket + if not hasattr(socket.socket, 'fromfd'): + raise ValueError( + 'Dynamic FCGI server not available on this platform. ' + 'You must use a static or external one by providing a ' + 'legal bindAddress.') + self.args = args + self.kwargs = kwargs + self.ready = False + + def start(self): + """Start the FCGI server.""" + # We have to instantiate the server class here because its __init__ + # starts a threadpool. If we do it too early, daemonize won't work. + from flup.server.fcgi import WSGIServer + self.fcgiserver = WSGIServer(*self.args, **self.kwargs) + # TODO: report this bug upstream to flup. + # If we don't set _oldSIGs on Windows, we get: + # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", + # line 108, in run + # self._restoreSignalHandlers() + # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", + # line 156, in _restoreSignalHandlers + # for signum,handler in self._oldSIGs: + # AttributeError: 'WSGIServer' object has no attribute '_oldSIGs' + self.fcgiserver._installSignalHandlers = lambda: None + self.fcgiserver._oldSIGs = [] + self.ready = True + self.fcgiserver.run() + + def stop(self): + """Stop the HTTP server.""" + # Forcibly stop the fcgi server main event loop. + self.fcgiserver._keepGoing = False + # Force all worker threads to die off. + self.fcgiserver._threadPool.maxSpare = self.fcgiserver._threadPool._idleCount + self.ready = False + + +class FlupSCGIServer(object): + """Adapter for a flup.server.scgi.WSGIServer.""" + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.ready = False + + def start(self): + """Start the SCGI server.""" + # We have to instantiate the server class here because its __init__ + # starts a threadpool. If we do it too early, daemonize won't work. + from flup.server.scgi import WSGIServer + self.scgiserver = WSGIServer(*self.args, **self.kwargs) + # TODO: report this bug upstream to flup. + # If we don't set _oldSIGs on Windows, we get: + # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", + # line 108, in run + # self._restoreSignalHandlers() + # File "C:\Python24\Lib\site-packages\flup\server\threadedserver.py", + # line 156, in _restoreSignalHandlers + # for signum,handler in self._oldSIGs: + # AttributeError: 'WSGIServer' object has no attribute '_oldSIGs' + self.scgiserver._installSignalHandlers = lambda: None + self.scgiserver._oldSIGs = [] + self.ready = True + self.scgiserver.run() + + def stop(self): + """Stop the HTTP server.""" + self.ready = False + # Forcibly stop the scgi server main event loop. + self.scgiserver._keepGoing = False + # Force all worker threads to die off. + self.scgiserver._threadPool.maxSpare = 0 + + +def client_host(server_host): + """Return the host on which a client can connect to the given listener.""" + if server_host == '0.0.0.0': + # 0.0.0.0 is INADDR_ANY, which should answer on localhost. + return '127.0.0.1' + if server_host == '::': + # :: is IN6ADDR_ANY, which should answer on localhost. + return '::1' + return server_host + +def check_port(host, port, timeout=1.0): + """Raise an error if the given port is not free on the given host.""" + if not host: + raise ValueError("Host values of '' or None are not allowed.") + host = client_host(host) + port = int(port) + + import socket + + # AF_INET or AF_INET6 socket + # Get the correct address family for our host (allows IPv6 addresses) + try: + info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM) + except socket.gaierror: + if ':' in host: + info = [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", (host, port, 0, 0))] + else: + info = [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (host, port))] + + for res in info: + af, socktype, proto, canonname, sa = res + s = None + try: + s = socket.socket(af, socktype, proto) + # See http://groups.google.com/group/cherrypy-users/ + # browse_frm/thread/bbfe5eb39c904fe0 + s.settimeout(timeout) + s.connect((host, port)) + s.close() + raise IOError("Port %s is in use on %s; perhaps the previous " + "httpserver did not shut down properly." % + (repr(port), repr(host))) + except socket.error: + if s: + s.close() + +def wait_for_free_port(host, port): + """Wait for the specified port to become free (drop requests).""" + if not host: + raise ValueError("Host values of '' or None are not allowed.") + + for trial in range(50): + try: + # we are expecting a free port, so reduce the timeout + check_port(host, port, timeout=0.1) + except IOError: + # Give the old server thread time to free the port. + time.sleep(0.1) + else: + return + + raise IOError("Port %r not free on %r" % (port, host)) + +def wait_for_occupied_port(host, port): + """Wait for the specified port to become active (receive requests).""" + if not host: + raise ValueError("Host values of '' or None are not allowed.") + + for trial in range(50): + try: + check_port(host, port) + except IOError: + return + else: + time.sleep(.1) + + raise IOError("Port %r not bound on %r" % (port, host)) diff --git a/cherrypy/process/wspbus.py b/cherrypy/process/wspbus.py index cfb4e88b74..db38a99d97 100644 --- a/cherrypy/process/wspbus.py +++ b/cherrypy/process/wspbus.py @@ -1,384 +1,384 @@ -"""An implementation of the Web Site Process Bus. - -This module is completely standalone, depending only on the stdlib. - -Web Site Process Bus --------------------- - -A Bus object is used to contain and manage site-wide behavior: -daemonization, HTTP server start/stop, process reload, signal handling, -drop privileges, PID file management, logging for all of these, -and many more. - -In addition, a Bus object provides a place for each web framework -to register code that runs in response to site-wide events (like -process start and stop), or which controls or otherwise interacts with -the site-wide components mentioned above. For example, a framework which -uses file-based templates would add known template filenames to an -autoreload component. - -Ideally, a Bus object will be flexible enough to be useful in a variety -of invocation scenarios: - - 1. The deployer starts a site from the command line via a framework- - neutral deployment script; applications from multiple frameworks - are mixed in a single site. Command-line arguments and configuration - files are used to define site-wide components such as the HTTP server, - WSGI component graph, autoreload behavior, signal handling, etc. - 2. The deployer starts a site via some other process, such as Apache; - applications from multiple frameworks are mixed in a single site. - Autoreload and signal handling (from Python at least) are disabled. - 3. The deployer starts a site via a framework-specific mechanism; - for example, when running tests, exploring tutorials, or deploying - single applications from a single framework. The framework controls - which site-wide components are enabled as it sees fit. - -The Bus object in this package uses topic-based publish-subscribe -messaging to accomplish all this. A few topic channels are built in -('start', 'stop', 'exit', 'graceful', 'log', and 'main'). Frameworks and -site containers are free to define their own. If a message is sent to a -channel that has not been defined or has no listeners, there is no effect. - -In general, there should only ever be a single Bus object per process. -Frameworks and site containers share a single Bus object by publishing -messages and subscribing listeners. - -The Bus object works as a finite state machine which models the current -state of the process. Bus methods move it from one state to another; -those methods then publish to subscribed listeners on the channel for -the new state. - - O - | - V - STOPPING --> STOPPED --> EXITING -> X - A A | - | \___ | - | \ | - | V V - STARTED <-- STARTING - -""" - -import atexit -import os -try: - set -except NameError: - from sets import Set as set -import sys -import threading -import time -import traceback as _traceback -import warnings - -# Here I save the value of os.getcwd(), which, if I am imported early enough, -# will be the directory from which the startup script was run. This is needed -# by _do_execv(), to change back to the original directory before execv()ing a -# new process. This is a defense against the application having changed the -# current working directory (which could make sys.executable "not found" if -# sys.executable is a relative-path, and/or cause other problems). -_startup_cwd = os.getcwd() - -class ChannelFailures(Exception): - delimiter = '\n' - - def __init__(self, *args, **kwargs): - # Don't use 'super' here; Exceptions are old-style in Py2.4 - # See http://www.cherrypy.org/ticket/959 - Exception.__init__(self, *args, **kwargs) - self._exceptions = list() - - def handle_exception(self): - self._exceptions.append(sys.exc_info()) - - def get_instances(self): - return [instance for cls, instance, traceback in self._exceptions] - - def __str__(self): - exception_strings = map(repr, self.get_instances()) - return self.delimiter.join(exception_strings) - - def __nonzero__(self): - return bool(self._exceptions) - -# Use a flag to indicate the state of the bus. -class _StateEnum(object): - class State(object): - name = None - def __repr__(self): - return "states.%s" % self.name - - def __setattr__(self, key, value): - if isinstance(value, self.State): - value.name = key - object.__setattr__(self, key, value) -states = _StateEnum() -states.STOPPED = states.State() -states.STARTING = states.State() -states.STARTED = states.State() -states.STOPPING = states.State() -states.EXITING = states.State() - - -class Bus(object): - """Process state-machine and messenger for HTTP site deployment. - - All listeners for a given channel are guaranteed to be called even - if others at the same channel fail. Each failure is logged, but - execution proceeds on to the next listener. The only way to stop all - processing from inside a listener is to raise SystemExit and stop the - whole server. - """ - - states = states - state = states.STOPPED - execv = False - - def __init__(self): - self.execv = False - self.state = states.STOPPED - self.listeners = dict( - [(channel, set()) for channel - in ('start', 'stop', 'exit', 'graceful', 'log', 'main')]) - self._priorities = {} - - def subscribe(self, channel, callback, priority=None): - """Add the given callback at the given channel (if not present).""" - if channel not in self.listeners: - self.listeners[channel] = set() - self.listeners[channel].add(callback) - - if priority is None: - priority = getattr(callback, 'priority', 50) - self._priorities[(channel, callback)] = priority - - def unsubscribe(self, channel, callback): - """Discard the given callback (if present).""" - listeners = self.listeners.get(channel) - if listeners and callback in listeners: - listeners.discard(callback) - del self._priorities[(channel, callback)] - - def publish(self, channel, *args, **kwargs): - """Return output of all subscribers for the given channel.""" - if channel not in self.listeners: - return [] - - exc = ChannelFailures() - output = [] - - items = [(self._priorities[(channel, listener)], listener) - for listener in self.listeners[channel]] - items.sort() - for priority, listener in items: - try: - output.append(listener(*args, **kwargs)) - except KeyboardInterrupt: - raise - except SystemExit, e: - # If we have previous errors ensure the exit code is non-zero - if exc and e.code == 0: - e.code = 1 - raise - except: - exc.handle_exception() - if channel == 'log': - # Assume any further messages to 'log' will fail. - pass - else: - self.log("Error in %r listener %r" % (channel, listener), - level=40, traceback=True) - if exc: - raise exc - return output - - def _clean_exit(self): - """An atexit handler which asserts the Bus is not running.""" - if self.state != states.EXITING: - warnings.warn( - "The main thread is exiting, but the Bus is in the %r state; " - "shutting it down automatically now. You must either call " - "bus.block() after start(), or call bus.exit() before the " - "main thread exits." % self.state, RuntimeWarning) - self.exit() - - def start(self): - """Start all services.""" - atexit.register(self._clean_exit) - - self.state = states.STARTING - self.log('Bus STARTING') - try: - self.publish('start') - self.state = states.STARTED - self.log('Bus STARTED') - except (KeyboardInterrupt, SystemExit): - raise - except: - self.log("Shutting down due to error in start listener:", - level=40, traceback=True) - e_info = sys.exc_info() - try: - self.exit() - except: - # Any stop/exit errors will be logged inside publish(). - pass - raise e_info[0], e_info[1], e_info[2] - - def exit(self): - """Stop all services and prepare to exit the process.""" - exitstate = self.state - try: - self.stop() - - self.state = states.EXITING - self.log('Bus EXITING') - self.publish('exit') - # This isn't strictly necessary, but it's better than seeing - # "Waiting for child threads to terminate..." and then nothing. - self.log('Bus EXITED') - except: - # This method is often called asynchronously (whether thread, - # signal handler, console handler, or atexit handler), so we - # can't just let exceptions propagate out unhandled. - # Assume it's been logged and just die. - os._exit(70) # EX_SOFTWARE - - if exitstate == states.STARTING: - # exit() was called before start() finished, possibly due to - # Ctrl-C because a start listener got stuck. In this case, - # we could get stuck in a loop where Ctrl-C never exits the - # process, so we just call os.exit here. - os._exit(70) # EX_SOFTWARE - - def restart(self): - """Restart the process (may close connections). - - This method does not restart the process from the calling thread; - instead, it stops the bus and asks the main thread to call execv. - """ - self.execv = True - self.exit() - - def graceful(self): - """Advise all services to reload.""" - self.log('Bus graceful') - self.publish('graceful') - - def block(self, interval=0.1): - """Wait for the EXITING state, KeyboardInterrupt or SystemExit. - - This function is intended to be called only by the main thread. - After waiting for the EXITING state, it also waits for all threads - to terminate, and then calls os.execv if self.execv is True. This - design allows another thread to call bus.restart, yet have the main - thread perform the actual execv call (required on some platforms). - """ - try: - self.wait(states.EXITING, interval=interval, channel='main') - except (KeyboardInterrupt, IOError): - # The time.sleep call might raise - # "IOError: [Errno 4] Interrupted function call" on KBInt. - self.log('Keyboard Interrupt: shutting down bus') - self.exit() - except SystemExit: - self.log('SystemExit raised: shutting down bus') - self.exit() - raise - - # Waiting for ALL child threads to finish is necessary on OS X. - # See http://www.cherrypy.org/ticket/581. - # It's also good to let them all shut down before allowing - # the main thread to call atexit handlers. - # See http://www.cherrypy.org/ticket/751. - self.log("Waiting for child threads to terminate...") - for t in threading.enumerate(): - if t != threading.currentThread() and t.isAlive(): - # Note that any dummy (external) threads are always daemonic. - if hasattr(threading.Thread, "daemon"): - # Python 2.6+ - d = t.daemon - else: - d = t.isDaemon() - if not d: - t.join() - - if self.execv: - self._do_execv() - - def wait(self, state, interval=0.1, channel=None): - """Wait for the given state(s).""" - if isinstance(state, (tuple, list)): - states = state - else: - states = [state] - - def _wait(): - while self.state not in states: - time.sleep(interval) - self.publish(channel) - - # From http://psyco.sourceforge.net/psycoguide/bugs.html: - # "The compiled machine code does not include the regular polling - # done by Python, meaning that a KeyboardInterrupt will not be - # detected before execution comes back to the regular Python - # interpreter. Your program cannot be interrupted if caught - # into an infinite Psyco-compiled loop." - try: - sys.modules['psyco'].cannotcompile(_wait) - except (KeyError, AttributeError): - pass - - _wait() - - def _do_execv(self): - """Re-execute the current process. - - This must be called from the main thread, because certain platforms - (OS X) don't allow execv to be called in a child thread very well. - """ - args = sys.argv[:] - self.log('Re-spawning %s' % ' '.join(args)) - args.insert(0, sys.executable) - if sys.platform == 'win32': - args = ['"%s"' % arg for arg in args] - - os.chdir(_startup_cwd) - os.execv(sys.executable, args) - - def stop(self): - """Stop all services.""" - self.state = states.STOPPING - self.log('Bus STOPPING') - self.publish('stop') - self.state = states.STOPPED - self.log('Bus STOPPED') - - def start_with_callback(self, func, args=None, kwargs=None): - """Start 'func' in a new thread T, then start self (and return T).""" - if args is None: - args = () - if kwargs is None: - kwargs = {} - args = (func,) + args - - def _callback(func, *a, **kw): - self.wait(states.STARTED) - func(*a, **kw) - t = threading.Thread(target=_callback, args=args, kwargs=kwargs) - t.setName('Bus Callback ' + t.getName()) - t.start() - - self.start() - - return t - - def log(self, msg="", level=20, traceback=False): - """Log the given message. Append the last traceback if requested.""" - if traceback: - exc = sys.exc_info() - msg += "\n" + "".join(_traceback.format_exception(*exc)) - self.publish('log', msg, level) - -bus = Bus() +"""An implementation of the Web Site Process Bus. + +This module is completely standalone, depending only on the stdlib. + +Web Site Process Bus +-------------------- + +A Bus object is used to contain and manage site-wide behavior: +daemonization, HTTP server start/stop, process reload, signal handling, +drop privileges, PID file management, logging for all of these, +and many more. + +In addition, a Bus object provides a place for each web framework +to register code that runs in response to site-wide events (like +process start and stop), or which controls or otherwise interacts with +the site-wide components mentioned above. For example, a framework which +uses file-based templates would add known template filenames to an +autoreload component. + +Ideally, a Bus object will be flexible enough to be useful in a variety +of invocation scenarios: + + 1. The deployer starts a site from the command line via a framework- + neutral deployment script; applications from multiple frameworks + are mixed in a single site. Command-line arguments and configuration + files are used to define site-wide components such as the HTTP server, + WSGI component graph, autoreload behavior, signal handling, etc. + 2. The deployer starts a site via some other process, such as Apache; + applications from multiple frameworks are mixed in a single site. + Autoreload and signal handling (from Python at least) are disabled. + 3. The deployer starts a site via a framework-specific mechanism; + for example, when running tests, exploring tutorials, or deploying + single applications from a single framework. The framework controls + which site-wide components are enabled as it sees fit. + +The Bus object in this package uses topic-based publish-subscribe +messaging to accomplish all this. A few topic channels are built in +('start', 'stop', 'exit', 'graceful', 'log', and 'main'). Frameworks and +site containers are free to define their own. If a message is sent to a +channel that has not been defined or has no listeners, there is no effect. + +In general, there should only ever be a single Bus object per process. +Frameworks and site containers share a single Bus object by publishing +messages and subscribing listeners. + +The Bus object works as a finite state machine which models the current +state of the process. Bus methods move it from one state to another; +those methods then publish to subscribed listeners on the channel for +the new state. + + O + | + V + STOPPING --> STOPPED --> EXITING -> X + A A | + | \___ | + | \ | + | V V + STARTED <-- STARTING + +""" + +import atexit +import os +try: + set +except NameError: + from sets import Set as set +import sys +import threading +import time +import traceback as _traceback +import warnings + +# Here I save the value of os.getcwd(), which, if I am imported early enough, +# will be the directory from which the startup script was run. This is needed +# by _do_execv(), to change back to the original directory before execv()ing a +# new process. This is a defense against the application having changed the +# current working directory (which could make sys.executable "not found" if +# sys.executable is a relative-path, and/or cause other problems). +_startup_cwd = os.getcwd() + +class ChannelFailures(Exception): + delimiter = '\n' + + def __init__(self, *args, **kwargs): + # Don't use 'super' here; Exceptions are old-style in Py2.4 + # See http://www.cherrypy.org/ticket/959 + Exception.__init__(self, *args, **kwargs) + self._exceptions = list() + + def handle_exception(self): + self._exceptions.append(sys.exc_info()) + + def get_instances(self): + return [instance for cls, instance, traceback in self._exceptions] + + def __str__(self): + exception_strings = map(repr, self.get_instances()) + return self.delimiter.join(exception_strings) + + def __nonzero__(self): + return bool(self._exceptions) + +# Use a flag to indicate the state of the bus. +class _StateEnum(object): + class State(object): + name = None + def __repr__(self): + return "states.%s" % self.name + + def __setattr__(self, key, value): + if isinstance(value, self.State): + value.name = key + object.__setattr__(self, key, value) +states = _StateEnum() +states.STOPPED = states.State() +states.STARTING = states.State() +states.STARTED = states.State() +states.STOPPING = states.State() +states.EXITING = states.State() + + +class Bus(object): + """Process state-machine and messenger for HTTP site deployment. + + All listeners for a given channel are guaranteed to be called even + if others at the same channel fail. Each failure is logged, but + execution proceeds on to the next listener. The only way to stop all + processing from inside a listener is to raise SystemExit and stop the + whole server. + """ + + states = states + state = states.STOPPED + execv = False + + def __init__(self): + self.execv = False + self.state = states.STOPPED + self.listeners = dict( + [(channel, set()) for channel + in ('start', 'stop', 'exit', 'graceful', 'log', 'main')]) + self._priorities = {} + + def subscribe(self, channel, callback, priority=None): + """Add the given callback at the given channel (if not present).""" + if channel not in self.listeners: + self.listeners[channel] = set() + self.listeners[channel].add(callback) + + if priority is None: + priority = getattr(callback, 'priority', 50) + self._priorities[(channel, callback)] = priority + + def unsubscribe(self, channel, callback): + """Discard the given callback (if present).""" + listeners = self.listeners.get(channel) + if listeners and callback in listeners: + listeners.discard(callback) + del self._priorities[(channel, callback)] + + def publish(self, channel, *args, **kwargs): + """Return output of all subscribers for the given channel.""" + if channel not in self.listeners: + return [] + + exc = ChannelFailures() + output = [] + + items = [(self._priorities[(channel, listener)], listener) + for listener in self.listeners[channel]] + items.sort() + for priority, listener in items: + try: + output.append(listener(*args, **kwargs)) + except KeyboardInterrupt: + raise + except SystemExit, e: + # If we have previous errors ensure the exit code is non-zero + if exc and e.code == 0: + e.code = 1 + raise + except: + exc.handle_exception() + if channel == 'log': + # Assume any further messages to 'log' will fail. + pass + else: + self.log("Error in %r listener %r" % (channel, listener), + level=40, traceback=True) + if exc: + raise exc + return output + + def _clean_exit(self): + """An atexit handler which asserts the Bus is not running.""" + if self.state != states.EXITING: + warnings.warn( + "The main thread is exiting, but the Bus is in the %r state; " + "shutting it down automatically now. You must either call " + "bus.block() after start(), or call bus.exit() before the " + "main thread exits." % self.state, RuntimeWarning) + self.exit() + + def start(self): + """Start all services.""" + atexit.register(self._clean_exit) + + self.state = states.STARTING + self.log('Bus STARTING') + try: + self.publish('start') + self.state = states.STARTED + self.log('Bus STARTED') + except (KeyboardInterrupt, SystemExit): + raise + except: + self.log("Shutting down due to error in start listener:", + level=40, traceback=True) + e_info = sys.exc_info() + try: + self.exit() + except: + # Any stop/exit errors will be logged inside publish(). + pass + raise e_info[0], e_info[1], e_info[2] + + def exit(self): + """Stop all services and prepare to exit the process.""" + exitstate = self.state + try: + self.stop() + + self.state = states.EXITING + self.log('Bus EXITING') + self.publish('exit') + # This isn't strictly necessary, but it's better than seeing + # "Waiting for child threads to terminate..." and then nothing. + self.log('Bus EXITED') + except: + # This method is often called asynchronously (whether thread, + # signal handler, console handler, or atexit handler), so we + # can't just let exceptions propagate out unhandled. + # Assume it's been logged and just die. + os._exit(70) # EX_SOFTWARE + + if exitstate == states.STARTING: + # exit() was called before start() finished, possibly due to + # Ctrl-C because a start listener got stuck. In this case, + # we could get stuck in a loop where Ctrl-C never exits the + # process, so we just call os.exit here. + os._exit(70) # EX_SOFTWARE + + def restart(self): + """Restart the process (may close connections). + + This method does not restart the process from the calling thread; + instead, it stops the bus and asks the main thread to call execv. + """ + self.execv = True + self.exit() + + def graceful(self): + """Advise all services to reload.""" + self.log('Bus graceful') + self.publish('graceful') + + def block(self, interval=0.1): + """Wait for the EXITING state, KeyboardInterrupt or SystemExit. + + This function is intended to be called only by the main thread. + After waiting for the EXITING state, it also waits for all threads + to terminate, and then calls os.execv if self.execv is True. This + design allows another thread to call bus.restart, yet have the main + thread perform the actual execv call (required on some platforms). + """ + try: + self.wait(states.EXITING, interval=interval, channel='main') + except (KeyboardInterrupt, IOError): + # The time.sleep call might raise + # "IOError: [Errno 4] Interrupted function call" on KBInt. + self.log('Keyboard Interrupt: shutting down bus') + self.exit() + except SystemExit: + self.log('SystemExit raised: shutting down bus') + self.exit() + raise + + # Waiting for ALL child threads to finish is necessary on OS X. + # See http://www.cherrypy.org/ticket/581. + # It's also good to let them all shut down before allowing + # the main thread to call atexit handlers. + # See http://www.cherrypy.org/ticket/751. + self.log("Waiting for child threads to terminate...") + for t in threading.enumerate(): + if t != threading.currentThread() and t.isAlive(): + # Note that any dummy (external) threads are always daemonic. + if hasattr(threading.Thread, "daemon"): + # Python 2.6+ + d = t.daemon + else: + d = t.isDaemon() + if not d: + t.join() + + if self.execv: + self._do_execv() + + def wait(self, state, interval=0.1, channel=None): + """Wait for the given state(s).""" + if isinstance(state, (tuple, list)): + states = state + else: + states = [state] + + def _wait(): + while self.state not in states: + time.sleep(interval) + self.publish(channel) + + # From http://psyco.sourceforge.net/psycoguide/bugs.html: + # "The compiled machine code does not include the regular polling + # done by Python, meaning that a KeyboardInterrupt will not be + # detected before execution comes back to the regular Python + # interpreter. Your program cannot be interrupted if caught + # into an infinite Psyco-compiled loop." + try: + sys.modules['psyco'].cannotcompile(_wait) + except (KeyError, AttributeError): + pass + + _wait() + + def _do_execv(self): + """Re-execute the current process. + + This must be called from the main thread, because certain platforms + (OS X) don't allow execv to be called in a child thread very well. + """ + args = sys.argv[:] + self.log('Re-spawning %s' % ' '.join(args)) + args.insert(0, sys.executable) + if sys.platform == 'win32': + args = ['"%s"' % arg for arg in args] + + os.chdir(_startup_cwd) + os.execv(sys.executable, args) + + def stop(self): + """Stop all services.""" + self.state = states.STOPPING + self.log('Bus STOPPING') + self.publish('stop') + self.state = states.STOPPED + self.log('Bus STOPPED') + + def start_with_callback(self, func, args=None, kwargs=None): + """Start 'func' in a new thread T, then start self (and return T).""" + if args is None: + args = () + if kwargs is None: + kwargs = {} + args = (func,) + args + + def _callback(func, *a, **kw): + self.wait(states.STARTED) + func(*a, **kw) + t = threading.Thread(target=_callback, args=args, kwargs=kwargs) + t.setName('Bus Callback ' + t.getName()) + t.start() + + self.start() + + return t + + def log(self, msg="", level=20, traceback=False): + """Log the given message. Append the last traceback if requested.""" + if traceback: + exc = sys.exc_info() + msg += "\n" + "".join(_traceback.format_exception(*exc)) + self.publish('log', msg, level) + +bus = Bus() diff --git a/cherrypy/wsgiserver/__init__.py b/cherrypy/wsgiserver/__init__.py index 949ddc33b9..d44cb31ed7 100644 --- a/cherrypy/wsgiserver/__init__.py +++ b/cherrypy/wsgiserver/__init__.py @@ -1,2074 +1,2074 @@ -"""A high-speed, production ready, thread pooled, generic HTTP server. - -Simplest example on how to use this module directly -(without using CherryPy's application machinery): - - from cherrypy import wsgiserver - - def my_crazy_app(environ, start_response): - status = '200 OK' - response_headers = [('Content-type','text/plain')] - start_response(status, response_headers) - return ['Hello world!\n'] - - server = wsgiserver.CherryPyWSGIServer( - ('0.0.0.0', 8070), my_crazy_app, - server_name='www.cherrypy.example') - -The CherryPy WSGI server can serve as many WSGI applications -as you want in one instance by using a WSGIPathInfoDispatcher: - - d = WSGIPathInfoDispatcher({'/': my_crazy_app, '/blog': my_blog_app}) - server = wsgiserver.CherryPyWSGIServer(('0.0.0.0', 80), d) - -Want SSL support? Just set server.ssl_adapter to an SSLAdapter instance. - -This won't call the CherryPy engine (application side) at all, only the -HTTP server, which is independent from the rest of CherryPy. Don't -let the name "CherryPyWSGIServer" throw you; the name merely reflects -its origin, not its coupling. - -For those of you wanting to understand internals of this module, here's the -basic call flow. The server's listening thread runs a very tight loop, -sticking incoming connections onto a Queue: - - server = CherryPyWSGIServer(...) - server.start() - while True: - tick() - # This blocks until a request comes in: - child = socket.accept() - conn = HTTPConnection(child, ...) - server.requests.put(conn) - -Worker threads are kept in a pool and poll the Queue, popping off and then -handling each connection in turn. Each connection can consist of an arbitrary -number of requests and their responses, so we run a nested loop: - - while True: - conn = server.requests.get() - conn.communicate() - -> while True: - req = HTTPRequest(...) - req.parse_request() - -> # Read the Request-Line, e.g. "GET /page HTTP/1.1" - req.rfile.readline() - read_headers(req.rfile, req.inheaders) - req.respond() - -> response = app(...) - try: - for chunk in response: - if chunk: - req.write(chunk) - finally: - if hasattr(response, "close"): - response.close() - if req.close_connection: - return -""" - -CRLF = '\r\n' -import os -import Queue -import re -quoted_slash = re.compile("(?i)%2F") -import rfc822 -import socket -import sys -if 'win' in sys.platform and not hasattr(socket, 'IPPROTO_IPV6'): - socket.IPPROTO_IPV6 = 41 -try: - import cStringIO as StringIO -except ImportError: - import StringIO - -_fileobject_uses_str_type = isinstance(socket._fileobject(None)._rbuf, basestring) - -import threading -import time -import traceback -from urllib import unquote -from urlparse import urlparse -import warnings - -import errno - -def plat_specific_errors(*errnames): - """Return error numbers for all errors in errnames on this platform. - - The 'errno' module contains different global constants depending on - the specific platform (OS). This function will return the list of - numeric values for a given list of potential names. - """ - errno_names = dir(errno) - nums = [getattr(errno, k) for k in errnames if k in errno_names] - # de-dupe the list - return dict.fromkeys(nums).keys() - -socket_error_eintr = plat_specific_errors("EINTR", "WSAEINTR") - -socket_errors_to_ignore = plat_specific_errors( - "EPIPE", - "EBADF", "WSAEBADF", - "ENOTSOCK", "WSAENOTSOCK", - "ETIMEDOUT", "WSAETIMEDOUT", - "ECONNREFUSED", "WSAECONNREFUSED", - "ECONNRESET", "WSAECONNRESET", - "ECONNABORTED", "WSAECONNABORTED", - "ENETRESET", "WSAENETRESET", - "EHOSTDOWN", "EHOSTUNREACH", - ) -socket_errors_to_ignore.append("timed out") -socket_errors_to_ignore.append("The read operation timed out") - -socket_errors_nonblocking = plat_specific_errors( - 'EAGAIN', 'EWOULDBLOCK', 'WSAEWOULDBLOCK') - -comma_separated_headers = ['Accept', 'Accept-Charset', 'Accept-Encoding', - 'Accept-Language', 'Accept-Ranges', 'Allow', 'Cache-Control', - 'Connection', 'Content-Encoding', 'Content-Language', 'Expect', - 'If-Match', 'If-None-Match', 'Pragma', 'Proxy-Authenticate', 'TE', - 'Trailer', 'Transfer-Encoding', 'Upgrade', 'Vary', 'Via', 'Warning', - 'WWW-Authenticate'] - - -def read_headers(rfile, hdict=None): - """Read headers from the given stream into the given header dict. - - If hdict is None, a new header dict is created. Returns the populated - header dict. - - Headers which are repeated are folded together using a comma if their - specification so dictates. - - This function raises ValueError when the read bytes violate the HTTP spec. - You should probably return "400 Bad Request" if this happens. - """ - if hdict is None: - hdict = {} - - while True: - line = rfile.readline() - if not line: - # No more data--illegal end of headers - raise ValueError("Illegal end of headers.") - - if line == CRLF: - # Normal end of headers - break - if not line.endswith(CRLF): - raise ValueError("HTTP requires CRLF terminators") - - if line[0] in ' \t': - # It's a continuation line. - v = line.strip() - else: - try: - k, v = line.split(":", 1) - except ValueError: - raise ValueError("Illegal header line.") - # TODO: what about TE and WWW-Authenticate? - k = k.strip().title() - v = v.strip() - hname = k - - if k in comma_separated_headers: - existing = hdict.get(hname) - if existing: - v = ", ".join((existing, v)) - hdict[hname] = v - - return hdict - - -class MaxSizeExceeded(Exception): - pass - -class SizeCheckWrapper(object): - """Wraps a file-like object, raising MaxSizeExceeded if too large.""" - - def __init__(self, rfile, maxlen): - self.rfile = rfile - self.maxlen = maxlen - self.bytes_read = 0 - - def _check_length(self): - if self.maxlen and self.bytes_read > self.maxlen: - raise MaxSizeExceeded() - - def read(self, size=None): - data = self.rfile.read(size) - self.bytes_read += len(data) - self._check_length() - return data - - def readline(self, size=None): - if size is not None: - data = self.rfile.readline(size) - self.bytes_read += len(data) - self._check_length() - return data - - # User didn't specify a size ... - # We read the line in chunks to make sure it's not a 100MB line ! - res = [] - while True: - data = self.rfile.readline(256) - self.bytes_read += len(data) - self._check_length() - res.append(data) - # See http://www.cherrypy.org/ticket/421 - if len(data) < 256 or data[-1:] == "\n": - return ''.join(res) - - def readlines(self, sizehint=0): - # Shamelessly stolen from StringIO - total = 0 - lines = [] - line = self.readline() - while line: - lines.append(line) - total += len(line) - if 0 < sizehint <= total: - break - line = self.readline() - return lines - - def close(self): - self.rfile.close() - - def __iter__(self): - return self - - def next(self): - data = self.rfile.next() - self.bytes_read += len(data) - self._check_length() - return data - - -class KnownLengthRFile(object): - """Wraps a file-like object, returning an empty string when exhausted.""" - - def __init__(self, rfile, content_length): - self.rfile = rfile - self.remaining = content_length - - def read(self, size=None): - if self.remaining == 0: - return '' - if size is None: - size = self.remaining - else: - size = min(size, self.remaining) - - data = self.rfile.read(size) - self.remaining -= len(data) - return data - - def readline(self, size=None): - if self.remaining == 0: - return '' - if size is None: - size = self.remaining - else: - size = min(size, self.remaining) - - data = self.rfile.readline(size) - self.remaining -= len(data) - return data - - def readlines(self, sizehint=0): - # Shamelessly stolen from StringIO - total = 0 - lines = [] - line = self.readline(sizehint) - while line: - lines.append(line) - total += len(line) - if 0 < sizehint <= total: - break - line = self.readline(sizehint) - return lines - - def close(self): - self.rfile.close() - - def __iter__(self): - return self - - def __next__(self): - data = next(self.rfile) - self.remaining -= len(data) - return data - - -class MaxSizeExceeded(Exception): - pass - - -class ChunkedRFile(object): - """Wraps a file-like object, returning an empty string when exhausted. - - This class is intended to provide a conforming wsgi.input value for - request entities that have been encoded with the 'chunked' transfer - encoding. - """ - - def __init__(self, rfile, maxlen, bufsize=8192): - self.rfile = rfile - self.maxlen = maxlen - self.bytes_read = 0 - self.buffer = '' - self.bufsize = bufsize - self.closed = False - - def _fetch(self): - if self.closed: - return - - line = self.rfile.readline() - self.bytes_read += len(line) - - if self.maxlen and self.bytes_read > self.maxlen: - raise MaxSizeExceeded("Request Entity Too Large", self.maxlen) - - line = line.strip().split(";", 1) - - try: - chunk_size = line.pop(0) - chunk_size = int(chunk_size, 16) - except ValueError: - raise ValueError("Bad chunked transfer size: " + repr(chunk_size)) - - if chunk_size <= 0: - self.closed = True - return - -## if line: chunk_extension = line[0] - - if self.maxlen and self.bytes_read + chunk_size > self.maxlen: - raise IOError("Request Entity Too Large") - - chunk = self.rfile.read(chunk_size) - self.bytes_read += len(chunk) - self.buffer += chunk - - crlf = self.rfile.read(2) - if crlf != CRLF: - raise ValueError( - "Bad chunked transfer coding (expected '\\r\\n', " - "got " + repr(crlf) + ")") - - def read(self, size=None): - data = '' - while True: - if size and len(data) >= size: - return data - - if not self.buffer: - self._fetch() - if not self.buffer: - # EOF - return data - - if size: - remaining = size - len(data) - data += self.buffer[:remaining] - self.buffer = self.buffer[remaining:] - else: - data += self.buffer - - def readline(self, size=None): - data = '' - while True: - if size and len(data) >= size: - return data - - if not self.buffer: - self._fetch() - if not self.buffer: - # EOF - return data - - newline_pos = self.buffer.find('\n') - if size: - if newline_pos == -1: - remaining = size - len(data) - data += self.buffer[:remaining] - self.buffer = self.buffer[remaining:] - else: - remaining = min(size - len(data), newline_pos) - data += self.buffer[:remaining] - self.buffer = self.buffer[remaining:] - else: - if newline_pos == -1: - data += self.buffer - else: - data += self.buffer[:newline_pos] - self.buffer = self.buffer[newline_pos:] - - def readlines(self, sizehint=0): - # Shamelessly stolen from StringIO - total = 0 - lines = [] - line = self.readline(sizehint) - while line: - lines.append(line) - total += len(line) - if 0 < sizehint <= total: - break - line = self.readline(sizehint) - return lines - - def read_trailer_lines(self): - if not self.closed: - raise ValueError( - "Cannot read trailers until the request body has been read.") - - while True: - line = self.rfile.readline() - if not line: - # No more data--illegal end of headers - raise ValueError("Illegal end of headers.") - - self.bytes_read += len(line) - if self.maxlen and self.bytes_read > self.maxlen: - raise IOError("Request Entity Too Large") - - if line == CRLF: - # Normal end of headers - break - if not line.endswith(CRLF): - raise ValueError("HTTP requires CRLF terminators") - - yield line - - def close(self): - self.rfile.close() - - def __iter__(self): - # Shamelessly stolen from StringIO - total = 0 - line = self.readline(sizehint) - while line: - yield line - total += len(line) - if 0 < sizehint <= total: - break - line = self.readline(sizehint) - - -class HTTPRequest(object): - """An HTTP Request (and response). - - A single HTTP connection may consist of multiple request/response pairs. - - server: the Server object which is receiving this request. - conn: the HTTPConnection object on which this request connected. - - inheaders: a dict of request headers. - outheaders: a list of header tuples to write in the response. - ready: when True, the request has been parsed and is ready to begin - generating the response. When False, signals the calling Connection - that the response should not be generated and the connection should - close. - close_connection: signals the calling Connection that the request - should close. This does not imply an error! The client and/or - server may each request that the connection be closed. - chunked_write: if True, output will be encoded with the "chunked" - transfer-coding. This value is set automatically inside - send_headers. - """ - - def __init__(self, server, conn): - self.server = server - self.conn = conn - - self.ready = False - self.started_request = False - self.scheme = "http" - if self.server.ssl_adapter is not None: - self.scheme = "https" - self.inheaders = {} - - self.status = "" - self.outheaders = [] - self.sent_headers = False - self.close_connection = False - self.chunked_write = False - - def parse_request(self): - """Parse the next HTTP request start-line and message-headers.""" - self.rfile = SizeCheckWrapper(self.conn.rfile, - self.server.max_request_header_size) - try: - self._parse_request() - except MaxSizeExceeded: - self.simple_response("413 Request Entity Too Large") - return - - def _parse_request(self): - # HTTP/1.1 connections are persistent by default. If a client - # requests a page, then idles (leaves the connection open), - # then rfile.readline() will raise socket.error("timed out"). - # Note that it does this based on the value given to settimeout(), - # and doesn't need the client to request or acknowledge the close - # (although your TCP stack might suffer for it: cf Apache's history - # with FIN_WAIT_2). - request_line = self.rfile.readline() - - # Set started_request to True so communicate() knows to send 408 - # from here on out. - self.started_request = True - if not request_line: - # Force self.ready = False so the connection will close. - self.ready = False - return - - if request_line == CRLF: - # RFC 2616 sec 4.1: "...if the server is reading the protocol - # stream at the beginning of a message and receives a CRLF - # first, it should ignore the CRLF." - # But only ignore one leading line! else we enable a DoS. - request_line = self.rfile.readline() - if not request_line: - self.ready = False - return - - if not request_line.endswith(CRLF): - self.simple_response(400, "HTTP requires CRLF terminators") - return - - try: - method, uri, req_protocol = request_line.strip().split(" ", 2) - except ValueError: - self.simple_response(400, "Malformed Request-Line") - return - - self.uri = uri - self.method = method - - # uri may be an abs_path (including "http://host.domain.tld"); - scheme, authority, path = self.parse_request_uri(uri) - if '#' in path: - self.simple_response("400 Bad Request", - "Illegal #fragment in Request-URI.") - return - - if scheme: - self.scheme = scheme - - qs = '' - if '?' in path: - path, qs = path.split('?', 1) - - # Unquote the path+params (e.g. "/this%20path" -> "/this path"). - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.2 - # - # But note that "...a URI must be separated into its components - # before the escaped characters within those components can be - # safely decoded." http://www.ietf.org/rfc/rfc2396.txt, sec 2.4.2 - # Therefore, "/this%2Fpath" becomes "/this%2Fpath", not "/this/path". - try: - atoms = [unquote(x) for x in quoted_slash.split(path)] - except ValueError, ex: - self.simple_response("400 Bad Request", ex.args[0]) - return - path = "%2F".join(atoms) - self.path = path - - # Note that, like wsgiref and most other HTTP servers, - # we "% HEX HEX"-unquote the path but not the query string. - self.qs = qs - - # Compare request and server HTTP protocol versions, in case our - # server does not support the requested protocol. Limit our output - # to min(req, server). We want the following output: - # request server actual written supported response - # protocol protocol response protocol feature set - # a 1.0 1.0 1.0 1.0 - # b 1.0 1.1 1.1 1.0 - # c 1.1 1.0 1.0 1.0 - # d 1.1 1.1 1.1 1.1 - # Notice that, in (b), the response will be "HTTP/1.1" even though - # the client only understands 1.0. RFC 2616 10.5.6 says we should - # only return 505 if the _major_ version is different. - rp = int(req_protocol[5]), int(req_protocol[7]) - sp = int(self.server.protocol[5]), int(self.server.protocol[7]) - - if sp[0] != rp[0]: - self.simple_response("505 HTTP Version Not Supported") - return - self.request_protocol = req_protocol - self.response_protocol = "HTTP/%s.%s" % min(rp, sp) - - # then all the http headers - try: - read_headers(self.rfile, self.inheaders) - except ValueError, ex: - self.simple_response("400 Bad Request", ex.args[0]) - return - - mrbs = self.server.max_request_body_size - if mrbs and int(self.inheaders.get("Content-Length", 0)) > mrbs: - self.simple_response("413 Request Entity Too Large") - return - - # Persistent connection support - if self.response_protocol == "HTTP/1.1": - # Both server and client are HTTP/1.1 - if self.inheaders.get("Connection", "") == "close": - self.close_connection = True - else: - # Either the server or client (or both) are HTTP/1.0 - if self.inheaders.get("Connection", "") != "Keep-Alive": - self.close_connection = True - - # Transfer-Encoding support - te = None - if self.response_protocol == "HTTP/1.1": - te = self.inheaders.get("Transfer-Encoding") - if te: - te = [x.strip().lower() for x in te.split(",") if x.strip()] - - self.chunked_read = False - - if te: - for enc in te: - if enc == "chunked": - self.chunked_read = True - else: - # Note that, even if we see "chunked", we must reject - # if there is an extension we don't recognize. - self.simple_response("501 Unimplemented") - self.close_connection = True - return - - # From PEP 333: - # "Servers and gateways that implement HTTP 1.1 must provide - # transparent support for HTTP 1.1's "expect/continue" mechanism. - # This may be done in any of several ways: - # 1. Respond to requests containing an Expect: 100-continue request - # with an immediate "100 Continue" response, and proceed normally. - # 2. Proceed with the request normally, but provide the application - # with a wsgi.input stream that will send the "100 Continue" - # response if/when the application first attempts to read from - # the input stream. The read request must then remain blocked - # until the client responds. - # 3. Wait until the client decides that the server does not support - # expect/continue, and sends the request body on its own. - # (This is suboptimal, and is not recommended.) - # - # We used to do 3, but are now doing 1. Maybe we'll do 2 someday, - # but it seems like it would be a big slowdown for such a rare case. - if self.inheaders.get("Expect", "") == "100-continue": - # Don't use simple_response here, because it emits headers - # we don't want. See http://www.cherrypy.org/ticket/951 - msg = self.server.protocol + " 100 Continue\r\n\r\n" - try: - self.conn.wfile.sendall(msg) - except socket.error, x: - if x.args[0] not in socket_errors_to_ignore: - raise - - self.ready = True - - def parse_request_uri(self, uri): - """Parse a Request-URI into (scheme, authority, path). - - Note that Request-URI's must be one of: - - Request-URI = "*" | absoluteURI | abs_path | authority - - Therefore, a Request-URI which starts with a double forward-slash - cannot be a "net_path": - - net_path = "//" authority [ abs_path ] - - Instead, it must be interpreted as an "abs_path" with an empty first - path segment: - - abs_path = "/" path_segments - path_segments = segment *( "/" segment ) - segment = *pchar *( ";" param ) - param = *pchar - """ - if uri == "*": - return None, None, uri - - i = uri.find('://') - if i > 0 and '?' not in uri[:i]: - # An absoluteURI. - # If there's a scheme (and it must be http or https), then: - # http_URL = "http:" "//" host [ ":" port ] [ abs_path [ "?" query ]] - scheme, remainder = uri[:i].lower(), uri[i + 3:] - authority, path = remainder.split("/", 1) - return scheme, authority, path - - if uri.startswith('/'): - # An abs_path. - return None, None, uri - else: - # An authority. - return None, uri, None - - def respond(self): - """Call the gateway and write its iterable output.""" - mrbs = self.server.max_request_body_size - if self.chunked_read: - self.rfile = ChunkedRFile(self.conn.rfile, mrbs) - else: - cl = int(self.inheaders.get("Content-Length", 0)) - if mrbs and mrbs < cl: - if not self.sent_headers: - self.simple_response("413 Request Entity Too Large") - return - self.rfile = KnownLengthRFile(self.conn.rfile, cl) - - self.server.gateway(self).respond() - - if (self.ready and not self.sent_headers): - self.sent_headers = True - self.send_headers() - if self.chunked_write: - self.conn.wfile.sendall("0\r\n\r\n") - - def simple_response(self, status, msg=""): - """Write a simple response back to the client.""" - status = str(status) - buf = [self.server.protocol + " " + - status + CRLF, - "Content-Length: %s\r\n" % len(msg), - "Content-Type: text/plain\r\n"] - - if status[:3] == "413" and self.response_protocol == 'HTTP/1.1': - # Request Entity Too Large - self.close_connection = True - buf.append("Connection: close\r\n") - - buf.append(CRLF) - if msg: - if isinstance(msg, unicode): - msg = msg.encode("ISO-8859-1") - buf.append(msg) - - try: - self.conn.wfile.sendall("".join(buf)) - except socket.error, x: - if x.args[0] not in socket_errors_to_ignore: - raise - - def write(self, chunk): - """Write unbuffered data to the client.""" - if self.chunked_write and chunk: - buf = [hex(len(chunk))[2:], CRLF, chunk, CRLF] - self.conn.wfile.sendall("".join(buf)) - else: - self.conn.wfile.sendall(chunk) - - def send_headers(self): - """Assert, process, and send the HTTP response message-headers. - - You must set self.status, and self.outheaders before calling this. - """ - hkeys = [key.lower() for key, value in self.outheaders] - status = int(self.status[:3]) - - if status == 413: - # Request Entity Too Large. Close conn to avoid garbage. - self.close_connection = True - elif "content-length" not in hkeys: - # "All 1xx (informational), 204 (no content), - # and 304 (not modified) responses MUST NOT - # include a message-body." So no point chunking. - if status < 200 or status in (204, 205, 304): - pass - else: - if (self.response_protocol == 'HTTP/1.1' - and self.method != 'HEAD'): - # Use the chunked transfer-coding - self.chunked_write = True - self.outheaders.append(("Transfer-Encoding", "chunked")) - else: - # Closing the conn is the only way to determine len. - self.close_connection = True - - if "connection" not in hkeys: - if self.response_protocol == 'HTTP/1.1': - # Both server and client are HTTP/1.1 or better - if self.close_connection: - self.outheaders.append(("Connection", "close")) - else: - # Server and/or client are HTTP/1.0 - if not self.close_connection: - self.outheaders.append(("Connection", "Keep-Alive")) - - if (not self.close_connection) and (not self.chunked_read): - # Read any remaining request body data on the socket. - # "If an origin server receives a request that does not include an - # Expect request-header field with the "100-continue" expectation, - # the request includes a request body, and the server responds - # with a final status code before reading the entire request body - # from the transport connection, then the server SHOULD NOT close - # the transport connection until it has read the entire request, - # or until the client closes the connection. Otherwise, the client - # might not reliably receive the response message. However, this - # requirement is not be construed as preventing a server from - # defending itself against denial-of-service attacks, or from - # badly broken client implementations." - remaining = getattr(self.rfile, 'remaining', 0) - if remaining > 0: - self.rfile.read(remaining) - - if "date" not in hkeys: - self.outheaders.append(("Date", rfc822.formatdate())) - - if "server" not in hkeys: - self.outheaders.append(("Server", self.server.server_name)) - - buf = [self.server.protocol + " " + self.status + CRLF] - for k, v in self.outheaders: - buf.append(k + ": " + v + CRLF) - buf.append(CRLF) - self.conn.wfile.sendall("".join(buf)) - - -class NoSSLError(Exception): - """Exception raised when a client speaks HTTP to an HTTPS socket.""" - pass - - -class FatalSSLAlert(Exception): - """Exception raised when the SSL implementation signals a fatal alert.""" - pass - - -if not _fileobject_uses_str_type: - class CP_fileobject(socket._fileobject): - """Faux file object attached to a socket object.""" - - def sendall(self, data): - """Sendall for non-blocking sockets.""" - while data: - try: - bytes_sent = self.send(data) - data = data[bytes_sent:] - except socket.error, e: - if e.args[0] not in socket_errors_nonblocking: - raise - - def send(self, data): - return self._sock.send(data) - - def flush(self): - if self._wbuf: - buffer = "".join(self._wbuf) - self._wbuf = [] - self.sendall(buffer) - - def recv(self, size): - while True: - try: - return self._sock.recv(size) - except socket.error, e: - if (e.args[0] not in socket_errors_nonblocking - and e.args[0] not in socket_error_eintr): - raise - - def read(self, size= -1): - # Use max, disallow tiny reads in a loop as they are very inefficient. - # We never leave read() with any leftover data from a new recv() call - # in our internal buffer. - rbufsize = max(self._rbufsize, self.default_bufsize) - # Our use of StringIO rather than lists of string objects returned by - # recv() minimizes memory usage and fragmentation that occurs when - # rbufsize is large compared to the typical return value of recv(). - buf = self._rbuf - buf.seek(0, 2) # seek end - if size < 0: - # Read until EOF - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - data = self.recv(rbufsize) - if not data: - break - buf.write(data) - return buf.getvalue() - else: - # Read until size bytes or EOF seen, whichever comes first - buf_len = buf.tell() - if buf_len >= size: - # Already have size bytes in our buffer? Extract and return. - buf.seek(0) - rv = buf.read(size) - self._rbuf = StringIO.StringIO() - self._rbuf.write(buf.read()) - return rv - - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - left = size - buf_len - # recv() will malloc the amount of memory given as its - # parameter even though it often returns much less data - # than that. The returned data string is short lived - # as we copy it into a StringIO and free it. This avoids - # fragmentation issues on many platforms. - data = self.recv(left) - if not data: - break - n = len(data) - if n == size and not buf_len: - # Shortcut. Avoid buffer data copies when: - # - We have no data in our buffer. - # AND - # - Our call to recv returned exactly the - # number of bytes we were asked to read. - return data - if n == left: - buf.write(data) - del data # explicit free - break - assert n <= left, "recv(%d) returned %d bytes" % (left, n) - buf.write(data) - buf_len += n - del data # explicit free - #assert buf_len == buf.tell() - return buf.getvalue() - - def readline(self, size= -1): - buf = self._rbuf - buf.seek(0, 2) # seek end - if buf.tell() > 0: - # check if we already have it in our buffer - buf.seek(0) - bline = buf.readline(size) - if bline.endswith('\n') or len(bline) == size: - self._rbuf = StringIO.StringIO() - self._rbuf.write(buf.read()) - return bline - del bline - if size < 0: - # Read until \n or EOF, whichever comes first - if self._rbufsize <= 1: - # Speed up unbuffered case - buf.seek(0) - buffers = [buf.read()] - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - data = None - recv = self.recv - while data != "\n": - data = recv(1) - if not data: - break - buffers.append(data) - return "".join(buffers) - - buf.seek(0, 2) # seek end - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - data = self.recv(self._rbufsize) - if not data: - break - nl = data.find('\n') - if nl >= 0: - nl += 1 - buf.write(data[:nl]) - self._rbuf.write(data[nl:]) - del data - break - buf.write(data) - return buf.getvalue() - else: - # Read until size bytes or \n or EOF seen, whichever comes first - buf.seek(0, 2) # seek end - buf_len = buf.tell() - if buf_len >= size: - buf.seek(0) - rv = buf.read(size) - self._rbuf = StringIO.StringIO() - self._rbuf.write(buf.read()) - return rv - self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. - while True: - data = self.recv(self._rbufsize) - if not data: - break - left = size - buf_len - # did we just receive a newline? - nl = data.find('\n', 0, left) - if nl >= 0: - nl += 1 - # save the excess data to _rbuf - self._rbuf.write(data[nl:]) - if buf_len: - buf.write(data[:nl]) - break - else: - # Shortcut. Avoid data copy through buf when returning - # a substring of our first recv(). - return data[:nl] - n = len(data) - if n == size and not buf_len: - # Shortcut. Avoid data copy through buf when - # returning exactly all of our first recv(). - return data - if n >= left: - buf.write(data[:left]) - self._rbuf.write(data[left:]) - break - buf.write(data) - buf_len += n - #assert buf_len == buf.tell() - return buf.getvalue() - -else: - class CP_fileobject(socket._fileobject): - """Faux file object attached to a socket object.""" - - def sendall(self, data): - """Sendall for non-blocking sockets.""" - while data: - try: - bytes_sent = self.send(data) - data = data[bytes_sent:] - except socket.error, e: - if e.args[0] not in socket_errors_nonblocking: - raise - - def send(self, data): - return self._sock.send(data) - - def flush(self): - if self._wbuf: - buffer = "".join(self._wbuf) - self._wbuf = [] - self.sendall(buffer) - - def recv(self, size): - while True: - try: - return self._sock.recv(size) - except socket.error, e: - if (e.args[0] not in socket_errors_nonblocking - and e.args[0] not in socket_error_eintr): - raise - - def read(self, size= -1): - if size < 0: - # Read until EOF - buffers = [self._rbuf] - self._rbuf = "" - if self._rbufsize <= 1: - recv_size = self.default_bufsize - else: - recv_size = self._rbufsize - - while True: - data = self.recv(recv_size) - if not data: - break - buffers.append(data) - return "".join(buffers) - else: - # Read until size bytes or EOF seen, whichever comes first - data = self._rbuf - buf_len = len(data) - if buf_len >= size: - self._rbuf = data[size:] - return data[:size] - buffers = [] - if data: - buffers.append(data) - self._rbuf = "" - while True: - left = size - buf_len - recv_size = max(self._rbufsize, left) - data = self.recv(recv_size) - if not data: - break - buffers.append(data) - n = len(data) - if n >= left: - self._rbuf = data[left:] - buffers[-1] = data[:left] - break - buf_len += n - return "".join(buffers) - - def readline(self, size= -1): - data = self._rbuf - if size < 0: - # Read until \n or EOF, whichever comes first - if self._rbufsize <= 1: - # Speed up unbuffered case - assert data == "" - buffers = [] - while data != "\n": - data = self.recv(1) - if not data: - break - buffers.append(data) - return "".join(buffers) - nl = data.find('\n') - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - return data[:nl] - buffers = [] - if data: - buffers.append(data) - self._rbuf = "" - while True: - data = self.recv(self._rbufsize) - if not data: - break - buffers.append(data) - nl = data.find('\n') - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - buffers[-1] = data[:nl] - break - return "".join(buffers) - else: - # Read until size bytes or \n or EOF seen, whichever comes first - nl = data.find('\n', 0, size) - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - return data[:nl] - buf_len = len(data) - if buf_len >= size: - self._rbuf = data[size:] - return data[:size] - buffers = [] - if data: - buffers.append(data) - self._rbuf = "" - while True: - data = self.recv(self._rbufsize) - if not data: - break - buffers.append(data) - left = size - buf_len - nl = data.find('\n', 0, left) - if nl >= 0: - nl += 1 - self._rbuf = data[nl:] - buffers[-1] = data[:nl] - break - n = len(data) - if n >= left: - self._rbuf = data[left:] - buffers[-1] = data[:left] - break - buf_len += n - return "".join(buffers) - - -class HTTPConnection(object): - """An HTTP connection (active socket). - - server: the Server object which received this connection. - socket: the raw socket object (usually TCP) for this connection. - makefile: a fileobject class for reading from the socket. - """ - - remote_addr = None - remote_port = None - ssl_env = None - rbufsize = -1 - RequestHandlerClass = HTTPRequest - - def __init__(self, server, sock, makefile=CP_fileobject): - self.server = server - self.socket = sock - self.rfile = makefile(sock, "rb", self.rbufsize) - self.wfile = makefile(sock, "wb", -1) - - def communicate(self): - """Read each request and respond appropriately.""" - request_seen = False - try: - while True: - # (re)set req to None so that if something goes wrong in - # the RequestHandlerClass constructor, the error doesn't - # get written to the previous request. - req = None - req = self.RequestHandlerClass(self.server, self) - - # This order of operations should guarantee correct pipelining. - req.parse_request() - if not req.ready: - # Something went wrong in the parsing (and the server has - # probably already made a simple_response). Return and - # let the conn close. - return - - request_seen = True - req.respond() - if req.close_connection: - return - except socket.error, e: - errnum = e.args[0] - if errnum == 'timed out': - # Don't error if we're between requests; only error - # if 1) no request has been started at all, or 2) we're - # in the middle of a request. - # See http://www.cherrypy.org/ticket/853 - if (not request_seen) or (req and req.started_request): - # Don't bother writing the 408 if the response - # has already started being written. - if req and not req.sent_headers: - try: - req.simple_response("408 Request Timeout") - except FatalSSLAlert: - # Close the connection. - return - elif errnum not in socket_errors_to_ignore: - if req and not req.sent_headers: - try: - req.simple_response("500 Internal Server Error", - format_exc()) - except FatalSSLAlert: - # Close the connection. - return - return - except (KeyboardInterrupt, SystemExit): - raise - except FatalSSLAlert: - # Close the connection. - return - except NoSSLError: - if req and not req.sent_headers: - # Unwrap our wfile - self.wfile = CP_fileobject(self.socket._sock, "wb", -1) - req.simple_response("400 Bad Request", - "The client sent a plain HTTP request, but " - "this server only speaks HTTPS on this port.") - self.linger = True - except Exception: - if req and not req.sent_headers: - try: - req.simple_response("500 Internal Server Error", format_exc()) - except FatalSSLAlert: - # Close the connection. - return - - linger = False - - def close(self): - """Close the socket underlying this connection.""" - self.rfile.close() - - if not self.linger: - # Python's socket module does NOT call close on the kernel socket - # when you call socket.close(). We do so manually here because we - # want this server to send a FIN TCP segment immediately. Note this - # must be called *before* calling socket.close(), because the latter - # drops its reference to the kernel socket. - if hasattr(self.socket, '_sock'): - self.socket._sock.close() - self.socket.close() - else: - # On the other hand, sometimes we want to hang around for a bit - # to make sure the client has a chance to read our entire - # response. Skipping the close() calls here delays the FIN - # packet until the socket object is garbage-collected later. - # Someday, perhaps, we'll do the full lingering_close that - # Apache does, but not today. - pass - - -def format_exc(limit=None): - """Like print_exc() but return a string. Backport for Python 2.3.""" - try: - etype, value, tb = sys.exc_info() - return ''.join(traceback.format_exception(etype, value, tb, limit)) - finally: - etype = value = tb = None - - -_SHUTDOWNREQUEST = None - -class WorkerThread(threading.Thread): - """Thread which continuously polls a Queue for Connection objects. - - server: the HTTP Server which spawned this thread, and which owns the - Queue and is placing active connections into it. - ready: a simple flag for the calling server to know when this thread - has begun polling the Queue. - - Due to the timing issues of polling a Queue, a WorkerThread does not - check its own 'ready' flag after it has started. To stop the thread, - it is necessary to stick a _SHUTDOWNREQUEST object onto the Queue - (one for each running WorkerThread). - """ - - conn = None - - def __init__(self, server): - self.ready = False - self.server = server - threading.Thread.__init__(self) - - def run(self): - try: - self.ready = True - while True: - conn = self.server.requests.get() - if conn is _SHUTDOWNREQUEST: - return - - self.conn = conn - try: - conn.communicate() - finally: - conn.close() - self.conn = None - except (KeyboardInterrupt, SystemExit), exc: - self.server.interrupt = exc - - -class ThreadPool(object): - """A Request Queue for the CherryPyWSGIServer which pools threads. - - ThreadPool objects must provide min, get(), put(obj), start() - and stop(timeout) attributes. - """ - - def __init__(self, server, min=10, max= -1): - self.server = server - self.min = min - self.max = max - self._threads = [] - self._queue = Queue.Queue() - self.get = self._queue.get - - def start(self): - """Start the pool of threads.""" - for i in range(self.min): - self._threads.append(WorkerThread(self.server)) - for worker in self._threads: - worker.setName("CP Server " + worker.getName()) - worker.start() - for worker in self._threads: - while not worker.ready: - time.sleep(.1) - - def _get_idle(self): - """Number of worker threads which are idle. Read-only.""" - return len([t for t in self._threads if t.conn is None]) - idle = property(_get_idle, doc=_get_idle.__doc__) - - def put(self, obj): - self._queue.put(obj) - if obj is _SHUTDOWNREQUEST: - return - - def grow(self, amount): - """Spawn new worker threads (not above self.max).""" - for i in range(amount): - if self.max > 0 and len(self._threads) >= self.max: - break - worker = WorkerThread(self.server) - worker.setName("CP Server " + worker.getName()) - self._threads.append(worker) - worker.start() - - def shrink(self, amount): - """Kill off worker threads (not below self.min).""" - # Grow/shrink the pool if necessary. - # Remove any dead threads from our list - for t in self._threads: - if not t.isAlive(): - self._threads.remove(t) - amount -= 1 - - if amount > 0: - for i in range(min(amount, len(self._threads) - self.min)): - # Put a number of shutdown requests on the queue equal - # to 'amount'. Once each of those is processed by a worker, - # that worker will terminate and be culled from our list - # in self.put. - self._queue.put(_SHUTDOWNREQUEST) - - def stop(self, timeout=5): - # Must shut down threads here so the code that calls - # this method can know when all threads are stopped. - for worker in self._threads: - self._queue.put(_SHUTDOWNREQUEST) - - # Don't join currentThread (when stop is called inside a request). - current = threading.currentThread() - if timeout and timeout >= 0: - endtime = time.time() + timeout - while self._threads: - worker = self._threads.pop() - if worker is not current and worker.isAlive(): - try: - if timeout is None or timeout < 0: - worker.join() - else: - remaining_time = endtime - time.time() - if remaining_time > 0: - worker.join(remaining_time) - if worker.isAlive(): - # We exhausted the timeout. - # Forcibly shut down the socket. - c = worker.conn - if c and not c.rfile.closed: - try: - c.socket.shutdown(socket.SHUT_RD) - except TypeError: - # pyOpenSSL sockets don't take an arg - c.socket.shutdown() - worker.join() - except (AssertionError, - # Ignore repeated Ctrl-C. - # See http://www.cherrypy.org/ticket/691. - KeyboardInterrupt), exc1: - pass - - - -try: - import fcntl -except ImportError: - try: - from ctypes import windll, WinError - except ImportError: - def prevent_socket_inheritance(sock): - """Dummy function, since neither fcntl nor ctypes are available.""" - pass - else: - def prevent_socket_inheritance(sock): - """Mark the given socket fd as non-inheritable (Windows).""" - if not windll.kernel32.SetHandleInformation(sock.fileno(), 1, 0): - raise WinError() -else: - def prevent_socket_inheritance(sock): - """Mark the given socket fd as non-inheritable (POSIX).""" - fd = sock.fileno() - old_flags = fcntl.fcntl(fd, fcntl.F_GETFD) - fcntl.fcntl(fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC) - - -class SSLAdapter(object): - - def __init__(self, certificate, private_key, certificate_chain=None): - self.certificate = certificate - self.private_key = private_key - self.certificate_chain = certificate_chain - - def wrap(self, sock): - raise NotImplemented - - def makefile(self, sock, mode='r', bufsize= -1): - raise NotImplemented - - -class HTTPServer(object): - """An HTTP server. - - bind_addr: The interface on which to listen for connections. - For TCP sockets, a (host, port) tuple. Host values may be any IPv4 - or IPv6 address, or any valid hostname. The string 'localhost' is a - synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). - The string '0.0.0.0' is a special IPv4 entry meaning "any active - interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for - IPv6. The empty string or None are not allowed. - - For UNIX sockets, supply the filename as a string. - gateway: a Gateway instance. - minthreads: the minimum number of worker threads to create (default 10). - maxthreads: the maximum number of worker threads to create (default -1 = no limit). - server_name: defaults to socket.gethostname(). - - request_queue_size: the 'backlog' argument to socket.listen(); - specifies the maximum number of queued connections (default 5). - timeout: the timeout in seconds for accepted connections (default 10). - nodelay: if True (the default since 3.1), sets the TCP_NODELAY socket - option. - protocol: the version string to write in the Status-Line of all - HTTP responses. For example, "HTTP/1.1" (the default). This - also limits the supported features used in the response. - - - SSL/HTTPS - --------- - You must have an ssl library installed and set self.ssl_adapter to an - instance of SSLAdapter (or a subclass) which provides the methods: - wrap(sock) -> wrapped socket, ssl environ dict - makefile(sock, mode='r', bufsize=-1) -> socket file object - """ - - protocol = "HTTP/1.1" - _bind_addr = "127.0.0.1" - version = "CherryPy/3.2.0rc1" - response_header = None - ready = False - _interrupt = None - max_request_header_size = 0 - max_request_body_size = 0 - nodelay = True - - ConnectionClass = HTTPConnection - - ssl_adapter = None - - def __init__(self, bind_addr, gateway, minthreads=10, maxthreads= -1, - server_name=None): - self.bind_addr = bind_addr - self.gateway = gateway - - self.requests = ThreadPool(self, min=minthreads or 1, max=maxthreads) - - if not server_name: - server_name = socket.gethostname() - self.server_name = server_name - - def __str__(self): - return "%s.%s(%r)" % (self.__module__, self.__class__.__name__, - self.bind_addr) - - def _get_bind_addr(self): - return self._bind_addr - def _set_bind_addr(self, value): - if isinstance(value, tuple) and value[0] in ('', None): - # Despite the socket module docs, using '' does not - # allow AI_PASSIVE to work. Passing None instead - # returns '0.0.0.0' like we want. In other words: - # host AI_PASSIVE result - # '' Y 192.168.x.y - # '' N 192.168.x.y - # None Y 0.0.0.0 - # None N 127.0.0.1 - # But since you can get the same effect with an explicit - # '0.0.0.0', we deny both the empty string and None as values. - raise ValueError("Host values of '' or None are not allowed. " - "Use '0.0.0.0' (IPv4) or '::' (IPv6) instead " - "to listen on all active interfaces.") - self._bind_addr = value - bind_addr = property(_get_bind_addr, _set_bind_addr, - doc="""The interface on which to listen for connections. - - For TCP sockets, a (host, port) tuple. Host values may be any IPv4 - or IPv6 address, or any valid hostname. The string 'localhost' is a - synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). - The string '0.0.0.0' is a special IPv4 entry meaning "any active - interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for - IPv6. The empty string or None are not allowed. - - For UNIX sockets, supply the filename as a string.""") - - def start(self): - """Run the server forever.""" - # We don't have to trap KeyboardInterrupt or SystemExit here, - # because cherrpy.server already does so, calling self.stop() for us. - # If you're using this server with another framework, you should - # trap those exceptions in whatever code block calls start(). - self._interrupt = None - - # SSL backward compatibility - if (self.ssl_adapter is None and - getattr(self, 'ssl_certificate', None) and - getattr(self, 'ssl_private_key', None)): - warnings.warn( - "SSL attributes are deprecated in CherryPy 3.2, and will " - "be removed in CherryPy 3.3. Use an ssl_adapter attribute " - "instead.", - DeprecationWarning - ) - try: - from cherrypy.wsgiserver.ssl_pyopenssl import pyOpenSSLAdapter - except ImportError: - pass - else: - self.ssl_adapter = pyOpenSSLAdapter( - self.ssl_certificate, self.ssl_private_key, - getattr(self, 'ssl_certificate_chain', None)) - - # Select the appropriate socket - if isinstance(self.bind_addr, basestring): - # AF_UNIX socket - - # So we can reuse the socket... - try: os.unlink(self.bind_addr) - except: pass - - # So everyone can access the socket... - try: os.chmod(self.bind_addr, 0777) - except: pass - - info = [(socket.AF_UNIX, socket.SOCK_STREAM, 0, "", self.bind_addr)] - else: - # AF_INET or AF_INET6 socket - # Get the correct address family for our host (allows IPv6 addresses) - host, port = self.bind_addr - try: - info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, - socket.SOCK_STREAM, 0, socket.AI_PASSIVE) - except socket.gaierror: - if ':' in self.bind_addr[0]: - info = [(socket.AF_INET6, socket.SOCK_STREAM, - 0, "", self.bind_addr + (0, 0))] - else: - info = [(socket.AF_INET, socket.SOCK_STREAM, - 0, "", self.bind_addr)] - - self.socket = None - msg = "No socket could be created" - for res in info: - af, socktype, proto, canonname, sa = res - try: - self.bind(af, socktype, proto) - except socket.error, msg: - if self.socket: - self.socket.close() - self.socket = None - continue - break - if not self.socket: - raise socket.error(msg) - - # Timeout so KeyboardInterrupt can be caught on Win32 - self.socket.settimeout(1) - self.socket.listen(self.request_queue_size) - - # Create worker threads - self.requests.start() - - self.ready = True - while self.ready: - self.tick() - if self.interrupt: - while self.interrupt is True: - # Wait for self.stop() to complete. See _set_interrupt. - time.sleep(0.1) - if self.interrupt: - raise self.interrupt - - def bind(self, family, type, proto=0): - """Create (or recreate) the actual socket object.""" - self.socket = socket.socket(family, type, proto) - prevent_socket_inheritance(self.socket) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if self.nodelay and not isinstance(self.bind_addr, str): - self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - - if self.ssl_adapter is not None: - self.socket = self.ssl_adapter.bind(self.socket) - - # If listening on the IPV6 any address ('::' = IN6ADDR_ANY), - # activate dual-stack. See http://www.cherrypy.org/ticket/871. - if (family == socket.AF_INET6 - and self.bind_addr[0] in ('::', '::0', '::0.0.0.0')): - try: - self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) - except (AttributeError, socket.error): - # Apparently, the socket option is not available in - # this machine's TCP stack - pass - - self.socket.bind(self.bind_addr) - - def tick(self): - """Accept a new connection and put it on the Queue.""" - try: - s, addr = self.socket.accept() - if not self.ready: - return - - prevent_socket_inheritance(s) - if hasattr(s, 'settimeout'): - s.settimeout(self.timeout) - - if self.response_header is None: - self.response_header = "%s Server" % self.version - - makefile = CP_fileobject - ssl_env = {} - # if ssl cert and key are set, we try to be a secure HTTP server - if self.ssl_adapter is not None: - try: - s, ssl_env = self.ssl_adapter.wrap(s) - except NoSSLError: - msg = ("The client sent a plain HTTP request, but " - "this server only speaks HTTPS on this port.") - buf = ["%s 400 Bad Request\r\n" % self.protocol, - "Content-Length: %s\r\n" % len(msg), - "Content-Type: text/plain\r\n\r\n", - msg] - - wfile = CP_fileobject(s, "wb", -1) - try: - wfile.sendall("".join(buf)) - except socket.error, x: - if x.args[0] not in socket_errors_to_ignore: - raise - return - if not s: - return - makefile = self.ssl_adapter.makefile - - conn = self.ConnectionClass(self, s, makefile) - - if not isinstance(self.bind_addr, basestring): - # optional values - # Until we do DNS lookups, omit REMOTE_HOST - if addr is None: # sometimes this can happen - # figure out if AF_INET or AF_INET6. - if len(s.getsockname()) == 2: - # AF_INET - addr = ('0.0.0.0', 0) - else: - # AF_INET6 - addr = ('::', 0) - conn.remote_addr = addr[0] - conn.remote_port = addr[1] - - conn.ssl_env = ssl_env - - self.requests.put(conn) - except socket.timeout: - # The only reason for the timeout in start() is so we can - # notice keyboard interrupts on Win32, which don't interrupt - # accept() by default - return - except socket.error, x: - if x.args[0] in socket_error_eintr: - # I *think* this is right. EINTR should occur when a signal - # is received during the accept() call; all docs say retry - # the call, and I *think* I'm reading it right that Python - # will then go ahead and poll for and handle the signal - # elsewhere. See http://www.cherrypy.org/ticket/707. - return - if x.args[0] in socket_errors_nonblocking: - # Just try again. See http://www.cherrypy.org/ticket/479. - return - if x.args[0] in socket_errors_to_ignore: - # Our socket was closed. - # See http://www.cherrypy.org/ticket/686. - return - raise - - def _get_interrupt(self): - return self._interrupt - def _set_interrupt(self, interrupt): - self._interrupt = True - self.stop() - self._interrupt = interrupt - interrupt = property(_get_interrupt, _set_interrupt, - doc="Set this to an Exception instance to " - "interrupt the server.") - - def stop(self): - """Gracefully shutdown a server that is serving forever.""" - self.ready = False - - sock = getattr(self, "socket", None) - if sock: - if not isinstance(self.bind_addr, basestring): - # Touch our own socket to make accept() return immediately. - try: - host, port = sock.getsockname()[:2] - except socket.error, x: - if x.args[0] not in socket_errors_to_ignore: - # Changed to use error code and not message - # See http://www.cherrypy.org/ticket/860. - raise - else: - # Note that we're explicitly NOT using AI_PASSIVE, - # here, because we want an actual IP to touch. - # localhost won't work if we've bound to a public IP, - # but it will if we bound to '0.0.0.0' (INADDR_ANY). - for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, - socket.SOCK_STREAM): - af, socktype, proto, canonname, sa = res - s = None - try: - s = socket.socket(af, socktype, proto) - # See http://groups.google.com/group/cherrypy-users/ - # browse_frm/thread/bbfe5eb39c904fe0 - s.settimeout(1.0) - s.connect((host, port)) - s.close() - except socket.error: - if s: - s.close() - if hasattr(sock, "close"): - sock.close() - self.socket = None - - self.requests.stop(self.shutdown_timeout) - - -class Gateway(object): - - def __init__(self, req): - self.req = req - - def respond(self): - raise NotImplemented - - -# These may either be wsgiserver.SSLAdapter subclasses or the string names -# of such classes (in which case they will be lazily loaded). -ssl_adapters = { - 'builtin': 'cherrypy.wsgiserver.ssl_builtin.BuiltinSSLAdapter', - 'pyopenssl': 'cherrypy.wsgiserver.ssl_pyopenssl.pyOpenSSLAdapter', - } - -def get_ssl_adapter_class(name='pyopenssl'): - adapter = ssl_adapters[name.lower()] - if isinstance(adapter, basestring): - last_dot = adapter.rfind(".") - attr_name = adapter[last_dot + 1:] - mod_path = adapter[:last_dot] - - try: - mod = sys.modules[mod_path] - if mod is None: - raise KeyError() - except KeyError: - # The last [''] is important. - mod = __import__(mod_path, globals(), locals(), ['']) - - # Let an AttributeError propagate outward. - try: - adapter = getattr(mod, attr_name) - except AttributeError: - raise AttributeError("'%s' object has no attribute '%s'" - % (mod_path, attr_name)) - - return adapter - -# -------------------------------- WSGI Stuff -------------------------------- # - - -class CherryPyWSGIServer(HTTPServer): - - wsgi_version = (1, 1) - - def __init__(self, bind_addr, wsgi_app, numthreads=10, server_name=None, - max= -1, request_queue_size=5, timeout=10, shutdown_timeout=5): - self.requests = ThreadPool(self, min=numthreads or 1, max=max) - self.wsgi_app = wsgi_app - self.gateway = wsgi_gateways[self.wsgi_version] - - self.bind_addr = bind_addr - if not server_name: - server_name = socket.gethostname() - self.server_name = server_name - self.request_queue_size = request_queue_size - - self.timeout = timeout - self.shutdown_timeout = shutdown_timeout - - def _get_numthreads(self): - return self.requests.min - def _set_numthreads(self, value): - self.requests.min = value - numthreads = property(_get_numthreads, _set_numthreads) - - -class WSGIGateway(Gateway): - - def __init__(self, req): - self.req = req - self.started_response = False - self.env = self.get_environ() - - def get_environ(self): - """Return a new environ dict targeting the given wsgi.version""" - raise NotImplemented - - def respond(self): - response = self.req.server.wsgi_app(self.env, self.start_response) - try: - for chunk in response: - # "The start_response callable must not actually transmit - # the response headers. Instead, it must store them for the - # server or gateway to transmit only after the first - # iteration of the application return value that yields - # a NON-EMPTY string, or upon the application's first - # invocation of the write() callable." (PEP 333) - if chunk: - if isinstance(chunk, unicode): - chunk = chunk.encode('ISO-8859-1') - self.write(chunk) - finally: - if hasattr(response, "close"): - response.close() - - def start_response(self, status, headers, exc_info=None): - """WSGI callable to begin the HTTP response.""" - # "The application may call start_response more than once, - # if and only if the exc_info argument is provided." - if self.started_response and not exc_info: - raise AssertionError("WSGI start_response called a second " - "time with no exc_info.") - self.started_response = True - - # "if exc_info is provided, and the HTTP headers have already been - # sent, start_response must raise an error, and should raise the - # exc_info tuple." - if self.req.sent_headers: - try: - raise exc_info[0], exc_info[1], exc_info[2] - finally: - exc_info = None - - self.req.status = status - for k, v in headers: - if not isinstance(k, str): - raise TypeError("WSGI response header key %r is not a byte string." % k) - if not isinstance(v, str): - raise TypeError("WSGI response header value %r is not a byte string." % v) - self.req.outheaders.extend(headers) - - return self.write - - def write(self, chunk): - """WSGI callable to write unbuffered data to the client. - - This method is also used internally by start_response (to write - data from the iterable returned by the WSGI application). - """ - if not self.started_response: - raise AssertionError("WSGI write called before start_response.") - - if not self.req.sent_headers: - self.req.sent_headers = True - self.req.send_headers() - - self.req.write(chunk) - - -class WSGIGateway_10(WSGIGateway): - - def get_environ(self): - """Return a new environ dict targeting the given wsgi.version""" - req = self.req - env = { - # set a non-standard environ entry so the WSGI app can know what - # the *real* server protocol is (and what features to support). - # See http://www.faqs.org/rfcs/rfc2145.html. - 'ACTUAL_SERVER_PROTOCOL': req.server.protocol, - 'PATH_INFO': req.path, - 'QUERY_STRING': req.qs, - 'REMOTE_ADDR': req.conn.remote_addr or '', - 'REMOTE_PORT': str(req.conn.remote_port or ''), - 'REQUEST_METHOD': req.method, - 'REQUEST_URI': req.uri, - 'SCRIPT_NAME': '', - 'SERVER_NAME': req.server.server_name, - # Bah. "SERVER_PROTOCOL" is actually the REQUEST protocol. - 'SERVER_PROTOCOL': req.request_protocol, - 'wsgi.errors': sys.stderr, - 'wsgi.input': req.rfile, - 'wsgi.multiprocess': False, - 'wsgi.multithread': True, - 'wsgi.run_once': False, - 'wsgi.url_scheme': req.scheme, - 'wsgi.version': (1, 0), - } - - if isinstance(req.server.bind_addr, basestring): - # AF_UNIX. This isn't really allowed by WSGI, which doesn't - # address unix domain sockets. But it's better than nothing. - env["SERVER_PORT"] = "" - else: - env["SERVER_PORT"] = str(req.server.bind_addr[1]) - - # CONTENT_TYPE/CONTENT_LENGTH - for k, v in req.inheaders.iteritems(): - env["HTTP_" + k.upper().replace("-", "_")] = v - ct = env.pop("HTTP_CONTENT_TYPE", None) - if ct is not None: - env["CONTENT_TYPE"] = ct - cl = env.pop("HTTP_CONTENT_LENGTH", None) - if cl is not None: - env["CONTENT_LENGTH"] = cl - - if req.conn.ssl_env: - env.update(req.conn.ssl_env) - - return env - - -class WSGIGateway_11(WSGIGateway_10): - - def get_environ(self): - env = WSGIGateway_10.get_environ(self) - env['wsgi.version'] = (1, 1) - return env - - -class WSGIGateway_u0(WSGIGateway_10): - - def get_environ(self): - """Return a new environ dict targeting the given wsgi.version""" - req = self.req - env_10 = WSGIGateway_10.get_environ(self) - env = dict([(k.decode('ISO-8859-1'), v) for k, v in env_10.iteritems()]) - env[u'wsgi.version'] = ('u', 0) - - # Request-URI - env.setdefault(u'wsgi.url_encoding', u'utf-8') - try: - for key in [u"PATH_INFO", u"SCRIPT_NAME", u"QUERY_STRING"]: - env[key] = env_10[str(key)].decode(env[u'wsgi.url_encoding']) - except UnicodeDecodeError: - # Fall back to latin 1 so apps can transcode if needed. - env[u'wsgi.url_encoding'] = u'ISO-8859-1' - for key in [u"PATH_INFO", u"SCRIPT_NAME", u"QUERY_STRING"]: - env[key] = env_10[str(key)].decode(env[u'wsgi.url_encoding']) - - for k, v in sorted(env.items()): - if isinstance(v, str) and k not in ('REQUEST_URI', 'wsgi.input'): - env[k] = v.decode('ISO-8859-1') - - return env - -wsgi_gateways = { - (1, 0): WSGIGateway_10, - (1, 1): WSGIGateway_11, - ('u', 0): WSGIGateway_u0, -} - -class WSGIPathInfoDispatcher(object): - """A WSGI dispatcher for dispatch based on the PATH_INFO. - - apps: a dict or list of (path_prefix, app) pairs. - """ - - def __init__(self, apps): - try: - apps = apps.items() - except AttributeError: - pass - - # Sort the apps by len(path), descending - apps.sort(cmp=lambda x, y: cmp(len(x[0]), len(y[0]))) - apps.reverse() - - # The path_prefix strings must start, but not end, with a slash. - # Use "" instead of "/". - self.apps = [(p.rstrip("/"), a) for p, a in apps] - - def __call__(self, environ, start_response): - path = environ["PATH_INFO"] or "/" - for p, app in self.apps: - # The apps list should be sorted by length, descending. - if path.startswith(p + "/") or path == p: - environ = environ.copy() - environ["SCRIPT_NAME"] = environ["SCRIPT_NAME"] + p - environ["PATH_INFO"] = path[len(p):] - return app(environ, start_response) - - start_response('404 Not Found', [('Content-Type', 'text/plain'), - ('Content-Length', '0')]) - return [''] +"""A high-speed, production ready, thread pooled, generic HTTP server. + +Simplest example on how to use this module directly +(without using CherryPy's application machinery): + + from cherrypy import wsgiserver + + def my_crazy_app(environ, start_response): + status = '200 OK' + response_headers = [('Content-type','text/plain')] + start_response(status, response_headers) + return ['Hello world!\n'] + + server = wsgiserver.CherryPyWSGIServer( + ('0.0.0.0', 8070), my_crazy_app, + server_name='www.cherrypy.example') + +The CherryPy WSGI server can serve as many WSGI applications +as you want in one instance by using a WSGIPathInfoDispatcher: + + d = WSGIPathInfoDispatcher({'/': my_crazy_app, '/blog': my_blog_app}) + server = wsgiserver.CherryPyWSGIServer(('0.0.0.0', 80), d) + +Want SSL support? Just set server.ssl_adapter to an SSLAdapter instance. + +This won't call the CherryPy engine (application side) at all, only the +HTTP server, which is independent from the rest of CherryPy. Don't +let the name "CherryPyWSGIServer" throw you; the name merely reflects +its origin, not its coupling. + +For those of you wanting to understand internals of this module, here's the +basic call flow. The server's listening thread runs a very tight loop, +sticking incoming connections onto a Queue: + + server = CherryPyWSGIServer(...) + server.start() + while True: + tick() + # This blocks until a request comes in: + child = socket.accept() + conn = HTTPConnection(child, ...) + server.requests.put(conn) + +Worker threads are kept in a pool and poll the Queue, popping off and then +handling each connection in turn. Each connection can consist of an arbitrary +number of requests and their responses, so we run a nested loop: + + while True: + conn = server.requests.get() + conn.communicate() + -> while True: + req = HTTPRequest(...) + req.parse_request() + -> # Read the Request-Line, e.g. "GET /page HTTP/1.1" + req.rfile.readline() + read_headers(req.rfile, req.inheaders) + req.respond() + -> response = app(...) + try: + for chunk in response: + if chunk: + req.write(chunk) + finally: + if hasattr(response, "close"): + response.close() + if req.close_connection: + return +""" + +CRLF = '\r\n' +import os +import Queue +import re +quoted_slash = re.compile("(?i)%2F") +import rfc822 +import socket +import sys +if 'win' in sys.platform and not hasattr(socket, 'IPPROTO_IPV6'): + socket.IPPROTO_IPV6 = 41 +try: + import cStringIO as StringIO +except ImportError: + import StringIO + +_fileobject_uses_str_type = isinstance(socket._fileobject(None)._rbuf, basestring) + +import threading +import time +import traceback +from urllib import unquote +from urlparse import urlparse +import warnings + +import errno + +def plat_specific_errors(*errnames): + """Return error numbers for all errors in errnames on this platform. + + The 'errno' module contains different global constants depending on + the specific platform (OS). This function will return the list of + numeric values for a given list of potential names. + """ + errno_names = dir(errno) + nums = [getattr(errno, k) for k in errnames if k in errno_names] + # de-dupe the list + return dict.fromkeys(nums).keys() + +socket_error_eintr = plat_specific_errors("EINTR", "WSAEINTR") + +socket_errors_to_ignore = plat_specific_errors( + "EPIPE", + "EBADF", "WSAEBADF", + "ENOTSOCK", "WSAENOTSOCK", + "ETIMEDOUT", "WSAETIMEDOUT", + "ECONNREFUSED", "WSAECONNREFUSED", + "ECONNRESET", "WSAECONNRESET", + "ECONNABORTED", "WSAECONNABORTED", + "ENETRESET", "WSAENETRESET", + "EHOSTDOWN", "EHOSTUNREACH", + ) +socket_errors_to_ignore.append("timed out") +socket_errors_to_ignore.append("The read operation timed out") + +socket_errors_nonblocking = plat_specific_errors( + 'EAGAIN', 'EWOULDBLOCK', 'WSAEWOULDBLOCK') + +comma_separated_headers = ['Accept', 'Accept-Charset', 'Accept-Encoding', + 'Accept-Language', 'Accept-Ranges', 'Allow', 'Cache-Control', + 'Connection', 'Content-Encoding', 'Content-Language', 'Expect', + 'If-Match', 'If-None-Match', 'Pragma', 'Proxy-Authenticate', 'TE', + 'Trailer', 'Transfer-Encoding', 'Upgrade', 'Vary', 'Via', 'Warning', + 'WWW-Authenticate'] + + +def read_headers(rfile, hdict=None): + """Read headers from the given stream into the given header dict. + + If hdict is None, a new header dict is created. Returns the populated + header dict. + + Headers which are repeated are folded together using a comma if their + specification so dictates. + + This function raises ValueError when the read bytes violate the HTTP spec. + You should probably return "400 Bad Request" if this happens. + """ + if hdict is None: + hdict = {} + + while True: + line = rfile.readline() + if not line: + # No more data--illegal end of headers + raise ValueError("Illegal end of headers.") + + if line == CRLF: + # Normal end of headers + break + if not line.endswith(CRLF): + raise ValueError("HTTP requires CRLF terminators") + + if line[0] in ' \t': + # It's a continuation line. + v = line.strip() + else: + try: + k, v = line.split(":", 1) + except ValueError: + raise ValueError("Illegal header line.") + # TODO: what about TE and WWW-Authenticate? + k = k.strip().title() + v = v.strip() + hname = k + + if k in comma_separated_headers: + existing = hdict.get(hname) + if existing: + v = ", ".join((existing, v)) + hdict[hname] = v + + return hdict + + +class MaxSizeExceeded(Exception): + pass + +class SizeCheckWrapper(object): + """Wraps a file-like object, raising MaxSizeExceeded if too large.""" + + def __init__(self, rfile, maxlen): + self.rfile = rfile + self.maxlen = maxlen + self.bytes_read = 0 + + def _check_length(self): + if self.maxlen and self.bytes_read > self.maxlen: + raise MaxSizeExceeded() + + def read(self, size=None): + data = self.rfile.read(size) + self.bytes_read += len(data) + self._check_length() + return data + + def readline(self, size=None): + if size is not None: + data = self.rfile.readline(size) + self.bytes_read += len(data) + self._check_length() + return data + + # User didn't specify a size ... + # We read the line in chunks to make sure it's not a 100MB line ! + res = [] + while True: + data = self.rfile.readline(256) + self.bytes_read += len(data) + self._check_length() + res.append(data) + # See http://www.cherrypy.org/ticket/421 + if len(data) < 256 or data[-1:] == "\n": + return ''.join(res) + + def readlines(self, sizehint=0): + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline() + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline() + return lines + + def close(self): + self.rfile.close() + + def __iter__(self): + return self + + def next(self): + data = self.rfile.next() + self.bytes_read += len(data) + self._check_length() + return data + + +class KnownLengthRFile(object): + """Wraps a file-like object, returning an empty string when exhausted.""" + + def __init__(self, rfile, content_length): + self.rfile = rfile + self.remaining = content_length + + def read(self, size=None): + if self.remaining == 0: + return '' + if size is None: + size = self.remaining + else: + size = min(size, self.remaining) + + data = self.rfile.read(size) + self.remaining -= len(data) + return data + + def readline(self, size=None): + if self.remaining == 0: + return '' + if size is None: + size = self.remaining + else: + size = min(size, self.remaining) + + data = self.rfile.readline(size) + self.remaining -= len(data) + return data + + def readlines(self, sizehint=0): + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def close(self): + self.rfile.close() + + def __iter__(self): + return self + + def __next__(self): + data = next(self.rfile) + self.remaining -= len(data) + return data + + +class MaxSizeExceeded(Exception): + pass + + +class ChunkedRFile(object): + """Wraps a file-like object, returning an empty string when exhausted. + + This class is intended to provide a conforming wsgi.input value for + request entities that have been encoded with the 'chunked' transfer + encoding. + """ + + def __init__(self, rfile, maxlen, bufsize=8192): + self.rfile = rfile + self.maxlen = maxlen + self.bytes_read = 0 + self.buffer = '' + self.bufsize = bufsize + self.closed = False + + def _fetch(self): + if self.closed: + return + + line = self.rfile.readline() + self.bytes_read += len(line) + + if self.maxlen and self.bytes_read > self.maxlen: + raise MaxSizeExceeded("Request Entity Too Large", self.maxlen) + + line = line.strip().split(";", 1) + + try: + chunk_size = line.pop(0) + chunk_size = int(chunk_size, 16) + except ValueError: + raise ValueError("Bad chunked transfer size: " + repr(chunk_size)) + + if chunk_size <= 0: + self.closed = True + return + +## if line: chunk_extension = line[0] + + if self.maxlen and self.bytes_read + chunk_size > self.maxlen: + raise IOError("Request Entity Too Large") + + chunk = self.rfile.read(chunk_size) + self.bytes_read += len(chunk) + self.buffer += chunk + + crlf = self.rfile.read(2) + if crlf != CRLF: + raise ValueError( + "Bad chunked transfer coding (expected '\\r\\n', " + "got " + repr(crlf) + ")") + + def read(self, size=None): + data = '' + while True: + if size and len(data) >= size: + return data + + if not self.buffer: + self._fetch() + if not self.buffer: + # EOF + return data + + if size: + remaining = size - len(data) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + data += self.buffer + + def readline(self, size=None): + data = '' + while True: + if size and len(data) >= size: + return data + + if not self.buffer: + self._fetch() + if not self.buffer: + # EOF + return data + + newline_pos = self.buffer.find('\n') + if size: + if newline_pos == -1: + remaining = size - len(data) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + remaining = min(size - len(data), newline_pos) + data += self.buffer[:remaining] + self.buffer = self.buffer[remaining:] + else: + if newline_pos == -1: + data += self.buffer + else: + data += self.buffer[:newline_pos] + self.buffer = self.buffer[newline_pos:] + + def readlines(self, sizehint=0): + # Shamelessly stolen from StringIO + total = 0 + lines = [] + line = self.readline(sizehint) + while line: + lines.append(line) + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + return lines + + def read_trailer_lines(self): + if not self.closed: + raise ValueError( + "Cannot read trailers until the request body has been read.") + + while True: + line = self.rfile.readline() + if not line: + # No more data--illegal end of headers + raise ValueError("Illegal end of headers.") + + self.bytes_read += len(line) + if self.maxlen and self.bytes_read > self.maxlen: + raise IOError("Request Entity Too Large") + + if line == CRLF: + # Normal end of headers + break + if not line.endswith(CRLF): + raise ValueError("HTTP requires CRLF terminators") + + yield line + + def close(self): + self.rfile.close() + + def __iter__(self): + # Shamelessly stolen from StringIO + total = 0 + line = self.readline(sizehint) + while line: + yield line + total += len(line) + if 0 < sizehint <= total: + break + line = self.readline(sizehint) + + +class HTTPRequest(object): + """An HTTP Request (and response). + + A single HTTP connection may consist of multiple request/response pairs. + + server: the Server object which is receiving this request. + conn: the HTTPConnection object on which this request connected. + + inheaders: a dict of request headers. + outheaders: a list of header tuples to write in the response. + ready: when True, the request has been parsed and is ready to begin + generating the response. When False, signals the calling Connection + that the response should not be generated and the connection should + close. + close_connection: signals the calling Connection that the request + should close. This does not imply an error! The client and/or + server may each request that the connection be closed. + chunked_write: if True, output will be encoded with the "chunked" + transfer-coding. This value is set automatically inside + send_headers. + """ + + def __init__(self, server, conn): + self.server = server + self.conn = conn + + self.ready = False + self.started_request = False + self.scheme = "http" + if self.server.ssl_adapter is not None: + self.scheme = "https" + self.inheaders = {} + + self.status = "" + self.outheaders = [] + self.sent_headers = False + self.close_connection = False + self.chunked_write = False + + def parse_request(self): + """Parse the next HTTP request start-line and message-headers.""" + self.rfile = SizeCheckWrapper(self.conn.rfile, + self.server.max_request_header_size) + try: + self._parse_request() + except MaxSizeExceeded: + self.simple_response("413 Request Entity Too Large") + return + + def _parse_request(self): + # HTTP/1.1 connections are persistent by default. If a client + # requests a page, then idles (leaves the connection open), + # then rfile.readline() will raise socket.error("timed out"). + # Note that it does this based on the value given to settimeout(), + # and doesn't need the client to request or acknowledge the close + # (although your TCP stack might suffer for it: cf Apache's history + # with FIN_WAIT_2). + request_line = self.rfile.readline() + + # Set started_request to True so communicate() knows to send 408 + # from here on out. + self.started_request = True + if not request_line: + # Force self.ready = False so the connection will close. + self.ready = False + return + + if request_line == CRLF: + # RFC 2616 sec 4.1: "...if the server is reading the protocol + # stream at the beginning of a message and receives a CRLF + # first, it should ignore the CRLF." + # But only ignore one leading line! else we enable a DoS. + request_line = self.rfile.readline() + if not request_line: + self.ready = False + return + + if not request_line.endswith(CRLF): + self.simple_response(400, "HTTP requires CRLF terminators") + return + + try: + method, uri, req_protocol = request_line.strip().split(" ", 2) + except ValueError: + self.simple_response(400, "Malformed Request-Line") + return + + self.uri = uri + self.method = method + + # uri may be an abs_path (including "http://host.domain.tld"); + scheme, authority, path = self.parse_request_uri(uri) + if '#' in path: + self.simple_response("400 Bad Request", + "Illegal #fragment in Request-URI.") + return + + if scheme: + self.scheme = scheme + + qs = '' + if '?' in path: + path, qs = path.split('?', 1) + + # Unquote the path+params (e.g. "/this%20path" -> "/this path"). + # http://www.w3.org/Protocols/rfc2616/rfc2616-sec5.html#sec5.1.2 + # + # But note that "...a URI must be separated into its components + # before the escaped characters within those components can be + # safely decoded." http://www.ietf.org/rfc/rfc2396.txt, sec 2.4.2 + # Therefore, "/this%2Fpath" becomes "/this%2Fpath", not "/this/path". + try: + atoms = [unquote(x) for x in quoted_slash.split(path)] + except ValueError, ex: + self.simple_response("400 Bad Request", ex.args[0]) + return + path = "%2F".join(atoms) + self.path = path + + # Note that, like wsgiref and most other HTTP servers, + # we "% HEX HEX"-unquote the path but not the query string. + self.qs = qs + + # Compare request and server HTTP protocol versions, in case our + # server does not support the requested protocol. Limit our output + # to min(req, server). We want the following output: + # request server actual written supported response + # protocol protocol response protocol feature set + # a 1.0 1.0 1.0 1.0 + # b 1.0 1.1 1.1 1.0 + # c 1.1 1.0 1.0 1.0 + # d 1.1 1.1 1.1 1.1 + # Notice that, in (b), the response will be "HTTP/1.1" even though + # the client only understands 1.0. RFC 2616 10.5.6 says we should + # only return 505 if the _major_ version is different. + rp = int(req_protocol[5]), int(req_protocol[7]) + sp = int(self.server.protocol[5]), int(self.server.protocol[7]) + + if sp[0] != rp[0]: + self.simple_response("505 HTTP Version Not Supported") + return + self.request_protocol = req_protocol + self.response_protocol = "HTTP/%s.%s" % min(rp, sp) + + # then all the http headers + try: + read_headers(self.rfile, self.inheaders) + except ValueError, ex: + self.simple_response("400 Bad Request", ex.args[0]) + return + + mrbs = self.server.max_request_body_size + if mrbs and int(self.inheaders.get("Content-Length", 0)) > mrbs: + self.simple_response("413 Request Entity Too Large") + return + + # Persistent connection support + if self.response_protocol == "HTTP/1.1": + # Both server and client are HTTP/1.1 + if self.inheaders.get("Connection", "") == "close": + self.close_connection = True + else: + # Either the server or client (or both) are HTTP/1.0 + if self.inheaders.get("Connection", "") != "Keep-Alive": + self.close_connection = True + + # Transfer-Encoding support + te = None + if self.response_protocol == "HTTP/1.1": + te = self.inheaders.get("Transfer-Encoding") + if te: + te = [x.strip().lower() for x in te.split(",") if x.strip()] + + self.chunked_read = False + + if te: + for enc in te: + if enc == "chunked": + self.chunked_read = True + else: + # Note that, even if we see "chunked", we must reject + # if there is an extension we don't recognize. + self.simple_response("501 Unimplemented") + self.close_connection = True + return + + # From PEP 333: + # "Servers and gateways that implement HTTP 1.1 must provide + # transparent support for HTTP 1.1's "expect/continue" mechanism. + # This may be done in any of several ways: + # 1. Respond to requests containing an Expect: 100-continue request + # with an immediate "100 Continue" response, and proceed normally. + # 2. Proceed with the request normally, but provide the application + # with a wsgi.input stream that will send the "100 Continue" + # response if/when the application first attempts to read from + # the input stream. The read request must then remain blocked + # until the client responds. + # 3. Wait until the client decides that the server does not support + # expect/continue, and sends the request body on its own. + # (This is suboptimal, and is not recommended.) + # + # We used to do 3, but are now doing 1. Maybe we'll do 2 someday, + # but it seems like it would be a big slowdown for such a rare case. + if self.inheaders.get("Expect", "") == "100-continue": + # Don't use simple_response here, because it emits headers + # we don't want. See http://www.cherrypy.org/ticket/951 + msg = self.server.protocol + " 100 Continue\r\n\r\n" + try: + self.conn.wfile.sendall(msg) + except socket.error, x: + if x.args[0] not in socket_errors_to_ignore: + raise + + self.ready = True + + def parse_request_uri(self, uri): + """Parse a Request-URI into (scheme, authority, path). + + Note that Request-URI's must be one of: + + Request-URI = "*" | absoluteURI | abs_path | authority + + Therefore, a Request-URI which starts with a double forward-slash + cannot be a "net_path": + + net_path = "//" authority [ abs_path ] + + Instead, it must be interpreted as an "abs_path" with an empty first + path segment: + + abs_path = "/" path_segments + path_segments = segment *( "/" segment ) + segment = *pchar *( ";" param ) + param = *pchar + """ + if uri == "*": + return None, None, uri + + i = uri.find('://') + if i > 0 and '?' not in uri[:i]: + # An absoluteURI. + # If there's a scheme (and it must be http or https), then: + # http_URL = "http:" "//" host [ ":" port ] [ abs_path [ "?" query ]] + scheme, remainder = uri[:i].lower(), uri[i + 3:] + authority, path = remainder.split("/", 1) + return scheme, authority, path + + if uri.startswith('/'): + # An abs_path. + return None, None, uri + else: + # An authority. + return None, uri, None + + def respond(self): + """Call the gateway and write its iterable output.""" + mrbs = self.server.max_request_body_size + if self.chunked_read: + self.rfile = ChunkedRFile(self.conn.rfile, mrbs) + else: + cl = int(self.inheaders.get("Content-Length", 0)) + if mrbs and mrbs < cl: + if not self.sent_headers: + self.simple_response("413 Request Entity Too Large") + return + self.rfile = KnownLengthRFile(self.conn.rfile, cl) + + self.server.gateway(self).respond() + + if (self.ready and not self.sent_headers): + self.sent_headers = True + self.send_headers() + if self.chunked_write: + self.conn.wfile.sendall("0\r\n\r\n") + + def simple_response(self, status, msg=""): + """Write a simple response back to the client.""" + status = str(status) + buf = [self.server.protocol + " " + + status + CRLF, + "Content-Length: %s\r\n" % len(msg), + "Content-Type: text/plain\r\n"] + + if status[:3] == "413" and self.response_protocol == 'HTTP/1.1': + # Request Entity Too Large + self.close_connection = True + buf.append("Connection: close\r\n") + + buf.append(CRLF) + if msg: + if isinstance(msg, unicode): + msg = msg.encode("ISO-8859-1") + buf.append(msg) + + try: + self.conn.wfile.sendall("".join(buf)) + except socket.error, x: + if x.args[0] not in socket_errors_to_ignore: + raise + + def write(self, chunk): + """Write unbuffered data to the client.""" + if self.chunked_write and chunk: + buf = [hex(len(chunk))[2:], CRLF, chunk, CRLF] + self.conn.wfile.sendall("".join(buf)) + else: + self.conn.wfile.sendall(chunk) + + def send_headers(self): + """Assert, process, and send the HTTP response message-headers. + + You must set self.status, and self.outheaders before calling this. + """ + hkeys = [key.lower() for key, value in self.outheaders] + status = int(self.status[:3]) + + if status == 413: + # Request Entity Too Large. Close conn to avoid garbage. + self.close_connection = True + elif "content-length" not in hkeys: + # "All 1xx (informational), 204 (no content), + # and 304 (not modified) responses MUST NOT + # include a message-body." So no point chunking. + if status < 200 or status in (204, 205, 304): + pass + else: + if (self.response_protocol == 'HTTP/1.1' + and self.method != 'HEAD'): + # Use the chunked transfer-coding + self.chunked_write = True + self.outheaders.append(("Transfer-Encoding", "chunked")) + else: + # Closing the conn is the only way to determine len. + self.close_connection = True + + if "connection" not in hkeys: + if self.response_protocol == 'HTTP/1.1': + # Both server and client are HTTP/1.1 or better + if self.close_connection: + self.outheaders.append(("Connection", "close")) + else: + # Server and/or client are HTTP/1.0 + if not self.close_connection: + self.outheaders.append(("Connection", "Keep-Alive")) + + if (not self.close_connection) and (not self.chunked_read): + # Read any remaining request body data on the socket. + # "If an origin server receives a request that does not include an + # Expect request-header field with the "100-continue" expectation, + # the request includes a request body, and the server responds + # with a final status code before reading the entire request body + # from the transport connection, then the server SHOULD NOT close + # the transport connection until it has read the entire request, + # or until the client closes the connection. Otherwise, the client + # might not reliably receive the response message. However, this + # requirement is not be construed as preventing a server from + # defending itself against denial-of-service attacks, or from + # badly broken client implementations." + remaining = getattr(self.rfile, 'remaining', 0) + if remaining > 0: + self.rfile.read(remaining) + + if "date" not in hkeys: + self.outheaders.append(("Date", rfc822.formatdate())) + + if "server" not in hkeys: + self.outheaders.append(("Server", self.server.server_name)) + + buf = [self.server.protocol + " " + self.status + CRLF] + for k, v in self.outheaders: + buf.append(k + ": " + v + CRLF) + buf.append(CRLF) + self.conn.wfile.sendall("".join(buf)) + + +class NoSSLError(Exception): + """Exception raised when a client speaks HTTP to an HTTPS socket.""" + pass + + +class FatalSSLAlert(Exception): + """Exception raised when the SSL implementation signals a fatal alert.""" + pass + + +if not _fileobject_uses_str_type: + class CP_fileobject(socket._fileobject): + """Faux file object attached to a socket object.""" + + def sendall(self, data): + """Sendall for non-blocking sockets.""" + while data: + try: + bytes_sent = self.send(data) + data = data[bytes_sent:] + except socket.error, e: + if e.args[0] not in socket_errors_nonblocking: + raise + + def send(self, data): + return self._sock.send(data) + + def flush(self): + if self._wbuf: + buffer = "".join(self._wbuf) + self._wbuf = [] + self.sendall(buffer) + + def recv(self, size): + while True: + try: + return self._sock.recv(size) + except socket.error, e: + if (e.args[0] not in socket_errors_nonblocking + and e.args[0] not in socket_error_eintr): + raise + + def read(self, size= -1): + # Use max, disallow tiny reads in a loop as they are very inefficient. + # We never leave read() with any leftover data from a new recv() call + # in our internal buffer. + rbufsize = max(self._rbufsize, self.default_bufsize) + # Our use of StringIO rather than lists of string objects returned by + # recv() minimizes memory usage and fragmentation that occurs when + # rbufsize is large compared to the typical return value of recv(). + buf = self._rbuf + buf.seek(0, 2) # seek end + if size < 0: + # Read until EOF + self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. + while True: + data = self.recv(rbufsize) + if not data: + break + buf.write(data) + return buf.getvalue() + else: + # Read until size bytes or EOF seen, whichever comes first + buf_len = buf.tell() + if buf_len >= size: + # Already have size bytes in our buffer? Extract and return. + buf.seek(0) + rv = buf.read(size) + self._rbuf = StringIO.StringIO() + self._rbuf.write(buf.read()) + return rv + + self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. + while True: + left = size - buf_len + # recv() will malloc the amount of memory given as its + # parameter even though it often returns much less data + # than that. The returned data string is short lived + # as we copy it into a StringIO and free it. This avoids + # fragmentation issues on many platforms. + data = self.recv(left) + if not data: + break + n = len(data) + if n == size and not buf_len: + # Shortcut. Avoid buffer data copies when: + # - We have no data in our buffer. + # AND + # - Our call to recv returned exactly the + # number of bytes we were asked to read. + return data + if n == left: + buf.write(data) + del data # explicit free + break + assert n <= left, "recv(%d) returned %d bytes" % (left, n) + buf.write(data) + buf_len += n + del data # explicit free + #assert buf_len == buf.tell() + return buf.getvalue() + + def readline(self, size= -1): + buf = self._rbuf + buf.seek(0, 2) # seek end + if buf.tell() > 0: + # check if we already have it in our buffer + buf.seek(0) + bline = buf.readline(size) + if bline.endswith('\n') or len(bline) == size: + self._rbuf = StringIO.StringIO() + self._rbuf.write(buf.read()) + return bline + del bline + if size < 0: + # Read until \n or EOF, whichever comes first + if self._rbufsize <= 1: + # Speed up unbuffered case + buf.seek(0) + buffers = [buf.read()] + self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. + data = None + recv = self.recv + while data != "\n": + data = recv(1) + if not data: + break + buffers.append(data) + return "".join(buffers) + + buf.seek(0, 2) # seek end + self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. + while True: + data = self.recv(self._rbufsize) + if not data: + break + nl = data.find('\n') + if nl >= 0: + nl += 1 + buf.write(data[:nl]) + self._rbuf.write(data[nl:]) + del data + break + buf.write(data) + return buf.getvalue() + else: + # Read until size bytes or \n or EOF seen, whichever comes first + buf.seek(0, 2) # seek end + buf_len = buf.tell() + if buf_len >= size: + buf.seek(0) + rv = buf.read(size) + self._rbuf = StringIO.StringIO() + self._rbuf.write(buf.read()) + return rv + self._rbuf = StringIO.StringIO() # reset _rbuf. we consume it via buf. + while True: + data = self.recv(self._rbufsize) + if not data: + break + left = size - buf_len + # did we just receive a newline? + nl = data.find('\n', 0, left) + if nl >= 0: + nl += 1 + # save the excess data to _rbuf + self._rbuf.write(data[nl:]) + if buf_len: + buf.write(data[:nl]) + break + else: + # Shortcut. Avoid data copy through buf when returning + # a substring of our first recv(). + return data[:nl] + n = len(data) + if n == size and not buf_len: + # Shortcut. Avoid data copy through buf when + # returning exactly all of our first recv(). + return data + if n >= left: + buf.write(data[:left]) + self._rbuf.write(data[left:]) + break + buf.write(data) + buf_len += n + #assert buf_len == buf.tell() + return buf.getvalue() + +else: + class CP_fileobject(socket._fileobject): + """Faux file object attached to a socket object.""" + + def sendall(self, data): + """Sendall for non-blocking sockets.""" + while data: + try: + bytes_sent = self.send(data) + data = data[bytes_sent:] + except socket.error, e: + if e.args[0] not in socket_errors_nonblocking: + raise + + def send(self, data): + return self._sock.send(data) + + def flush(self): + if self._wbuf: + buffer = "".join(self._wbuf) + self._wbuf = [] + self.sendall(buffer) + + def recv(self, size): + while True: + try: + return self._sock.recv(size) + except socket.error, e: + if (e.args[0] not in socket_errors_nonblocking + and e.args[0] not in socket_error_eintr): + raise + + def read(self, size= -1): + if size < 0: + # Read until EOF + buffers = [self._rbuf] + self._rbuf = "" + if self._rbufsize <= 1: + recv_size = self.default_bufsize + else: + recv_size = self._rbufsize + + while True: + data = self.recv(recv_size) + if not data: + break + buffers.append(data) + return "".join(buffers) + else: + # Read until size bytes or EOF seen, whichever comes first + data = self._rbuf + buf_len = len(data) + if buf_len >= size: + self._rbuf = data[size:] + return data[:size] + buffers = [] + if data: + buffers.append(data) + self._rbuf = "" + while True: + left = size - buf_len + recv_size = max(self._rbufsize, left) + data = self.recv(recv_size) + if not data: + break + buffers.append(data) + n = len(data) + if n >= left: + self._rbuf = data[left:] + buffers[-1] = data[:left] + break + buf_len += n + return "".join(buffers) + + def readline(self, size= -1): + data = self._rbuf + if size < 0: + # Read until \n or EOF, whichever comes first + if self._rbufsize <= 1: + # Speed up unbuffered case + assert data == "" + buffers = [] + while data != "\n": + data = self.recv(1) + if not data: + break + buffers.append(data) + return "".join(buffers) + nl = data.find('\n') + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + return data[:nl] + buffers = [] + if data: + buffers.append(data) + self._rbuf = "" + while True: + data = self.recv(self._rbufsize) + if not data: + break + buffers.append(data) + nl = data.find('\n') + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + buffers[-1] = data[:nl] + break + return "".join(buffers) + else: + # Read until size bytes or \n or EOF seen, whichever comes first + nl = data.find('\n', 0, size) + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + return data[:nl] + buf_len = len(data) + if buf_len >= size: + self._rbuf = data[size:] + return data[:size] + buffers = [] + if data: + buffers.append(data) + self._rbuf = "" + while True: + data = self.recv(self._rbufsize) + if not data: + break + buffers.append(data) + left = size - buf_len + nl = data.find('\n', 0, left) + if nl >= 0: + nl += 1 + self._rbuf = data[nl:] + buffers[-1] = data[:nl] + break + n = len(data) + if n >= left: + self._rbuf = data[left:] + buffers[-1] = data[:left] + break + buf_len += n + return "".join(buffers) + + +class HTTPConnection(object): + """An HTTP connection (active socket). + + server: the Server object which received this connection. + socket: the raw socket object (usually TCP) for this connection. + makefile: a fileobject class for reading from the socket. + """ + + remote_addr = None + remote_port = None + ssl_env = None + rbufsize = -1 + RequestHandlerClass = HTTPRequest + + def __init__(self, server, sock, makefile=CP_fileobject): + self.server = server + self.socket = sock + self.rfile = makefile(sock, "rb", self.rbufsize) + self.wfile = makefile(sock, "wb", -1) + + def communicate(self): + """Read each request and respond appropriately.""" + request_seen = False + try: + while True: + # (re)set req to None so that if something goes wrong in + # the RequestHandlerClass constructor, the error doesn't + # get written to the previous request. + req = None + req = self.RequestHandlerClass(self.server, self) + + # This order of operations should guarantee correct pipelining. + req.parse_request() + if not req.ready: + # Something went wrong in the parsing (and the server has + # probably already made a simple_response). Return and + # let the conn close. + return + + request_seen = True + req.respond() + if req.close_connection: + return + except socket.error, e: + errnum = e.args[0] + if errnum == 'timed out': + # Don't error if we're between requests; only error + # if 1) no request has been started at all, or 2) we're + # in the middle of a request. + # See http://www.cherrypy.org/ticket/853 + if (not request_seen) or (req and req.started_request): + # Don't bother writing the 408 if the response + # has already started being written. + if req and not req.sent_headers: + try: + req.simple_response("408 Request Timeout") + except FatalSSLAlert: + # Close the connection. + return + elif errnum not in socket_errors_to_ignore: + if req and not req.sent_headers: + try: + req.simple_response("500 Internal Server Error", + format_exc()) + except FatalSSLAlert: + # Close the connection. + return + return + except (KeyboardInterrupt, SystemExit): + raise + except FatalSSLAlert: + # Close the connection. + return + except NoSSLError: + if req and not req.sent_headers: + # Unwrap our wfile + self.wfile = CP_fileobject(self.socket._sock, "wb", -1) + req.simple_response("400 Bad Request", + "The client sent a plain HTTP request, but " + "this server only speaks HTTPS on this port.") + self.linger = True + except Exception: + if req and not req.sent_headers: + try: + req.simple_response("500 Internal Server Error", format_exc()) + except FatalSSLAlert: + # Close the connection. + return + + linger = False + + def close(self): + """Close the socket underlying this connection.""" + self.rfile.close() + + if not self.linger: + # Python's socket module does NOT call close on the kernel socket + # when you call socket.close(). We do so manually here because we + # want this server to send a FIN TCP segment immediately. Note this + # must be called *before* calling socket.close(), because the latter + # drops its reference to the kernel socket. + if hasattr(self.socket, '_sock'): + self.socket._sock.close() + self.socket.close() + else: + # On the other hand, sometimes we want to hang around for a bit + # to make sure the client has a chance to read our entire + # response. Skipping the close() calls here delays the FIN + # packet until the socket object is garbage-collected later. + # Someday, perhaps, we'll do the full lingering_close that + # Apache does, but not today. + pass + + +def format_exc(limit=None): + """Like print_exc() but return a string. Backport for Python 2.3.""" + try: + etype, value, tb = sys.exc_info() + return ''.join(traceback.format_exception(etype, value, tb, limit)) + finally: + etype = value = tb = None + + +_SHUTDOWNREQUEST = None + +class WorkerThread(threading.Thread): + """Thread which continuously polls a Queue for Connection objects. + + server: the HTTP Server which spawned this thread, and which owns the + Queue and is placing active connections into it. + ready: a simple flag for the calling server to know when this thread + has begun polling the Queue. + + Due to the timing issues of polling a Queue, a WorkerThread does not + check its own 'ready' flag after it has started. To stop the thread, + it is necessary to stick a _SHUTDOWNREQUEST object onto the Queue + (one for each running WorkerThread). + """ + + conn = None + + def __init__(self, server): + self.ready = False + self.server = server + threading.Thread.__init__(self) + + def run(self): + try: + self.ready = True + while True: + conn = self.server.requests.get() + if conn is _SHUTDOWNREQUEST: + return + + self.conn = conn + try: + conn.communicate() + finally: + conn.close() + self.conn = None + except (KeyboardInterrupt, SystemExit), exc: + self.server.interrupt = exc + + +class ThreadPool(object): + """A Request Queue for the CherryPyWSGIServer which pools threads. + + ThreadPool objects must provide min, get(), put(obj), start() + and stop(timeout) attributes. + """ + + def __init__(self, server, min=10, max= -1): + self.server = server + self.min = min + self.max = max + self._threads = [] + self._queue = Queue.Queue() + self.get = self._queue.get + + def start(self): + """Start the pool of threads.""" + for i in range(self.min): + self._threads.append(WorkerThread(self.server)) + for worker in self._threads: + worker.setName("CP Server " + worker.getName()) + worker.start() + for worker in self._threads: + while not worker.ready: + time.sleep(.1) + + def _get_idle(self): + """Number of worker threads which are idle. Read-only.""" + return len([t for t in self._threads if t.conn is None]) + idle = property(_get_idle, doc=_get_idle.__doc__) + + def put(self, obj): + self._queue.put(obj) + if obj is _SHUTDOWNREQUEST: + return + + def grow(self, amount): + """Spawn new worker threads (not above self.max).""" + for i in range(amount): + if self.max > 0 and len(self._threads) >= self.max: + break + worker = WorkerThread(self.server) + worker.setName("CP Server " + worker.getName()) + self._threads.append(worker) + worker.start() + + def shrink(self, amount): + """Kill off worker threads (not below self.min).""" + # Grow/shrink the pool if necessary. + # Remove any dead threads from our list + for t in self._threads: + if not t.isAlive(): + self._threads.remove(t) + amount -= 1 + + if amount > 0: + for i in range(min(amount, len(self._threads) - self.min)): + # Put a number of shutdown requests on the queue equal + # to 'amount'. Once each of those is processed by a worker, + # that worker will terminate and be culled from our list + # in self.put. + self._queue.put(_SHUTDOWNREQUEST) + + def stop(self, timeout=5): + # Must shut down threads here so the code that calls + # this method can know when all threads are stopped. + for worker in self._threads: + self._queue.put(_SHUTDOWNREQUEST) + + # Don't join currentThread (when stop is called inside a request). + current = threading.currentThread() + if timeout and timeout >= 0: + endtime = time.time() + timeout + while self._threads: + worker = self._threads.pop() + if worker is not current and worker.isAlive(): + try: + if timeout is None or timeout < 0: + worker.join() + else: + remaining_time = endtime - time.time() + if remaining_time > 0: + worker.join(remaining_time) + if worker.isAlive(): + # We exhausted the timeout. + # Forcibly shut down the socket. + c = worker.conn + if c and not c.rfile.closed: + try: + c.socket.shutdown(socket.SHUT_RD) + except TypeError: + # pyOpenSSL sockets don't take an arg + c.socket.shutdown() + worker.join() + except (AssertionError, + # Ignore repeated Ctrl-C. + # See http://www.cherrypy.org/ticket/691. + KeyboardInterrupt), exc1: + pass + + + +try: + import fcntl +except ImportError: + try: + from ctypes import windll, WinError + except ImportError: + def prevent_socket_inheritance(sock): + """Dummy function, since neither fcntl nor ctypes are available.""" + pass + else: + def prevent_socket_inheritance(sock): + """Mark the given socket fd as non-inheritable (Windows).""" + if not windll.kernel32.SetHandleInformation(sock.fileno(), 1, 0): + raise WinError() +else: + def prevent_socket_inheritance(sock): + """Mark the given socket fd as non-inheritable (POSIX).""" + fd = sock.fileno() + old_flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, old_flags | fcntl.FD_CLOEXEC) + + +class SSLAdapter(object): + + def __init__(self, certificate, private_key, certificate_chain=None): + self.certificate = certificate + self.private_key = private_key + self.certificate_chain = certificate_chain + + def wrap(self, sock): + raise NotImplemented + + def makefile(self, sock, mode='r', bufsize= -1): + raise NotImplemented + + +class HTTPServer(object): + """An HTTP server. + + bind_addr: The interface on which to listen for connections. + For TCP sockets, a (host, port) tuple. Host values may be any IPv4 + or IPv6 address, or any valid hostname. The string 'localhost' is a + synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). + The string '0.0.0.0' is a special IPv4 entry meaning "any active + interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for + IPv6. The empty string or None are not allowed. + + For UNIX sockets, supply the filename as a string. + gateway: a Gateway instance. + minthreads: the minimum number of worker threads to create (default 10). + maxthreads: the maximum number of worker threads to create (default -1 = no limit). + server_name: defaults to socket.gethostname(). + + request_queue_size: the 'backlog' argument to socket.listen(); + specifies the maximum number of queued connections (default 5). + timeout: the timeout in seconds for accepted connections (default 10). + nodelay: if True (the default since 3.1), sets the TCP_NODELAY socket + option. + protocol: the version string to write in the Status-Line of all + HTTP responses. For example, "HTTP/1.1" (the default). This + also limits the supported features used in the response. + + + SSL/HTTPS + --------- + You must have an ssl library installed and set self.ssl_adapter to an + instance of SSLAdapter (or a subclass) which provides the methods: + wrap(sock) -> wrapped socket, ssl environ dict + makefile(sock, mode='r', bufsize=-1) -> socket file object + """ + + protocol = "HTTP/1.1" + _bind_addr = "127.0.0.1" + version = "CherryPy/3.2.0rc1" + response_header = None + ready = False + _interrupt = None + max_request_header_size = 0 + max_request_body_size = 0 + nodelay = True + + ConnectionClass = HTTPConnection + + ssl_adapter = None + + def __init__(self, bind_addr, gateway, minthreads=10, maxthreads= -1, + server_name=None): + self.bind_addr = bind_addr + self.gateway = gateway + + self.requests = ThreadPool(self, min=minthreads or 1, max=maxthreads) + + if not server_name: + server_name = socket.gethostname() + self.server_name = server_name + + def __str__(self): + return "%s.%s(%r)" % (self.__module__, self.__class__.__name__, + self.bind_addr) + + def _get_bind_addr(self): + return self._bind_addr + def _set_bind_addr(self, value): + if isinstance(value, tuple) and value[0] in ('', None): + # Despite the socket module docs, using '' does not + # allow AI_PASSIVE to work. Passing None instead + # returns '0.0.0.0' like we want. In other words: + # host AI_PASSIVE result + # '' Y 192.168.x.y + # '' N 192.168.x.y + # None Y 0.0.0.0 + # None N 127.0.0.1 + # But since you can get the same effect with an explicit + # '0.0.0.0', we deny both the empty string and None as values. + raise ValueError("Host values of '' or None are not allowed. " + "Use '0.0.0.0' (IPv4) or '::' (IPv6) instead " + "to listen on all active interfaces.") + self._bind_addr = value + bind_addr = property(_get_bind_addr, _set_bind_addr, + doc="""The interface on which to listen for connections. + + For TCP sockets, a (host, port) tuple. Host values may be any IPv4 + or IPv6 address, or any valid hostname. The string 'localhost' is a + synonym for '127.0.0.1' (or '::1', if your hosts file prefers IPv6). + The string '0.0.0.0' is a special IPv4 entry meaning "any active + interface" (INADDR_ANY), and '::' is the similar IN6ADDR_ANY for + IPv6. The empty string or None are not allowed. + + For UNIX sockets, supply the filename as a string.""") + + def start(self): + """Run the server forever.""" + # We don't have to trap KeyboardInterrupt or SystemExit here, + # because cherrpy.server already does so, calling self.stop() for us. + # If you're using this server with another framework, you should + # trap those exceptions in whatever code block calls start(). + self._interrupt = None + + # SSL backward compatibility + if (self.ssl_adapter is None and + getattr(self, 'ssl_certificate', None) and + getattr(self, 'ssl_private_key', None)): + warnings.warn( + "SSL attributes are deprecated in CherryPy 3.2, and will " + "be removed in CherryPy 3.3. Use an ssl_adapter attribute " + "instead.", + DeprecationWarning + ) + try: + from cherrypy.wsgiserver.ssl_pyopenssl import pyOpenSSLAdapter + except ImportError: + pass + else: + self.ssl_adapter = pyOpenSSLAdapter( + self.ssl_certificate, self.ssl_private_key, + getattr(self, 'ssl_certificate_chain', None)) + + # Select the appropriate socket + if isinstance(self.bind_addr, basestring): + # AF_UNIX socket + + # So we can reuse the socket... + try: os.unlink(self.bind_addr) + except: pass + + # So everyone can access the socket... + try: os.chmod(self.bind_addr, 0777) + except: pass + + info = [(socket.AF_UNIX, socket.SOCK_STREAM, 0, "", self.bind_addr)] + else: + # AF_INET or AF_INET6 socket + # Get the correct address family for our host (allows IPv6 addresses) + host, port = self.bind_addr + try: + info = socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM, 0, socket.AI_PASSIVE) + except socket.gaierror: + if ':' in self.bind_addr[0]: + info = [(socket.AF_INET6, socket.SOCK_STREAM, + 0, "", self.bind_addr + (0, 0))] + else: + info = [(socket.AF_INET, socket.SOCK_STREAM, + 0, "", self.bind_addr)] + + self.socket = None + msg = "No socket could be created" + for res in info: + af, socktype, proto, canonname, sa = res + try: + self.bind(af, socktype, proto) + except socket.error, msg: + if self.socket: + self.socket.close() + self.socket = None + continue + break + if not self.socket: + raise socket.error(msg) + + # Timeout so KeyboardInterrupt can be caught on Win32 + self.socket.settimeout(1) + self.socket.listen(self.request_queue_size) + + # Create worker threads + self.requests.start() + + self.ready = True + while self.ready: + self.tick() + if self.interrupt: + while self.interrupt is True: + # Wait for self.stop() to complete. See _set_interrupt. + time.sleep(0.1) + if self.interrupt: + raise self.interrupt + + def bind(self, family, type, proto=0): + """Create (or recreate) the actual socket object.""" + self.socket = socket.socket(family, type, proto) + prevent_socket_inheritance(self.socket) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if self.nodelay and not isinstance(self.bind_addr, str): + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + if self.ssl_adapter is not None: + self.socket = self.ssl_adapter.bind(self.socket) + + # If listening on the IPV6 any address ('::' = IN6ADDR_ANY), + # activate dual-stack. See http://www.cherrypy.org/ticket/871. + if (family == socket.AF_INET6 + and self.bind_addr[0] in ('::', '::0', '::0.0.0.0')): + try: + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + except (AttributeError, socket.error): + # Apparently, the socket option is not available in + # this machine's TCP stack + pass + + self.socket.bind(self.bind_addr) + + def tick(self): + """Accept a new connection and put it on the Queue.""" + try: + s, addr = self.socket.accept() + if not self.ready: + return + + prevent_socket_inheritance(s) + if hasattr(s, 'settimeout'): + s.settimeout(self.timeout) + + if self.response_header is None: + self.response_header = "%s Server" % self.version + + makefile = CP_fileobject + ssl_env = {} + # if ssl cert and key are set, we try to be a secure HTTP server + if self.ssl_adapter is not None: + try: + s, ssl_env = self.ssl_adapter.wrap(s) + except NoSSLError: + msg = ("The client sent a plain HTTP request, but " + "this server only speaks HTTPS on this port.") + buf = ["%s 400 Bad Request\r\n" % self.protocol, + "Content-Length: %s\r\n" % len(msg), + "Content-Type: text/plain\r\n\r\n", + msg] + + wfile = CP_fileobject(s, "wb", -1) + try: + wfile.sendall("".join(buf)) + except socket.error, x: + if x.args[0] not in socket_errors_to_ignore: + raise + return + if not s: + return + makefile = self.ssl_adapter.makefile + + conn = self.ConnectionClass(self, s, makefile) + + if not isinstance(self.bind_addr, basestring): + # optional values + # Until we do DNS lookups, omit REMOTE_HOST + if addr is None: # sometimes this can happen + # figure out if AF_INET or AF_INET6. + if len(s.getsockname()) == 2: + # AF_INET + addr = ('0.0.0.0', 0) + else: + # AF_INET6 + addr = ('::', 0) + conn.remote_addr = addr[0] + conn.remote_port = addr[1] + + conn.ssl_env = ssl_env + + self.requests.put(conn) + except socket.timeout: + # The only reason for the timeout in start() is so we can + # notice keyboard interrupts on Win32, which don't interrupt + # accept() by default + return + except socket.error, x: + if x.args[0] in socket_error_eintr: + # I *think* this is right. EINTR should occur when a signal + # is received during the accept() call; all docs say retry + # the call, and I *think* I'm reading it right that Python + # will then go ahead and poll for and handle the signal + # elsewhere. See http://www.cherrypy.org/ticket/707. + return + if x.args[0] in socket_errors_nonblocking: + # Just try again. See http://www.cherrypy.org/ticket/479. + return + if x.args[0] in socket_errors_to_ignore: + # Our socket was closed. + # See http://www.cherrypy.org/ticket/686. + return + raise + + def _get_interrupt(self): + return self._interrupt + def _set_interrupt(self, interrupt): + self._interrupt = True + self.stop() + self._interrupt = interrupt + interrupt = property(_get_interrupt, _set_interrupt, + doc="Set this to an Exception instance to " + "interrupt the server.") + + def stop(self): + """Gracefully shutdown a server that is serving forever.""" + self.ready = False + + sock = getattr(self, "socket", None) + if sock: + if not isinstance(self.bind_addr, basestring): + # Touch our own socket to make accept() return immediately. + try: + host, port = sock.getsockname()[:2] + except socket.error, x: + if x.args[0] not in socket_errors_to_ignore: + # Changed to use error code and not message + # See http://www.cherrypy.org/ticket/860. + raise + else: + # Note that we're explicitly NOT using AI_PASSIVE, + # here, because we want an actual IP to touch. + # localhost won't work if we've bound to a public IP, + # but it will if we bound to '0.0.0.0' (INADDR_ANY). + for res in socket.getaddrinfo(host, port, socket.AF_UNSPEC, + socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + s = None + try: + s = socket.socket(af, socktype, proto) + # See http://groups.google.com/group/cherrypy-users/ + # browse_frm/thread/bbfe5eb39c904fe0 + s.settimeout(1.0) + s.connect((host, port)) + s.close() + except socket.error: + if s: + s.close() + if hasattr(sock, "close"): + sock.close() + self.socket = None + + self.requests.stop(self.shutdown_timeout) + + +class Gateway(object): + + def __init__(self, req): + self.req = req + + def respond(self): + raise NotImplemented + + +# These may either be wsgiserver.SSLAdapter subclasses or the string names +# of such classes (in which case they will be lazily loaded). +ssl_adapters = { + 'builtin': 'cherrypy.wsgiserver.ssl_builtin.BuiltinSSLAdapter', + 'pyopenssl': 'cherrypy.wsgiserver.ssl_pyopenssl.pyOpenSSLAdapter', + } + +def get_ssl_adapter_class(name='pyopenssl'): + adapter = ssl_adapters[name.lower()] + if isinstance(adapter, basestring): + last_dot = adapter.rfind(".") + attr_name = adapter[last_dot + 1:] + mod_path = adapter[:last_dot] + + try: + mod = sys.modules[mod_path] + if mod is None: + raise KeyError() + except KeyError: + # The last [''] is important. + mod = __import__(mod_path, globals(), locals(), ['']) + + # Let an AttributeError propagate outward. + try: + adapter = getattr(mod, attr_name) + except AttributeError: + raise AttributeError("'%s' object has no attribute '%s'" + % (mod_path, attr_name)) + + return adapter + +# -------------------------------- WSGI Stuff -------------------------------- # + + +class CherryPyWSGIServer(HTTPServer): + + wsgi_version = (1, 1) + + def __init__(self, bind_addr, wsgi_app, numthreads=10, server_name=None, + max= -1, request_queue_size=5, timeout=10, shutdown_timeout=5): + self.requests = ThreadPool(self, min=numthreads or 1, max=max) + self.wsgi_app = wsgi_app + self.gateway = wsgi_gateways[self.wsgi_version] + + self.bind_addr = bind_addr + if not server_name: + server_name = socket.gethostname() + self.server_name = server_name + self.request_queue_size = request_queue_size + + self.timeout = timeout + self.shutdown_timeout = shutdown_timeout + + def _get_numthreads(self): + return self.requests.min + def _set_numthreads(self, value): + self.requests.min = value + numthreads = property(_get_numthreads, _set_numthreads) + + +class WSGIGateway(Gateway): + + def __init__(self, req): + self.req = req + self.started_response = False + self.env = self.get_environ() + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version""" + raise NotImplemented + + def respond(self): + response = self.req.server.wsgi_app(self.env, self.start_response) + try: + for chunk in response: + # "The start_response callable must not actually transmit + # the response headers. Instead, it must store them for the + # server or gateway to transmit only after the first + # iteration of the application return value that yields + # a NON-EMPTY string, or upon the application's first + # invocation of the write() callable." (PEP 333) + if chunk: + if isinstance(chunk, unicode): + chunk = chunk.encode('ISO-8859-1') + self.write(chunk) + finally: + if hasattr(response, "close"): + response.close() + + def start_response(self, status, headers, exc_info=None): + """WSGI callable to begin the HTTP response.""" + # "The application may call start_response more than once, + # if and only if the exc_info argument is provided." + if self.started_response and not exc_info: + raise AssertionError("WSGI start_response called a second " + "time with no exc_info.") + self.started_response = True + + # "if exc_info is provided, and the HTTP headers have already been + # sent, start_response must raise an error, and should raise the + # exc_info tuple." + if self.req.sent_headers: + try: + raise exc_info[0], exc_info[1], exc_info[2] + finally: + exc_info = None + + self.req.status = status + for k, v in headers: + if not isinstance(k, str): + raise TypeError("WSGI response header key %r is not a byte string." % k) + if not isinstance(v, str): + raise TypeError("WSGI response header value %r is not a byte string." % v) + self.req.outheaders.extend(headers) + + return self.write + + def write(self, chunk): + """WSGI callable to write unbuffered data to the client. + + This method is also used internally by start_response (to write + data from the iterable returned by the WSGI application). + """ + if not self.started_response: + raise AssertionError("WSGI write called before start_response.") + + if not self.req.sent_headers: + self.req.sent_headers = True + self.req.send_headers() + + self.req.write(chunk) + + +class WSGIGateway_10(WSGIGateway): + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version""" + req = self.req + env = { + # set a non-standard environ entry so the WSGI app can know what + # the *real* server protocol is (and what features to support). + # See http://www.faqs.org/rfcs/rfc2145.html. + 'ACTUAL_SERVER_PROTOCOL': req.server.protocol, + 'PATH_INFO': req.path, + 'QUERY_STRING': req.qs, + 'REMOTE_ADDR': req.conn.remote_addr or '', + 'REMOTE_PORT': str(req.conn.remote_port or ''), + 'REQUEST_METHOD': req.method, + 'REQUEST_URI': req.uri, + 'SCRIPT_NAME': '', + 'SERVER_NAME': req.server.server_name, + # Bah. "SERVER_PROTOCOL" is actually the REQUEST protocol. + 'SERVER_PROTOCOL': req.request_protocol, + 'wsgi.errors': sys.stderr, + 'wsgi.input': req.rfile, + 'wsgi.multiprocess': False, + 'wsgi.multithread': True, + 'wsgi.run_once': False, + 'wsgi.url_scheme': req.scheme, + 'wsgi.version': (1, 0), + } + + if isinstance(req.server.bind_addr, basestring): + # AF_UNIX. This isn't really allowed by WSGI, which doesn't + # address unix domain sockets. But it's better than nothing. + env["SERVER_PORT"] = "" + else: + env["SERVER_PORT"] = str(req.server.bind_addr[1]) + + # CONTENT_TYPE/CONTENT_LENGTH + for k, v in req.inheaders.iteritems(): + env["HTTP_" + k.upper().replace("-", "_")] = v + ct = env.pop("HTTP_CONTENT_TYPE", None) + if ct is not None: + env["CONTENT_TYPE"] = ct + cl = env.pop("HTTP_CONTENT_LENGTH", None) + if cl is not None: + env["CONTENT_LENGTH"] = cl + + if req.conn.ssl_env: + env.update(req.conn.ssl_env) + + return env + + +class WSGIGateway_11(WSGIGateway_10): + + def get_environ(self): + env = WSGIGateway_10.get_environ(self) + env['wsgi.version'] = (1, 1) + return env + + +class WSGIGateway_u0(WSGIGateway_10): + + def get_environ(self): + """Return a new environ dict targeting the given wsgi.version""" + req = self.req + env_10 = WSGIGateway_10.get_environ(self) + env = dict([(k.decode('ISO-8859-1'), v) for k, v in env_10.iteritems()]) + env[u'wsgi.version'] = ('u', 0) + + # Request-URI + env.setdefault(u'wsgi.url_encoding', u'utf-8') + try: + for key in [u"PATH_INFO", u"SCRIPT_NAME", u"QUERY_STRING"]: + env[key] = env_10[str(key)].decode(env[u'wsgi.url_encoding']) + except UnicodeDecodeError: + # Fall back to latin 1 so apps can transcode if needed. + env[u'wsgi.url_encoding'] = u'ISO-8859-1' + for key in [u"PATH_INFO", u"SCRIPT_NAME", u"QUERY_STRING"]: + env[key] = env_10[str(key)].decode(env[u'wsgi.url_encoding']) + + for k, v in sorted(env.items()): + if isinstance(v, str) and k not in ('REQUEST_URI', 'wsgi.input'): + env[k] = v.decode('ISO-8859-1') + + return env + +wsgi_gateways = { + (1, 0): WSGIGateway_10, + (1, 1): WSGIGateway_11, + ('u', 0): WSGIGateway_u0, +} + +class WSGIPathInfoDispatcher(object): + """A WSGI dispatcher for dispatch based on the PATH_INFO. + + apps: a dict or list of (path_prefix, app) pairs. + """ + + def __init__(self, apps): + try: + apps = apps.items() + except AttributeError: + pass + + # Sort the apps by len(path), descending + apps.sort(cmp=lambda x, y: cmp(len(x[0]), len(y[0]))) + apps.reverse() + + # The path_prefix strings must start, but not end, with a slash. + # Use "" instead of "/". + self.apps = [(p.rstrip("/"), a) for p, a in apps] + + def __call__(self, environ, start_response): + path = environ["PATH_INFO"] or "/" + for p, app in self.apps: + # The apps list should be sorted by length, descending. + if path.startswith(p + "/") or path == p: + environ = environ.copy() + environ["SCRIPT_NAME"] = environ["SCRIPT_NAME"] + p + environ["PATH_INFO"] = path[len(p):] + return app(environ, start_response) + + start_response('404 Not Found', [('Content-Type', 'text/plain'), + ('Content-Length', '0')]) + return [''] From 2d368105a4202675f364274b25bce79eae2c4935 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 2 Jun 2013 17:20:56 +0200 Subject: [PATCH 159/492] added exceptions --- Used_Files/exceptions.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Used_Files/exceptions.txt b/Used_Files/exceptions.txt index e715f22bbb..61f19a4577 100644 --- a/Used_Files/exceptions.txt +++ b/Used_Files/exceptions.txt @@ -251,5 +251,6 @@ 72449: 'Stargate SG1', 264030: 'Avengers Assemble', 269552: 'Harry (2013)', -71663: 'Les Simpson','The Simpsons','Les Simpsons','Simpsons, The', +71663: 'Les Simpson','The Simpsons','Les Simpsons' 121361: 'Le trône de fer','Game of thrones', +73141: 'American Dad', From 63cb1b12617a9fae588ae871d83644d157bba117 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 2 Jun 2013 17:57:28 +0200 Subject: [PATCH 160/492] removed unused custom_search name code --- sickbeard/show_name_helpers.py | 4 ---- sickbeard/tv.py | 7 +------ sickbeard/webserve.py | 5 ++--- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/sickbeard/show_name_helpers.py b/sickbeard/show_name_helpers.py index 686fe95ab7..9bacc303a8 100644 --- a/sickbeard/show_name_helpers.py +++ b/sickbeard/show_name_helpers.py @@ -254,10 +254,6 @@ def allPossibleShowNames(show): if show.tvrname != "" and show.tvrname != None: showNames.append(show.tvrname) - if show.custom_search_names != "" and show.custom_search_names != None : - for custom_name in show.custom_search_names.split(','): - showNames.append(custom_name) - newShowNames = [] country_list = countryList diff --git a/sickbeard/tv.py b/sickbeard/tv.py index db7ebb14de..ea98ca512a 100644 --- a/sickbeard/tv.py +++ b/sickbeard/tv.py @@ -82,7 +82,6 @@ def __init__ (self, tvdbid, lang="", audio_lang=""): self.subtitles = int(sickbeard.SUBTITLES_DEFAULT if sickbeard.SUBTITLES_DEFAULT else 0) self.lang = lang self.audio_lang = audio_lang - self.custom_search_names = "" self.lock = threading.Lock() self._isDirGood = False @@ -645,9 +644,6 @@ def loadFromDB(self, skipNFO=False): if self.audio_lang == "": self.audio_lang = sqlResults[0]["audio_lang"] - if self.custom_search_names == "": - self.custom_search_names = sqlResults[0]["custom_search_names"] - if self.imdbid == "": self.imdbid = sqlResults[0]["imdb_id"] @@ -963,8 +959,7 @@ def saveToDB(self): "tvr_name": self.tvrname, "lang": self.lang, "imdb_id": self.imdbid, - "audio_lang": self.audio_lang, - "custom_search_names": self.custom_search_names + "audio_lang": self.audio_lang } myDB.upsert("tv_shows", newValueDict, controlValueDict) diff --git a/sickbeard/webserve.py b/sickbeard/webserve.py index 8275194596..83c3e00c1a 100644 --- a/sickbeard/webserve.py +++ b/sickbeard/webserve.py @@ -626,7 +626,7 @@ def massEditSubmit(self, paused=None, flatten_folders=None, quality_preset=False exceptions_list = [] - curErrors += Home().editShow(curShow, new_show_dir, anyQualities, bestQualities, exceptions_list, new_flatten_folders, new_paused, subtitles=new_subtitles, tvdbLang=new_lang, audio_lang=new_audio_lang, custom_search_names=showObj.custom_search_names, directCall=True) + curErrors += Home().editShow(curShow, new_show_dir, anyQualities, bestQualities, exceptions_list, new_flatten_folders, new_paused, subtitles=new_subtitles, tvdbLang=new_lang, audio_lang=new_audio_lang, directCall=True) if curErrors: logger.log(u"Errors: "+str(curErrors), logger.ERROR) @@ -2901,7 +2901,7 @@ def plotDetails(self, show, season, episode): return result['description'] if result else 'Episode not found.' @cherrypy.expose - def editShow(self, show=None, location=None, anyQualities=[], bestQualities=[], exceptions_list=[], flatten_folders=None, paused=None, directCall=False, air_by_date=None, tvdbLang=None, audio_lang=None, custom_search_names=None, subtitles=None): + def editShow(self, show=None, location=None, anyQualities=[], bestQualities=[], exceptions_list=[], flatten_folders=None, paused=None, directCall=False, air_by_date=None, tvdbLang=None, audio_lang=None, subtitles=None): if show == None: errString = "Invalid show ID: "+str(show) @@ -3000,7 +3000,6 @@ def editShow(self, show=None, location=None, anyQualities=[], bestQualities=[], showObj.subtitles = subtitles showObj.lang = tvdb_lang showObj.audio_lang = audio_lang - showObj.custom_search_names = custom_search_names # if we change location clear the db of episodes, change it, write to db, and rescan if os.path.normpath(showObj._location) != os.path.normpath(location): From 1bffa3f6f7ee51f26a851ccd1e2a3bf864896237 Mon Sep 17 00:00:00 2001 From: a_dartois Date: Sun, 2 Jun 2013 18:46:05 +0200 Subject: [PATCH 161/492] Add Nolife (http://www.nolife-tv.com/) image network --- data/images/network/nolife.png | Bin 0 -> 2373 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 data/images/network/nolife.png diff --git a/data/images/network/nolife.png b/data/images/network/nolife.png new file mode 100644 index 0000000000000000000000000000000000000000..c8b5c598b420bd5c889e5f0d3e699e3661618dbd GIT binary patch literal 2373 zcmV-L3A*-)P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!~g&e!~vBn4jTXf02y>eSaefwW^{L9 za%BKPWN%_+AW3auXJt}lVPtu6$z?nM00^~7L_t(oN8MO!a8*|o{_ZQeH}9JV3V9Gl zYH83GrBIM5G@-Vm<8q4keKD-fXYR|rF5FMbxm^~Y-pMMTl zKD~nHR;@yJcQ-sTmSGr(%2m8%36?E?*6L-VP;9BGse#YuQ@uzeiYZg4VE@4ioI7(C z@9f@<>({QKtE&qxmpP-szo>zv-8cW4kIoQz~!q~@ML}gKDu;Cl4p`262DQF-D7X3meZaIk-8oF)`w(2%4IjWc(;@-EKizFjdE;j{<=>)upYp@Pk8#(b9TX z*X+v8$~H1GGB9`EJcRqh(!q`R_;^wFAVvm`iHX%o5Bzl>s;a8gxVE-7vrTsC*S}eh zu{mQ^hZA@`Uie~sxKMimFKyhY4hV-r_;Fz&UfNu)(UF#x2ANkExBZnJ5(UfE0Y&rX zgq80;Ysj_-HVHhvFnZ3t(KwS z<|g<9>X7pnF92gRvy2~n@B1SE?|60RP9vC*XylB^G$xFHz>?k{5`IRaKVW<%E!`+v zyVjDL$jQt!f{97SySsN=62p+i8j^(8n~B8>78)7BRO7M9Q!L57aqZeQBhD9NOrG>D zOLBiuFDVc&ed-K1L#?;B*YeL7qh!q*Was4I&h6Xy%X@n*sR^bB$6>hJVAz(fTc@Gl z(bi!}O?ce*=9}SQRCP-Vys)Okl9*V%VkMS7^9+9Z^IxEN;Ubil{7R#>XhD&sVPeL# zY52xNd6+oiA>@7iK}>vbf~H^8h$7M-g*u?8yT?*Dv8ZSP#7lZT{p5Eosfk%nJb})> zUOX~sk|hBuj~&;PNKH&q@^T_?VxBrB;s|TPC;GA(*4zCN7Z;~#Lq4$4diO342>I5W zJ%>vdFXCS}Zs5b}YO!JH+(bskD9oHW6Z!f1LgI7L($b>b6cL-oKQJC{Dn%lLyGC&5 zl2cNUF5J-6*l0;j*v!lPwb2*qLogu;V=^-l5T^|gDq-n6BZC-1o))M{mxTI4`cBYT zf)QpTMR=&Or5Uv!UBcPg^Egpmg^rF+oy*ymIDGU7-u&a+c>S$6@$&X<=$CQGVSnDc zN83#YI1LtXHwg(3`85|{!#*ne9BE_9%-y7e`olxhfICf=cETJRh)+n+I}szyPB^YX6bH9=ix1kt%fC^U4Fj!I}(9y^BW zlT|qN;XgEL>C`*cI5swH{4M6l5xnGQKgF^YE3jeHM%|^dvNBBu zQeL;5^>XLAYvb{mkK@egGx*zyN?gBwUHQYtr=NU+fZvbWn)8;##NSV!(mDEse|n_; z=J5au5 zGhQeu(ZVWTilc@b4dMVE%a$%x zPca&}>s*YRA;Vf<4C25nPPjlC?GxhkiLumIU6FZ3k74GP?~AzjIFU(+iaz^uO!oIn z{(59g5-;1`68;I|WPXeABSz)YUn7sU$UE;GpJTUcQXAauT@n3ko!h>#kn4n!o(mymgDF)cKkkxcJ3J!jZ2+ z)O}X3VfFt2Ilv|-5*4|IWok$+&3Cyd3rkI4>X`pipp5@h2uZEMdZDMMTaRiUpR7Mf zkQ|qxu478Su*f(^mlSoJmmE@Y;DDy8O9aM=zgn6waHc-~1%2s1Q-Nd?@Lp z(ve1yxmgBx(HM(iIXK((A0Be2oRy;X5u&6KBqaV9h%))nyYiM0l*wIk=L6sEOU351 z`DknE%gA>7U2pO`Am_6s6WIL0|5d01DCB=rHoW*Es;jC8>TXr%fL2F>&-P`*ejXyz rpONz6aJE^d{LjG+B>^8g@&Wz>60WGgA;Kno00000NkvXXu0mjf?kk Date: Sun, 2 Jun 2013 19:56:55 +0200 Subject: [PATCH 162/492] correct home search bug --- data/interfaces/default/home.tmpl | 137 +++++++++++++++--------------- 1 file changed, 70 insertions(+), 67 deletions(-) diff --git a/data/interfaces/default/home.tmpl b/data/interfaces/default/home.tmpl index 0a9206788f..5e49da88ac 100644 --- a/data/interfaces/default/home.tmpl +++ b/data/interfaces/default/home.tmpl @@ -163,47 +163,47 @@ #end if headers: { 0: {filter : false ,sorter: 'isoDate' }, - #if $sickbeard.FRENCH_COLUMN: - #if ($layout == 'poster'): - 1: {filter : false ,sorter: 'loadingNames'}, - 2: {sorter: 'loadingNames'}, - 3: { filter: false}, - 4: { filter : false ,sorter: 'quality' }, - 5: { filter : false ,sorter: 'eps' }, - 6: { filter : false ,sorter: 'eps' }, - 7: { filter: false}, - 8: { filter: false}, - 9: { filter: false}, - #else: - 1: {sorter: 'loadingNames'}, - 2: { filter: false}, - 3: { filter : false ,sorter: 'quality' }, - 4: { filter : false ,sorter: 'eps' }, - 5: { filter : false ,sorter: 'eps' }, - 6: { filter: false}, - 7: { filter: false}, - 8: { filter: false}, - #end if - #else: - #if ($layout == 'poster'): - 1: {filter : false ,sorter: 'loadingNames'}, - 2: {sorter: 'loadingNames'}, - 3: { filter: false}, - 4: { filter : false ,sorter: 'quality' }, - 5: { filter : false ,sorter: 'eps' }, - 6: { filter: false}, - 7: { filter: false}, - 8: { filter: false}, - #else: - 1: {sorter: 'loadingNames'}, - 2: { filter: false}, - 3: { filter : false ,sorter: 'quality' }, - 4: { filter : false ,sorter: 'eps' }, - 5: { filter: false}, - 6: { filter: false}, - 7: { filter: false}, - #end if - #end if + #if $sickbeard.FRENCH_COLUMN: + #if ($layout == 'poster'): + 1: {filter : false ,sorter: 'loadingNames'}, + 2: {sorter: 'loadingNames'}, + 3: { filter: false}, + 4: { filter : false ,sorter: 'quality' }, + 5: { filter : false ,sorter: 'eps' }, + 6: { filter : false ,sorter: 'eps' }, + 7: { filter: false}, + 8: { filter: false}, + 9: { filter: false}, + #else: + 1: {sorter: 'loadingNames'}, + 2: { filter: false}, + 3: { filter : false ,sorter: 'quality' }, + 4: { filter : false ,sorter: 'eps' }, + 5: { filter : false ,sorter: 'eps' }, + 6: { filter: false}, + 7: { filter: false}, + 8: { filter: false}, + #end if + #else: + #if ($layout == 'poster'): + 1: {filter : false ,sorter: 'loadingNames'}, + 2: {sorter: 'loadingNames'}, + 3: { filter: false}, + 4: { filter : false ,sorter: 'quality' }, + 5: { filter : false ,sorter: 'eps' }, + 6: { filter: false}, + 7: { filter: false}, + 8: { filter: false}, + #else: + 1: {sorter: 'loadingNames'}, + 2: { filter: false}, + 3: { filter : false ,sorter: 'quality' }, + 4: { filter : false ,sorter: 'eps' }, + 5: { filter: false}, + 6: { filter: false}, + 7: { filter: false}, + #end if + #end if } }); @@ -356,7 +356,8 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name)) $curShow.name
    - - - - - - - - - -
    Downloaded : $nom
    Snatched : $nomSna
    Total : $den
    +
    +
    +
    + Downloaded $nom +
    +
    + Snatched $nomSna +
    +
    + Total $den +
    +
    @@ -454,20 +455,21 @@ $myShowList.sort(lambda x, y: cmp(x.name, y.name))
    \"Y\"" $curShow.audio_lang From 4e496e04a76d694936280f55579397e56b31f2fe Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 2 Jun 2013 23:46:50 +0200 Subject: [PATCH 163/492] added pack TV category in gks for season search --- sickbeard/providers/gks.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/sickbeard/providers/gks.py b/sickbeard/providers/gks.py index 345a161a86..4c2276da8d 100644 --- a/sickbeard/providers/gks.py +++ b/sickbeard/providers/gks.py @@ -45,18 +45,24 @@ def isEnabled(self): def imageName(self): return 'gks.png' - def getSearchParams(self, searchString, audio_lang): + def getSearchParams(self, searchString, audio_lang, season=None): results = [] - if audio_lang == "en": - results.append( urllib.urlencode( {'q': searchString, 'category' : 22, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - if sickbeard.USE_SUBTITLES : - results.append( urllib.urlencode( {'q': searchString, 'category' : 11, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - results.append( urllib.urlencode( {'q': searchString, 'category' : 13, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - elif audio_lang == "fr": - results.append( urllib.urlencode( {'q': searchString, 'category' : 12, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - results.append( urllib.urlencode( {'q': searchString, 'category' : 14, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + if season: + if audio_lang == "en": + results.append( urllib.urlencode( {'q': searchString, 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + else: + results.append( urllib.urlencode( {'q': searchString + ' french', 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) else: - results.append( urllib.urlencode( {'q': searchString, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + if audio_lang == "en": + results.append( urllib.urlencode( {'q': searchString, 'category' : 22, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + if sickbeard.USE_SUBTITLES : + results.append( urllib.urlencode( {'q': searchString, 'category' : 11, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + results.append( urllib.urlencode( {'q': searchString, 'category' : 13, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + elif audio_lang == "fr": + results.append( urllib.urlencode( {'q': searchString, 'category' : 12, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + results.append( urllib.urlencode( {'q': searchString, 'category' : 14, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + else: + results.append( urllib.urlencode( {'q': searchString, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) return results def _get_season_search_strings(self, show, season): @@ -64,9 +70,9 @@ def _get_season_search_strings(self, show, season): showNames = show_name_helpers.allPossibleShowNames(show) results = [] for showName in showNames: - for result in self.getSearchParams(showName + "+S%02d" % season, show.audio_lang) : + for result in self.getSearchParams(showName + "+S%02d" % season, show.audio_lang, season) : results.append(result) - for result in self.getSearchParams(showName + "+saison+%02d" % season, show.audio_lang): + for result in self.getSearchParams(showName + "+saison+%02d" % season, show.audio_lang, season): results.append(result) return results From 814977dd1f6dabebb240827eb83b28551edd0662 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Sun, 2 Jun 2013 23:46:50 +0200 Subject: [PATCH 164/492] merge --- data/css/default.css | 6 +++--- sickbeard/providers/gks.py | 30 ++++++++++++++++++------------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/data/css/default.css b/data/css/default.css index a171753d89..a8d64210dd 100644 --- a/data/css/default.css +++ b/data/css/default.css @@ -606,9 +606,9 @@ width: 30%; background-image: -o-linear-gradient(#a3e532, #90cc2a) !important; filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; -ms-filter: progid:dximagetransform.microsoft.gradient(startColorstr=#fdf0d5, endColorstr=#fff9ee) !important; - -moz-border-radius: 3px; - -webkit-border-radius: 3px; - border-radius: 3px; + -moz-border-radius: 10px; + -webkit-border-radius: 10px; + border-radius: 10px; } tr.seasonheader { background-color: #FFFFFF; diff --git a/sickbeard/providers/gks.py b/sickbeard/providers/gks.py index 345a161a86..4c2276da8d 100644 --- a/sickbeard/providers/gks.py +++ b/sickbeard/providers/gks.py @@ -45,18 +45,24 @@ def isEnabled(self): def imageName(self): return 'gks.png' - def getSearchParams(self, searchString, audio_lang): + def getSearchParams(self, searchString, audio_lang, season=None): results = [] - if audio_lang == "en": - results.append( urllib.urlencode( {'q': searchString, 'category' : 22, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - if sickbeard.USE_SUBTITLES : - results.append( urllib.urlencode( {'q': searchString, 'category' : 11, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - results.append( urllib.urlencode( {'q': searchString, 'category' : 13, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - elif audio_lang == "fr": - results.append( urllib.urlencode( {'q': searchString, 'category' : 12, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - results.append( urllib.urlencode( {'q': searchString, 'category' : 14, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + if season: + if audio_lang == "en": + results.append( urllib.urlencode( {'q': searchString, 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + else: + results.append( urllib.urlencode( {'q': searchString + ' french', 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) else: - results.append( urllib.urlencode( {'q': searchString, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + if audio_lang == "en": + results.append( urllib.urlencode( {'q': searchString, 'category' : 22, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + if sickbeard.USE_SUBTITLES : + results.append( urllib.urlencode( {'q': searchString, 'category' : 11, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + results.append( urllib.urlencode( {'q': searchString, 'category' : 13, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + elif audio_lang == "fr": + results.append( urllib.urlencode( {'q': searchString, 'category' : 12, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + results.append( urllib.urlencode( {'q': searchString, 'category' : 14, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + else: + results.append( urllib.urlencode( {'q': searchString, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) return results def _get_season_search_strings(self, show, season): @@ -64,9 +70,9 @@ def _get_season_search_strings(self, show, season): showNames = show_name_helpers.allPossibleShowNames(show) results = [] for showName in showNames: - for result in self.getSearchParams(showName + "+S%02d" % season, show.audio_lang) : + for result in self.getSearchParams(showName + "+S%02d" % season, show.audio_lang, season) : results.append(result) - for result in self.getSearchParams(showName + "+saison+%02d" % season, show.audio_lang): + for result in self.getSearchParams(showName + "+saison+%02d" % season, show.audio_lang, season): results.append(result) return results From 0ae32ba62b415117f7bd948992a6b1841a61968d Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Tue, 4 Jun 2013 01:21:42 +0200 Subject: [PATCH 165/492] improved gks season search a little and added 2 more quality for release with little descriptions --- sickbeard/common.py | 4 ++++ sickbeard/providers/gks.py | 22 ++++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/sickbeard/common.py b/sickbeard/common.py index 70ac584264..9897631486 100644 --- a/sickbeard/common.py +++ b/sickbeard/common.py @@ -172,6 +172,10 @@ def nameQuality(name): return Quality.HDBLURAY elif checkName(["1080p", "bluray|hddvd|b[r|d]rip", "x264"], all): return Quality.FULLHDBLURAY + elif checkName(["dvdrip"],all): + return Quality.SDDVD + elif checkName(["tvrip"],all): + return Quality.SDTV else: return Quality.UNKNOWN diff --git a/sickbeard/providers/gks.py b/sickbeard/providers/gks.py index 4c2276da8d..5578c4229f 100644 --- a/sickbeard/providers/gks.py +++ b/sickbeard/providers/gks.py @@ -48,10 +48,7 @@ def imageName(self): def getSearchParams(self, searchString, audio_lang, season=None): results = [] if season: - if audio_lang == "en": - results.append( urllib.urlencode( {'q': searchString, 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) - else: - results.append( urllib.urlencode( {'q': searchString + ' french', 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) + results.append( urllib.urlencode( {'q': searchString, 'category' : 10, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) else: if audio_lang == "en": results.append( urllib.urlencode( {'q': searchString, 'category' : 22, 'ak' : sickbeard.GKS_KEY} ) + "&order=desc&sort=normal&exact" ) @@ -113,9 +110,26 @@ def _doSearch(self, searchString, show=None, season=None): if "aucun resultat" in title.lower() : logger.log(u"No results found in " + searchUrl, logger.DEBUG) return [] + count=1 + if season: + count=0 + if show: + if show.audio_lang=='fr': + for frword in['french', 'truefrench', 'multi']: + if frword in title.lower(): + count+=1 + else: + count +=1 + else: + count +=1 + if count==0: + continue else : downloadURL = helpers.get_xml_text(item.getElementsByTagName('link')[0]) quality = Quality.nameQuality(title) + if quality==Quality.UNKNOWN and title: + if '720p' not in title.lower() and '1080p' not in title.lower(): + quality=Quality.SDTV if show: results.append( GksSearchResult( self.opener, title, downloadURL, quality, str(show.audio_lang) ) ) else: From cf9472b0326284c1a1865e87c8abfe5e7317c4ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Tue, 4 Jun 2013 07:47:34 +0200 Subject: [PATCH 166/492] Correct error javascript on home --- data/js/script.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/data/js/script.js b/data/js/script.js index b65614e30e..8bb83d8ebf 100644 --- a/data/js/script.js +++ b/data/js/script.js @@ -2,20 +2,23 @@ function initHeader() { //settings var header = $("#header"); var inside = false; + + var topDistance = $(header).offset().top; + //do $(window).scroll(function() { position = $(window).scrollTop(); if(position > topDistance && !inside) { //add events - topbarML(); - $(header).bind('mouseenter',topbarME); - $(header).bind('mouseleave',topbarML); + //topbarML(); + //$(header).bind('mouseenter',topbarME); + //$(header).bind('mouseleave',topbarML); inside = true; } else if (position < topDistance){ - topbarME(); - $(header).unbind('mouseenter',topbarME); - $(header).unbind('mouseleave',topbarML); + //topbarME(); + //$(header).unbind('mouseenter',topbarME); + //$(header).unbind('mouseleave',topbarML); inside = false; } }); From 49e321610239f25d4e0d7f12c30d6ad828c60506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Tue, 4 Jun 2013 08:11:11 +0200 Subject: [PATCH 167/492] Correct sticky header --- data/js/script.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/data/js/script.js b/data/js/script.js index 8bb83d8ebf..a67918569b 100644 --- a/data/js/script.js +++ b/data/js/script.js @@ -5,20 +5,24 @@ function initHeader() { var topDistance = $(header).offset().top; + var fadeSpeed = 100, fadeTo = 1; + var topbarME = function() { $(header).fadeTo(fadeSpeed,1); }, topbarML = function() { $(header).fadeTo(fadeSpeed,fadeTo); }; + var inside = false; + //do $(window).scroll(function() { position = $(window).scrollTop(); if(position > topDistance && !inside) { //add events - //topbarML(); - //$(header).bind('mouseenter',topbarME); - //$(header).bind('mouseleave',topbarML); + topbarML(); + $(header).bind('mouseenter',topbarME); + $(header).bind('mouseleave',topbarML); inside = true; } else if (position < topDistance){ //topbarME(); - //$(header).unbind('mouseenter',topbarME); - //$(header).unbind('mouseleave',topbarML); + $(header).unbind('mouseenter',topbarME); + $(header).unbind('mouseleave',topbarML); inside = false; } }); From 520aeefc8d22891eefeef5bdec899198d9982c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Tue, 4 Jun 2013 08:23:17 +0200 Subject: [PATCH 168/492] little display bug --- data/js/script.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/js/script.js b/data/js/script.js index a67918569b..963ca5b784 100644 --- a/data/js/script.js +++ b/data/js/script.js @@ -3,7 +3,7 @@ function initHeader() { var header = $("#header"); var inside = false; - var topDistance = $(header).offset().top; + var topDistance = $(header).offset().top; var fadeSpeed = 100, fadeTo = 1; var topbarME = function() { $(header).fadeTo(fadeSpeed,1); }, topbarML = function() { $(header).fadeTo(fadeSpeed,fadeTo); }; @@ -20,7 +20,7 @@ function initHeader() { inside = true; } else if (position < topDistance){ - //topbarME(); + topbarME(); $(header).unbind('mouseenter',topbarME); $(header).unbind('mouseleave',topbarML); inside = false; From 718a2e90a5e65b1b3a925d5b3f920b31161eca0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?CREMEL=20St=C3=A9phane?= Date: Tue, 4 Jun 2013 08:32:03 +0200 Subject: [PATCH 169/492] merge --- data/js/script.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/js/script.js b/data/js/script.js index a67918569b..963ca5b784 100644 --- a/data/js/script.js +++ b/data/js/script.js @@ -3,7 +3,7 @@ function initHeader() { var header = $("#header"); var inside = false; - var topDistance = $(header).offset().top; + var topDistance = $(header).offset().top; var fadeSpeed = 100, fadeTo = 1; var topbarME = function() { $(header).fadeTo(fadeSpeed,1); }, topbarML = function() { $(header).fadeTo(fadeSpeed,fadeTo); }; @@ -20,7 +20,7 @@ function initHeader() { inside = true; } else if (position < topDistance){ - //topbarME(); + topbarME(); $(header).unbind('mouseenter',topbarME); $(header).unbind('mouseleave',topbarML); inside = false; From d645f7c08df026e7ec09a99f45479ceaf75eabc9 Mon Sep 17 00:00:00 2001 From: Sarakha63 Date: Tue, 4 Jun 2013 12:57:21 +0200 Subject: [PATCH 170/492] added a regexp for season like XXXXX year Sxx XXXXX --- sickbeard/name_parser/regexes.py | 528 ++++++++++++++++--------------- 1 file changed, 270 insertions(+), 258 deletions(-) diff --git a/sickbeard/name_parser/regexes.py b/sickbeard/name_parser/regexes.py index 2d0382b048..7734c58228 100644 --- a/sickbeard/name_parser/regexes.py +++ b/sickbeard/name_parser/regexes.py @@ -1,258 +1,270 @@ -# Author: Nic Wolfe -# URL: http://code.google.com/p/sickbeard/ -# -# This file is part of Sick Beard. -# -# Sick Beard 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. -# -# Sick Beard 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 Sick Beard. If not, see . - -# all regexes are case insensitive -from sickbeard.common import showLanguages - -ep_regexes = [ - - ('standard_repeat', - # Show.Name.S01E02.S01E03.Source.Quality.Etc-Group - # Show Name - S01E02 - S01E03 - S01E04 - Ep Name - ''' - ^(?P.+?)[. _-]+ # Show_Name and separator - s(?P\d+)[. _-]* # S01 and optional separator - e(?P\d+) # E02 and separator - ([. _-]+s(?P=season_num)[. _-]* # S01 and optional separator - e(?P\d+))+ # E03/etc and separator - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - - ('scene_date_format_bis', - # Show.Name.2010.S01E01.Source.Quality.Etc-Group - ''' - ^(?P.+?)[. _-]+ # Show_Name and separator - (?P\d{4})[. _-]+ # 2010 and separator - s(?P\d+)[. _-]* # S01 and optional separator - e(?P\d+) - '''), - - ('fov_repeat', - # Show.Name.1x02.1x03.Source.Quality.Etc-Group - # Show Name - 1x02 - 1x03 - 1x04 - Ep Name - ''' - ^(?P.+?)[. _-]+ # Show_Name and separator - (?P\d+)x # 1x - (?P\d+) # 02 and separator - ([. _-]+(?P=season_num)x # 1x - (?P\d+))+ # 03/etc and separator - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - ('standard_cpas_bien', - # [www.Cpasbien.me] Dexter.S07E04.FRENCH.LD.HDTV.XviD-MiNDe - ''' - \[[a-zA-Z0-9\.]{2,20}\][. _-]+ - (?P.+?)[. _-]+ # Show_Name and separator - s(?P\d+)[. _-]* # S01 and optional separator - e(?P\d+) # E02 and separator - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - ('standard_ep', - # Show.Name.S01EP02.Source.Quality.Etc-Group - # Show Name - S01EP02 - My Ep Name - # Show.Name.S01.EP03.My.Ep.Name - ''' - ^((?P.+?)[. _-]+)? # Show_Name and separator - s(?P\d+)[. _-]* # S01 and optional separator - ep(?P\d+) # E02 and separator - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - ('standard', - # Show.Name.S01E02.Source.Quality.Etc-Group - # Show Name - S01E02 - My Ep Name - # Show.Name.S01.E03.My.Ep.Name - # Show.Name.S01E02E03.Source.Quality.Etc-Group - # Show Name - S01E02-03 - My Ep Name - # Show.Name.S01.E02.E03 - ''' - ^((?P.+?)[. _-]+)? # Show_Name and separator - s(?P\d+)[. _-]* # S01 and optional separator - e(?P\d+) # E02 and separator - (([. _-]*e|-) # linking e/- char - (?P(?!(1080|720)[pi])\d+))* # additional E03/etc - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - ('fov', - # Show_Name.1x02.Source_Quality_Etc-Group - # Show Name - 1x02 - My Ep Name - # Show_Name.1x02x03x04.Source_Quality_Etc-Group - # Show Name - 1x02-03-04 - My Ep Name - ''' - ^((?P.+?)[\[. _-]+)? # Show_Name and separator - (?P\d+)x # 1x - (?P\d+) # 02 and separator - (([. _-]*x|-) # linking x/- char - (?P - (?!(1080|720)[pi])(?!(?<=x)264) # ignore obviously wrong multi-eps - \d+))* # additional x03/etc - [\]. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - ('scene_date_format', - # Show.Name.2010.11.23.Source.Quality.Etc-Group - # Show Name - 2010-11-23 - Ep Name - ''' - ^((?P.+?)[. _-]+)? # Show_Name and separator - (?P\d{4})[. _-]+ # 2010 and separator - (?P\d{2})[. _-]+ # 11 and separator - (?P\d{2}) # 23 and separator - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - '''), - - ('stupid-mix', - # tpz-show102Source_Quality_Etc - ''' - [a-zA-Z0-9]{2,6}[. _-]+ # tpz-abc - (?P.+?)[. _-]+ # Show Name and separator - (?!264) # don't count x264 - (?P\d{1,2}) # 1 - (?P\d{2})[. _-]+ # 02 - (?P.+)$ # Source_Quality_Etc- - '''), - - ('stupid', - # tpz-abc102 - ''' - (?P.+?)-\w+?[\. ]? # tpz-abc - (?!264) # don't count x264 - (?P\d{1,2}) # 1 - (?P\d{2})$ # 02 - '''), - - ('verbose', - # Show Name Season 1 Episode 2 Ep Name - ''' - ^(?P.+?)[. _-]+ # Show Name and separator - (sea|sai)son[. _-]+ # season and separator - (?P\d+)[. _-]+ # 1 - episode[. _-]+ # episode and separator - (?P\d+)[. _-]+ # 02 and separator - (?P.+)$ # Source_Quality_Etc- - '''), - - ('season_only', - # Show.Name.S01.Source.Quality.Etc-Group - ''' - ^((?P.+?)[. _-]+)? # Show_Name and separator - s((ea|ai)son[. _-])? # S01/Season 01 - (?P\d+)[. _-]* # S01 and optional separator - [. _-]*((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - ''' - ), - - - ('no_season_multi_ep', - # Show.Name.E02-03 - # Show.Name.E02.2010 - ''' - ^((?P.+?)[. _-]+)? # Show_Name and separator - (e(p(isode)?)?|part|pt)[. _-]? # e, ep, episode, or part - (?P(\d+|[ivx]+)) # first ep num - ((([. _-]+(and|&|to)[. _-]+)|-) # and/&/to joiner - (?P(?!(1080|720)[pi])(\d+|[ivx]+))[. _-]) # second ep num - ([. _-]*(?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - ''' - ), - - ('no_season_general', - # Show.Name.E23.Test - # Show.Name.Part.3.Source.Quality.Etc-Group - # Show.Name.Part.1.and.Part.2.Blah-Group - ''' - ^((?P.+?)[. _-]+)? # Show_Name and separator - (e(p(isode)?)?|part|pt)[. _-]? # e, ep, episode, or part - (?P(\d+|([ivx]+(?=[. _-])))) # first ep num - ([. _-]+((and|&|to)[. _-]+)? # and/&/to joiner - ((e(p(isode)?)?|part|pt)[. _-]?) # e, ep, episode, or part - (?P(?!(1080|720)[pi]) - (\d+|([ivx]+(?=[. _-]))))[. _-])* # second ep num - ([. _-]*(?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - ''' - ), - - ('bare', - # Show.Name.102.Source.Quality.Etc-Group - ''' - ^(?P.+?)[. _-]+ # Show_Name and separator - (?P\d{1,2}) # 1 - (?P\d{2}) # 02 and separator - ([. _-]+(?P(?!\d{3}[. _-]+)[^-]+) # Source_Quality_Etc- - (-(?P.+))?)?$ # Group - '''), - - ('no_season', - # Show Name - 01 - Ep Name - # 01 - Ep Name - # 01 - Ep Name - ''' - ^((?P.+?)(?:[. _-]{2,}|[. _]))? # Show_Name and separator - (?P\d{1,2}) # 02 - (?:-(?P\d{1,2}))* # 02 - [. _-]+((?P.+?) # Source_Quality_Etc- - ((?[^- ]+))?)?$ # Group - ''' - ), - - ('mm', - # engrenages S0311 HDTV Divx - ''' - ^(?P.+?)[. _-]+ # Show_Name and separator - s(?P\d+)[. _-]* # S01 and optional separator - (?P\d+) # 02 and separator - ''' - ), - - - ] - -language_regexes = {} - -for k,v in showLanguages.iteritems(): - language_regexes[k] = '(^|\w|[. _-])*('+v+')(([. _-])(dubbed))?\w*([. _-]|$)' - - - - - - +# Author: Nic Wolfe +# URL: http://code.google.com/p/sickbeard/ +# +# This file is part of Sick Beard. +# +# Sick Beard 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. +# +# Sick Beard 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 Sick Beard. If not, see . + +# all regexes are case insensitive +from sickbeard.common import showLanguages + +ep_regexes = [ + + ('season only_year', + # Show.Name.2010.S01.Source.Quality.Etc-Group + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + (?P\d{4})[. _-]+ # 2010 and separator + s((ea|ai)son[. _-])? # S01/Season 01 + (?P\d+)[. _-]* # S01 and optional separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + ('standard_repeat', + # Show.Name.S01E02.S01E03.Source.Quality.Etc-Group + # Show Name - S01E02 - S01E03 - S01E04 - Ep Name + ''' + ^(?P.+?)[. _-]+ # Show_Name and separator + s(?P\d+)[. _-]* # S01 and optional separator + e(?P\d+) # E02 and separator + ([. _-]+s(?P=season_num)[. _-]* # S01 and optional separator + e(?P\d+))+ # E03/etc and separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + + ('scene_date_format_bis', + # Show.Name.2010.S01E01.Source.Quality.Etc-Group + ''' + ^(?P.+?)[. _-]+ # Show_Name and separator + (?P\d{4})[. _-]+ # 2010 and separator + s(?P\d+)[. _-]* # S01 and optional separator + e(?P\d+) + '''), + + ('fov_repeat', + # Show.Name.1x02.1x03.Source.Quality.Etc-Group + # Show Name - 1x02 - 1x03 - 1x04 - Ep Name + ''' + ^(?P.+?)[. _-]+ # Show_Name and separator + (?P\d+)x # 1x + (?P\d+) # 02 and separator + ([. _-]+(?P=season_num)x # 1x + (?P\d+))+ # 03/etc and separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + ('standard_cpas_bien', + # [www.Cpasbien.me] Dexter.S07E04.FRENCH.LD.HDTV.XviD-MiNDe + ''' + \[[a-zA-Z0-9\.]{2,20}\][. _-]+ + (?P.+?)[. _-]+ # Show_Name and separator + s(?P\d+)[. _-]* # S01 and optional separator + e(?P\d+) # E02 and separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + ('standard_ep', + # Show.Name.S01EP02.Source.Quality.Etc-Group + # Show Name - S01EP02 - My Ep Name + # Show.Name.S01.EP03.My.Ep.Name + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + s(?P\d+)[. _-]* # S01 and optional separator + ep(?P\d+) # E02 and separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + ('standard', + # Show.Name.S01E02.Source.Quality.Etc-Group + # Show Name - S01E02 - My Ep Name + # Show.Name.S01.E03.My.Ep.Name + # Show.Name.S01E02E03.Source.Quality.Etc-Group + # Show Name - S01E02-03 - My Ep Name + # Show.Name.S01.E02.E03 + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + s(?P\d+)[. _-]* # S01 and optional separator + e(?P\d+) # E02 and separator + (([. _-]*e|-) # linking e/- char + (?P(?!(1080|720)[pi])\d+))* # additional E03/etc + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + ('fov', + # Show_Name.1x02.Source_Quality_Etc-Group + # Show Name - 1x02 - My Ep Name + # Show_Name.1x02x03x04.Source_Quality_Etc-Group + # Show Name - 1x02-03-04 - My Ep Name + ''' + ^((?P.+?)[\[. _-]+)? # Show_Name and separator + (?P\d+)x # 1x + (?P\d+) # 02 and separator + (([. _-]*x|-) # linking x/- char + (?P + (?!(1080|720)[pi])(?!(?<=x)264) # ignore obviously wrong multi-eps + \d+))* # additional x03/etc + [\]. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + ('scene_date_format', + # Show.Name.2010.11.23.Source.Quality.Etc-Group + # Show Name - 2010-11-23 - Ep Name + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + (?P\d{4})[. _-]+ # 2010 and separator + (?P\d{2})[. _-]+ # 11 and separator + (?P\d{2}) # 23 and separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + '''), + + ('stupid-mix', + # tpz-show102Source_Quality_Etc + ''' + [a-zA-Z0-9]{2,6}[. _-]+ # tpz-abc + (?P.+?)[. _-]+ # Show Name and separator + (?!264) # don't count x264 + (?P\d{1,2}) # 1 + (?P\d{2})[. _-]+ # 02 + (?P.+)$ # Source_Quality_Etc- + '''), + + ('stupid', + # tpz-abc102 + ''' + (?P.+?)-\w+?[\. ]? # tpz-abc + (?!264) # don't count x264 + (?P\d{1,2}) # 1 + (?P\d{2})$ # 02 + '''), + + ('verbose', + # Show Name Season 1 Episode 2 Ep Name + ''' + ^(?P.+?)[. _-]+ # Show Name and separator + (sea|sai)son[. _-]+ # season and separator + (?P\d+)[. _-]+ # 1 + episode[. _-]+ # episode and separator + (?P\d+)[. _-]+ # 02 and separator + (?P.+)$ # Source_Quality_Etc- + '''), + + + ('season_only', + # Show.Name.S01.Source.Quality.Etc-Group + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + s((ea|ai)son[. _-])? # S01/Season 01 + (?P\d+)[. _-]* # S01 and optional separator + [. _-]*((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + ''' + ), + + + ('no_season_multi_ep', + # Show.Name.E02-03 + # Show.Name.E02.2010 + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + (e(p(isode)?)?|part|pt)[. _-]? # e, ep, episode, or part + (?P(\d+|[ivx]+)) # first ep num + ((([. _-]+(and|&|to)[. _-]+)|-) # and/&/to joiner + (?P(?!(1080|720)[pi])(\d+|[ivx]+))[. _-]) # second ep num + ([. _-]*(?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + ''' + ), + + ('no_season_general', + # Show.Name.E23.Test + # Show.Name.Part.3.Source.Quality.Etc-Group + # Show.Name.Part.1.and.Part.2.Blah-Group + ''' + ^((?P.+?)[. _-]+)? # Show_Name and separator + (e(p(isode)?)?|part|pt)[. _-]? # e, ep, episode, or part + (?P(\d+|([ivx]+(?=[. _-])))) # first ep num + ([. _-]+((and|&|to)[. _-]+)? # and/&/to joiner + ((e(p(isode)?)?|part|pt)[. _-]?) # e, ep, episode, or part + (?P(?!(1080|720)[pi]) + (\d+|([ivx]+(?=[. _-]))))[. _-])* # second ep num + ([. _-]*(?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + ''' + ), + + ('bare', + # Show.Name.102.Source.Quality.Etc-Group + ''' + ^(?P.+?)[. _-]+ # Show_Name and separator + (?P\d{1,2}) # 1 + (?P\d{2}) # 02 and separator + ([. _-]+(?P(?!\d{3}[. _-]+)[^-]+) # Source_Quality_Etc- + (-(?P.+))?)?$ # Group + '''), + + ('no_season', + # Show Name - 01 - Ep Name + # 01 - Ep Name + # 01 - Ep Name + ''' + ^((?P.+?)(?:[. _-]{2,}|[. _]))? # Show_Name and separator + (?P\d{1,2}) # 02 + (?:-(?P\d{1,2}))* # 02 + [. _-]+((?P.+?) # Source_Quality_Etc- + ((?[^- ]+))?)?$ # Group + ''' + ), + + ('mm', + # engrenages S0311 HDTV Divx + ''' + ^(?P.+?)[. _-]+ # Show_Name and separator + s(?P\d+)[. _-]* # S01 and optional separator + (?P\d+) # 02 and separator + ''' + ), + + + ] + +language_regexes = {} + +for k,v in showLanguages.iteritems(): + language_regexes[k] = '(^|\w|[. _-])*('+v+')(([. _-])(dubbed))?\w*([. _-]|$)' + + + + + + From 7f4aa3739a9d0879f078bed3298b284f903d99c6 Mon Sep 17 00:00:00 2001 From: stephane CREMEL Date: Tue, 4 Jun 2013 17:13:21 +0200 Subject: [PATCH 171/492] Add jqgrid for home page --- data/css/lib/ui.jqgrid.css | 151 +++++++++ data/js/lib/jquery.jqGrid.min.js | 531 +++++++++++++++++++++++++++++++ 2 files changed, 682 insertions(+) create mode 100755 data/css/lib/ui.jqgrid.css create mode 100755 data/js/lib/jquery.jqGrid.min.js diff --git a/data/css/lib/ui.jqgrid.css b/data/css/lib/ui.jqgrid.css new file mode 100755 index 0000000000..cea2cd56e4 --- /dev/null +++ b/data/css/lib/ui.jqgrid.css @@ -0,0 +1,151 @@ +/*Grid*/ +.ui-jqgrid {position: relative;} +.ui-jqgrid .ui-jqgrid-view {position: relative;left:0; top: 0; padding: 0; font-size:11px;} +/* caption*/ +.ui-jqgrid .ui-jqgrid-titlebar {padding: .3em .2em .2em .3em; position: relative; border-left: 0 none;border-right: 0 none; border-top: 0 none;} +.ui-jqgrid .ui-jqgrid-title { float: left; margin: .1em 0 .2em; } +.ui-jqgrid .ui-jqgrid-titlebar-close { position: absolute;top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height:18px;}.ui-jqgrid .ui-jqgrid-titlebar-close span { display: block; margin: 1px; } +.ui-jqgrid .ui-jqgrid-titlebar-close:hover { padding: 0; } +/* header*/ +.ui-jqgrid .ui-jqgrid-hdiv {position: relative; margin: 0;padding: 0; overflow-x: hidden; border-left: 0 none !important; border-top : 0 none !important; border-right : 0 none !important;} +.ui-jqgrid .ui-jqgrid-hbox {float: left; padding-right: 20px;} +.ui-jqgrid .ui-jqgrid-htable {table-layout:fixed;margin:0;} +.ui-jqgrid .ui-jqgrid-htable th {height:22px;padding: 0 2px 0 2px;} +.ui-jqgrid .ui-jqgrid-htable th div {overflow: hidden; position:relative; height:17px;} +.ui-th-column, .ui-jqgrid .ui-jqgrid-htable th.ui-th-column {overflow: hidden;white-space: nowrap;text-align:center;border-top : 0 none;border-bottom : 0 none;} +.ui-th-ltr, .ui-jqgrid .ui-jqgrid-htable th.ui-th-ltr {border-left : 0 none;} +.ui-th-rtl, .ui-jqgrid .ui-jqgrid-htable th.ui-th-rtl {border-right : 0 none;} +.ui-first-th-ltr {border-right: 1px solid; } +.ui-first-th-rtl {border-left: 1px solid; } +.ui-jqgrid .ui-th-div-ie {white-space: nowrap; zoom :1; height:17px;} +.ui-jqgrid .ui-jqgrid-resize {height:20px !important;position: relative; cursor :e-resize;display: inline;overflow: hidden;} +.ui-jqgrid .ui-grid-ico-sort {overflow:hidden;position:absolute;display:inline; cursor: pointer !important;} +.ui-jqgrid .ui-icon-asc {margin-top:-3px; height:12px;} +.ui-jqgrid .ui-icon-desc {margin-top:3px;height:12px;} +.ui-jqgrid .ui-i-asc {margin-top:0;height:16px;} +.ui-jqgrid .ui-i-desc {margin-top:0;margin-left:13px;height:16px;} +.ui-jqgrid .ui-jqgrid-sortable {cursor:pointer;} +.ui-jqgrid tr.ui-search-toolbar th { border-top-width: 1px !important; border-top-color: inherit !important; border-top-style: ridge !important } +tr.ui-search-toolbar input {margin: 1px 0 0 0} +tr.ui-search-toolbar select {margin: 1px 0 0 0} +/* body */ +.ui-jqgrid .ui-jqgrid-bdiv {position: relative; margin: 0; padding:0; overflow: auto; text-align:left;} +.ui-jqgrid .ui-jqgrid-btable {table-layout:fixed; margin:0; outline-style: none; } +.ui-jqgrid tr.jqgrow { outline-style: none; } +.ui-jqgrid tr.jqgroup { outline-style: none; } +.ui-jqgrid tr.jqgrow td {font-weight: normal; overflow: hidden; white-space: pre; height: 22px;padding: 0 2px 0 2px;border-bottom-width: 1px; border-bottom-color: inherit; border-bottom-style: solid;} +.ui-jqgrid tr.jqgfirstrow td {padding: 0 2px 0 2px;border-right-width: 1px; border-right-style: solid;} +.ui-jqgrid tr.jqgroup td {font-weight: normal; overflow: hidden; white-space: pre; height: 22px;padding: 0 2px 0 2px;border-bottom-width: 1px; border-bottom-color: inherit; border-bottom-style: solid;} +.ui-jqgrid tr.jqfoot td {font-weight: bold; overflow: hidden; white-space: pre; height: 22px;padding: 0 2px 0 2px;border-bottom-width: 1px; border-bottom-color: inherit; border-bottom-style: solid;} +.ui-jqgrid tr.ui-row-ltr td {text-align:left;border-right-width: 1px; border-right-color: inherit; border-right-style: solid;} +.ui-jqgrid tr.ui-row-rtl td {text-align:right;border-left-width: 1px; border-left-color: inherit; border-left-style: solid;} +.ui-jqgrid td.jqgrid-rownum { padding: 0 2px 0 2px; margin: 0; border: 0 none;} +.ui-jqgrid .ui-jqgrid-resize-mark { width:2px; left:0; background-color:#777; cursor: e-resize; cursor: col-resize; position:absolute; top:0; height:100px; overflow:hidden; display:none; border:0 none; z-index: 99999;} +/* footer */ +.ui-jqgrid .ui-jqgrid-sdiv {position: relative; margin: 0;padding: 0; overflow: hidden; border-left: 0 none !important; border-top : 0 none !important; border-right : 0 none !important;} +.ui-jqgrid .ui-jqgrid-ftable {table-layout:fixed; margin-bottom:0;} +.ui-jqgrid tr.footrow td {font-weight: bold; overflow: hidden; white-space:nowrap; height: 21px;padding: 0 2px 0 2px;border-top-width: 1px; border-top-color: inherit; border-top-style: solid;} +.ui-jqgrid tr.footrow-ltr td {text-align:left;border-right-width: 1px; border-right-color: inherit; border-right-style: solid;} +.ui-jqgrid tr.footrow-rtl td {text-align:right;border-left-width: 1px; border-left-color: inherit; border-left-style: solid;} +/* Pager*/ +.ui-jqgrid .ui-jqgrid-pager { border-left: 0 none !important;border-right: 0 none !important; border-bottom: 0 none !important; margin: 0 !important; padding: 0 !important; position: relative; height: 25px;white-space: nowrap;overflow: hidden;font-size:11px;} +.ui-jqgrid .ui-pager-control {position: relative;} +.ui-jqgrid .ui-pg-table {position: relative; padding-bottom:2px; width:auto; margin: 0;} +.ui-jqgrid .ui-pg-table td {font-weight:normal; vertical-align:middle; padding:1px;} +.ui-jqgrid .ui-pg-button { height:19px !important;} +.ui-jqgrid .ui-pg-button span { display: block; margin: 1px; float:left;} +.ui-jqgrid .ui-pg-button:hover { padding: 0; } +.ui-jqgrid .ui-state-disabled:hover {padding:1px;} +.ui-jqgrid .ui-pg-input { height:13px;font-size:.8em; margin: 0;} +.ui-jqgrid .ui-pg-selbox {font-size:.8em; line-height:18px; display:block; height:18px; margin: 0;} +.ui-jqgrid .ui-separator {height: 18px; border-left: 1px solid #ccc ; border-right: 1px solid #ccc ; margin: 1px; float: right;} +.ui-jqgrid .ui-paging-info {font-weight: normal;height:19px; margin-top:3px;margin-right:4px;} +.ui-jqgrid .ui-jqgrid-pager .ui-pg-div {padding:1px 0;float:left;position:relative;} +.ui-jqgrid .ui-jqgrid-pager .ui-pg-button { cursor:pointer; } +.ui-jqgrid .ui-jqgrid-pager .ui-pg-div span.ui-icon {float:left;margin:0 2px;} +.ui-jqgrid td input, .ui-jqgrid td select .ui-jqgrid td textarea { margin: 0;} +.ui-jqgrid td textarea {width:auto;height:auto;} +.ui-jqgrid .ui-jqgrid-toppager {border-left: 0 none !important;border-right: 0 none !important; border-top: 0 none !important; margin: 0 !important; padding: 0 !important; position: relative; height: 25px !important;white-space: nowrap;overflow: hidden;} +.ui-jqgrid .ui-jqgrid-toppager .ui-pg-div {padding:1px 0;float:left;position:relative;} +.ui-jqgrid .ui-jqgrid-toppager .ui-pg-button { cursor:pointer; } +.ui-jqgrid .ui-jqgrid-toppager .ui-pg-div span.ui-icon {float:left;margin:0 2px;} +/*subgrid*/ +.ui-jqgrid .ui-jqgrid-btable .ui-sgcollapsed span {display: block;} +.ui-jqgrid .ui-subgrid {margin:0;padding:0; width:100%;} +.ui-jqgrid .ui-subgrid table {table-layout: fixed;} +.ui-jqgrid .ui-subgrid tr.ui-subtblcell td {height:18px;border-right-width: 1px; border-right-color: inherit; border-right-style: solid;border-bottom-width: 1px; border-bottom-color: inherit; border-bottom-style: solid;} +.ui-jqgrid .ui-subgrid td.subgrid-data {border-top: 0 none !important;} +.ui-jqgrid .ui-subgrid td.subgrid-cell {border-width: 0 0 1px 0;} +.ui-jqgrid .ui-th-subgrid {height:20px;} +/* loading */ +.ui-jqgrid .loading {position: absolute; top: 45%;left: 45%;width: auto;z-index:101;padding: 6px; margin: 5px;text-align: center;font-weight: bold;display: none;border-width: 2px !important; font-size:11px;} +.ui-jqgrid .jqgrid-overlay {display:none;z-index:100;} +* html .jqgrid-overlay {width: expression(this.parentNode.offsetWidth+'px');height: expression(this.parentNode.offsetHeight+'px');} +* .jqgrid-overlay iframe {position:absolute;top:0;left:0;z-index:-1;width: expression(this.parentNode.offsetWidth+'px');height: expression(this.parentNode.offsetHeight+'px');} +/* end loading div */ +/* toolbar */ +.ui-jqgrid .ui-userdata {border-left: 0 none; border-right: 0 none; height : 21px;overflow: hidden; } +/*Modal Window */ +.ui-jqdialog { display: none; width: 300px; position: absolute; padding: .2em; font-size:11px; overflow:visible;} +.ui-jqdialog .ui-jqdialog-titlebar { padding: .3em .2em; position: relative; } +.ui-jqdialog .ui-jqdialog-title { margin: .1em 0 .2em; } +.ui-jqdialog .ui-jqdialog-titlebar-close { position: absolute; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; } + +.ui-jqdialog .ui-jqdialog-titlebar-close span { display: block; margin: 1px; } +.ui-jqdialog .ui-jqdialog-titlebar-close:hover, .ui-jqdialog .ui-jqdialog-titlebar-close:focus { padding: 0; } +.ui-jqdialog-content, .ui-jqdialog .ui-jqdialog-content { border: 0; padding: .3em .2em; background: none; height:auto;} +.ui-jqdialog .ui-jqconfirm {padding: .4em 1em; border-width:3px;position:absolute;bottom:10px;right:10px;overflow:visible;display:none;height:80px;width:220px;text-align:center;} +.ui-jqdialog>.ui-resizable-se { bottom: -3px; right: -3px} +/* end Modal window*/ +/* Form edit */ +.ui-jqdialog-content .FormGrid {margin: 0;} +.ui-jqdialog-content .EditTable { width: 100%; margin-bottom:0;} +.ui-jqdialog-content .DelTable { width: 100%; margin-bottom:0;} +.EditTable td input, .EditTable td select, .EditTable td textarea {margin: 0;} +.EditTable td textarea { width:auto; height:auto;} +.ui-jqdialog-content td.EditButton {text-align: right;border-top: 0 none;border-left: 0 none;border-right: 0 none; padding-bottom:5px; padding-top:5px;} +.ui-jqdialog-content td.navButton {text-align: center; border-left: 0 none;border-top: 0 none;border-right: 0 none; padding-bottom:5px; padding-top:5px;} +.ui-jqdialog-content input.FormElement {padding:.3em} +.ui-jqdialog-content select.FormElement {padding:.3em} +.ui-jqdialog-content .data-line {padding-top:.1em;border: 0 none;} + +.ui-jqdialog-content .CaptionTD {vertical-align: middle;border: 0 none; padding: 2px;white-space: nowrap;} +.ui-jqdialog-content .DataTD {padding: 2px; border: 0 none; vertical-align: top;} +.ui-jqdialog-content .form-view-data {white-space:pre} +.fm-button { display: inline-block; margin:0 4px 0 0; padding: .4em .5em; text-decoration:none !important; cursor:pointer; position: relative; text-align: center; zoom: 1; } +.fm-button-icon-left { padding-left: 1.9em; } +.fm-button-icon-right { padding-right: 1.9em; } +.fm-button-icon-left .ui-icon { right: auto; left: .2em; margin-left: 0; position: absolute; top: 50%; margin-top: -8px; } +.fm-button-icon-right .ui-icon { left: auto; right: .2em; margin-left: 0; position: absolute; top: 50%; margin-top: -8px;} +#nData, #pData { float: left; margin:3px;padding: 0; width: 15px; } +/* End Eorm edit */ +/*.ui-jqgrid .edit-cell {}*/ +.ui-jqgrid .selected-row, div.ui-jqgrid .selected-row td {font-style : normal;border-left: 0 none;} +/* inline edit actions button*/ +.ui-inline-del.ui-state-hover span, .ui-inline-edit.ui-state-hover span, +.ui-inline-save.ui-state-hover span, .ui-inline-cancel.ui-state-hover span { + margin: -1px; +} +/* Tree Grid */ +.ui-jqgrid .tree-wrap {float: left; position: relative;height: 18px;white-space: nowrap;overflow: hidden;} +.ui-jqgrid .tree-minus {position: absolute; height: 18px; width: 18px; overflow: hidden;} +.ui-jqgrid .tree-plus {position: absolute; height: 18px; width: 18px; overflow: hidden;} +.ui-jqgrid .tree-leaf {position: absolute; height: 18px; width: 18px;overflow: hidden;} +.ui-jqgrid .treeclick {cursor: pointer;} +/* moda dialog */ +* iframe.jqm {position:absolute;top:0;left:0;z-index:-1;width: expression(this.parentNode.offsetWidth+'px');height: expression(this.parentNode.offsetHeight+'px');} +.ui-jqgrid-dnd tr td {border-right-width: 1px; border-right-color: inherit; border-right-style: solid; height:20px} +/* RTL Support */ +.ui-jqgrid .ui-jqgrid-title-rtl {float:right;margin: .1em 0 .2em; } +.ui-jqgrid .ui-jqgrid-hbox-rtl {float: right; padding-left: 20px;} +.ui-jqgrid .ui-jqgrid-resize-ltr {float: right;margin: -2px -2px -2px 0;} +.ui-jqgrid .ui-jqgrid-resize-rtl {float: left;margin: -2px 0 -1px -3px;} +.ui-jqgrid .ui-sort-rtl {left:0;} +.ui-jqgrid .tree-wrap-ltr {float: left;} +.ui-jqgrid .tree-wrap-rtl {float: right;} +.ui-jqgrid .ui-ellipsis {text-overflow:ellipsis;} + +/* Toolbar Search Menu */ +.ui-search-menu { position: absolute; padding: 2px 5px;} +.ui-jqgrid .ui-search-table { padding: 0px 0px; border: 0px none; height:20px; width:100%;} +.ui-jqgrid .ui-search-table .ui-search-oper { width:20px; } \ No newline at end of file diff --git a/data/js/lib/jquery.jqGrid.min.js b/data/js/lib/jquery.jqGrid.min.js new file mode 100755 index 0000000000..981b42fe61 --- /dev/null +++ b/data/js/lib/jquery.jqGrid.min.js @@ -0,0 +1,531 @@ +/* +* jqGrid 4.5.2 - jQuery Grid +* Copyright (c) 2008, Tony Tomov, tony@trirand.com +* Dual licensed under the MIT and GPL licenses +* http://www.opensource.org/licenses/mit-license.php +* http://www.gnu.org/licenses/gpl-2.0.html +* Date:2013-05-21 +* Modules: grid.base.js; jquery.fmatter.js; grid.custom.js; grid.common.js; grid.formedit.js; grid.filter.js; grid.inlinedit.js; grid.celledit.js; jqModal.js; jqDnR.js; grid.subgrid.js; grid.grouping.js; grid.treegrid.js; grid.import.js; JsonXml.js; grid.tbltogrid.js; grid.jqueryui.js; +*/ + +(function(b){b.jgrid=b.jgrid||{};b.extend(b.jgrid,{version:"4.5.2",htmlDecode:function(b){return b&&(" "===b||" "===b||1===b.length&&160===b.charCodeAt(0))?"":!b?b:(""+b).replace(/>/g,">").replace(/</g,"<").replace(/"/g,'"').replace(/&/g,"&")},htmlEncode:function(b){return!b?b:(""+b).replace(/&/g,"&").replace(/\"/g,""").replace(//g,">")},format:function(d){var f=b.makeArray(arguments).slice(1);null==d&&(d="");return d.replace(/\{(\d+)\}/g, +function(b,e){return f[e]})},msie:"Microsoft Internet Explorer"===navigator.appName,msiever:function(){var b=-1;null!=/MSIE ([0-9]{1,}[.0-9]{0,})/.exec(navigator.userAgent)&&(b=parseFloat(RegExp.$1));return b},getCellIndex:function(d){d=b(d);if(d.is("tr"))return-1;d=(!d.is("td")&&!d.is("th")?d.closest("td,th"):d)[0];return b.jgrid.msie?b.inArray(d,d.parentNode.cells):d.cellIndex},stripHtml:function(b){var b=""+b,f=/<("[^"]*"|'[^']*'|[^'">])*>/gi;return b?(b=b.replace(f,""))&&" "!==b&&" "!== +b?b.replace(/\"/g,"'"):"":b},stripPref:function(d,f){var c=b.type(d);if("string"===c||"number"===c)d=""+d,f=""!==d?(""+f).replace(""+d,""):f;return f},parse:function(d){"while(1);"===d.substr(0,9)&&(d=d.substr(9));"/*"===d.substr(0,2)&&(d=d.substr(2,d.length-4));d||(d="{}");return!0===b.jgrid.useJSON&&"object"===typeof JSON&&"function"===typeof JSON.parse?JSON.parse(d):eval("("+d+")")},parseDate:function(d,f,c,e){var a=/^\/Date\((([-+])?[0-9]+)(([-+])([0-9]{2})([0-9]{2}))?\)\/$/,j="string"===typeof f? +f.match(a):null,a=function(a,b){a=""+a;for(b=parseInt(b,10)||2;a.lengthj&&(f[i]=j+1,g.m=f[i])),"F"===d[i]&&(j=b.inArray(f[i],e.monthNames,12),-1!==j&&11j&&f[i]===e.AmPm[j]&& +(f[i]=j,g.h=h(f[i],g.h))),"A"===d[i]&&(j=b.inArray(f[i],e.AmPm),-1!==j&&1=h?g.y=1900+g.y:0<=h&&69>=h&&(g.y=2E3+g.y);h=new Date(g.y,g.m,g.d,g.h,g.i,g.s,g.u)}else h=new Date(g.y,g.m,g.d,g.h,g.i,g.s,g.u);if(void 0===c)return h;e.masks.hasOwnProperty(c)?c=e.masks[c]: +c||(c="Y-m-d");d=h.getHours();f=h.getMinutes();g=h.getDate();j=h.getMonth()+1;i=h.getTimezoneOffset();k=h.getSeconds();var l=h.getMilliseconds(),o=h.getDay(),n=h.getFullYear(),m=(o+6)%7+1,t=(new Date(n,j-1,g)-new Date(n,0,1))/864E5,A={d:a(g),D:e.dayNames[o],j:g,l:e.dayNames[o+7],N:m,S:e.S(g),w:o,z:t,W:5>m?Math.floor((t+m-1)/7)+1:Math.floor((t+m-1)/7)||(4>((new Date(n-1,0,1)).getDay()+6)%7?53:52),F:e.monthNames[j-1+12],m:a(j),M:e.monthNames[j-1],n:j,t:"?",L:"?",o:"?",Y:n,y:(""+n).substring(2),a:12> +d?e.AmPm[0]:e.AmPm[1],A:12>d?e.AmPm[2]:e.AmPm[3],B:"?",g:d%12||12,G:d,h:a(d%12||12),H:a(d),i:a(f),s:a(k),u:l,e:"?",I:"?",O:(0?@\[\\\]\^`{|}~]/g,"\\$&")},guid:1,uidPref:"jqg",randId:function(d){return(d||b.jgrid.uidPref)+b.jgrid.guid++},getAccessor:function(b,f){var c,e,a=[],j;if("function"===typeof f)return f(b);c=b[f];if(void 0===c)try{if("string"===typeof f&&(a=f.split(".")),j=a.length)for(c=b;c&&j--;)e=a.shift(),c=c[e]}catch(g){}return c},getXmlData:function(d,f,c){var e="string"===typeof f?f.match(/^(.*)\[(\w+)\]$/):null;if("function"=== +typeof f)return f(d);if(e&&e[2])return e[1]?b(e[1],d).attr(e[2]):b(d).attr(e[2]);d=b(f,d);return c?d:0
    "),f=d.appendTo("body").find("td").width();d.remove();return 5!==f},cell_width:!0,ajaxOptions:{},from:function(d){return new function(d,c){"string"===typeof d&&(d=b.data(d));var e= +this,a=d,j=!0,g=!1,h=c,i=/[\$,%]/g,k=null,l=null,o=0,n=!1,m="",t=[],A=!0;if("object"===typeof d&&d.push)0b?e:0;!j&&"number"!==typeof a&&"number"!==typeof b&&(a=""+a,b=""+b);return ab?e:0};this._performSort=function(){0!== +t.length&&(a=e._doSort(a,0))};this._doSort=function(a,b){var d=t[b].by,g=t[b].dir,j=t[b].type,c=t[b].datefmt;if(b===t.length-1)return e._getOrder(a,d,g,j,c);b++;for(var d=e._getGroup(a,d,g,j,c),g=[],f,j=0;j",d)};this.less=function(a,b,d){return e._compareValues(e.less,a,b,"<",d)};this.greaterOrEquals=function(a,b,d){return e._compareValues(e.greaterOrEquals,a,b,">=",d)};this.lessOrEquals=function(a,b,d){return e._compareValues(e.lessOrEquals, +a,b,"<=",d)};this.startsWith=function(a,d){var c=null==d?a:d,c=g?b.trim(c.toString()).length:c.toString().length;A?e._append(e._getStr("jQuery.jgrid.getAccessor(this,'"+a+"')")+".substr(0,"+c+") == "+e._getStr('"'+e._toStr(d)+'"')):(c=g?b.trim(d.toString()).length:d.toString().length,e._append(e._getStr("this")+".substr(0,"+c+") == "+e._getStr('"'+e._toStr(a)+'"')));e._setCommand(e.startsWith,a);e._resetNegate();return e};this.endsWith=function(a,d){var c=null==d?a:d,c=g?b.trim(c.toString()).length: +c.toString().length;A?e._append(e._getStr("jQuery.jgrid.getAccessor(this,'"+a+"')")+".substr("+e._getStr("jQuery.jgrid.getAccessor(this,'"+a+"')")+".length-"+c+","+c+') == "'+e._toStr(d)+'"'):e._append(e._getStr("this")+".substr("+e._getStr("this")+'.length-"'+e._toStr(a)+'".length,"'+e._toStr(a)+'".length) == "'+e._toStr(a)+'"');e._setCommand(e.endsWith,a);e._resetNegate();return e};this.contains=function(a,b){A?e._append(e._getStr("jQuery.jgrid.getAccessor(this,'"+a+"')")+'.indexOf("'+e._toStr(b)+ +'",0) > -1'):e._append(e._getStr("this")+'.indexOf("'+e._toStr(a)+'",0) > -1');e._setCommand(e.contains,a);e._resetNegate();return e};this.groupBy=function(b,d,g,c){return!e._hasData()?null:e._getGroup(a,b,d,g,c)};this.orderBy=function(a,d,g,c){d=null==d?"a":b.trim(d.toString().toLowerCase());null==g&&(g="text");null==c&&(c="Y-m-d");if("desc"===d||"descending"===d)d="d";if("asc"===d||"ascending"===d)d="a";t.push({by:a,dir:d,type:g,datefmt:c});return e};return e}(d,null)},getMethod:function(d){return this.getAccessor(b.fn.jqGrid, +d)},extend:function(d){b.extend(b.fn.jqGrid,d);this.no_legacy_api||b.fn.extend(d)}});b.fn.jqGrid=function(d){if("string"===typeof d){var f=b.jgrid.getMethod(d);if(!f)throw"jqGrid - No such method: "+d;var c=b.makeArray(arguments).slice(1);return f.apply(this,c)}return this.each(function(){if(!this.grid){var e=b.extend(!0,{url:"",height:150,page:1,rowNum:20,rowTotal:null,records:0,pager:"",pgbuttons:!0,pginput:!0,colModel:[],rowList:[],colNames:[],sortorder:"asc",sortname:"",datatype:"xml",mtype:"GET", +altRows:!1,selarrrow:[],savedRow:[],shrinkToFit:!0,xmlReader:{},jsonReader:{},subGrid:!1,subGridModel:[],reccount:0,lastpage:0,lastsort:0,selrow:null,beforeSelectRow:null,onSelectRow:null,onSortCol:null,ondblClickRow:null,onRightClickRow:null,onPaging:null,onSelectAll:null,onInitGrid:null,loadComplete:null,gridComplete:null,loadError:null,loadBeforeSend:null,afterInsertRow:null,beforeRequest:null,beforeProcessing:null,onHeaderClick:null,viewrecords:!1,loadonce:!1,multiselect:!1,multikey:!1,editurl:null, +search:!1,caption:"",hidegrid:!0,hiddengrid:!1,postData:{},userData:{},treeGrid:!1,treeGridModel:"nested",treeReader:{},treeANode:-1,ExpandColumn:null,tree_root_level:0,prmNames:{page:"page",rows:"rows",sort:"sidx",order:"sord",search:"_search",nd:"nd",id:"id",oper:"oper",editoper:"edit",addoper:"add",deloper:"del",subgridid:"id",npage:null,totalrows:"totalrows"},forceFit:!1,gridstate:"visible",cellEdit:!1,cellsubmit:"remote",nv:0,loadui:"enable",toolbar:[!1,""],scroll:!1,multiboxonly:!1,deselectAfterSort:!0, +scrollrows:!1,autowidth:!1,scrollOffset:18,cellLayout:5,subGridWidth:20,multiselectWidth:20,gridview:!1,rownumWidth:25,rownumbers:!1,pagerpos:"center",recordpos:"right",footerrow:!1,userDataOnFooter:!1,hoverrows:!0,altclass:"ui-priority-secondary",viewsortcols:[!1,"vertical",!0],resizeclass:"",autoencode:!1,remapColumns:[],ajaxGridOptions:{},direction:"ltr",toppager:!1,headertitles:!1,scrollTimeout:40,data:[],_index:{},grouping:!1,groupingView:{groupField:[],groupOrder:[],groupText:[],groupColumnShow:[], +groupSummary:[],showSummaryOnHide:!1,sortitems:[],sortnames:[],summary:[],summaryval:[],plusicon:"ui-icon-circlesmall-plus",minusicon:"ui-icon-circlesmall-minus",displayField:[]},ignoreCase:!1,cmTemplate:{},idPrefix:"",multiSort:!1},b.jgrid.defaults,d||{}),a=this,c={headers:[],cols:[],footers:[],dragStart:function(c,d,g){this.resizing={idx:c,startX:d.clientX,sOL:g[0]};this.hDiv.style.cursor="col-resize";this.curGbox=b("#rs_m"+b.jgrid.jqID(e.id),"#gbox_"+b.jgrid.jqID(e.id));this.curGbox.css({display:"block", +left:g[0],top:g[1],height:g[2]});b(a).triggerHandler("jqGridResizeStart",[d,c]);b.isFunction(e.resizeStart)&&e.resizeStart.call(a,d,c);document.onselectstart=function(){return!1}},dragMove:function(a){if(this.resizing){var b=a.clientX-this.resizing.startX,a=this.headers[this.resizing.idx],c="ltr"===e.direction?a.width+b:a.width-b,d;33=i&&(void 0===e.lastpage||parseInt((k+g+f-1)/f,10)<=e.lastpage))F=parseInt((a-k+f-1)/f,10),0<=k||2>F||!0===e.scroll?(z=Math.round((k+g)/f)+1,i=-1):i=1;0e.lastpage||1===e.lastpage||z===e.page&&z===e.lastpage)))c.hDiv.loading?c.timer=setTimeout(c.populateVisible,e.scrollTimeout):(e.page=z,y&&(c.selectionPreserver(d[0]), +c.emptyRows.call(d[0],!1,!1)),c.populate(F))}}},scrollGrid:function(a){if(e.scroll){var b=c.bDiv.scrollTop;void 0===c.scrollTop&&(c.scrollTop=0);b!==c.scrollTop&&(c.scrollTop=b,c.timer&&clearTimeout(c.timer),c.timer=setTimeout(c.populateVisible,e.scrollTimeout))}c.hDiv.scrollLeft=c.bDiv.scrollLeft;e.footerrow&&(c.sDiv.scrollLeft=c.bDiv.scrollLeft);a&&a.stopPropagation()},selectionPreserver:function(a){var c=a.p,d=c.selrow,e=c.selarrrow?b.makeArray(c.selarrrow):null,g=a.grid.bDiv.scrollLeft,f=function(){var h; +c.selrow=null;c.selarrrow=[];if(c.multiselect&&e&&0=document.documentMode)alert("Grid can not be used in this ('quirks') mode!");else{b(this).empty().attr("tabindex", +"0");this.p=e;this.p.useProp=!!b.fn.prop;var g,f;if(0===this.p.colNames.length)for(g=0;g"),k=b.jgrid.msie;a.p.direction=b.trim(a.p.direction.toLowerCase());-1===b.inArray(a.p.direction,["ltr","rtl"])&&(a.p.direction="ltr");f=a.p.direction;b(i).insertBefore(this);b(this).removeClass("scroll").appendTo(i); +var l=b("
    ");b(l).attr({id:"gbox_"+this.id,dir:f}).insertBefore(i);b(i).attr("id","gview_"+this.id).appendTo(l);b("
    ").insertBefore(i);b("
    "+this.p.loadtext+"
    ").insertBefore(i);b(this).attr({cellspacing:"0",cellpadding:"0",border:"0",role:"grid","aria-multiselectable":!!this.p.multiselect, +"aria-labelledby":"gbox_"+this.id});var o=function(a,b){a=parseInt(a,10);return isNaN(a)?b||0:a},n=function(d,e,g,f,h,i){var R=a.p.colModel[d],k=R.align,z='style="',F=R.classes,y=R.name,G=[];k&&(z=z+("text-align:"+k+";"));R.hidden===true&&(z=z+"display:none;");if(e===0)z=z+("width: "+c.headers[d].width+"px;");else if(R.cellattr&&b.isFunction(R.cellattr))if((d=R.cellattr.call(a,h,g,f,R,i))&&typeof d==="string"){d=d.replace(/style/i,"style").replace(/title/i,"title");if(d.indexOf("title")>-1)R.title= +false;d.indexOf("class")>-1&&(F=void 0);G=d.split(/[^-]style/);if(G.length===2){G[1]=b.trim(G[1].replace("=",""));if(G[1].indexOf("'")===0||G[1].indexOf('"')===0)G[1]=G[1].substring(1);z=z+G[1].replace(/'/gi,'"')}else z=z+'"'}if(!G.length){G[0]="";z=z+'"'}z=z+((F!==void 0?' class="'+F+'"':"")+(R.title&&g?' title="'+b.jgrid.stripHtml(g)+'"':""));z=z+(' aria-describedby="'+a.p.id+"_"+y+'"');return z+G[0]},m=function(c){return c==null||c===""?" ":a.p.autoencode?b.jgrid.htmlEncode(c):""+c},t=function(c, +d,e,g,f){var h=a.p.colModel[e];if(h.formatter!==void 0){c=""+a.p.idPrefix!==""?b.jgrid.stripPref(a.p.idPrefix,c):c;c={rowId:c,colModel:h,gid:a.p.id,pos:e};d=b.isFunction(h.formatter)?h.formatter.call(a,d,c,g,f):b.fmatter?b.fn.fmatter.call(a,h.formatter,d,c,g,f):m(d)}else d=m(d);return d},A=function(a,b,c,d,e,g){b=t(a,b,c,e,"add");return'
    "+b+""+e+""+c+"
    ",i="",j,k,l,m,n=function(c){var d;b.isFunction(a.p.onPaging)&&(d=a.p.onPaging.call(a,c));a.p.selrow=null;if(a.p.multiselect){a.p.selarrrow= +[];ca(false)}a.p.savedRow=[];return d==="stop"?false:true},c=c.substr(1),d=d+("_"+c);j="pg_"+c;k=c+"_left";l=c+"_center";m=c+"_right";b("#"+b.jgrid.jqID(c)).append("
    ").attr("dir", +"ltr");if(a.p.rowList.length>0){i="
    ";i=i+""+b.jgrid.format(a.p.pgtext||"","","")+"
    ";a.p.viewrecords===true&&b("td#"+c+"_"+a.p.recordpos,"#"+j).append("
    ");b("td#"+c+"_"+a.p.pagerpos,"#"+j).append(g);i=b(".ui-jqgrid").css("font-size")|| +"11px";b(document.body).append("");g=b(g).clone().appendTo("#testpg").width();b("#testpg").remove();if(g>0){e!==""&&(g=g+50);b("td#"+c+"_"+a.p.pagerpos,"#"+j).width(g)}a.p._nvtd=[];a.p._nvtd[0]=g?Math.floor((a.p.width-g)/2):Math.floor(a.p.width/3);a.p._nvtd[1]=0;g=null;b(".ui-pg-selbox","#"+j).bind("change",function(){if(!n("records"))return false;a.p.page=Math.round(a.p.rowNum*(a.p.page- +1)/this.value-0.5)+1;a.p.rowNum=this.value;a.p.pager&&b(".ui-pg-selbox",a.p.pager).val(this.value);a.p.toppager&&b(".ui-pg-selbox",a.p.toppager).val(this.value);O();return false});if(a.p.pgbuttons===true){b(".ui-pg-button","#"+j).hover(function(){if(b(this).hasClass("ui-state-disabled"))this.style.cursor="default";else{b(this).addClass("ui-state-hover");this.style.cursor="pointer"}},function(){if(!b(this).hasClass("ui-state-disabled")){b(this).removeClass("ui-state-hover");this.style.cursor="default"}}); +b("#first"+b.jgrid.jqID(d)+", #prev"+b.jgrid.jqID(d)+", #next"+b.jgrid.jqID(d)+", #last"+b.jgrid.jqID(d)).click(function(){var b=o(a.p.page,1),c=o(a.p.lastpage,1),e=false,g=true,f=true,h=true,i=true;if(c===0||c===1)i=h=f=g=false;else if(c>1&&b>=1)if(b===1)f=g=false;else{if(b===c)i=h=false}else if(c>1&&b===0){i=h=false;b=c-1}if(!n(this.id))return false;if(this.id==="first"+d&&g){a.p.page=1;e=true}if(this.id==="prev"+d&&f){a.p.page=b-1;e=true}if(this.id==="next"+d&&h){a.p.page=b+1;e=true}if(this.id=== +"last"+d&&i){a.p.page=c;e=true}e&&O();return false})}a.p.pginput===true&&b("input.ui-pg-input","#"+j).keypress(function(c){if((c.charCode||c.keyCode||0)===13){if(!n("user"))return false;b(this).val(o(b(this).val(),1));a.p.page=b(this).val()>0?b(this).val():a.p.page;O();return false}return this})},ra=function(c,d){var e,g="",f=a.p.colModel,h=false,i;i=a.p.frozenColumns?d:a.grid.headers[c].el;var j="";b("span.ui-grid-ico-sort",i).addClass("ui-state-disabled");b(i).attr("aria-selected","false");if(f[c].lso)if(f[c].lso=== +"asc"){f[c].lso=f[c].lso+"-desc";j="desc"}else if(f[c].lso==="desc"){f[c].lso=f[c].lso+"-asc";j="asc"}else{if(f[c].lso==="asc-desc"||f[c].lso==="desc-asc")f[c].lso=""}else f[c].lso=j=f.firstsortorder||"asc";if(j){b("span.s-ico",i).show();b("span.ui-icon-"+j,i).removeClass("ui-state-disabled");b(i).attr("aria-selected","true")}else a.p.viewsortcols[0]||b("span.s-ico",i).hide();a.p.sortorder="";b.each(f,function(b){if(this.lso){b>0&&h&&(g=g+", ");e=this.lso.split("-");g=g+(f[b].index||f[b].name);g= +g+(" "+e[e.length-1]);h=true;a.p.sortorder=e[e.length-1]}});i=g.lastIndexOf(a.p.sortorder);g=g.substring(0,i);a.p.sortname=g},la=function(c,d,e,g,f){if(a.p.colModel[d].sortable){var h;if(!(a.p.savedRow.length>0)){if(!e){if(a.p.lastsort===d)if(a.p.sortorder==="asc")a.p.sortorder="desc";else{if(a.p.sortorder==="desc")a.p.sortorder="asc"}else a.p.sortorder=a.p.colModel[d].firstsortorder||"asc";a.p.page=1}if(a.p.multiSort)ra(d,f);else{if(g){if(a.p.lastsort===d&&a.p.sortorder===g&&!e)return;a.p.sortorder= +g}e=a.grid.headers[a.p.lastsort].el;f=a.p.frozenColumns?f:a.grid.headers[d].el;b("span.ui-grid-ico-sort",e).addClass("ui-state-disabled");b(e).attr("aria-selected","false");if(a.p.frozenColumns){a.grid.fhDiv.find("span.ui-grid-ico-sort").addClass("ui-state-disabled");a.grid.fhDiv.find("th").attr("aria-selected","false")}b("span.ui-icon-"+a.p.sortorder,f).removeClass("ui-state-disabled");b(f).attr("aria-selected","true");if(!a.p.viewsortcols[0]&&a.p.lastsort!==d){a.p.frozenColumns&&a.grid.fhDiv.find("span.s-ico").hide(); +b("span.s-ico",e).hide();b("span.s-ico",f).show()}c=c.substring(5+a.p.id.length+1);a.p.sortname=a.p.colModel[d].index||c;h=a.p.sortorder}if(b(a).triggerHandler("jqGridSortCol",[c,d,h])==="stop")a.p.lastsort=d;else if(b.isFunction(a.p.onSortCol)&&a.p.onSortCol.call(a,c,d,h)==="stop")a.p.lastsort=d;else{if(a.p.datatype==="local")a.p.deselectAfterSort&&b(a).jqGrid("resetSelection");else{a.p.selrow=null;a.p.multiselect&&ca(false);a.p.selarrrow=[];a.p.savedRow=[]}if(a.p.scroll){f=a.grid.bDiv.scrollLeft; +V.call(a,true,false);a.grid.hDiv.scrollLeft=f}a.p.subGrid&&a.p.datatype==="local"&&b("td.sgexpanded","#"+b.jgrid.jqID(a.p.id)).each(function(){b(this).trigger("click")});O();a.p.lastsort=d;if(a.p.sortname!==c&&d)a.p.lastsort=d}}}},sa=function(c){c=b(a.grid.headers[c].el);c=[c.position().left+c.outerWidth()];a.p.direction==="rtl"&&(c[0]=a.p.width-c[0]);c[0]=c[0]-a.grid.bDiv.scrollLeft;c.push(b(a.grid.hDiv).position().top);c.push(b(a.grid.bDiv).offset().top-b(a.grid.hDiv).offset().top+b(a.grid.bDiv).height()); +return c},ma=function(c){var d,e=a.grid.headers,g=b.jgrid.getCellIndex(c);for(d=0;d"),this.p.colModel.unshift({name:"cb",width:b.jgrid.cell_width?a.p.multiselectWidth+a.p.cellLayout:a.p.multiselectWidth, +sortable:!1,resizable:!1,hidedlg:!0,search:!1,align:"center",fixed:!0}));this.p.rownumbers&&(this.p.colNames.unshift(""),this.p.colModel.unshift({name:"rn",width:a.p.rownumWidth,sortable:!1,resizable:!1,hidedlg:!0,search:!1,align:"center",fixed:!0}));a.p.xmlReader=b.extend(!0,{root:"rows",row:"row",page:"rows>page",total:"rows>total",records:"rows>records",repeatitems:!0,cell:"cell",id:"[id]",userdata:"userdata",subgrid:{root:"rows",row:"row",repeatitems:!0,cell:"cell"}},a.p.xmlReader);a.p.jsonReader= +b.extend(!0,{root:"rows",page:"page",total:"total",records:"records",repeatitems:!0,cell:"cell",id:"id",userdata:"userdata",subgrid:{root:"rows",repeatitems:!0,cell:"cell"}},a.p.jsonReader);a.p.localReader=b.extend(!0,{root:"rows",page:"page",total:"total",records:"records",repeatitems:!1,cell:"cell",id:"id",userdata:"userdata",subgrid:{root:"rows",repeatitems:!0,cell:"cell"}},a.p.localReader);a.p.scroll&&(a.p.pgbuttons=!1,a.p.pginput=!1,a.p.rowList=[]);a.p.data.length&&N();var B="", +na,C,da,ea,fa,v,p,W,oa=W="",ba=[],pa=[];C=[];if(!0===a.p.shrinkToFit&&!0===a.p.forceFit)for(g=a.p.colModel.length-1;0<=g;g--)if(!a.p.colModel[g].hidden){a.p.colModel[g].resizable=!1;break}"horizontal"===a.p.viewsortcols[1]&&(W=" ui-i-asc",oa=" ui-i-desc");na=k?"class='ui-th-div-ie'":"";W="");if(a.p.multiSort){ba=a.p.sortname.split(",");for(g=0;g",C=a.p.colModel[g].index|| +a.p.colModel[g].name,B+="
    "+a.p.colNames[g],a.p.colModel[g].width=a.p.colModel[g].width?parseInt(a.p.colModel[g].width,10):150,"boolean"!==typeof a.p.colModel[g].title&&(a.p.colModel[g].title=!0),a.p.colModel[g].lso="",C===a.p.sortname&&(a.p.lastsort=g),a.p.multiSort&&(C=b.inArray(C,ba),-1!==C&&(a.p.colModel[g].lso=pa[C])),B+=W+"
    ";W=null;b(this).append(B+"");b("thead tr:first th",this).hover(function(){b(this).addClass("ui-state-hover")}, +function(){b(this).removeClass("ui-state-hover")});if(this.p.multiselect){var ga=[],Y;b("#cb_"+b.jgrid.jqID(a.p.id),this).bind("click",function(){a.p.selarrrow=[];var c=a.p.frozenColumns===true?a.p.id+"_frozen":"";if(this.checked){b(a.rows).each(function(d){if(d>0&&!b(this).hasClass("ui-subgrid")&&!b(this).hasClass("jqgroup")&&!b(this).hasClass("ui-state-disabled")){b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+b.jgrid.jqID(this.id))[a.p.useProp?"prop":"attr"]("checked",true);b(this).addClass("ui-state-highlight").attr("aria-selected", +"true");a.p.selarrrow.push(this.id);a.p.selrow=this.id;if(c){b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+b.jgrid.jqID(this.id),a.grid.fbDiv)[a.p.useProp?"prop":"attr"]("checked",true);b("#"+b.jgrid.jqID(this.id),a.grid.fbDiv).addClass("ui-state-highlight")}}});Y=true;ga=[]}else{b(a.rows).each(function(d){if(d>0&&!b(this).hasClass("ui-subgrid")&&!b(this).hasClass("ui-state-disabled")){b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+b.jgrid.jqID(this.id))[a.p.useProp?"prop":"attr"]("checked",false);b(this).removeClass("ui-state-highlight").attr("aria-selected", +"false");ga.push(this.id);if(c){b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+b.jgrid.jqID(this.id),a.grid.fbDiv)[a.p.useProp?"prop":"attr"]("checked",false);b("#"+b.jgrid.jqID(this.id),a.grid.fbDiv).removeClass("ui-state-highlight")}}});a.p.selrow=null;Y=false}b(a).triggerHandler("jqGridSelectAll",[Y?a.p.selarrrow:ga,Y]);b.isFunction(a.p.onSelectAll)&&a.p.onSelectAll.call(a,Y?a.p.selarrrow:ga,Y)})}!0===a.p.autowidth&&(B=b(l).innerWidth(),a.p.width=0=0&&a.p.groupingView.groupColumnShow.length>c)this.hidden=!a.p.groupingView.groupColumnShow[c]}this.widthOrg=i=o(this.width,0);if(this.hidden===false){d=d+(i+e);this.fixed?m=m+(i+e):g++}});if(isNaN(a.p.width))a.p.width=d+(a.p.shrinkToFit===false&&!isNaN(a.p.height)?h:0);c.width=a.p.width; +a.p.tblwidth=d;if(a.p.shrinkToFit===false&&a.p.forceFit===true)a.p.forceFit=false;if(a.p.shrinkToFit===true&&g>0){l=c.width-e*g-m;if(!isNaN(a.p.height)){l=l-h;k=true}d=0;b.each(a.p.colModel,function(b){if(this.hidden===false&&!this.fixed){this.width=i=Math.round(l*this.width/(a.p.tblwidth-e*g-m));d=d+i;f=b}});n=0;k?c.width-m-(d+e*g)!==h&&(n=c.width-m-(d+e*g)-h):!k&&Math.abs(c.width-m-(d+e*g))!==1&&(n=c.width-m-(d+e*g));a.p.colModel[f].width=a.p.colModel[f].width+n;a.p.tblwidth=d+n+e*g+m;if(a.p.tblwidth> +a.p.width){a.p.colModel[f].width=a.p.colModel[f].width-(a.p.tblwidth-parseInt(a.p.width,10));a.p.tblwidth=a.p.width}}})();b(l).css("width",c.width+"px").append("
     
    ");b(i).css("width",c.width+"px");var B=b("thead:first",a).get(0),Q="";a.p.footerrow&&(Q+="");var i=b("tr:first",B),Z="";a.p.disableClick=!1;b("th",i).each(function(d){da=a.p.colModel[d].width;if(a.p.colModel[d].resizable===void 0)a.p.colModel[d].resizable=true;if(a.p.colModel[d].resizable){ea=document.createElement("span");b(ea).html(" ").addClass("ui-jqgrid-resize ui-jqgrid-resize-"+f).css("cursor","col-resize");b(this).addClass(a.p.resizeclass)}else ea="";b(this).css("width",da+"px").prepend(ea);var e="";if(a.p.colModel[d].hidden){b(this).css("display", +"none");e="display:none;"}Z=Z+("");c.headers[d]={width:da,el:this};fa=a.p.colModel[d].sortable;if(typeof fa!=="boolean")fa=a.p.colModel[d].sortable=true;e=a.p.colModel[d].name;e==="cb"||e==="subgrid"||e==="rn"||a.p.viewsortcols[2]&&b(">div",this).addClass("ui-jqgrid-sortable");if(fa)if(a.p.multiSort)if(a.p.viewsortcols[0]){b("div span.s-ico",this).show();a.p.colModel[d].lso&&b("div span.ui-icon-"+a.p.colModel[d].lso,this).removeClass("ui-state-disabled")}else{if(a.p.colModel[d].lso){b("div span.s-ico", +this).show();b("div span.ui-icon-"+a.p.colModel[d].lso,this).removeClass("ui-state-disabled")}}else if(a.p.viewsortcols[0]){b("div span.s-ico",this).show();d===a.p.lastsort&&b("div span.ui-icon-"+a.p.sortorder,this).removeClass("ui-state-disabled")}else if(d===a.p.lastsort){b("div span.s-ico",this).show();b("div span.ui-icon-"+a.p.sortorder,this).removeClass("ui-state-disabled")}a.p.footerrow&&(Q=Q+(""))}).mousedown(function(d){if(b(d.target).closest("th>span.ui-jqgrid-resize").length=== +1){var e=ma(this);if(a.p.forceFit===true){var g=a.p,f=e,h;for(h=e+1;h
     
    ").append(B),H=a.p.caption&&!0===a.p.hiddengrid?!0:!1;g=b("
    ");B=null;c.hDiv=document.createElement("div");b(c.hDiv).css({width:c.width+"px"}).addClass("ui-state-default ui-jqgrid-hdiv").append(g);b(g).append(i);i=null;H&&b(c.hDiv).hide();a.p.pager&&("string"===typeof a.p.pager?"#"!==a.p.pager.substr(0,1)&&(a.p.pager="#"+a.p.pager):a.p.pager="#"+b(a.p.pager).attr("id"),b(a.p.pager).css({width:c.width+ +"px"}).addClass("ui-state-default ui-jqgrid-pager ui-corner-bottom").appendTo(l),H&&b(a.p.pager).hide(),ka(a.p.pager,""));!1===a.p.cellEdit&&!0===a.p.hoverrows&&b(a).bind("mouseover",function(a){p=b(a.target).closest("tr.jqgrow");b(p).attr("class")!=="ui-subgrid"&&b(p).addClass("ui-state-hover")}).bind("mouseout",function(a){p=b(a.target).closest("tr.jqgrow");b(p).removeClass("ui-state-hover")});var w,I,ha;b(a).before(c.hDiv).click(function(c){v=c.target;p=b(v,a.rows).closest("tr.jqgrow");if(b(p).length=== +0||p[0].className.indexOf("ui-state-disabled")>-1||(b(v,a).closest("table.ui-jqgrid-btable").attr("id")||"").replace("_frozen","")!==a.id)return this;var d=b(v).hasClass("cbox"),e=b(a).triggerHandler("jqGridBeforeSelectRow",[p[0].id,c]);(e=e===false||e==="stop"?false:true)&&b.isFunction(a.p.beforeSelectRow)&&(e=a.p.beforeSelectRow.call(a,p[0].id,c));if(!(v.tagName==="A"||(v.tagName==="INPUT"||v.tagName==="TEXTAREA"||v.tagName==="OPTION"||v.tagName==="SELECT")&&!d)&&e===true){w=p[0].id;I=b.jgrid.getCellIndex(v); +ha=b(v).closest("td,th").html();b(a).triggerHandler("jqGridCellSelect",[w,I,ha,c]);b.isFunction(a.p.onCellSelect)&&a.p.onCellSelect.call(a,w,I,ha,c);if(a.p.cellEdit===true)if(a.p.multiselect&&d)b(a).jqGrid("setSelection",w,true,c);else{w=p[0].rowIndex;try{b(a).jqGrid("editCell",w,I,true)}catch(g){}}else if(a.p.multikey)if(c[a.p.multikey])b(a).jqGrid("setSelection",w,true,c);else{if(a.p.multiselect&&d){d=b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+w).is(":checked");b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+w)[a.p.useProp? +"prop":"attr"]("checked",d)}}else{if(a.p.multiselect&&a.p.multiboxonly&&!d){var f=a.p.frozenColumns?a.p.id+"_frozen":"";b(a.p.selarrrow).each(function(c,d){var e=a.rows.namedItem(d);b(e).removeClass("ui-state-highlight");b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+b.jgrid.jqID(d))[a.p.useProp?"prop":"attr"]("checked",false);if(f){b("#"+b.jgrid.jqID(d),"#"+b.jgrid.jqID(f)).removeClass("ui-state-highlight");b("#jqg_"+b.jgrid.jqID(a.p.id)+"_"+b.jgrid.jqID(d),"#"+b.jgrid.jqID(f))[a.p.useProp?"prop":"attr"]("checked", +false)}});a.p.selarrrow=[]}b(a).jqGrid("setSelection",w,true,c)}}}).bind("reloadGrid",function(c,d){if(a.p.treeGrid===true)a.p.datatype=a.p.treedatatype;d&&d.current&&a.grid.selectionPreserver(a);if(a.p.datatype==="local"){b(a).jqGrid("resetSelection");a.p.data.length&&N()}else if(!a.p.treeGrid){a.p.selrow=null;if(a.p.multiselect){a.p.selarrrow=[];ca(false)}a.p.savedRow=[]}a.p.scroll&&V.call(a,true,false);if(d&&d.page){var e=d.page;if(e>a.p.lastpage)e=a.p.lastpage;e<1&&(e=1);a.p.page=e;a.grid.bDiv.scrollTop= +a.grid.prevRowHeight?(e-1)*a.grid.prevRowHeight*a.p.rowNum:0}if(a.grid.prevRowHeight&&a.p.scroll){delete a.p.lastpage;a.grid.populateVisible()}else a.grid.populate();a.p._inlinenav===true&&b(a).jqGrid("showAddEditButtons");return false}).dblclick(function(c){v=c.target;p=b(v,a.rows).closest("tr.jqgrow");if(b(p).length!==0){w=p[0].rowIndex;I=b.jgrid.getCellIndex(v);b(a).triggerHandler("jqGridDblClickRow",[b(p).attr("id"),w,I,c]);b.isFunction(a.p.ondblClickRow)&&a.p.ondblClickRow.call(a,b(p).attr("id"), +w,I,c)}}).bind("contextmenu",function(c){v=c.target;p=b(v,a.rows).closest("tr.jqgrow");if(b(p).length!==0){a.p.multiselect||b(a).jqGrid("setSelection",p[0].id,true,c);w=p[0].rowIndex;I=b.jgrid.getCellIndex(v);b(a).triggerHandler("jqGridRightClickRow",[b(p).attr("id"),w,I,c]);b.isFunction(a.p.onRightClickRow)&&a.p.onRightClickRow.call(a,b(p).attr("id"),w,I,c)}});c.bDiv=document.createElement("div");k&&"auto"===(""+a.p.height).toLowerCase()&&(a.p.height="100%");b(c.bDiv).append(b('
    ').append("
    ").append(this)).addClass("ui-jqgrid-bdiv").css({height:a.p.height+(isNaN(a.p.height)?"":"px"),width:c.width+"px"}).scroll(c.scrollGrid);b("table:first",c.bDiv).css({width:a.p.tblwidth+"px"});b.support.tbody||2===b("tbody",this).length&&b("tbody:gt(0)",this).remove();a.p.multikey&&(b.jgrid.msie?b(c.bDiv).bind("selectstart",function(){return false}):b(c.bDiv).bind("mousedown",function(){return false}));H&&b(c.bDiv).hide();c.cDiv= +document.createElement("div");var ia=!0===a.p.hidegrid?b("").addClass("ui-jqgrid-titlebar-close HeaderButton").hover(function(){ia.addClass("ui-state-hover")},function(){ia.removeClass("ui-state-hover")}).append("").css("rtl"===f?"left":"right","0px"):"";b(c.cDiv).append(ia).append(""+a.p.caption+"").addClass("ui-jqgrid-titlebar ui-widget-header ui-corner-top ui-helper-clearfix"); +b(c.cDiv).insertBefore(c.hDiv);a.p.toolbar[0]&&(c.uDiv=document.createElement("div"),"top"===a.p.toolbar[1]?b(c.uDiv).insertBefore(c.hDiv):"bottom"===a.p.toolbar[1]&&b(c.uDiv).insertAfter(c.hDiv),"both"===a.p.toolbar[1]?(c.ubDiv=document.createElement("div"),b(c.uDiv).addClass("ui-userdata ui-state-default").attr("id","t_"+this.id).insertBefore(c.hDiv),b(c.ubDiv).addClass("ui-userdata ui-state-default").attr("id","tb_"+this.id).insertAfter(c.hDiv),H&&b(c.ubDiv).hide()):b(c.uDiv).width(c.width).addClass("ui-userdata ui-state-default").attr("id", +"t_"+this.id),H&&b(c.uDiv).hide());a.p.toppager&&(a.p.toppager=b.jgrid.jqID(a.p.id)+"_toppager",c.topDiv=b("
    ")[0],a.p.toppager="#"+a.p.toppager,b(c.topDiv).addClass("ui-state-default ui-jqgrid-toppager").width(c.width).insertBefore(c.hDiv),ka(a.p.toppager,"_t"));a.p.footerrow&&(c.sDiv=b("
    ")[0],g=b("
    "),b(c.sDiv).append(g).width(c.width).insertAfter(c.hDiv),b(g).append(Q),c.footers= +b(".ui-jqgrid-ftable",c.sDiv)[0].rows[0].cells,a.p.rownumbers&&(c.footers[0].className="ui-state-default jqgrid-rownum"),H&&b(c.sDiv).hide());g=null;if(a.p.caption){var ta=a.p.datatype;!0===a.p.hidegrid&&(b(".ui-jqgrid-titlebar-close",c.cDiv).click(function(d){var e=b.isFunction(a.p.onHeaderClick),g=".ui-jqgrid-bdiv, .ui-jqgrid-hdiv, .ui-jqgrid-pager, .ui-jqgrid-sdiv",f,h=this;if(a.p.toolbar[0]===true){a.p.toolbar[1]==="both"&&(g=g+(", #"+b(c.ubDiv).attr("id")));g=g+(", #"+b(c.uDiv).attr("id"))}f= +b(g,"#gview_"+b.jgrid.jqID(a.p.id)).length;a.p.gridstate==="visible"?b(g,"#gbox_"+b.jgrid.jqID(a.p.id)).slideUp("fast",function(){f--;if(f===0){b("span",h).removeClass("ui-icon-circle-triangle-n").addClass("ui-icon-circle-triangle-s");a.p.gridstate="hidden";b("#gbox_"+b.jgrid.jqID(a.p.id)).hasClass("ui-resizable")&&b(".ui-resizable-handle","#gbox_"+b.jgrid.jqID(a.p.id)).hide();b(a).triggerHandler("jqGridHeaderClick",[a.p.gridstate,d]);e&&(H||a.p.onHeaderClick.call(a,a.p.gridstate,d))}}):a.p.gridstate=== +"hidden"&&b(g,"#gbox_"+b.jgrid.jqID(a.p.id)).slideDown("fast",function(){f--;if(f===0){b("span",h).removeClass("ui-icon-circle-triangle-s").addClass("ui-icon-circle-triangle-n");if(H){a.p.datatype=ta;O();H=false}a.p.gridstate="visible";b("#gbox_"+b.jgrid.jqID(a.p.id)).hasClass("ui-resizable")&&b(".ui-resizable-handle","#gbox_"+b.jgrid.jqID(a.p.id)).show();b(a).triggerHandler("jqGridHeaderClick",[a.p.gridstate,d]);e&&(H||a.p.onHeaderClick.call(a,a.p.gridstate,d))}});return false}),H&&(a.p.datatype= +"local",b(".ui-jqgrid-titlebar-close",c.cDiv).trigger("click")))}else b(c.cDiv).hide();b(c.hDiv).after(c.bDiv).mousemove(function(a){if(c.resizing){c.dragMove(a);return false}});b(".ui-jqgrid-labels",c.hDiv).bind("selectstart",function(){return false});b(document).mouseup(function(){if(c.resizing){c.dragEnd();return false}return true});a.formatCol=n;a.sortData=la;a.updatepager=function(c,d){var e,g,f,h,i,j,k,l="",m=a.p.pager?"_"+b.jgrid.jqID(a.p.pager.substr(1)):"",n=a.p.toppager?"_"+a.p.toppager.substr(1): +"";f=parseInt(a.p.page,10)-1;f<0&&(f=0);f=f*parseInt(a.p.rowNum,10);i=f+a.p.reccount;if(a.p.scroll){e=b("tbody:first > tr:gt(0)",a.grid.bDiv);f=i-e.length;a.p.reccount=e.length;if(e=e.outerHeight()||a.grid.prevRowHeight){g=f*e;k=parseInt(a.p.records,10)*e;b(">div:first",a.grid.bDiv).css({height:k}).children("div:first").css({height:g,display:g?"":"none"});if(a.grid.bDiv.scrollTop==0&&a.p.page>1)a.grid.bDiv.scrollTop=a.p.rowNum*(a.p.page-1)*e}a.grid.bDiv.scrollLeft=a.grid.hDiv.scrollLeft}l=a.p.pager|| +"";if(l=l+(a.p.toppager?l?","+a.p.toppager:a.p.toppager:"")){k=b.jgrid.formatter.integer||{};e=o(a.p.page);g=o(a.p.lastpage);b(".selbox",l)[this.p.useProp?"prop":"attr"]("disabled",false);if(a.p.pginput===true){b(".ui-pg-input",l).val(a.p.page);h=a.p.toppager?"#sp_1"+m+",#sp_1"+n:"#sp_1"+m;b(h).html(b.fmatter?b.fmatter.util.NumberFormat(a.p.lastpage,k):a.p.lastpage)}if(a.p.viewrecords)if(a.p.reccount===0)b(".ui-paging-info",l).html(a.p.emptyrecords);else{h=f+1;j=a.p.records;if(b.fmatter){h=b.fmatter.util.NumberFormat(h, +k);i=b.fmatter.util.NumberFormat(i,k);j=b.fmatter.util.NumberFormat(j,k)}b(".ui-paging-info",l).html(b.jgrid.format(a.p.recordtext,h,i,j))}if(a.p.pgbuttons===true){e<=0&&(e=g=0);if(e===1||e===0){b("#first"+m+", #prev"+m).addClass("ui-state-disabled").removeClass("ui-state-hover");a.p.toppager&&b("#first_t"+n+", #prev_t"+n).addClass("ui-state-disabled").removeClass("ui-state-hover")}else{b("#first"+m+", #prev"+m).removeClass("ui-state-disabled");a.p.toppager&&b("#first_t"+n+", #prev_t"+n).removeClass("ui-state-disabled")}if(e=== +g||e===0){b("#next"+m+", #last"+m).addClass("ui-state-disabled").removeClass("ui-state-hover");a.p.toppager&&b("#next_t"+n+", #last_t"+n).addClass("ui-state-disabled").removeClass("ui-state-hover")}else{b("#next"+m+", #last"+m).removeClass("ui-state-disabled");a.p.toppager&&b("#next_t"+n+", #last_t"+n).removeClass("ui-state-disabled")}}}c===true&&a.p.rownumbers===true&&b(">td.jqgrid-rownum",a.rows).each(function(a){b(this).html(f+1+a)});d&&a.p.jqgdnd&&b(a).jqGrid("gridDnD","updateDnD");b(a).triggerHandler("jqGridGridComplete"); +b.isFunction(a.p.gridComplete)&&a.p.gridComplete.call(a);b(a).triggerHandler("jqGridAfterGridComplete")};a.refreshIndex=N;a.setHeadCheckBox=ca;a.constructTr=X;a.formatter=function(a,b,c,d,e){return t(a,b,c,d,e)};b.extend(c,{populate:O,emptyRows:V});this.grid=c;a.addXmlData=function(b){J(b,a.grid.bDiv)};a.addJSONData=function(b){S(b,a.grid.bDiv)};this.grid.cols=this.rows[0].cells;b(a).triggerHandler("jqGridInitGrid");b.isFunction(a.p.onInitGrid)&&a.p.onInitGrid.call(a);O();a.p.hiddengrid=!1}}}})}; +b.jgrid.extend({getGridParam:function(b){var f=this[0];return!f||!f.grid?void 0:!b?f.p:void 0!==f.p[b]?f.p[b]:null},setGridParam:function(d){return this.each(function(){this.grid&&"object"===typeof d&&b.extend(!0,this.p,d)})},getDataIDs:function(){var d=[],f=0,c,e=0;this.each(function(){if((c=this.rows.length)&&0=e+g?b(this.grid.bDiv)[0].scrollTop=h-(e+g)+j+g:h span:first",i).html(h).attr(j):b("td[role='gridcell']:eq("+a+")",i).html(h).attr(j))}),"local"===g.p.datatype){var o=b.jgrid.stripPref(g.p.idPrefix,d),n=g.p._index[o],m;if(g.p.treeGrid)for(m in g.p.treeReader)g.p.treeReader.hasOwnProperty(m)&& +delete l[g.p.treeReader[m]];void 0!==n&&(g.p.data[n]=b.extend(!0,g.p.data[n],l));l=null}}catch(t){a=!1}a&&("string"===k?b(i).addClass(c):"object"===k&&b(i).css(c),b(g).triggerHandler("jqGridAfterGridComplete"))});return a},addRowData:function(d,f,c,e){c||(c="last");var a=!1,j,g,h,i,k,l,o,n,m="",t,A,T,M,$,U;f&&(b.isArray(f)?(t=!0,c="last",A=d):(f=[f],t=!1),this.each(function(){var V=f.length;k=this.p.rownumbers===true?1:0;h=this.p.multiselect===true?1:0;i=this.p.subGrid===true?1:0;if(!t)if(d!==void 0)d= +""+d;else{d=b.jgrid.randId();if(this.p.keyIndex!==false){A=this.p.colModel[this.p.keyIndex+h+i+k].name;f[0][A]!==void 0&&(d=f[0][A])}}T=this.p.altclass;for(var N=0,X="",J={},S=b.isFunction(this.p.afterInsertRow)?true:false;N0"}if(h){n='';m=this.formatCol(k,1,"",null,d,true);g[g.length]='"+n+""}i&&(g[g.length]=b(this).jqGrid("addSubGridCell",h+k,1));for(o=h+i+k;o"+n+""}g.unshift(this.constructTr(d,false,X,J,M, +false));g[g.length]="";if(this.rows.length===0)b("table:first",this.grid.bDiv).append(g.join(""));else switch(c){case "last":b(this.rows[this.rows.length-1]).after(g.join(""));l=this.rows.length-1;break;case "first":b(this.rows[0]).after(g.join(""));l=1;break;case "after":(l=this.rows.namedItem(e))&&(b(this.rows[l.rowIndex+1]).hasClass("ui-subgrid")?b(this.rows[l.rowIndex+1]).after(g):b(l).after(g.join("")));l++;break;case "before":if(l=this.rows.namedItem(e)){b(l).before(g.join(""));l=l.rowIndex}l--}this.p.subGrid=== +true&&b(this).jqGrid("addSubGrid",h+k,l);this.p.records++;this.p.reccount++;b(this).triggerHandler("jqGridAfterInsertRow",[d,M,M]);S&&this.p.afterInsertRow.call(this,d,M,M);N++;if(this.p.datatype==="local"){J[this.p.localReader.id]=U;this.p._index[U]=this.p.data.length;this.p.data.push(J);J={}}}this.p.altRows===true&&!t&&(c==="last"?(this.rows.length-1)%2===1&&b(this.rows[this.rows.length-1]).addClass(T):b(this.rows).each(function(a){a%2===1?b(this).addClass(T):b(this).removeClass(T)}));this.updatepager(true, +true);a=true}));return a},footerData:function(d,f,c){function e(a){for(var b in a)if(a.hasOwnProperty(b))return!1;return!0}var a,j=!1,g={},h;void 0===d&&(d="get");"boolean"!==typeof c&&(c=!0);d=d.toLowerCase();this.each(function(){var i=this,k;if(!i.grid||!i.p.footerrow||"set"===d&&e(f))return!1;j=!0;b(this.p.colModel).each(function(e){a=this.name;"set"===d?void 0!==f[a]&&(k=c?i.formatter("",f[a],e,f,"edit"):f[a],h=this.title?{title:b.jgrid.stripHtml(k)}:{},b("tr.footrow td:eq("+e+")",i.grid.sDiv).html(k).attr(h), +j=!0):"get"===d&&(g[a]=b("tr.footrow td:eq("+e+")",i.grid.sDiv).html())})});return"get"===d?g:j},showHideCol:function(d,f){return this.each(function(){var c=this,e=!1,a=b.jgrid.cell_width?0:c.p.cellLayout,j;if(c.grid){"string"===typeof d&&(d=[d]);f="none"!==f?"":"none";var g=""===f?!0:!1,h=c.p.groupHeader&&("object"===typeof c.p.groupHeader||b.isFunction(c.p.groupHeader));h&&b(c).jqGrid("destroyGroupHeader",!1);b(this.p.colModel).each(function(h){if(-1!==b.inArray(this.name,d)&&this.hidden===g){if(!0=== +c.p.frozenColumns&&!0===this.frozen)return!0;b("tr",c.grid.hDiv).each(function(){b(this.cells[h]).css("display",f)});b(c.rows).each(function(){b(this).hasClass("jqgroup")||b(this.cells[h]).css("display",f)});c.p.footerrow&&b("tr.footrow td:eq("+h+")",c.grid.sDiv).css("display",f);j=parseInt(this.width,10);c.p.tblwidth="none"===f?c.p.tblwidth-(j+a):c.p.tblwidth+(j+a);this.hidden=!g;e=!0;b(c).triggerHandler("jqGridShowHideCol",[g,this.name,h])}});!0===e&&(!0===c.p.shrinkToFit&&!isNaN(c.p.height)&&(c.p.tblwidth+= +parseInt(c.p.scrollOffset,10)),b(c).jqGrid("setGridWidth",!0===c.p.shrinkToFit?c.p.tblwidth:c.p.width));h&&b(c).jqGrid("setGroupHeaders",c.p.groupHeader)}})},hideCol:function(d){return this.each(function(){b(this).jqGrid("showHideCol",d,"none")})},showCol:function(d){return this.each(function(){b(this).jqGrid("showHideCol",d,"")})},remapColumns:function(d,f,c){function e(a){var c;c=a.length?b.makeArray(a):b.extend({},a);b.each(d,function(b){a[b]=c[this]})}function a(a,c){b(">tr"+(c||""),a).each(function(){var a= +this,c=b.makeArray(a.cells);b.each(d,function(){var b=c[this];b&&a.appendChild(b)})})}var j=this.get(0);e(j.p.colModel);e(j.p.colNames);e(j.grid.headers);a(b("thead:first",j.grid.hDiv),c&&":not(.ui-jqgrid-labels)");f&&a(b("#"+b.jgrid.jqID(j.p.id)+" tbody:first"),".jqgfirstrow, tr.jqgrow, tr.jqfoot");j.p.footerrow&&a(b("tbody:first",j.grid.sDiv));j.p.remapColumns&&(j.p.remapColumns.length?e(j.p.remapColumns):j.p.remapColumns=b.makeArray(d));j.p.lastsort=b.inArray(j.p.lastsort,d);j.p.treeGrid&&(j.p.expColInd= +b.inArray(j.p.expColInd,d));b(j).triggerHandler("jqGridRemapColumns",[d,f,c])},setGridWidth:function(d,f){return this.each(function(){if(this.grid){var c=this,e,a=0,j=b.jgrid.cell_width?0:c.p.cellLayout,g,h=0,i=!1,k=c.p.scrollOffset,l,o=0,n;"boolean"!==typeof f&&(f=c.p.shrinkToFit);if(!isNaN(d)){d=parseInt(d,10);c.grid.width=c.p.width=d;b("#gbox_"+b.jgrid.jqID(c.p.id)).css("width",d+"px");b("#gview_"+b.jgrid.jqID(c.p.id)).css("width",d+"px");b(c.grid.bDiv).css("width",d+"px");b(c.grid.hDiv).css("width", +d+"px");c.p.pager&&b(c.p.pager).css("width",d+"px");c.p.toppager&&b(c.p.toppager).css("width",d+"px");!0===c.p.toolbar[0]&&(b(c.grid.uDiv).css("width",d+"px"),"both"===c.p.toolbar[1]&&b(c.grid.ubDiv).css("width",d+"px"));c.p.footerrow&&b(c.grid.sDiv).css("width",d+"px");!1===f&&!0===c.p.forceFit&&(c.p.forceFit=!1);if(!0===f){b.each(c.p.colModel,function(){if(this.hidden===false){e=this.widthOrg;a=a+(e+j);this.fixed?o=o+(e+j):h++}});if(0===h)return;c.p.tblwidth=a;l=d-j*h-o;if(!isNaN(c.p.height)&&(b(c.grid.bDiv)[0].clientHeight< +b(c.grid.bDiv)[0].scrollHeight||1===c.rows.length))i=!0,l-=k;var a=0,m=0d?(i=c.p.tblwidth-parseInt(d,10),c.p.tblwidth=d,e=c.p.colModel[g].width-=i):e=c.p.colModel[g].width;c.grid.headers[g].width=e;c.grid.headers[g].el.style.width=e+"px";m&&(c.grid.cols[g].style.width=e+"px");c.p.footerrow&&(c.grid.footers[g].style.width=e+"px")}c.p.tblwidth&&(b("table:first",c.grid.bDiv).css("width",c.p.tblwidth+"px"),b("table:first",c.grid.hDiv).css("width",c.p.tblwidth+"px"),c.grid.hDiv.scrollLeft=c.grid.bDiv.scrollLeft, +c.p.footerrow&&b("table:first",c.grid.sDiv).css("width",c.p.tblwidth+"px"))}}})},setGridHeight:function(d){return this.each(function(){if(this.grid){var f=b(this.grid.bDiv);f.css({height:d+(isNaN(d)?"":"px")});!0===this.p.frozenColumns&&b("#"+b.jgrid.jqID(this.p.id)+"_frozen").parent().height(f.height()-16);this.p.height=d;this.p.scroll&&this.grid.populateVisible()}})},setCaption:function(d){return this.each(function(){this.p.caption=d;b("span.ui-jqgrid-title, span.ui-jqgrid-title-rtl",this.grid.cDiv).html(d); +b(this.grid.cDiv).show()})},setLabel:function(d,f,c,e){return this.each(function(){var a=-1;if(this.grid&&void 0!==d&&(b(this.p.colModel).each(function(b){if(this.name===d)return a=b,!1}),0<=a)){var j=b("tr.ui-jqgrid-labels th:eq("+a+")",this.grid.hDiv);if(f){var g=b(".s-ico",j);b("[id^=jqgh_]",j).empty().html(f).append(g);this.p.colNames[a]=f}c&&("string"===typeof c?b(j).addClass(c):b(j).css(c));"object"===typeof e&&b(j).attr(e)}})},setCell:function(d,f,c,e,a,j){return this.each(function(){var g= +-1,h,i;if(this.grid&&(isNaN(f)?b(this.p.colModel).each(function(a){if(this.name===f)return g=a,!1}):g=parseInt(f,10),0<=g&&(h=this.rows.namedItem(d)))){var k=b("td:eq("+g+")",h);if(""!==c||!0===j)h=this.formatter(d,c,g,h,"edit"),i=this.p.colModel[g].title?{title:b.jgrid.stripHtml(h)}:{},this.p.treeGrid&&0c,e=""+c,f=b.decimalSeparator||".",g;if(a.fmatter.isNumber(b.decimalPlaces)){var h=b.decimalPlaces,e=Math.pow(10,h),e=""+Math.round(c*e)/e;g=e.lastIndexOf(".");if(0g?(e+=f,g=e.length-1):"."!==f&&(e=e.replace(".", +f));for(;e.length-1-g'+c+"
    ":a.fn.fmatter.defaultFormat(c, +b)};a.fn.fmatter.checkbox=function(c,b){var d=a.extend({},b.checkbox),e;void 0!==b.colModel&&void 0!==b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));e=!0===d.disabled?'disabled="disabled"':"";if(a.fmatter.isEmpty(c)||void 0===c)c=a.fn.fmatter.defaultFormat(c,d);c=(""+c).toLowerCase();return'c.search(/(false|f|0|no|n|off|undefined)/i)?" checked='checked' ":"")+' value="'+c+'" offval="no" '+e+"/>"};a.fn.fmatter.link=function(c,b){var d={target:b.target}, +e="";void 0!==b.colModel&&void 0!==b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));d.target&&(e="target="+d.target);return!a.fmatter.isEmpty(c)?"'+c+"":a.fn.fmatter.defaultFormat(c,b)};a.fn.fmatter.showlink=function(c,b){var d={baseLinkUrl:b.baseLinkUrl,showAction:b.showAction,addParam:b.addParam||"",target:b.target,idName:b.idName},e="";void 0!==b.colModel&&void 0!==b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));d.target&&(e= +"target="+d.target);d=d.baseLinkUrl+d.showAction+"?"+d.idName+"="+b.rowId+d.addParam;return a.fmatter.isString(c)||a.fmatter.isNumber(c)?"'+c+"":a.fn.fmatter.defaultFormat(c,b)};a.fn.fmatter.integer=function(c,b){var d=a.extend({},b.integer);void 0!==b.colModel&&void 0!==b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));return a.fmatter.isEmpty(c)?d.defaultValue:a.fmatter.util.NumberFormat(c,d)};a.fn.fmatter.number=function(c,b){var d=a.extend({},b.number); +void 0!==b.colModel&&void 0!==b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));return a.fmatter.isEmpty(c)?d.defaultValue:a.fmatter.util.NumberFormat(c,d)};a.fn.fmatter.currency=function(c,b){var d=a.extend({},b.currency);void 0!==b.colModel&&void 0!==b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));return a.fmatter.isEmpty(c)?d.defaultValue:a.fmatter.util.NumberFormat(c,d)};a.fn.fmatter.date=function(c,b,d,e){d=a.extend({},b.date);void 0!==b.colModel&&void 0!== +b.colModel.formatoptions&&(d=a.extend({},d,b.colModel.formatoptions));return!d.reformatAfterEdit&&"edit"===e?a.fn.fmatter.defaultFormat(c,b):!a.fmatter.isEmpty(c)?a.jgrid.parseDate(d.srcformat,c,d.newformat,d):a.fn.fmatter.defaultFormat(c,b)};a.fn.fmatter.select=function(c,b){var c=""+c,d=!1,e=[],f,g;void 0!==b.colModel.formatoptions?(d=b.colModel.formatoptions.value,f=void 0===b.colModel.formatoptions.separator?":":b.colModel.formatoptions.separator,g=void 0===b.colModel.formatoptions.delimiter? +";":b.colModel.formatoptions.delimiter):void 0!==b.colModel.editoptions&&(d=b.colModel.editoptions.value,f=void 0===b.colModel.editoptions.separator?":":b.colModel.editoptions.separator,g=void 0===b.colModel.editoptions.delimiter?";":b.colModel.editoptions.delimiter);if(d){var h=!0===b.colModel.editoptions.multiple?!0:!1,j=[];h&&(j=c.split(","),j=a.map(j,function(b){return a.trim(b)}));if(a.fmatter.isString(d)){var i=d.split(g),k=0,l;for(l=0;l0)return a}).join(f)),h)-1 div",e):a(this).parent(),i={keys:!1,onEdit:null,onSuccess:null,afterSave:null,onError:null,afterRestore:null,extraparam:{},url:null,restoreAfterError:!0,mtype:"POST",delOptions:{},editOptions:{}},k=function(b){a.isFunction(i.afterRestore)&&i.afterRestore.call(f,b);j.find("div.ui-inline-edit,div.ui-inline-del").show();j.find("div.ui-inline-save,div.ui-inline-cancel").hide()};void 0!==h.formatoptions&&(i=a.extend(i,h.formatoptions));void 0!== +g.editOptions&&(i.editOptions=g.editOptions);void 0!==g.delOptions&&(i.delOptions=g.delOptions);b.hasClass("jqgrid-new-row")&&(i.extraparam[g.prmNames.oper]=g.prmNames.addoper);b={keys:i.keys,oneditfunc:i.onEdit,successfunc:i.onSuccess,url:i.url,extraparam:i.extraparam,aftersavefunc:function(b,c){a.isFunction(i.afterSave)&&i.afterSave.call(f,b,c);j.find("div.ui-inline-edit,div.ui-inline-del").show();j.find("div.ui-inline-save,div.ui-inline-cancel").hide()},errorfunc:i.onError,afterrestorefunc:k,restoreAfterError:i.restoreAfterError, +mtype:i.mtype};switch(c){case "edit":e.jqGrid("editRow",d,b);j.find("div.ui-inline-edit,div.ui-inline-del").hide();j.find("div.ui-inline-save,div.ui-inline-cancel").show();e.triggerHandler("jqGridAfterGridComplete");break;case "save":e.jqGrid("saveRow",d,b)&&(j.find("div.ui-inline-edit,div.ui-inline-del").show(),j.find("div.ui-inline-save,div.ui-inline-cancel").hide(),e.triggerHandler("jqGridAfterGridComplete"));break;case "cancel":e.jqGrid("restoreRow",d,k);j.find("div.ui-inline-edit,div.ui-inline-del").show(); +j.find("div.ui-inline-save,div.ui-inline-cancel").hide();e.triggerHandler("jqGridAfterGridComplete");break;case "del":e.jqGrid("delGridRow",d,i.delOptions);break;case "formedit":e.jqGrid("setSelection",d),e.jqGrid("editGridRow",d,i.editOptions)}};a.fn.fmatter.actions=function(c,b){var d={keys:!1,editbutton:!0,delbutton:!0,editformbutton:!1},e=b.rowId,f="";void 0!==b.colModel.formatoptions&&(d=a.extend(d,b.colModel.formatoptions));if(void 0===e||a.fmatter.isEmpty(e))return"";d.editformbutton?f+="
    ":d.editbutton&&(f+="
    ");d.delbutton&&(f+="
    ");f+="";f+="";return"
    "+f+"
    "};a.unformat=function(c,b,d,e){var f,g=b.colModel.formatter,h=b.colModel.formatoptions||{},j=/([\.\*\_\'\(\)\{\}\+\?\\])/g,i=b.colModel.unformat||a.fn.fmatter[g]&&a.fn.fmatter[g].unformat;if(void 0!==i&&a.isFunction(i))f=i.call(this,a(c).text(),b,c);else if(void 0!==g&&a.fmatter.isString(g))switch(f=a.jgrid.formatter||{},g){case "integer":h=a.extend({},f.integer,h);b=h.thousandsSeparator.replace(j, +"\\$1");f=a(c).text().replace(RegExp(b,"g"),"");break;case "number":h=a.extend({},f.number,h);b=h.thousandsSeparator.replace(j,"\\$1");f=a(c).text().replace(RegExp(b,"g"),"").replace(h.decimalSeparator,".");break;case "currency":h=a.extend({},f.currency,h);b=h.thousandsSeparator.replace(j,"\\$1");b=RegExp(b,"g");f=a(c).text();h.prefix&&h.prefix.length&&(f=f.substr(h.prefix.length));h.suffix&&h.suffix.length&&(f=f.substr(0,f.length-h.suffix.length));f=f.replace(b,"").replace(h.decimalSeparator,"."); +break;case "checkbox":h=b.colModel.editoptions?b.colModel.editoptions.value.split(":"):["Yes","No"];f=a("input",c).is(":checked")?h[0]:h[1];break;case "select":f=a.unformat.select(c,b,d,e);break;case "actions":return"";default:f=a(c).text()}return void 0!==f?f:!0===e?a(c).text():a.jgrid.htmlDecode(a(c).html())};a.unformat.select=function(c,b,d,e){d=[];c=a(c).text();if(!0===e)return c;var e=a.extend({},void 0!==b.colModel.formatoptions?b.colModel.formatoptions:b.colModel.editoptions),b=void 0===e.separator? +":":e.separator,f=void 0===e.delimiter?";":e.delimiter;if(e.value){var g=e.value,e=!0===e.multiple?!0:!1,h=[];e&&(h=c.split(","),h=a.map(h,function(b){return a.trim(b)}));if(a.fmatter.isString(g)){var j=g.split(f),i=0,k;for(k=0;k0)return a}).join(b)),e)-1",ge:">=",bw:"^",bn:"!^","in":"=",ni:"!=",ew:"|",en:"!@",cn:"~",nc:"!~",nu:"#",nn:"!#"}},a.jgrid.search,b||{});return this.each(function(){var c=this;if(!this.ftoolbar){var f=function(){var e={},d=0,f,l,j={},n;a.each(c.p.colModel,function(){var i=a("#gs_"+a.jgrid.jqID(this.name),!0===this.frozen&&!0===c.p.frozenColumns?c.grid.fhDiv:c.grid.hDiv);l=this.index||this.name;n=b.searchOperators?i.parent().prev().children("a").attr("soper")||b.defaultSearch:this.searchoptions&&this.searchoptions.sopt? +this.searchoptions.sopt[0]:"select"===this.stype?"eq":b.defaultSearch;if((f="custom"===this.stype&&a.isFunction(this.searchoptions.custom_value)&&0',j=a(e).attr("soper"), +l,h=[],n,i=0,k=a(e).attr("colname");for(l=c.p.colModel.length;i
    '+b.operands[b.odata[n].oper]+""+b.odata[n].text+"
    ");a("body").append(d+"");a("#sopt_menu").addClass("ui-menu ui-widget ui-widget-content ui-corner-all");a("#sopt_menu > li > a").hover(function(){a(this).addClass("ui-state-hover")},function(){a(this).removeClass("ui-state-hover")}).click(function(){var d=a(this).attr("value"),i=a(this).attr("oper"); +a(c).triggerHandler("jqGridToolbarSelectOper",[d,i,e]);a("#sopt_menu").hide();a(e).text(i).attr("soper",d);if(b.autosearch===true){i=a(e).parent().next().children()[0];(a(i).val()||d==="nu"||d==="nn")&&f()}})},j=a(""),h;a.each(c.p.colModel,function(){var e=this,d,g;g="";var l="=",s,n=a(""),i=a("
    "), +k=a("
    ");!0===this.hidden&&a(n).css("display","none");this.search=!1===this.search?!1:!0;void 0===this.stype&&(this.stype="text");d=a.extend({},this.searchoptions||{});if(this.search){if(b.searchOperators){g=d.sopt?d.sopt[0]:"select"===e.stype?"eq":b.defaultSearch;for(s=0;s"+l+""}a("td:eq(0)",k).append(g);switch(this.stype){case "select":if(g=this.surl||d.dataUrl)a.ajax(a.extend({url:g,dataType:"html",success:function(g){if(d.buildSelect!==void 0){if(g=d.buildSelect(g)){a("td:eq(1)",k).append(g);a(i).append(k)}}else{a("td:eq(1)",k).append(g);a(i).append(k)}d.defaultValue!==void 0&&a("select",i).val(d.defaultValue);a("select",i).attr({name:e.index|| +e.name,id:"gs_"+e.name});d.attr&&a("select",i).attr(d.attr);a("select",i).css({width:"100%"});a.jgrid.bindEv.call(c,a("select",i)[0],d);b.autosearch===true&&a("select",i).change(function(){f();return false});g=null}},a.jgrid.ajaxOptions,c.p.ajaxSelectOptions||{}));else{var m,r,o;e.searchoptions?(m=void 0===e.searchoptions.value?"":e.searchoptions.value,r=void 0===e.searchoptions.separator?":":e.searchoptions.separator,o=void 0===e.searchoptions.delimiter?";":e.searchoptions.delimiter):e.editoptions&& +(m=void 0===e.editoptions.value?"":e.editoptions.value,r=void 0===e.editoptions.separator?":":e.editoptions.separator,o=void 0===e.editoptions.delimiter?";":e.editoptions.delimiter);if(m){l=document.createElement("select");l.style.width="100%";a(l).attr({name:e.index||e.name,id:"gs_"+e.name});var q;if("string"===typeof m){g=m.split(o);for(q=0;q");a(i).append(k); +d.attr&&a("input",i).attr(d.attr);a.jgrid.bindEv.call(c,a("input",i)[0],d);!0===b.autosearch&&(b.searchOnEnter?a("input",i).keypress(function(a){if((a.charCode||a.keyCode||0)===13){f();return false}return this}):a("input",i).keydown(function(a){switch(a.which){case 13:return false;case 9:case 16:case 37:case 38:case 39:case 40:case 27:break;default:h&&clearTimeout(h);h=setTimeout(function(){f()},500)}}));break;case "custom":a("td:eq(1)",k).append("");a(i).append(k);try{if(a.isFunction(d.custom_element)){var u=d.custom_element.call(c,void 0!==d.defaultValue?d.defaultValue:"",d);if(u)u=a(u).addClass("customelement"),a(i).find(">span").append(u);else throw"e2";}else throw"e1";}catch(t){"e1"===t&&a.jgrid.info_dialog(a.jgrid.errors.errcap,"function 'custom_element' "+a.jgrid.edit.msg.nodefined,a.jgrid.edit.bClose),"e2"===t?a.jgrid.info_dialog(a.jgrid.errors.errcap,"function 'custom_element' "+a.jgrid.edit.msg.novalue, +a.jgrid.edit.bClose):a.jgrid.info_dialog(a.jgrid.errors.errcap,"string"===typeof t?t:t.message,a.jgrid.edit.bClose)}}}a(n).append(i);a(j).append(n);b.searchOperators||a("td:eq(0)",k).hide()});a("table thead",c.grid.hDiv).append(j);b.searchOperators&&(a(".soptclass").click(function(b){var c=a(this).offset();g(this,c.left,c.top);b.stopPropagation()}),a("body").on("click",function(b){"soptclass"!==b.target.className&&a("#sopt_menu").hide()}));this.ftoolbar=!0;this.triggerToolbar=f;this.clearToolbar= +function(e){var d={},f=0,g,e=typeof e!=="boolean"?true:e;a.each(c.p.colModel,function(){var b,e=a("#gs_"+a.jgrid.jqID(this.name),this.frozen===true&&c.p.frozenColumns===true?c.grid.fhDiv:c.grid.hDiv);if(this.searchoptions&&this.searchoptions.defaultValue!==void 0)b=this.searchoptions.defaultValue;g=this.index||this.name;switch(this.stype){case "select":e.find("option").each(function(c){if(c===0)this.selected=true;if(a(this).val()===b){this.selected=true;return false}});if(b!==void 0){d[g]=b;f++}else try{delete c.p.postData[g]}catch(i){}break; +case "text":e.val(b);if(b!==void 0){d[g]=b;f++}else try{delete c.p.postData[g]}catch(h){}break;case "custom":a.isFunction(this.searchoptions.custom_value)&&e.length>0&&e[0].nodeName.toUpperCase()==="SPAN"&&this.searchoptions.custom_value.call(c,e.children(".customelement:first"),"set",b)}});var j=f>0?true:false;if(b.stringResult===true||c.p.datatype==="local"){var h='{"groupOp":"'+b.groupOp+'","rules":[',i=0;a.each(d,function(a,b){i>0&&(h=h+",");h=h+('{"field":"'+a+'",');h=h+'"op":"eq",';h=h+('"data":"'+ +(b+"").replace(/\\/g,"\\\\").replace(/\"/g,'\\"')+'"}');i++});h=h+"]}";a.extend(c.p.postData,{filters:h});a.each(["searchField","searchString","searchOper"],function(a,b){c.p.postData.hasOwnProperty(b)&&delete c.p.postData[b]})}else a.extend(c.p.postData,d);var k;if(c.p.searchurl){k=c.p.url;a(c).jqGrid("setGridParam",{url:c.p.searchurl})}var m=a(c).triggerHandler("jqGridToolbarBeforeClear")==="stop"?true:false;!m&&a.isFunction(b.beforeClear)&&(m=b.beforeClear.call(c));m||e&&a(c).jqGrid("setGridParam", +{search:j}).trigger("reloadGrid",[{page:1}]);k&&a(c).jqGrid("setGridParam",{url:k});a(c).triggerHandler("jqGridToolbarAfterClear");a.isFunction(b.afterClear)&&b.afterClear()};this.toggleToolbar=function(){var b=a("tr.ui-search-toolbar",c.grid.hDiv),d=c.p.frozenColumns===true?a("tr.ui-search-toolbar",c.grid.fhDiv):false;if(b.css("display")==="none"){b.show();d&&d.show()}else{b.hide();d&&d.hide()}}}})},destroyFilterToolbar:function(){return this.each(function(){this.ftoolbar&&(this.toggleToolbar=this.clearToolbar= +this.triggerToolbar=null,this.ftoolbar=!1,a(this.grid.hDiv).find("table thead tr.ui-search-toolbar").remove())})},destroyGroupHeader:function(b){void 0===b&&(b=!0);return this.each(function(){var c,f,g,j,h,e;f=this.grid;var d=a("table.ui-jqgrid-htable thead",f.hDiv),p=this.p.colModel;if(f){a(this).unbind(".setGroupHeaders");c=a("",{role:"rowheader"}).addClass("ui-jqgrid-labels");j=f.headers;f=0;for(g=j.length;f",{role:"row","aria-hidden":"true"}).addClass("jqg-first-row-header").css("height","auto"):m.empty();var r,o=function(a,b){var c=b.length,d;for(d=0;d",{role:"rowheader"}).addClass("ui-jqgrid-labels jqg-third-row-header");for(c=0;c", +{role:"gridcell"}).css(h).addClass("ui-first-th-"+this.p.direction).appendTo(m),e.style.width="",h=o(f.name,b.groupHeaders),0<=h){h=b.groupHeaders[h];g=h.numberOfColumns;p=h.titleText;for(h=f=0;h").attr({role:"columnheader"}).addClass("ui-state-default ui-th-column-header ui-th-"+this.p.direction).css({height:"22px","border-top":"0px none"}).html(p);0",{role:"columnheader"}).addClass("ui-state-default ui-th-column-header ui-th-"+this.p.direction).css({display:f.hidden?"none":"","border-top":"0px none"}).insertBefore(d),j.append(e)):(j.append(e),g--);l=a(this).children("thead");l.prepend(m);j.insertAfter(k);i.append(l);b.useColSpanStyle&&(i.find("span.ui-jqgrid-resize").each(function(){var b=a(this).parent();b.is(":visible")&&(this.style.cssText="height: "+b.height()+"px !important; cursor: col-resize;")}), +i.find("div.ui-jqgrid-sortable").each(function(){var b=a(this),c=b.parent();c.is(":visible")&&c.is(":has(span.ui-jqgrid-resize)")&&b.css("top",(c.height()-b.outerHeight())/2+"px")}));r=l.find("tr.jqg-first-row-header");a(this).bind("jqGridResizeStop.setGroupHeaders",function(a,b,c){r.find("th").eq(c).width(b)})})},setFrozenColumns:function(){return this.each(function(){if(this.grid){var b=this,c=b.p.colModel,f=0,g=c.length,j=-1,h=!1;if(!(!0===b.p.subGrid||!0===b.p.treeGrid||!0===b.p.cellEdit||b.p.sortable|| +b.p.scroll||b.p.grouping)){b.p.rownumbers&&f++;for(b.p.multiselect&&f++;f

    '); +b.grid.fbDiv=a('
    ');a("#gview_"+a.jgrid.jqID(b.p.id)).append(b.grid.fhDiv);c=a(".ui-jqgrid-htable","#gview_"+a.jgrid.jqID(b.p.id)).clone(!0);if(b.p.groupHeader){a("tr.jqg-first-row-header, tr.jqg-third-row-header",c).each(function(){a("th:gt("+j+")",this).remove()});var e=-1,d=-1,p,l;a("tr.jqg-second-row-header th",c).each(function(){p=parseInt(a(this).attr("colspan"), +10);if(l=parseInt(a(this).attr("rowspan"),10))e++,d++;p&&(e+=p,d++);if(e===j)return!1});e!==j&&(d=j);a("tr.jqg-second-row-header",c).each(function(){a("th:gt("+d+")",this).remove()})}else a("tr",c).each(function(){a("th:gt("+j+")",this).remove()});a(c).width(1);a(b.grid.fhDiv).append(c).mousemove(function(a){if(b.grid.resizing)return b.grid.dragMove(a),!1});a(b).bind("jqGridResizeStop.setFrozenColumns",function(c,d,e){c=a(".ui-jqgrid-htable",b.grid.fhDiv);a("th:eq("+e+")",c).width(d);c=a(".ui-jqgrid-btable", +b.grid.fbDiv);a("tr:first td:eq("+e+")",c).width(d)});a(b).bind("jqGridOnSortCol.setFrozenColumns",function(c,d,e){c=a("tr.ui-jqgrid-labels:last th:eq("+b.p.lastsort+")",b.grid.fhDiv);d=a("tr.ui-jqgrid-labels:last th:eq("+e+")",b.grid.fhDiv);a("span.ui-grid-ico-sort",c).addClass("ui-state-disabled");a(c).attr("aria-selected","false");a("span.ui-icon-"+b.p.sortorder,d).removeClass("ui-state-disabled");a(d).attr("aria-selected","true");!b.p.viewsortcols[0]&&b.p.lastsort!==e&&(a("span.s-ico",c).hide(), +a("span.s-ico",d).show())});a("#gview_"+a.jgrid.jqID(b.p.id)).append(b.grid.fbDiv);a(b.grid.bDiv).scroll(function(){a(b.grid.fbDiv).scrollTop(a(this).scrollTop())});!0===b.p.hoverrows&&a("#"+a.jgrid.jqID(b.p.id)).unbind("mouseover").unbind("mouseout");a(b).bind("jqGridAfterGridComplete.setFrozenColumns",function(){a("#"+a.jgrid.jqID(b.p.id)+"_frozen").remove();a(b.grid.fbDiv).height(a(b.grid.bDiv).height()-16);var c=a("#"+a.jgrid.jqID(b.p.id)).clone(!0);a("tr",c).each(function(){a("td:gt("+j+")", +this).remove()});a(c).width(1).attr("id",b.p.id+"_frozen");a(b.grid.fbDiv).append(c);!0===b.p.hoverrows&&(a("tr.jqgrow",c).hover(function(){a(this).addClass("ui-state-hover");a("#"+a.jgrid.jqID(this.id),"#"+a.jgrid.jqID(b.p.id)).addClass("ui-state-hover")},function(){a(this).removeClass("ui-state-hover");a("#"+a.jgrid.jqID(this.id),"#"+a.jgrid.jqID(b.p.id)).removeClass("ui-state-hover")}),a("tr.jqgrow","#"+a.jgrid.jqID(b.p.id)).hover(function(){a(this).addClass("ui-state-hover");a("#"+a.jgrid.jqID(this.id), +"#"+a.jgrid.jqID(b.p.id)+"_frozen").addClass("ui-state-hover")},function(){a(this).removeClass("ui-state-hover");a("#"+a.jgrid.jqID(this.id),"#"+a.jgrid.jqID(b.p.id)+"_frozen").removeClass("ui-state-hover")}));c=null});b.p.frozenColumns=!0}}}})},destroyFrozenColumns:function(){return this.each(function(){if(this.grid&&!0===this.p.frozenColumns){a(this.grid.fhDiv).remove();a(this.grid.fbDiv).remove();this.grid.fhDiv=null;this.grid.fbDiv=null;a(this).unbind(".setFrozenColumns");if(!0===this.p.hoverrows){var b; +a("#"+a.jgrid.jqID(this.p.id)).bind("mouseover",function(c){b=a(c.target).closest("tr.jqgrow");"ui-subgrid"!==a(b).attr("class")&&a(b).addClass("ui-state-hover")}).bind("mouseout",function(c){b=a(c.target).closest("tr.jqgrow");a(b).removeClass("ui-state-hover")})}this.p.frozenColumns=!1}})}})})(jQuery); +(function(a){a.extend(a.jgrid,{showModal:function(a){a.w.show()},closeModal:function(a){a.w.hide().attr("aria-hidden","true");a.o&&a.o.remove()},hideModal:function(d,b){b=a.extend({jqm:!0,gb:""},b||{});if(b.onClose){var c=b.gb&&"string"===typeof b.gb&&"#gbox_"===b.gb.substr(0,6)?b.onClose.call(a("#"+b.gb.substr(6))[0],d):b.onClose(d);if("boolean"===typeof c&&!c)return}if(a.fn.jqm&&!0===b.jqm)a(d).attr("aria-hidden","true").jqmHide();else{if(""!==b.gb)try{a(".jqgrid-overlay:first",b.gb).hide()}catch(g){}a(d).hide().attr("aria-hidden", +"true")}},findPos:function(a){var b=0,c=0;if(a.offsetParent){do b+=a.offsetLeft,c+=a.offsetTop;while(a=a.offsetParent)}return[b,c]},createModal:function(d,b,c,g,e,h,f){var c=a.extend(!0,{},a.jgrid.jqModal||{},c),i=document.createElement("div"),j,n=this,f=a.extend({},f||{});j="rtl"===a(c.gbox).attr("dir")?!0:!1;i.className="ui-widget ui-widget-content ui-corner-all ui-jqdialog";i.id=d.themodal;var k=document.createElement("div");k.className="ui-jqdialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix"; +k.id=d.modalhead;a(k).append(""+c.caption+"");var l=a("").hover(function(){l.addClass("ui-state-hover")},function(){l.removeClass("ui-state-hover")}).append("");a(k).append(l);j?(i.dir="rtl",a(".ui-jqdialog-title",k).css("float","right"),a(".ui-jqdialog-titlebar-close",k).css("left","0.3em")):(i.dir="ltr",a(".ui-jqdialog-title",k).css("float", +"left"),a(".ui-jqdialog-titlebar-close",k).css("right","0.3em"));var m=document.createElement("div");a(m).addClass("ui-jqdialog-content ui-widget-content").attr("id",d.modalcontent);a(m).append(b);i.appendChild(m);a(i).prepend(k);!0===h?a("body").append(i):"string"===typeof h?a(h).append(i):a(i).insertBefore(g);a(i).css(f);void 0===c.jqModal&&(c.jqModal=!0);b={};if(a.fn.jqm&&!0===c.jqModal)0===c.left&&0===c.top&&c.overlay&&(f=[],f=a.jgrid.findPos(e),c.left=f[0]+4,c.top=f[1]+4),b.top=c.top+"px",b.left= +c.left;else if(0!==c.left||0!==c.top)b.left=c.left,b.top=c.top+"px";a("a.ui-jqdialog-titlebar-close",k).click(function(){var b=a("#"+a.jgrid.jqID(d.themodal)).data("onClose")||c.onClose,e=a("#"+a.jgrid.jqID(d.themodal)).data("gbox")||c.gbox;n.hideModal("#"+a.jgrid.jqID(d.themodal),{gb:e,jqm:c.jqModal,onClose:b});return false});if(0===c.width||!c.width)c.width=300;if(0===c.height||!c.height)c.height=200;c.zIndex||(g=a(g).parents("*[role=dialog]").filter(":first").css("z-index"),c.zIndex=g?parseInt(g, +10)+2:950);g=0;j&&b.left&&!h&&(g=a(c.gbox).width()-(!isNaN(c.width)?parseInt(c.width,10):0)-8,b.left=parseInt(b.left,10)+parseInt(g,10));b.left&&(b.left+="px");a(i).css(a.extend({width:isNaN(c.width)?"auto":c.width+"px",height:isNaN(c.height)?"auto":c.height+"px",zIndex:c.zIndex,overflow:"hidden"},b)).attr({tabIndex:"-1",role:"dialog","aria-labelledby":d.modalhead,"aria-hidden":"true"});void 0===c.drag&&(c.drag=!0);void 0===c.resize&&(c.resize=!0);if(c.drag)if(a(k).css("cursor","move"),a.fn.jqDrag)a(i).jqDrag(k); +else try{a(i).draggable({handle:a("#"+a.jgrid.jqID(k.id))})}catch(o){}if(c.resize)if(a.fn.jqResize)a(i).append("
    "),a("#"+a.jgrid.jqID(d.themodal)).jqResize(".jqResize",d.scrollelm?"#"+a.jgrid.jqID(d.scrollelm):!1);else try{a(i).resizable({handles:"se, sw",alsoResize:d.scrollelm?"#"+a.jgrid.jqID(d.scrollelm):!1})}catch(p){}!0===c.closeOnEscape&&a(i).keydown(function(b){if(b.which==27){b=a("#"+a.jgrid.jqID(d.themodal)).data("onClose")|| +c.onClose;n.hideModal("#"+a.jgrid.jqID(d.themodal),{gb:c.gbox,jqm:c.jqModal,onClose:b})}})},viewModal:function(d,b){b=a.extend({toTop:!0,overlay:10,modal:!1,overlayClass:"ui-widget-overlay",onShow:a.jgrid.showModal,onHide:a.jgrid.closeModal,gbox:"",jqm:!0,jqM:!0},b||{});if(a.fn.jqm&&!0===b.jqm)b.jqM?a(d).attr("aria-hidden","false").jqm(b).jqmShow():a(d).attr("aria-hidden","false").jqmShow();else{""!==b.gbox&&(a(".jqgrid-overlay:first",b.gbox).show(),a(d).data("gbox",b.gbox));a(d).show().attr("aria-hidden", +"false");try{a(":input:visible",d)[0].focus()}catch(c){}}},info_dialog:function(d,b,c,g){var e={width:290,height:"auto",dataheight:"auto",drag:!0,resize:!1,left:250,top:170,zIndex:1E3,jqModal:!0,modal:!1,closeOnEscape:!0,align:"center",buttonalign:"center",buttons:[]};a.extend(!0,e,a.jgrid.jqModal||{},{caption:""+d+""},g||{});var h=e.jqModal,f=this;a.fn.jqm&&!h&&(h=!1);d="";if(0"+e.buttons[g].text+"";g=isNaN(e.dataheight)?e.dataheight:e.dataheight+"px";b="
    "+("
    "+b+"
    ");b+=c?"
    "+ +c+""+d+"
    ":""!==d?"
    "+d+"
    ":"";b+="
    ";try{"false"===a("#info_dialog").attr("aria-hidden")&&a.jgrid.hideModal("#info_dialog",{jqm:h}),a("#info_dialog").remove()}catch(i){}a.jgrid.createModal({themodal:"info_dialog",modalhead:"info_head",modalcontent:"info_content",scrollelm:"infocnt"},b,e,"","",!0);d&&a.each(e.buttons, +function(d){a("#"+a.jgrid.jqID(this.id),"#info_id").bind("click",function(){e.buttons[d].onClick.call(a("#info_dialog"));return!1})});a("#closedialog","#info_id").click(function(){f.hideModal("#info_dialog",{jqm:h,onClose:a("#info_dialog").data("onClose")||e.onClose,gb:a("#info_dialog").data("gbox")||e.gbox});return!1});a(".fm-button","#info_dialog").hover(function(){a(this).addClass("ui-state-hover")},function(){a(this).removeClass("ui-state-hover")});a.isFunction(e.beforeOpen)&&e.beforeOpen();a.jgrid.viewModal("#info_dialog", +{onHide:function(a){a.w.hide().remove();a.o&&a.o.remove()},modal:e.modal,jqm:h});a.isFunction(e.afterOpen)&&e.afterOpen();try{a("#info_dialog").focus()}catch(j){}},bindEv:function(d,b){a.isFunction(b.dataInit)&&b.dataInit.call(this,d);b.dataEvents&&a.each(b.dataEvents,function(){void 0!==this.data?a(d).bind(this.type,this.data,this.fn):a(d).bind(this.type,this.fn)})},createEl:function(d,b,c,g,e){function h(d,b,c){var e="dataInit,dataEvents,dataUrl,buildSelect,sopt,searchhidden,defaultValue,attr,custom_element,custom_value".split(","); +void 0!==c&&a.isArray(c)&&a.merge(e,c);a.each(b,function(b,c){-1===a.inArray(b,e)&&a(d).attr(b,c)});b.hasOwnProperty("id")||a(d).attr("id",a.jgrid.randId())}var f="",i=this;switch(d){case "textarea":f=document.createElement("textarea");g?b.cols||a(f).css({width:"98%"}):b.cols||(b.cols=20);b.rows||(b.rows=2);if(" "===c||" "===c||1===c.length&&160===c.charCodeAt(0))c="";f.value=c;h(f,b);a(f).attr({role:"textbox",multiline:"true"});break;case "checkbox":f=document.createElement("input");f.type= +"checkbox";b.value?(d=b.value.split(":"),c===d[0]&&(f.checked=!0,f.defaultChecked=!0),f.value=d[0],a(f).attr("offval",d[1])):(d=c.toLowerCase(),0>d.search(/(false|f|0|no|n|off|undefined)/i)&&""!==d?(f.checked=!0,f.defaultChecked=!0,f.value=c):f.value="on",a(f).attr("offval","off"));h(f,b,["value"]);a(f).attr("role","checkbox");break;case "select":f=document.createElement("select");f.setAttribute("role","select");g=[];!0===b.multiple?(d=!0,f.multiple="multiple",a(f).attr("aria-multiselectable","true")): +d=!1;if(void 0!==b.dataUrl)d=b.name?(""+b.id).substring(0,(""+b.id).length-(""+b.name).length-1):""+b.id,g=b.postData||e.postData,i.p&&i.p.idPrefix?d=a.jgrid.stripPref(i.p.idPrefix,d):g=void 0,a.ajax(a.extend({url:b.dataUrl,type:"GET",dataType:"html",data:a.isFunction(g)?g.call(i,d,c,""+b.name):g,context:{elem:f,options:b,vl:c},success:function(d){var b=[],c=this.elem,e=this.vl,f=a.extend({},this.options),g=f.multiple===true,d=a.isFunction(f.buildSelect)?f.buildSelect.call(i,d):d;typeof d==="string"&& +(d=a(a.trim(d)).html());if(d){a(c).append(d);h(c,f);if(f.size===void 0)f.size=g?3:1;if(g){b=e.split(",");b=a.map(b,function(d){return a.trim(d)})}else b[0]=a.trim(e);setTimeout(function(){a("option",c).each(function(d){if(d===0&&c.multiple)this.selected=false;a(this).attr("role","option");if(a.inArray(a.trim(a(this).text()),b)>-1||a.inArray(a.trim(a(this).val()),b)>-1)this.selected="selected"})},0)}}},e||{}));else if(b.value){var j;void 0===b.size&&(b.size=d?3:1);d&&(g=c.split(","),g=a.map(g,function(d){return a.trim(d)})); +"function"===typeof b.value&&(b.value=b.value());var n,k,l=void 0===b.separator?":":b.separator,e=void 0===b.delimiter?";":b.delimiter;if("string"===typeof b.value){n=b.value.split(e);for(j=0;j0)return a}).join(l));e=document.createElement("option");e.setAttribute("role","option");e.value=k[0];e.innerHTML=k[1];f.appendChild(e);if(!d&&(a.trim(k[0])===a.trim(c)||a.trim(k[1])===a.trim(c)))e.selected="selected";if(d&&(-1j.length||1>c[a[f]]||12j.length|| +1>c[a[h]]||31(0===c[a[g]]%4&&(0!==c[a[g]]%100||0===c[a[g]]%400)?29:28)||c[a[h]]>i[c[a[f]]]?!1:!0},isEmpty:function(a){return a.match(/^\s+$/)||""===a?!0:!1},checkTime:function(d){var b=/^(\d{1,2}):(\d{2})([apAP][Mm])?$/;if(!a.jgrid.isEmpty(d))if(d=d.match(b)){if(d[3]){if(1>d[1]||12parseFloat(e.maxValue))return[!1,h+": "+a.jgrid.edit.msg.maxValue+" "+e.maxValue,""];if(!0===e.email&&!(!1===c&&a.jgrid.isEmpty(d))&&(g=/^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?$/i, +!g.test(d)))return[!1,h+": "+a.jgrid.edit.msg.email,""];if(!0===e.integer&&!(!1===c&&a.jgrid.isEmpty(d))&&(isNaN(d)||0!==d%1||-1!==d.indexOf(".")))return[!1,h+": "+a.jgrid.edit.msg.integer,""];if(!0===e.date&&!(!1===c&&a.jgrid.isEmpty(d))&&(f[b].formatoptions&&f[b].formatoptions.newformat?(f=f[b].formatoptions.newformat,a.jgrid.formatter.date.masks.hasOwnProperty(f)&&(f=a.jgrid.formatter.date.masks[f])):f=f[b].datefmt||"Y-m-d",!a.jgrid.checkDate(f,d)))return[!1,h+": "+a.jgrid.edit.msg.date+" - "+ +f,""];if(!0===e.time&&!(!1===c&&a.jgrid.isEmpty(d))&&!a.jgrid.checkTime(d))return[!1,h+": "+a.jgrid.edit.msg.date+" - hh:mm (am/pm)",""];if(!0===e.url&&!(!1===c&&a.jgrid.isEmpty(d))&&(g=/^(((https?)|(ftp)):\/\/([\-\w]+\.)+\w{2,3}(\/[%\-\w]+(\.\w{2,})?)*(([\w\-\.\?\\\/+@&#;`~=%!]*)(\.\w{2,})?)*\/?)/i,!g.test(d)))return[!1,h+": "+a.jgrid.edit.msg.url,""];if(!0===e.custom&&!(!1===c&&a.jgrid.isEmpty(d)))return a.isFunction(e.custom_func)?(d=e.custom_func.call(this,d,h,b),a.isArray(d)?d:[!1,a.jgrid.edit.msg.customarray, +""]):[!1,a.jgrid.edit.msg.customfcheck,""]}return[!0,"",""]}})})(jQuery); +(function(a){var b={};a.jgrid.extend({searchGrid:function(b){b=a.extend(!0,{recreateFilter:!1,drag:!0,sField:"searchField",sValue:"searchString",sOper:"searchOper",sFilter:"filters",loadDefaults:!0,beforeShowSearch:null,afterShowSearch:null,onInitializeSearch:null,afterRedraw:null,afterChange:null,closeAfterSearch:!1,closeAfterReset:!1,closeOnEscape:!1,searchOnEnter:!1,multipleSearch:!1,multipleGroup:!1,top:0,left:0,jqModal:!0,modal:!1,resize:!0,width:450,height:"auto",dataheight:"auto",showQuery:!1, +errorcheck:!0,sopt:null,stringResult:void 0,onClose:null,onSearch:null,onReset:null,toTop:!0,overlay:30,columns:[],tmplNames:null,tmplFilters:null,tmplLabel:" Template: ",showOnLoad:!1,layer:null,operands:{eq:"=",ne:"<>",lt:"<",le:"<=",gt:">",ge:">=",bw:"LIKE",bn:"NOT LIKE","in":"IN",ni:"NOT IN",ew:"LIKE",en:"NOT LIKE",cn:"LIKE",nc:"NOT LIKE",nu:"IS NULL",nn:"ISNOT NULL"}},a.jgrid.search,b||{});return this.each(function(){function c(c){t=a(e).triggerHandler("jqGridFilterBeforeShow",[c]);void 0=== +t&&(t=!0);t&&a.isFunction(b.beforeShowSearch)&&(t=b.beforeShowSearch.call(e,c));t&&(a.jgrid.viewModal("#"+a.jgrid.jqID(u.themodal),{gbox:"#gbox_"+a.jgrid.jqID(i),jqm:b.jqModal,modal:b.modal,overlay:b.overlay,toTop:b.toTop}),a(e).triggerHandler("jqGridFilterAfterShow",[c]),a.isFunction(b.afterShowSearch)&&b.afterShowSearch.call(e,c))}var e=this;if(e.grid){var i="fbox_"+e.p.id,t=!0,u={themodal:"searchmod"+i,modalhead:"searchhd"+i,modalcontent:"searchcnt"+i,scrollelm:i},s=e.p.postData[b.sFilter];"string"=== +typeof s&&(s=a.jgrid.parse(s));!0===b.recreateFilter&&a("#"+a.jgrid.jqID(u.themodal)).remove();if(void 0!==a("#"+a.jgrid.jqID(u.themodal))[0])c(a("#fbox_"+a.jgrid.jqID(+e.p.id)));else{var m=a("
    ").insertBefore("#gview_"+a.jgrid.jqID(e.p.id)),h="left",g="";"rtl"===e.p.direction&&(h="right",g=" style='text-align:left'",m.attr("dir","rtl"));var n=a.extend([],e.p.colModel),x=""+ +b.Find+"",d=""+b.Reset+"",q="",f="",o,k=!1,p=-1;b.showQuery&&(q="Query");b.columns.length?(n=b.columns,p=0,o=n[0].index||n[0].name):a.each(n,function(a, +b){if(!b.label)b.label=e.p.colNames[a];if(!k){var c=b.search===void 0?true:b.search,d=b.hidden===true;if(b.searchoptions&&b.searchoptions.searchhidden===true&&c||c&&!d){k=true;o=b.index||b.name;p=a}}});if(!s&&o||!1===b.multipleSearch){var y="eq";0<=p&&n[p].searchoptions&&n[p].searchoptions.sopt?y=n[p].searchoptions.sopt[0]:b.sopt&&b.sopt.length&&(y=b.sopt[0]);s={groupOp:"AND",rules:[{field:o,op:y,data:""}]}}k=!1;b.tmplNames&&b.tmplNames.length&&(k=!0,f=b.tmplLabel,f+="");h="

    "+d+f+""+q+x+"
    ";i=a.jgrid.jqID(i);a("#"+i).jqFilter({columns:n,filter:b.loadDefaults? +s:null,showQuery:b.showQuery,errorcheck:b.errorcheck,sopt:b.sopt,groupButton:b.multipleGroup,ruleButtons:b.multipleSearch,afterRedraw:b.afterRedraw,ops:b.odata,operands:b.operands,ajaxSelectOptions:e.p.ajaxSelectOptions,groupOps:b.groupOps,onChange:function(){this.p.showQuery&&a(".query",this).html(this.toUserFriendlyString());a.isFunction(b.afterChange)&&b.afterChange.call(e,a("#"+i),b)},direction:e.p.direction,id:e.p.id});m.append(h);k&&b.tmplFilters&&b.tmplFilters.length&&a(".ui-template",m).bind("change", +function(){var c=a(this).val();c==="default"?a("#"+i).jqFilter("addFilter",s):a("#"+i).jqFilter("addFilter",b.tmplFilters[parseInt(c,10)]);return false});!0===b.multipleGroup&&(b.multipleSearch=!0);a(e).triggerHandler("jqGridFilterInitialize",[a("#"+i)]);a.isFunction(b.onInitializeSearch)&&b.onInitializeSearch.call(e,a("#"+i));b.gbox="#gbox_"+i;b.layer?a.jgrid.createModal(u,m,b,"#gview_"+a.jgrid.jqID(e.p.id),a("#gbox_"+a.jgrid.jqID(e.p.id))[0],"#"+a.jgrid.jqID(b.layer),{position:"relative"}):a.jgrid.createModal(u, +m,b,"#gview_"+a.jgrid.jqID(e.p.id),a("#gbox_"+a.jgrid.jqID(e.p.id))[0]);(b.searchOnEnter||b.closeOnEscape)&&a("#"+a.jgrid.jqID(u.themodal)).keydown(function(c){var d=a(c.target);if(b.searchOnEnter&&c.which===13&&!d.hasClass("add-group")&&!d.hasClass("add-rule")&&!d.hasClass("delete-group")&&!d.hasClass("delete-rule")&&(!d.hasClass("fm-button")||!d.is("[id$=_query]"))){a("#"+i+"_search").focus().click();return false}if(b.closeOnEscape&&c.which===27){a("#"+a.jgrid.jqID(u.modalhead)).find(".ui-jqdialog-titlebar-close").focus().click(); +return false}});q&&a("#"+i+"_query").bind("click",function(){a(".queryresult",m).toggle();return false});void 0===b.stringResult&&(b.stringResult=b.multipleSearch);a("#"+i+"_search").bind("click",function(){var c=a("#"+i),d={},f,l=c.jqFilter("filterData");if(b.errorcheck){c[0].hideError();b.showQuery||c.jqFilter("toSQLString");if(c[0].p.error){c[0].showError();return false}}if(b.stringResult){try{f=xmlJsonClass.toJson(l,"","",false)}catch(g){try{f=JSON.stringify(l)}catch(h){}}if(typeof f==="string"){d[b.sFilter]= +f;a.each([b.sField,b.sValue,b.sOper],function(){d[this]=""})}}else if(b.multipleSearch){d[b.sFilter]=l;a.each([b.sField,b.sValue,b.sOper],function(){d[this]=""})}else{d[b.sField]=l.rules[0].field;d[b.sValue]=l.rules[0].data;d[b.sOper]=l.rules[0].op;d[b.sFilter]=""}e.p.search=true;a.extend(e.p.postData,d);a(e).triggerHandler("jqGridFilterSearch");a.isFunction(b.onSearch)&&b.onSearch.call(e);a(e).trigger("reloadGrid",[{page:1}]);b.closeAfterSearch&&a.jgrid.hideModal("#"+a.jgrid.jqID(u.themodal),{gb:"#gbox_"+ +a.jgrid.jqID(e.p.id),jqm:b.jqModal,onClose:b.onClose});return false});a("#"+i+"_reset").bind("click",function(){var c={},d=a("#"+i);e.p.search=false;b.multipleSearch===false?c[b.sField]=c[b.sValue]=c[b.sOper]="":c[b.sFilter]="";d[0].resetFilter();k&&a(".ui-template",m).val("default");a.extend(e.p.postData,c);a(e).triggerHandler("jqGridFilterReset");a.isFunction(b.onReset)&&b.onReset.call(e);a(e).trigger("reloadGrid",[{page:1}]);return false});c(a("#"+i));a(".fm-button:not(.ui-state-disabled)",m).hover(function(){a(this).addClass("ui-state-hover")}, +function(){a(this).removeClass("ui-state-hover")})}}})},editGridRow:function(r,c){c=a.extend(!0,{top:0,left:0,width:300,datawidth:"auto",height:"auto",dataheight:"auto",modal:!1,overlay:30,drag:!0,resize:!0,url:null,mtype:"POST",clearAfterAdd:!0,closeAfterEdit:!1,reloadAfterSubmit:!0,onInitializeForm:null,beforeInitData:null,beforeShowForm:null,afterShowForm:null,beforeSubmit:null,afterSubmit:null,onclickSubmit:null,afterComplete:null,onclickPgButtons:null,afterclickPgButtons:null,editData:{},recreateForm:!1, +jqModal:!0,closeOnEscape:!1,addedrow:"first",topinfo:"",bottominfo:"",saveicon:[],closeicon:[],savekey:[!1,13],navkeys:[!1,38,40],checkOnSubmit:!1,checkOnUpdate:!1,_savedData:{},processing:!1,onClose:null,ajaxEditOptions:{},serializeEditData:null,viewPagerButtons:!0,overlayClass:"ui-widget-overlay"},a.jgrid.edit,c||{});b[a(this)[0].p.id]=c;return this.each(function(){function e(){a(k+" > tbody > tr > td > .FormElement").each(function(){var c=a(".customelement",this);if(c.length){var b=a(c[0]).attr("name"); +a.each(d.p.colModel,function(){if(this.name===b&&this.editoptions&&a.isFunction(this.editoptions.custom_value)){try{if(j[b]=this.editoptions.custom_value.call(d,a("#"+a.jgrid.jqID(b),k),"get"),void 0===j[b])throw"e1";}catch(c){"e1"===c?a.jgrid.info_dialog(a.jgrid.errors.errcap,"function 'custom_value' "+a.jgrid.edit.msg.novalue,a.jgrid.edit.bClose):a.jgrid.info_dialog(a.jgrid.errors.errcap,c.message,a.jgrid.edit.bClose)}return!0}})}else{switch(a(this).get(0).type){case "checkbox":a(this).is(":checked")? +j[this.name]=a(this).val():(c=a(this).attr("offval"),j[this.name]=c);break;case "select-one":j[this.name]=a("option:selected",this).val();break;case "select-multiple":j[this.name]=a(this).val();j[this.name]=j[this.name]?j[this.name].join(","):"";a("option:selected",this).each(function(c,b){a(b).text()});break;case "password":case "text":case "textarea":case "button":j[this.name]=a(this).val()}d.p.autoencode&&(j[this.name]=a.jgrid.htmlEncode(j[this.name]))}});return!0}function i(c,e,l,g){var h,j,k, +p=0,i,m,o,n=[],q=!1,z="",r;for(r=1;r<=g;r++)z+="  ";"_empty"!==c&&(q=a(e).jqGrid("getInd",c));a(e.p.colModel).each(function(r){h=this.name;m=(j=this.editrules&&!0===this.editrules.edithidden?!1:!0===this.hidden?!0:!1)?"style='display:none'":"";if("cb"!==h&&"subgrid"!==h&&!0===this.editable&&"rn"!==h){if(!1===q)i="";else if(h===e.p.ExpandColumn&&!0===e.p.treeGrid)i=a("td[role='gridcell']:eq("+r+")",e.rows[q]).text();else{try{i=a.unformat.call(e, +a("td[role='gridcell']:eq("+r+")",e.rows[q]),{rowId:c,colModel:this},r)}catch(t){i=this.edittype&&"textarea"===this.edittype?a("td[role='gridcell']:eq("+r+")",e.rows[q]).text():a("td[role='gridcell']:eq("+r+")",e.rows[q]).html()}if(!i||" "===i||" "===i||1===i.length&&160===i.charCodeAt(0))i=""}var v=a.extend({},this.editoptions||{},{id:h,name:h}),s=a.extend({},{elmprefix:"",elmsuffix:"",rowabove:!1,rowcontent:""},this.formoptions||{}),w=parseInt(s.rowpos,10)||p+1,u=parseInt(2*(parseInt(s.colpos, +10)||1),10);"_empty"===c&&v.defaultValue&&(i=a.isFunction(v.defaultValue)?v.defaultValue.call(d):v.defaultValue);this.edittype||(this.edittype="text");d.p.autoencode&&(i=a.jgrid.htmlDecode(i));o=a.jgrid.createEl.call(d,this.edittype,v,i,!1,a.extend({},a.jgrid.ajaxOptions,e.p.ajaxSelectOptions||{}));if(b[d.p.id].checkOnSubmit||b[d.p.id].checkOnUpdate)b[d.p.id]._savedData[h]=i;a(o).addClass("FormElement");-1"+s.rowcontent+"");a(l).append(y);y[0].rp=w}0===k.length&&(k=a("").addClass("FormData").attr("id","tr_"+h),a(k).append(z),a(l).append(k),k[0].rp=w);a("td:eq("+(u-2)+")",k[0]).html(void 0===s.label?e.p.colNames[r]:s.label);a("td:eq("+(u-1)+")",k[0]).append(s.elmprefix).append(o).append(s.elmsuffix);a.isFunction(v.custom_value)&&"_empty"!==c&&v.custom_value.call(d, +a("#"+h,"#"+f),"set",i);a.jgrid.bindEv.call(d,o,v);n[p]=r;p++}});if(0
    - $sickbeard.version.SICKBEARD_VERSION + VO/VF