# Author: Nic Wolfe # URL: http://code.google.com/p/sickbeard/ # # This file is part of SickRage. # # SickRage 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. # # SickRage 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 SickRage. If not, see . import os import platform import shutil import subprocess import re import urllib import zipfile import tarfile import stat import traceback import gh_api as github import sickbeard from sickbeard import helpers, notifiers from sickbeard import ui from sickbeard import logger from sickbeard.exceptions import ex from sickbeard import encodingKludge as ek class CheckVersion(): """ Version check class meant to run as a thread object with the sr 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, force=False): # set current branch version sickbeard.BRANCH = self.get_branch() if self.check_for_new_version(force): if sickbeard.AUTO_UPDATE: logger.log(u"New update found for SickRage, starting auto-updater ...") ui.notifications.message('New update found for SickRage, starting auto-updater') if sickbeard.versionCheckScheduler.action.update(): logger.log(u"Update was successful!") ui.notifications.message('Update was successful') sickbeard.events.put(sickbeard.events.SystemEvent.RESTART) def find_install_type(self): """ Determines how this copy of sr 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 sickbeard.BRANCH.startswith('build '): install_type = 'win' elif os.path.isdir(ek.ek(os.path.join, sickbeard.PROG_DIR, u'.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 sickbeard.AUTO_UPDATE and not force: logger.log(u"Version checking is disabled, not checking for the newest version") return False if not sickbeard.AUTO_UPDATE: logger.log(u"Checking if " + self.install_type + " needs an update") if not self.updater.need_update(): sickbeard.NEWEST_VERSION_STRING = None if not sickbeard.AUTO_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): # update branch with current config branch value self.updater.branch = sickbeard.BRANCH # check for updates if self.updater.need_update(): return self.updater.update() def list_remote_branches(self): return self.updater.list_remote_branches() def get_branch(self): return self.updater.branch class UpdateManager(): def get_github_repo_user(self): return 'echel0n' def get_github_repo(self): return 'SickRage' def get_update_url(self): return sickbeard.WEB_ROOT + "/home/update/?pid=" + str(sickbeard.PID) class WindowsUpdateManager(UpdateManager): def __init__(self): self.github_repo_user = self.get_github_repo_user() self.github_repo = self.get_github_repo() self.branch = sickbeard.BRANCH if sickbeard.BRANCH == '': self.branch = self._find_installed_branch() 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/' + self.github_repo_user + '/' + self.github_repo + '/' + self.branch + '/updates.txt' def _find_installed_version(self): version = '' try: version = sickbeard.BRANCH return int(version[6:]) except ValueError: logger.log(u"Unknown SickRage Windows binary release: " + version, logger.ERROR) return None def _find_installed_branch(self): return 'windows_binaries' 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 = ".*SickRage\-win32\-alpha\-build(\d+)(?:\.\d+)?\.zip" version_url_data = helpers.getURL(self.version_url) if not version_url_data: return for curLine in version_url_data.splitlines(): 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)) def need_update(self): if self.branch != self._find_installed_branch(): logger.log(u"Branch checkout: " + self._find_installed_branch() + "->" + self.branch, logger.DEBUG) return True 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 return False def set_newest_text(self): sickbeard.NEWEST_VERSION_STRING = None if not self._cur_version: newest_text = "Unknown SickRage Windows binary version. Not updating with original version." else: newest_text = 'There is a newer version available (build ' + str( self._newest_version) + ')' newest_text += "— Update Now" sickbeard.NEWEST_VERSION_STRING = newest_text def update(self): zip_download_url = self._find_newest_version(True) logger.log(u"new_link: " + repr(zip_download_url), logger.DEBUG) if not zip_download_url: logger.log(u"Unable to find a new version link on google code, not updating") return False try: # prepare the update dir sr_update_dir = ek.ek(os.path.join, sickbeard.PROG_DIR, u'sr-update') if os.path.isdir(sr_update_dir): logger.log(u"Clearing out update folder " + sr_update_dir + " before extracting") shutil.rmtree(sr_update_dir) logger.log(u"Creating update folder " + sr_update_dir + " before extracting") os.makedirs(sr_update_dir) # retrieve file logger.log(u"Downloading update from " + zip_download_url) zip_download_path = os.path.join(sr_update_dir, u'sr-update.zip') urllib.urlretrieve(zip_download_url, zip_download_path) if not ek.ek(os.path.isfile, zip_download_path): logger.log(u"Unable to retrieve new version from " + zip_download_url + ", can't update", logger.ERROR) return False if not ek.ek(zipfile.is_zipfile, zip_download_path): logger.log(u"Retrieved version from " + zip_download_url + " is corrupt, can't update", logger.ERROR) return False # extract to sr-update dir logger.log(u"Unzipping from " + str(zip_download_path) + " to " + sr_update_dir) update_zip = zipfile.ZipFile(zip_download_path, 'r') update_zip.extractall(sr_update_dir) update_zip.close() # delete the zip logger.log(u"Deleting zip file from " + str(zip_download_path)) os.remove(zip_download_path) # find update dir name update_dir_contents = [x for x in os.listdir(sr_update_dir) if os.path.isdir(os.path.join(sr_update_dir, x))] if len(update_dir_contents) != 1: logger.log(u"Invalid update data, update failed. Maybe try deleting your sr-update folder?", logger.ERROR) return False content_dir = os.path.join(sr_update_dir, update_dir_contents[0]) old_update_path = os.path.join(content_dir, u'updater.exe') new_update_path = os.path.join(sickbeard.PROG_DIR, u'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) # Notify update successful notifiers.notify_git_update(sickbeard.NEWEST_VERSION_STRING) except Exception, e: logger.log(u"Error while trying to update: " + ex(e), logger.ERROR) return False return True def list_remote_branches(self): return ['windows_binaries'] class GitUpdateManager(UpdateManager): def __init__(self): self._git_path = self._find_working_git() self.github_repo_user = self.get_github_repo_user() self.github_repo = self.get_github_repo() self.branch = sickbeard.BRANCH if sickbeard.BRANCH == '': self.branch = self._find_installed_branch() self._cur_commit_hash = None self._newest_commit_hash = None self._num_commits_behind = 0 self._num_commits_ahead = 0 def _git_error(self): error_message = 'Unable to find your git executable - Shutdown SickRage and EITHER set git_path in your config.ini OR delete your .git folder and run from source to enable updates.' sickbeard.NEWEST_VERSION_STRING = error_message def _find_working_git(self): test_cmd = 'version' if sickbeard.GIT_PATH: main_git = '"' + sickbeard.GIT_PATH + '"' else: main_git = 'git' logger.log(u"Checking if we can use git commands: " + main_git + ' ' + test_cmd, logger.DEBUG) output, err, exit_status = self._run_git(main_git, test_cmd) if exit_status == 0: logger.log(u"Using: " + main_git, logger.DEBUG) return main_git else: logger.log(u"Not using: " + main_git, logger.DEBUG) # trying alternatives alternative_git = [] # osx people who start sr from launchd have a broken path, so try a hail-mary attempt for them if platform.system().lower() == 'darwin': alternative_git.append('/usr/local/git/bin/git') if platform.system().lower() == 'windows': if main_git != main_git.lower(): alternative_git.append(main_git.lower()) if alternative_git: logger.log(u"Trying known alternative git locations", logger.DEBUG) for cur_git in alternative_git: logger.log(u"Checking if we can use git commands: " + cur_git + ' ' + test_cmd, logger.DEBUG) output, err, exit_status = self._run_git(cur_git, test_cmd) if exit_status == 0: logger.log(u"Using: " + cur_git, logger.DEBUG) return cur_git else: logger.log(u"Not using: " + cur_git, logger.DEBUG) # Still haven't found a working git error_message = 'Unable to find your git executable - Shutdown SickRage and EITHER set git_path in your config.ini OR delete your .git folder and run from source to enable updates.' sickbeard.NEWEST_VERSION_STRING = error_message return None def _run_git(self, git_path, args): output = err = exit_status = None if not git_path: logger.log(u"No git specified, can't use git commands", logger.ERROR) exit_status = 1 return (output, err, exit_status) cmd = git_path + ' ' + args try: logger.log(u"Executing " + cmd + " with your shell in " + sickbeard.PROG_DIR, logger.DEBUG) p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=True, cwd=sickbeard.PROG_DIR) output, err = p.communicate() exit_status = p.returncode if output: output = output.strip() logger.log(u"git output: " + output, logger.DEBUG) except OSError: logger.log(u"Command " + cmd + " didn't work") exit_status = 1 if exit_status == 0: logger.log(cmd + u" : returned successful", logger.DEBUG) exit_status = 0 elif exit_status == 1: logger.log(cmd + u" returned : " + output, logger.ERROR) exit_status = 1 elif exit_status == 128 or 'fatal:' in output or err: logger.log(cmd + u" returned : " + output, logger.ERROR) exit_status = 128 else: logger.log(cmd + u" returned : " + output + u", treat as error for now", logger.ERROR) exit_status = 1 return (output, err, exit_status) def _find_installed_version(self): """ Attempts to find the currently installed version of SickRage. Uses git show to get commit version. Returns: True for success or False for failure """ output, err, exit_status = self._run_git(self._git_path, 'rev-parse HEAD') # @UnusedVariable if exit_status == 0 and output: 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 False self._cur_commit_hash = cur_commit_hash sickbeard.CUR_COMMIT_HASH = str(cur_commit_hash) return True else: return False def _find_installed_branch(self): branch_info, err, exit_status = self._run_git(self._git_path, 'symbolic-ref -q HEAD') # @UnusedVariable if exit_status == 0 and branch_info: branch = branch_info.strip().replace('refs/heads/', '', 1) if branch: return branch def _check_github_for_update(self): """ Uses git commands to check if there is a newer version that the provided commit hash. If there is a newer version it sets _num_commits_behind. """ self._num_commits_behind = 0 self._num_commits_ahead = 0 # get all new info from github output, err, exit_status = self._run_git(self._git_path, 'fetch origin') if not exit_status == 0: logger.log(u"Unable to contact github, can't check for update", logger.ERROR) return # get latest commit_hash from remote output, err, exit_status = self._run_git(self._git_path, 'rev-parse --verify --quiet "@{upstream}"') if exit_status == 0 and output: 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.DEBUG) return else: self._newest_commit_hash = cur_commit_hash else: logger.log(u"git didn't return newest commit hash", logger.DEBUG) return # get number of commits behind and ahead (option --count not supported git < 1.7.2) output, err, exit_status = self._run_git(self._git_path, 'rev-list --left-right "@{upstream}"...HEAD') if exit_status == 0 and output: try: self._num_commits_behind = int(output.count("<")) self._num_commits_ahead = int(output.count(">")) except: logger.log(u"git didn't return numbers for behind and ahead, not using it", logger.DEBUG) return logger.log(u"cur_commit = " + str(self._cur_commit_hash) + u", newest_commit = " + str(self._newest_commit_hash) + u", num_commits_behind = " + str(self._num_commits_behind) + u", num_commits_ahead = " + str( self._num_commits_ahead), logger.DEBUG) def set_newest_text(self): # if we're up to date then don't set this sickbeard.NEWEST_VERSION_STRING = None if self._num_commits_ahead: logger.log(u"Local branch is ahead of " + self.branch + ". Automatic update not possible.", logger.ERROR) newest_text = "Local branch is ahead of " + self.branch + ". Automatic update not possible." elif self._num_commits_behind > 0: base_url = 'http://github.com/' + self.github_repo_user + '/' + self.github_repo if self._newest_commit_hash: url = base_url + '/compare/' + self._cur_commit_hash + '...' + self._newest_commit_hash else: url = base_url + '/commits/' newest_text = 'There is a newer version available ' newest_text += " (you're " + str(self._num_commits_behind) + " commit" if self._num_commits_behind > 1: newest_text += 's' newest_text += ' behind)' + "— Update Now" else: return sickbeard.NEWEST_VERSION_STRING = newest_text def need_update(self): if self.branch != self._find_installed_branch(): logger.log(u"Branch checkout: " + self._find_installed_branch() + "->" + self.branch, logger.DEBUG) return True self._find_installed_version() if not self._cur_commit_hash: return True else: 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 if self._num_commits_behind > 0: return True return False def update(self): """ Calls git pull origin in order to update SickRage. Returns a bool depending on the call's success. """ if self.branch == self._find_installed_branch(): output, err, exit_status = self._run_git(self._git_path, 'pull -f origin ' + self.branch) # @UnusedVariable else: output, err, exit_status = self._run_git(self._git_path, 'checkout -f ' + self.branch) # @UnusedVariable if exit_status == 0: # Notify update successful if sickbeard.NOTIFY_ON_UPDATE: notifiers.notify_git_update(self._newest_commit_hash[:10]) return True return False def list_remote_branches(self): branches, err, exit_status = self._run_git(self._git_path, 'ls-remote --heads origin') # @UnusedVariable if exit_status == 0 and branches: return re.findall('\S+\Wrefs/heads/(.*)', branches) return [] class SourceUpdateManager(UpdateManager): def __init__(self): self.github_repo_user = self.get_github_repo_user() self.github_repo = self.get_github_repo() self.branch = sickbeard.BRANCH if sickbeard.BRANCH == '': self.branch = self._find_installed_branch() self._cur_commit_hash = None self._newest_commit_hash = None self._num_commits_behind = 0 def _find_installed_version(self): installed_path = os.path.dirname(os.path.normpath(os.path.abspath(__file__))) self._cur_commit_hash = self.hash_dir(installed_path) if not self._cur_commit_hash: self._cur_commit_hash = None sickbeard.CUR_COMMIT_HASH = str(self._cur_commit_hash) def _find_installed_branch(self): gh = github.GitHub(self.github_repo_user, self.github_repo, self.branch) for branch in gh.branches(): if 'commit' in branch and self._cur_commit_hash and branch.commit['sha'] == self._cur_commit_hash: return branch.name def need_update(self): if self.branch != self._find_installed_branch(): logger.log(u"Branch checkout: " + self._find_installed_branch() + "->" + self.branch, logger.DEBUG) return True 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 if not self._cur_commit_hash or self._num_commits_behind > 0: return True return False 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 SickRage's version text. commit_hash: hash that we're checking against """ self._num_commits_behind = 0 self._newest_commit_hash = None gh = github.GitHub(self.github_repo_user, self.github_repo, self.branch) # try to get newest commit hash and commits behind directly by comparing branch and current commit if self._cur_commit_hash: branch_compared = gh.compare(base=self.branch, head=self._cur_commit_hash) if 'base_commit' in branch_compared: self._newest_commit_hash = branch_compared['base_commit']['sha'] if 'behind_by' in branch_compared: self._num_commits_behind = int(branch_compared['behind_by']) # fall back and iterate over last 100 (items per page in gh_api) commits if not self._newest_commit_hash: for curCommit in gh.commits(): 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 # when _cur_commit_hash doesn't match anything _num_commits_behind == 100 self._num_commits_behind += 1 logger.log(u"cur_commit = " + str(self._cur_commit_hash) + u", newest_commit = " + str(self._newest_commit_hash) + u", num_commits_behind = " + str(self._num_commits_behind), logger.DEBUG) def set_newest_text(self): # if we're up to date then don't set this sickbeard.NEWEST_VERSION_STRING = None if not self._cur_commit_hash: logger.log(u"Unknown current version number, don't know if we should update or not", logger.DEBUG) newest_text = "Unknown current version number: If you've never used the SickRage upgrade system before then current version is not set." newest_text += "— Update Now" elif self._num_commits_behind > 0: base_url = 'http://github.com/' + self.github_repo_user + '/' + self.github_repo if self._newest_commit_hash: url = base_url + '/compare/' + self._cur_commit_hash + '...' + self._newest_commit_hash else: url = base_url + '/commits/' newest_text = 'There is a newer version available' newest_text += " (you're " + str(self._num_commits_behind) + " commit" if self._num_commits_behind > 1: newest_text += "s" newest_text += " behind)" + "— Update Now" else: return sickbeard.NEWEST_VERSION_STRING = newest_text def update(self): """ Downloads the latest source tarball from github and installs it over the existing version. """ base_url = 'http://github.com/' + self.github_repo_user + '/' + self.github_repo tar_download_url = base_url + '/tarball/' + self.branch try: # prepare the update dir sr_update_dir = ek.ek(os.path.join, sickbeard.PROG_DIR, u'sr-update') if os.path.isdir(sr_update_dir): logger.log(u"Clearing out update folder " + sr_update_dir + " before extracting") shutil.rmtree(sr_update_dir) logger.log(u"Creating update folder " + sr_update_dir + " before extracting") os.makedirs(sr_update_dir) # retrieve file logger.log(u"Downloading update from " + repr(tar_download_url)) tar_download_path = os.path.join(sr_update_dir, u'sr-update.tar') urllib.urlretrieve(tar_download_url, tar_download_path) if not ek.ek(os.path.isfile, tar_download_path): logger.log(u"Unable to retrieve new version from " + tar_download_url + ", can't update", logger.ERROR) return False if not ek.ek(tarfile.is_tarfile, tar_download_path): logger.log(u"Retrieved version from " + tar_download_url + " is corrupt, can't update", logger.ERROR) return False # extract to sr-update dir logger.log(u"Extracting file " + tar_download_path) tar = tarfile.open(tar_download_path) tar.extractall(sr_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(sr_update_dir) if os.path.isdir(os.path.join(sr_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(sr_update_dir, update_dir_contents[0]) # walk temp folder and move files to main folder logger.log(u"Moving files from " + content_dir + " to " + sickbeard.PROG_DIR) 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) # Avoid DLL access problem on WIN32/64 # These files needing to be updated manually #or find a way to kill the access from memory if curfile in ('unrar.dll', 'unrar64.dll'): try: os.chmod(new_path, stat.S_IWRITE) os.remove(new_path) os.renames(old_path, new_path) except Exception, e: logger.log(u"Unable to update " + new_path + ': ' + ex(e), logger.DEBUG) os.remove(old_path) # Trash the updated file without moving in new path continue if os.path.isfile(new_path): os.remove(new_path) os.renames(old_path, new_path) except Exception, e: logger.log(u"Error while trying to update: " + ex(e), logger.ERROR) logger.log(u"Traceback: " + traceback.format_exc(), logger.DEBUG) return False # Notify update successful notifiers.notify_git_update(sickbeard.NEWEST_VERSION_STRING) return True def list_remote_branches(self): gh = github.GitHub(self.github_repo_user, self.github_repo, self.branch) return [x.name for x in gh.branches()] def _lstree(self, files, dirs): """Make git ls-tree like output.""" for f, sha1 in files: yield "100644 blob {}\t{}\0".format(sha1, f) for d, sha1 in dirs: yield "040000 tree {}\t{}\0".format(sha1, d) def _mktree(self, files, dirs): mkt = subprocess.Popen(["git", "mktree", "-z"], stdin=subprocess.PIPE, stdout=subprocess.PIPE) return mkt.communicate("".join(self._lstree(files, dirs)))[0].strip() def hash_file(self, path): """Write file at path to Git index, return its SHA1 as a string.""" return subprocess.check_output(["git", "hash-object", "-w", "--", path]).strip() def hash_dir(self, path): """Write directory at path to Git index, return its SHA1 as a string.""" dir_hash = {} for root, dirs, files in os.walk(path, topdown=False): f_hash = ((f, self.hash_file(os.path.join(root, f))) for f in files) d_hash = ((d, dir_hash[os.path.join(root, d)]) for d in dirs) # split+join normalizes paths on Windows (note the imports) dir_hash[os.path.join(*os.path.split(root))] = self._mktree(f_hash, d_hash) return dir_hash[path]