import json import os import shutil import tarfile import time import traceback import zipfile from datetime import datetime from threading import RLock from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent, fireEventAsync from couchpotato.core.helpers.encoding import sp from couchpotato.core.helpers.variable import removePyc from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin from couchpotato.environment import Env from dateutil.parser import parse from git.repository import LocalRepository import version from six.moves import filter log = CPLog(__name__) class Updater(Plugin): available_notified = False _lock = RLock() def __init__(self): if Env.get('desktop'): self.updater = DesktopUpdater() elif os.path.isdir(os.path.join(Env.get('app_dir'), '.git')): self.updater = GitUpdater(self.conf('git_command', default = 'git')) else: self.updater = SourceUpdater() addEvent('app.load', self.logVersion, priority = 10000) addEvent('app.load', self.setCrons) addEvent('updater.info', self.info) addApiView('updater.info', self.info, docs = { 'desc': 'Get updater information', 'return': { 'type': 'object', 'example': """{ 'last_check': "last checked for update", 'update_version': "available update version or empty", 'version': current_cp_version }"""} }) addApiView('updater.update', self.doUpdateView) addApiView('updater.check', self.checkView, docs = { 'desc': 'Check for available update', 'return': {'type': 'see updater.info'} }) addEvent('setting.save.updater.enabled.after', self.setCrons) def logVersion(self): info = self.info() log.info('=== VERSION %s, using %s ===', (info.get('version', {}).get('repr', 'UNKNOWN'), self.updater.getName())) def setCrons(self): fireEvent('schedule.remove', 'updater.check', single = True) if self.isEnabled(): fireEvent('schedule.interval', 'updater.check', self.autoUpdate, hours = 6) self.autoUpdate() # Check after enabling def autoUpdate(self): if self.isEnabled() and self.check() and self.conf('automatic') and not self.updater.update_failed: if self.updater.doUpdate(): # Notify before restarting try: if self.conf('notification'): info = self.updater.info() version_date = datetime.fromtimestamp(info['update_version']['date']) fireEvent('updater.updated', 'Updated to a new version with hash "%s", this version is from %s' % (info['update_version']['hash'], version_date), data = info) except: log.error('Failed notifying for update: %s', traceback.format_exc()) fireEventAsync('app.restart') return True return False def check(self, force = False): if not force and self.isDisabled(): return if self.updater.check(): if not self.available_notified and self.conf('notification') and not self.conf('automatic'): info = self.updater.info() version_date = datetime.fromtimestamp(info['update_version']['date']) fireEvent('updater.available', message = 'A new update with hash "%s" is available, this version is from %s' % (info['update_version']['hash'], version_date), data = info) self.available_notified = True return True return False def info(self, **kwargs): self._lock.acquire() info = {} try: info = self.updater.info() except: log.error('Failed getting updater info: %s', traceback.format_exc()) self._lock.release() return info def checkView(self, **kwargs): return { 'update_available': self.check(force = True), 'info': self.updater.info() } def doUpdateView(self, **kwargs): self.check() if not self.updater.update_version: log.error('Trying to update when no update is available.') success = False else: success = self.updater.doUpdate() if success: fireEventAsync('app.restart') # Assume the updater handles things if not success: success = True return { 'success': success } def doShutdown(self, *args, **kwargs): if not Env.get('dev'): removePyc(Env.get('app_dir'), show_logs = False) return super(Updater, self).doShutdown(*args, **kwargs) class BaseUpdater(Plugin): repo_user = 'RuudBurger' repo_name = 'CouchPotatoServer' branch = version.BRANCH version = None update_failed = False update_version = None last_check = 0 auto_register_static = False def doUpdate(self): pass def info(self): current_version = self.getVersion() return { 'last_check': self.last_check, 'update_version': self.update_version, 'version': current_version, 'repo_name': '%s/%s' % (self.repo_user, self.repo_name), 'branch': current_version.get('branch', self.branch), } def getVersion(self): pass def check(self): pass class GitUpdater(BaseUpdater): def __init__(self, git_command): self.repo = LocalRepository(Env.get('app_dir'), command = git_command) def doUpdate(self): try: log.info('Updating to latest version') self.repo.pull() return True except: log.error('Failed updating via GIT: %s', traceback.format_exc()) self.update_failed = True return False def getVersion(self): if not self.version: try: output = self.repo.getHead() # Yes, please log.debug('Git version output: %s', output.hash) self.version = { 'repr': 'git:(%s:%s % s) %s (%s)' % (self.repo_user, self.repo_name, self.repo.getCurrentBranch().name or self.branch, output.hash[:8], datetime.fromtimestamp(output.getDate())), 'hash': output.hash[:8], 'date': output.getDate(), 'type': 'git', 'branch': self.repo.getCurrentBranch().name } except Exception as e: log.error('Failed using GIT updater, running from source, you need to have GIT installed. %s', e) return 'No GIT' return self.version def check(self): if self.update_version: return True log.info('Checking for new version on github for %s', self.repo_name) if not Env.get('dev'): self.repo.fetch() current_branch = self.repo.getCurrentBranch().name for branch in self.repo.getRemoteByName('origin').getBranches(): if current_branch == branch.name: local = self.repo.getHead() remote = branch.getHead() log.debug('Versions, local:%s, remote:%s', (local.hash[:8], remote.hash[:8])) if local.getDate() < remote.getDate(): self.update_version = { 'hash': remote.hash[:8], 'date': remote.getDate(), } return True self.last_check = time.time() return False class SourceUpdater(BaseUpdater): def __init__(self): # Create version file in cache self.version_file = os.path.join(Env.get('cache_dir'), 'version') if not os.path.isfile(self.version_file): self.createFile(self.version_file, json.dumps(self.latestCommit())) def doUpdate(self): try: download_data = fireEvent('cp.source_url', repo = self.repo_user, repo_name = self.repo_name, branch = self.branch, single = True) destination = os.path.join(Env.get('cache_dir'), self.update_version.get('hash')) + '.' + download_data.get('type') extracted_path = os.path.join(Env.get('cache_dir'), 'temp_updater') destination = fireEvent('file.download', url = download_data.get('url'), dest = destination, single = True) # Cleanup leftover from last time if os.path.isdir(extracted_path): self.removeDir(extracted_path) self.makeDir(extracted_path) # Extract if download_data.get('type') == 'zip': zip_file = zipfile.ZipFile(destination) zip_file.extractall(extracted_path) zip_file.close() else: tar = tarfile.open(destination) tar.extractall(path = extracted_path) tar.close() os.remove(destination) if self.replaceWith(os.path.join(extracted_path, os.listdir(extracted_path)[0])): self.removeDir(extracted_path) # Write update version to file self.createFile(self.version_file, json.dumps(self.update_version)) return True except: log.error('Failed updating: %s', traceback.format_exc()) self.update_failed = True return False def replaceWith(self, path): path = sp(path) app_dir = Env.get('app_dir') data_dir = Env.get('data_dir') # Get list of files we want to overwrite removePyc(app_dir) existing_files = [] for root, subfiles, filenames in os.walk(app_dir): for filename in filenames: existing_files.append(os.path.join(root, filename)) for root, subfiles, filenames in os.walk(path): for filename in filenames: fromfile = os.path.join(root, filename) tofile = os.path.join(app_dir, fromfile.replace(path + os.path.sep, '')) if not Env.get('dev'): try: if os.path.isfile(tofile): os.remove(tofile) dirname = os.path.dirname(tofile) if not os.path.isdir(dirname): self.makeDir(dirname) shutil.move(fromfile, tofile) try: existing_files.remove(tofile) except ValueError: pass except: log.error('Failed overwriting file "%s": %s', (tofile, traceback.format_exc())) return False for still_exists in existing_files: if data_dir in still_exists: continue try: os.remove(still_exists) except: log.error('Failed removing non-used file: %s', traceback.format_exc()) return True def removeDir(self, path): try: if os.path.isdir(path): shutil.rmtree(path) except OSError as inst: os.chmod(inst.filename, 0o777) self.removeDir(path) def getVersion(self): if not self.version: try: f = open(self.version_file, 'r') output = json.loads(f.read()) f.close() log.debug('Source version output: %s', output) self.version = output self.version['type'] = 'source' self.version['repr'] = 'source:(%s:%s % s) %s (%s)' % (self.repo_user, self.repo_name, self.branch, output.get('hash', '')[:8], datetime.fromtimestamp(output.get('date', 0))) except Exception as e: log.error('Failed using source updater. %s', e) return {} return self.version def check(self): current_version = self.getVersion() try: latest = self.latestCommit() if latest.get('hash') != current_version.get('hash') and latest.get('date') >= current_version.get('date'): self.update_version = latest self.last_check = time.time() except: log.error('Failed updating via source: %s', traceback.format_exc()) return self.update_version is not None def latestCommit(self): try: url = 'https://api.github.com/repos/%s/%s/commits?per_page=1&sha=%s' % (self.repo_user, self.repo_name, self.branch) data = self.getCache('github.commit', url = url) commit = json.loads(data)[0] return { 'hash': commit['sha'], 'date': int(time.mktime(parse(commit['commit']['committer']['date']).timetuple())), } except: log.error('Failed getting latest request from github: %s', traceback.format_exc()) return {} class DesktopUpdater(BaseUpdater): def __init__(self): self.desktop = Env.get('desktop') def doUpdate(self): try: def do_restart(e): if e['status'] == 'done': fireEventAsync('app.restart') elif e['status'] == 'error': log.error('Failed updating desktop: %s', e['exception']) self.update_failed = True self.desktop._esky.auto_update(callback = do_restart) return except: self.update_failed = True return False def info(self): return { 'last_check': self.last_check, 'update_version': self.update_version, 'version': self.getVersion(), 'branch': self.branch, } def check(self): current_version = self.getVersion() try: latest = self.desktop._esky.find_update() if latest and latest != current_version.get('hash'): self.update_version = { 'hash': latest, 'date': None, 'changelog': self.desktop._changelogURL, } self.last_check = time.time() except: log.error('Failed updating desktop: %s', traceback.format_exc()) return self.update_version is not None def getVersion(self): return { 'repr': 'desktop: %s' % self.desktop._esky.active_version, 'hash': self.desktop._esky.active_version, 'date': None, 'type': 'desktop', }