#!/bin/bash # SPDX-License-Identifier: GPL-2.0 # Copyright (C) 2016-present Team LibreELEC (https://libreelec.tv) set -e [ -z "${DEBUG_LOG}" ] && DEBUG_LOG=/tmp/distro-tool.log LIBREELEC_DIR=$HOME/LibreELEC.tv TARGET_DIR=$HOME/sources DOWNLOAD_DIR=$HOME/download REVISION= PACKAGE= DRY_RUN=no IGNORE_ERRORS=no DOGIT=no CHECK_NEWER=yes CHECK_FILTER_TYPE=All CHECK_FILTER_CLASS=All PROGRESS=yes IS_MIRROR=yes VERBOSE=0 PATH_FILTER= VERIFY_CHECKSUM=no WORKER_THREADS=16 LOCKFILE=/tmp/distro_tool.lock WORKFILES_I=/tmp/distro_tool.in WORKFILES_O=/tmp/distro_tool.out WORKER_MAX=${WORKER_MAX:-$(grep "^processor[[:space:]]*:" /proc/cpuinfo | wc -l)} # Source in GIT_USERNAME and GIT_PASSWORD to avoid API limitations [ -f ~/.git.conf ] && source ~/.git.conf PYTHON_PROG=' from __future__ import print_function import sys, os, json, codecs, re, threading, subprocess, glob, datetime, shutil, hashlib if sys.version_info >= (3, 0): import queue as Queue basestring = (str, bytes) else: import Queue class MyUtility(object): isPython3 = (sys.version_info >= (3, 0)) if sys.version_info >= (3, 1): sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach()) sys.stderr = codecs.getwriter("utf-8")(sys.stderr.detach()) else: sys.stdout = codecs.getwriter("utf-8")(sys.stdout) sys.stderr = codecs.getwriter("utf-8")(sys.stderr) search_major_minor_patch = re.compile("[0-9]+\.[0-9]+\.[0-9]+") extract_major_minor_patch = re.compile(".*([0-9]+\.[0-9]+\.[0-9]+).*") search_major_minor = re.compile("[0-9]+\.[0-9]+") extract_major_minor = re.compile(".*([0-9]+\.[0-9]+).*") leading_zeros = re.compile("^0[0-9]") search_HTTP_OK = re.compile("HTTP\/[1-9]\.[0-9] 200 OK", flags=re.IGNORECASE) search_HTTP_NOT_FOUND = re.compile("404 not found", flags=re.IGNORECASE) search_HTTP_NOT_ALLOWED = re.compile("405 method not allowed", flags=re.IGNORECASE) search_HTTP_CODE = re.compile("__HTTP_CODE__@([0-9]*)@") search_CONTENT_TYPE = re.compile("__CONTENT_TYPE__@(.*)@") colours = {} colours["red"] = "\x1b[31m" colours["green"] = "\x1b[32m" colours["yellow"] = "\x1b[33m" colours["blue"] = "\x1b[34m" colours["magenta"]= "\x1b[35m" colours["cyan"] = "\x1b[36m" colours["reset"] = "\x1b(B\x1b[m" @staticmethod def colour(colour, text): return "%s%s%s" % (MyUtility.colours[colour], text, MyUtility.colours["reset"]) @staticmethod def logmsg(msgs, level, text): msgs.append({"level": level, "text": text}) @staticmethod def show(msgs, level, colour, prefix, text): if colour: tc = MyUtility.colours[colour] tr = MyUtility.colours["reset"] else: tc = tr = "" MyUtility.logmsg(msgs, level, "%s%-21s%s: %s" % (tc, prefix, tr, text)) @staticmethod def runcommand(msgs, command, logfile=None, redacted=None): MyUtility.logmsg(msgs, 3, "Running command: [%s]" % (redacted if redacted else command)) _logfile = open(logfile, "w") if logfile else subprocess.STDOUT try: if MyUtility.isPython3: return (0, subprocess.check_output(command.split(" "), stderr=_logfile).decode("utf-8")) else: return (0, subprocess.check_output(command.split(" "), stderr=_logfile)) except subprocess.CalledProcessError as cpe: if MyUtility.isPython3: # Clean up undecodable garbage in response (eg. libftdi1 error page) output = bytearray() for c in cpe.output: if c <= 127: output.append(c) return (cpe.returncode, output.decode("utf-8")) else: return (cpe.returncode, cpe.output) finally: if logfile and _logfile: _logfile.close() @staticmethod def readfile(filename): inputfile = codecs.open(filename, "rb", encoding="utf-8") data = inputfile.read() inputfile.close() return data @staticmethod def get_alternate_versions(package_version): if not package_version: return if MyUtility.search_major_minor_patch.search(package_version): mmp = True ver = MyUtility.extract_major_minor_patch.search(package_version).group(1) elif MyUtility.search_major_minor.search(package_version): mmp = False ver = MyUtility.extract_major_minor.search(package_version).group(1) else: return # Split parts parts_p0 = ver.split(".") parts_p0_lz = ["", "", ""] parts_p1 = [0, 0, 0] parts_p1_lz = ["", "", ""] c = 0 for p in parts_p0: if c > 2: break parts_p1[c] = int(p) + 1 # Add leading zeros if originally present if MyUtility.leading_zeros.search(parts_p0[c]): parts_p0_lz[c] = "0" if parts_p0_lz[c] == "0" and parts_p1[c] < 10: parts_p1_lz[c] = "0" c += 1 if mmp: tmp1 = "%s%d.%s%d.%s%d" % (parts_p1_lz[0], parts_p1[0], parts_p0_lz[1], 0, parts_p0_lz[2], 0) yield package_version.replace(ver, tmp1) # If minor or patch >= 10, then we are not sure if leading zeros are # required, so try them anyway if int(parts_p0[1]) >= 10 or int(parts_p0[2]) >= 10: tmp2 = "%s%d.00.00" % (parts_p1_lz[0], parts_p1[0]) if tmp1 != tmp2: yield package_version.replace(ver, tmp2) tmp1 = "%s.%s%d.%s%d" % (parts_p0[0], parts_p1_lz[1], parts_p1[1], parts_p0_lz[2], 0) yield package_version.replace(ver, tmp1) if int(parts_p0[2]) >= 10: tmp2 = "%s.%s%d.00" % (parts_p0[0], parts_p1_lz[1], parts_p1[1]) if tmp1 != tmp2: yield package_version.replace(ver, tmp2) tmp1 = "%s.%s.%s%d" % (parts_p0[0], parts_p0[1], parts_p1_lz[2], parts_p1[2]) yield package_version.replace(ver, tmp1) else: tmp1 = "%s%d.%s%d" % (parts_p1_lz[0], parts_p1[0], parts_p0_lz[1], 0) yield package_version.replace(ver, tmp1) # If minor or patch >= 10, then we are not sure if leading zeros are # required, so try them anyway if int(parts_p0[1]) >= 10: tmp2 = "%s%d.00" % (parts_p1_lz[0], parts_p1[0]) if tmp1 != tmp2: yield package_version.replace(ver, tmp2) tmp1 = "%s.%s%d" % (parts_p0[0], parts_p1_lz[1], parts_p1[1]) yield package_version.replace(ver, tmp1) @staticmethod def get_latest_commit(msgs, package_url): urlfields = package_url.split("/") urlapi = "https://api.github.com/repos/%s/%s/commits" % (urlfields[3], urlfields[4]) tmpfile_data = "%s/%s" % (SCRATCH_DIR, threading.current_thread().name) curl_args = "curl --verbose --silent --fail --location --connect-timeout 15 --max-time 60 --retry 3 --write-out __HTTP_CODE__@%{http_code}@\\n__CONTENT_TYPE__@%{content_type}@" if GIT_USERNAME and GIT_PASSWORD: curl_args += " -u %s:%s" % (GIT_USERNAME, GIT_PASSWORD) if os.path.exists(tmpfile_data): os.remove(tmpfile_data) (result, headers) = MyUtility.runcommand(msgs, "%s --output %s --url %s" % (curl_args, tmpfile_data, urlapi), redacted=curl_args) search_obj = MyUtility.search_HTTP_CODE.search(headers) http_code = search_obj.group(1) if search_obj else "" search_obj = MyUtility.search_CONTENT_TYPE.search(headers) content_type = search_obj.group(1) if search_obj else "" MyUtility.logmsg(msgs, 3, "CURL exit code: %d, http_code: %s, content type: [%s]" % (result, http_code, content_type)) MyUtility.logmsg(msgs, 3, "[\n%s]" % headers) if os.path.exists(tmpfile_data): data = MyUtility.readfile(tmpfile_data) os.remove(tmpfile_data) MyUtility.logmsg(msgs, 3, "GITHUB RESPONSE (first 1024 bytes): [\n%s\n]" % data[0:1024]) if http_code == "200" and data and content_type.startswith("application/json"): jdata = json.loads(data) if "message" not in jdata: return jdata[0]["sha"] return "" @staticmethod def have_package(package_name, package_source): if IS_MIRROR: return os.path.exists("%s/%s/%s" % (TARGET_DIR, package_name, package_source)) or \ os.path.exists("%s/%s/%s" % (DOWNLOAD_DIR, package_name, package_source)) else: return os.path.exists("%s/%s" % (TARGET_DIR, package_source)) or \ os.path.exists("%s/%s" % (DOWNLOAD_DIR, package_source)) return False @staticmethod def remote_file_exists(msgs, url): retry = 5 maxattempts = retry * 3 headers = "" result = 0 HEAD_supported = True ts = datetime.datetime.now() curl_args = "curl --verbose --silent --fail --location --connect-timeout 15 --max-time 60 --retry 0 --write-out __HTTP_CODE__@%{http_code}@\\n__CONTENT_TYPE__@%{content_type}@" http_code = "" content_type = "" MyUtility.logmsg(msgs, 3, "Remote headers for %s..." % url) # Retry up to $retry times in case of transient errors. Do not count # 35/56 as retries, could have several of these before a successful # request - limit total number of retries as ${retry} * 3. while retry > 0 and maxattempts > 0: maxattempts -= 1 if stopped.is_set(): break ts_cmd = datetime.datetime.now() if HEAD_supported: MyUtility.logmsg(msgs, 3, "%s --head --output - --url %s" % (curl_args, url)) (result, headers) = MyUtility.runcommand(msgs, "%s --head --output - --url %s" % (curl_args, url)) else: MyUtility.logmsg(msgs, 3, "%s --range 0-1024 --output /dev/null --url %s" % (curl_args, url)) (result, headers) = MyUtility.runcommand(msgs, "%s --range 0-1024 --output /dev/null --url %s" % (curl_args, url)) search_obj = MyUtility.search_HTTP_CODE.search(headers) http_code = search_obj.group(1) if search_obj else "" search_obj = MyUtility.search_CONTENT_TYPE.search(headers) content_type = search_obj.group(1) if search_obj else "" tDelta = (datetime.datetime.now() - ts_cmd) MyUtility.logmsg(msgs, 3, \ "CURL exit code: %d, http_code: %s, content type: [%s], remaining retries %d, time taken %f seconds" % \ (result, http_code, content_type, retry, tDelta.total_seconds())) if result == 22: # 404 Not Found if http_code == "404" or MyUtility.search_HTTP_NOT_FOUND.search(headers): break # 403/405 Server does not support HEAD elif HEAD_supported and (http_code in ["403", "405"] or MyUtility.search_HTTP_NOT_ALLOWED.search(headers)): MyUtility.logmsg(msgs, 3, "HEAD not supported - retrying with range-limited GET") HEAD_supported = False continue # Success or fatal error - no point continuing # 0: CURLE_OK # 6: CURLE_COULDNT_RESOLVE_HOST # 7: CURLE_COULDNT_CONNECT # 9: CURLE_REMOTE_ACCESS_DENIED # 10: CURLE_FTP_ACCEPT_FAILED # 19: CURLE_FTP_COULDNT_RETR_FILE # 28: CURLE_OPERATION_TIMEDOUT if result in [0, 6, 7, 9, 10, 19, 28]: break # Keep retrying following errors until success or failure. # 35: CURLE_SSL_CONNECT_ERROR # 56: CURLE_RECV_ERROR if result not in [35, 56]: retry -= 1 MyUtility.logmsg(msgs, 3, "[\n%s]" % headers) # Success if HTTP 200 or 206 (partial content when using ranged request) # A content_type of "text/html" indicates we were served an error page of # some kind (eg. iperf) and not the requested file, which would be "application/". # "text/plain" indicates a misconfigured server (eg. libgcrypt, libgpg-error) so accept this. # Some servers (eg. files.pythonhosted.org) will respond with "binary/octet-stream". if http_code in ["200", "206"] or MyUtility.search_HTTP_OK.search(headers): result = True if (content_type.startswith("application/") or \ content_type.startswith("text/plain") or \ content_type.startswith("binary/octet-stream")) else False elif http_code == "350" and url.startswith("ftp:"): result = True else: result = False tDelta = (datetime.datetime.now() - ts) MyUtility.show(msgs, 2 if VERBOSE == 2 else 3, None, "Remote File Exists", "%s (%s) %f seconds" % (result, url, tDelta.total_seconds())) return result # Calculate hash for chunked data @staticmethod def hash_bytestr_iter(bytesiter, hasher, ashexstr=True): for block in bytesiter: hasher.update(block) return (hasher.hexdigest() if ashexstr else hasher.digest()) # Read file in blocks/chunks to be memory efficient @staticmethod def file_as_blockiter(afile, blocksize=65536): with afile: block = afile.read(blocksize) while len(block) > 0: yield block block = afile.read(blocksize) # Calculate sha256 hash for a file @staticmethod def calculate_sha256(fname): try: return MyUtility.hash_bytestr_iter(MyUtility.file_as_blockiter(open(fname, "rb")), hashlib.sha256()) except: raise return "" # Use wget with same parameters as scripts/get is using @staticmethod def download_file(msgs, filename_data, filename_log, url): retries=10 attempts=0 while attempts < retries: if stopped.is_set(): break attempts += 1 (result, output) = MyUtility.runcommand(msgs, "wget --output-file=- --timeout=30 --tries=3 --passive-ftp --no-check-certificate -O %s %s" % (filename_data, url), logfile=filename_log) if result == 0: return True return False @staticmethod def get_package(msgs, package_name, package_source, package_url, package_sha): onsource = MyUtility.remote_file_exists(msgs, package_url) onmirror = MyUtility.remote_file_exists(msgs, "%s/%s/%s" % (DISTRO_MIRROR, package_name, package_source)) # If the only PKG_URL source is the DISTRO_SRC server... if package_url.startswith(DISTRO_SOURCE): # Warn the user if package is not found on either source or mirror if not onsource and not onmirror: MyUtility.show(msgs, 0, "magenta", "MKPKG REQUIRED?", "%s (%s) unable to download from DISTRO_SRC %s - we have no source!" % \ (package_name, package_url, MyUtility.colour("red", "and not on mirror"))) return False if DRY_RUN: text = "Package status: %s (%s)" % (package_name, package_source) if not onsource and onmirror: text = "%s %s" % (text, MyUtility.colour("yellow", "(found on mirror)")) if onsource or onmirror: MyUtility.show(msgs, 0, "green", "PACKAGE AVAILABLE", text) return True else: MyUtility.show(msgs, 0, "red", "PACKAGE NOT AVAILABLE", text) return False tmpfile_data = "%s/%s" % (SCRATCH_DIR, threading.current_thread().name) tmpfile_log = "%s.log" % tmpfile_data if os.path.exists(tmpfile_data): os.remove(tmpfile_data) if os.path.exists(tmpfile_log): os.remove(tmpfile_log) if VERIFY_CHECKSUM == True and package_sha and onmirror: result = MyUtility.download_file(msgs, tmpfile_data, tmpfile_log, "%s/%s/%s" % (DISTRO_MIRROR, package_name, package_source)) calc_sha = MyUtility.calculate_sha256(tmpfile_data) if os.path.exists(tmpfile_data): os.remove(tmpfile_data) if os.path.exists(tmpfile_log): os.remove(tmpfile_log) if calc_sha != package_sha: MyUtility.show(msgs, 0, "yellow", "CHECKSUM MISMATCH FROM MIRROR - REFRESHING FROM SOURCE", "%s (%s)" % (package_name, package_url)) MyUtility.logmsg(msgs, 0, "Checksum mismatch - got [%s], expected [%s]" % (calc_sha, package_sha)) onmirror = False else: MyUtility.show(msgs, 1, "green", "Mirror checksum valid", "%s (%s)" % (package_name, package_source)) return True if onsource: result = MyUtility.download_file(msgs, tmpfile_data, tmpfile_log, package_url) elif onmirror: result = MyUtility.download_file(msgs, tmpfile_data, tmpfile_log, "%s/%s/%s" % (DISTRO_MIRROR, package_name, package_source)) else: result = False if result == False or not os.path.exists(tmpfile_data): result = False MyUtility.show(msgs, 0, "red", "DOWNLOAD FAILED!!", "%s (%s)" % (package_name, package_url)) if os.path.exists(tmpfile_log): MyUtility.logmsg(msgs, 0, MyUtility.readfile(tmpfile_log)) else: if package_sha: calc_sha = MyUtility.calculate_sha256(tmpfile_data) if calc_sha != package_sha: result = False MyUtility.show(msgs, 0, "red", "DOWNLOAD FAILED!!", "%s (%s)" % (package_name, package_url)) MyUtility.logmsg(msgs, 0, "Checksum mismatch - got [%s], wanted [%s]" % (calc_sha, package_sha)) if result == True: MyUtility.show(msgs, 0, "green", "Successful Download", "%s (%s)" % (package_name, package_source)) if IS_MIRROR: if not os.path.exists("%s/%s" % (DOWNLOAD_DIR, package_name)): os.makedirs("%s/%s" % (DOWNLOAD_DIR, package_name)) shutil.move(tmpfile_data, "%s/%s/%s" % (DOWNLOAD_DIR, package_name, package_source)) else: shutil.move(tmpfile_data, "%s/%s" % (DOWNLOAD_DIR, package_source)) if os.path.exists(tmpfile_data): os.remove(tmpfile_data) if os.path.exists(tmpfile_log): os.remove(tmpfile_log) return result # Check for newer versions: "X+1.[0]0[.[0]0]" "X.[0]Y+1[.[0]0]" "X.[0]Y.[0]Z+1" @staticmethod def check_newer(msgs, package_name, package_url, package_ver): alt_versions = [] is_git_rev = False for v in MyUtility.get_alternate_versions(package_ver): if v: alt_versions.append(v) if alt_versions == []: if not package_url.startswith("https://github.com/"): return if CHECK_FILTER_TYPE == "Ver": return is_git_rev = True latestrev = MyUtility.get_latest_commit(msgs, package_url) MyUtility.logmsg(msgs, 3, "Github latest commit [%s]" % latestrev) if latestrev == "" or latestrev.startswith(package_ver): return alt_versions.append(latestrev[0:len(package_ver)]) elif CHECK_FILTER_TYPE == "Rev": return MyUtility.show(msgs, 2 if VERBOSE == 2 else 3, None, "Checking for newer", "%s, current version %s - checking %s" % (package_name, package_ver, ", ".join(alt_versions))) newurl = package_url.replace(package_ver, "@@VER@@") for newver in alt_versions: url = newurl.replace("@@VER@@", newver) if MyUtility.remote_file_exists(msgs, url): if is_git_rev: MyUtility.show(msgs, 0, "yellow", "New Rev Pkg available", "%s (%s => %s) %s" % (package_name, package_ver, newver, url)) else: MyUtility.show(msgs, 0, "magenta","New Ver Pkg available", "%s (%s => %s) %s" % (package_name, package_ver, newver, url)) break @staticmethod def toUnicode(data): if MyUtility.isPython3: return data if isinstance(data, basestring): if not isinstance(data, unicode): try: data = unicode(data, encoding="utf-8", errors="ignore") except UnicodeDecodeError: pass return data @staticmethod def printout(data, end="\n"): sys.stdout.write("%s%s" % (MyUtility.toUnicode(data), end)) sys.stdout.flush() @staticmethod def printerr(data, end="\n"): sys.stderr.write("%s%s" % (MyUtility.toUnicode(data), end)) sys.stderr.flush() # # Thread # class MyThread(threading.Thread): def __init__(self, input_queue, output_queue): threading.Thread.__init__(self) self.input_queue = input_queue self.output_queue = output_queue def run(self): while not stopped.is_set(): try: qItem = self.input_queue.get(block=False) self.input_queue.task_done() pkg_is_addon = (qItem["PKG_IS_ADDON"] == "Yes") pkg_name = qItem["PKG_NAME"] pkg_version = qItem["PKG_VERSION"] pkg_url = qItem["PKG_URL"] pkg_sha = qItem["PKG_SHA256"] pkg_section = qItem["PKG_SECTION"] pkg_source_name = qItem["PKG_SOURCE_NAME"] msgs = [] if pkg_version == "" or pkg_version == "0.0invalid" or pkg_url == "": if pkg_section != "virtual" and CHECK_FILTER_CLASS == "All" and CHECK_FILTER_TYPE == "All": MyUtility.show(msgs, 0, "cyan", "UNKNOWN VER OR URL", "%s (ver [%s], url [%s])" % (pkg_name, pkg_version, pkg_url)) self.output_queue.put(msgs) continue tDelta_get_package = datetime.timedelta(0) tDelta_check_newer = datetime.timedelta(0) self.output_queue.put([{"start": True, "name": threading.current_thread().name, "data": {"url": pkg_url, "tstamp": datetime.datetime.now()}}]) MyUtility.logmsg(msgs, 3, ">>>>>>>>>>>>>>>>> %s, %s, %s, wanted sha256 %s" % (pkg_name, pkg_version, pkg_url, pkg_sha)) if not MyUtility.have_package(pkg_name, pkg_source_name) or (VERIFY_CHECKSUM and pkg_sha): tStart = datetime.datetime.now() if not stopped.is_set() and \ not MyUtility.get_package(msgs, pkg_name, pkg_source_name, pkg_url, pkg_sha): if not IGNORE_ERRORS: stopped.set() tDelta_get_package = datetime.datetime.now() - tStart else: MyUtility.show(msgs, 1, "green", "Already downloaded", "%s (%s)" % (pkg_name, pkg_source_name)) if CHECK_NEWER and not stopped.is_set(): if (CHECK_FILTER_CLASS == "All") or \ (CHECK_FILTER_CLASS == "Main" and pkg_is_addon == False) or \ (CHECK_FILTER_CLASS == "Addons" and pkg_is_addon == True): tStart = datetime.datetime.now() MyUtility.check_newer(msgs, pkg_name, pkg_url, pkg_version) tDelta_check_newer = datetime.datetime.now() - tStart self.output_queue.put([{"stop": True, "name": threading.current_thread().name, "url": pkg_url}]) MyUtility.logmsg(msgs, 3, "<<<<<<<<<<<<<<<<< %s %s %s get_package %f check_newer %f" % \ (pkg_name, pkg_version, pkg_url, tDelta_get_package.total_seconds(), tDelta_check_newer.total_seconds())) self.output_queue.put(msgs) except Queue.Empty: break # This thread is going down... self.output_queue.put(None) def main(): if not os.path.exists(SCRATCH_DIR): os.makedirs(SCRATCH_DIR) else: for file in glob.glob("%s/*" % SCRATCH_DIR): os.remove(file) data = [] for line in sys.stdin: data.append(line) if data == []: sys.exit(1) input_queue = Queue.Queue() output_queue = Queue.Queue() for item in sorted(json.loads("".join(data)), key=lambda k: k["PKG_NAME"]): input_queue.put(item) pcount = 0 pmax = input_queue.qsize() # Create threads to process input queue threadcount = input_queue.qsize() if input_queue.qsize() <= WORKER_THREADS else WORKER_THREADS threads = [] for i in range(threadcount): t = MyThread(input_queue, output_queue) threads.append(t) t.setDaemon(False) # Start the threads... for t in threads: t.start() qtimeout = 10 running = {} stopping = False while threadcount > 0: try: qItem = output_queue.get(block=True, timeout=qtimeout) output_queue.task_done() if qItem is None: threadcount -= 1 continue finished = False for msg in qItem: if msg.get("start", False): running[msg["name"]] = msg["data"] elif msg.get("stop", False): del running[msg["name"]] elif "level" in msg: finished = True if VERBOSE >= msg["level"]: if msg["level"] <= 2: MyUtility.printout(msg["text"]) else: MyUtility.printerr(msg["text"]) if not stopping and stopped.is_set(): stopping = True MyUtility.printout(MyUtility.colour("red", "** STOPPING DUE TO FAILURE - WAITING FOR %d THREADS TO FINISH **" % threadcount)) # Do not enable progress when detailed debug logging is enabled as # this will most likely be redirected to a file if finished and PROGRESS and VERBOSE <= 2: pcount += 1 MyUtility.printerr("Processing... %3d%% (%d threads active)\x1b[K\r" % ((pcount * 100 / pmax), threadcount), end="") except Queue.Empty: if VERBOSE >= 3 and len(running) != 0: MyUtility.printerr("============ WAITING ON FOLLOWING %d THREADS ============" % len(running)) for t in running: data = running[t] MyUtility.printerr("SLOW RUNNING THREAD %s for %f secs: %s" % (t, (datetime.datetime.now() - data["tstamp"]).total_seconds(), data["url"])) if PROGRESS and VERBOSE <= 2: MyUtility.printerr("\r\x1b[K", end="") sys.exit(1 if stopped.is_set() else 0) if __name__ == "__main__": try: args = sys.argv[1:] DOWNLOAD_DIR = args[0] TARGET_DIR = args[1] DISTRO_SOURCE = args[2] DISTRO_MIRROR = args[3] IS_MIRROR = True if args[4] == "yes" else False IGNORE_ERRORS = True if args[5] == "yes" else False DRY_RUN = True if args[6] == "yes" else False CHECK_NEWER = True if args[7] == "yes" else False PROGRESS=True if args[8] == "yes" else False WORKER_THREADS=int(args[9]) GIT_USERNAME = args[10] GIT_PASSWORD = args[11] CHECK_FILTER_CLASS = args[12] CHECK_FILTER_TYPE = args[13] VERIFY_CHECKSUM = True if args[14] == "yes" else False VERBOSE = int(args[15]) SCRATCH_DIR="%s/.tmp" % DOWNLOAD_DIR stopped = threading.Event() main() except (KeyboardInterrupt, SystemExit) as e: if type(e) == SystemExit: sys.exit(int(str(e))) except Exception: raise ' [ -z "${PROJECT}" ] && PROJECT=Generic [ -z "${ARCH}" ] && ARCH=x86_64 help() { [ -n "$1" ] && echo -e "ERROR: Unknown argument [$1]\n" cat < [-t|--target ] [-l|--libreelec ] [-m|--mirror] [-s|--source] [-a|-all] [-p|--package [-r|--revision ]] [--git] [-n|--notnewer] [--check-main | --check-addons] [--check-ver | --check-rev] [--no-progress] [-T #|--threads #] [-U|--gituser] [-P|--gitpass] [--dry-run] [--path-filter path] [--verify-checksum] [-v|--verbose] [-h|--help] Options: -d, --download: Directory path into which new package files will be downloaded - default is $HOME/download[1] -t, --target: Directory path for existing packages that are to be refreshed, default is $HOME/sources[2] -l, --libreelec: LibreELEC.tv repo, default is ${HOME}/LibreELEC.tv -m, --mirror: Target is mirror not source - mirror uses a hierarchical per-package folder structure -s, --source: Target is source not mirror - source uses a flattened file structure. Default is mirror -a, --all: Ignore download failures, continue processing all packages -p, --package: Package to process, otherwise process all packages -r, --revision: Version to use in place of PKG_VERSION, only applicable in conjunction with -p --git: Clone (if not available) or pull the LibreELEC.tv repository -n, --notnewer: Don't check for newer packages ('X.Y.Z+1' 'X.Y+1.0' 'X+1.0.0') --check-main: Check newer for main packages, ignore add-on packages --check-addons: Check newer for add-on packages, ignore main packages --check-ver: Check newer for version-based packages, ignore rev-based/github packages --check-rev: Check newer for rev-based/github packages, ignore version-based packages --no-progress: Do not show progress indicator -T, --threads: Number of download worker threads, default is 16 -U, --gituser: Git username (or source from ~/.git.conf) - avoids API limits -P, --gitpass: Git password (or source from ~/.git.conf) - avoids API limits --dry-run: Don't actually download anything (will still git clone/pull if configured) --path-filter Restrict workload to specified path, eg. packages/x11 (recursive) --verify-checksum: If checksum on mirror is no longer valid, attempt re-download of package from source -v, --verbose: Output more verbose sync information. Repeat for more detail -h, --help: This message Note#1. The download directory will have the LibreELEC version appended (eg. /devel) unless it is a mirror, in which case "/mirror" will be appended. Note#2. The target directory will have the LibreELEC version appended (eg. /devel) unless it is a mirror, in which case "/mirror" will be appended. EOF } get_libreelec_branch() { cd $LIBREELEC_DIR git rev-parse --abbrev-ref HEAD } # 1: LibreELEC variable, eg. LIBREELEC_VERSION get_libreelec_option() { local variable="$1" cd $LIBREELEC_DIR source config/options "" echo "${!variable}" } generate_work() { local package_name="$1" revision="$2" path_filter="$3" local packages pcount local workfile_o local tcount=0 [ ${PROGRESS} = yes ] && echo -en "Acquiring packages...\r" >&2 packages="$(get_packages "${package_name}" "${path_filter}")" pcount="$(echo "${packages}" | wc -l)" if [ -n "${package_name}" -a -z "${packages}" ]; then if [ -n "${path_filter}" ]; then echo "ERROR: ${package_name} is not a valid package when using filter ${path_filter} - package.mk does not exist" >&2 else echo "ERROR: ${package_name} is not a valid package - package.mk does not exist" >&2 fi exit 1 fi rm -f ${WORKFILES_I}.* ${WORKFILES_O}.* # Distribute packages across workers tcount=0 for package_name in ${packages}; do tcount=$((tcount + 1)) [ ${tcount} -gt ${WORKER_MAX} ] && tcount=1 echo "$package_name" >>$(printf "%s.%02d" "${WORKFILES_I}" ${tcount}) done # Generate workload using multiple "threads" (cores, hopefully...) init_progress tcount=0 while [ : ]; do tcount=$((tcount + 1)) [ ${tcount} -gt ${WORKER_MAX} ] && break generate_work_worker ${pcount} ${tcount} ${revision} & done wait end_progress # Combine workloads echo "[" tcount=0 while [ : ]; do tcount=$((tcount + 1)) [ ${tcount} -gt ${WORKER_MAX} ] && break workfile_o=$(printf "%s.%02d" "${WORKFILES_O}" ${tcount}) if [ -s ${workfile_o} ]; then [ ${tcount} -ne 1 ] && echo "," # Strip comma from line of file - not valid if this is the last file sed '$ s/ },/ }/' ${workfile_o} fi done echo "]" # Clean up rm -f ${WORKFILES_I}.* ${WORKFILES_O}.* } generate_work_worker() { local pcount=$1 worker="$2" revision="$3" local workfile_i="$(printf "%s.%02d" "${WORKFILES_I}" ${worker})" local workfile_o="$(printf "%s.%02d" "${WORKFILES_O}" ${worker})" local wanted_vars="PKG_NAME PKG_VERSION PKG_URL PKG_SHA256 PKG_DIR PKG_SECTION PKG_IS_ADDON PKG_SOURCE_NAME" local package_name var comma PKG_URL PKG_SOURCE_NAME PKG_VERSION PKG_IS_ADDON [ -f "${workfile_i}" ] || return 0 ( # Override exit function so that packages calling exit don't terminate this sub-shell exit() { : } cd $LIBREELEC_DIR source config/options "" while read -r package_name; do [ ${PROGRESS} = yes ] && progress ${pcount} source_package ${package_name} &>/dev/null || true [ -z "${PKG_DIR}" ] && continue if [ -n "${revision}" ]; then PKG_URL="${PKG_URL/${PKG_VERSION}/${revision}}" PKG_SOURCE_NAME="${PKG_SOURCE_NAME/${PKG_VERSION}/${revision}}" PKG_VERSION="${revision}" PKG_SHA256= fi PKG_IS_ADDON=No [[ ${PKG_DIR/${LIBREELEC_DIR}/} =~ ^/packages/addons/.* ]] && PKG_IS_ADDON=Yes [[ ${PKG_DIR/${LIBREELEC_DIR}/} =~ ^/packages/mediacenter/kodi-binary-addons/.* ]] && PKG_IS_ADDON=Yes echo " {" >>${workfile_o} for var in ${wanted_vars}; do [ "${var}" != "PKG_SOURCE_NAME" ] && comma="," || comma= echo " \"${var}\": \"${!var}\"${comma}" >>${workfile_o} done echo " }," >>${workfile_o} done < ${workfile_i} ) } check_exists() { if [ -z "${!1}" ]; then echo "ERROR: ${1} must not be undefined" return 1 fi [ -d ${!1} ] && return 0 echo "ERROR: ${1} ${!1} does not exist" return 1 } get_abs_path() { echo "$(readlink -f $1)" } get_package_path() { echo "$(basename "$(dirname "$0")") $0" } get_packages() { local package_name="$1" path_filter="$2" local anchor="@?+?@" ( cd $LIBREELEC_DIR # Generate package caches... source config/options "" cache_data="${BUILD}/.cache_package_global ${BUILD}/.cache_package_local" if [ -n "${path_filter}" ]; then if [ -n "${package_name}" ]; then grep -F "/${path_filter}/" ${cache_data} | grep -F "/${package_name}${anchor}" else grep -F "/${path_filter}/" ${cache_data} fi else if [ -n "${package_name}" ]; then grep -F "/${package_name}${anchor}" ${cache_data} else cat ${cache_data} fi fi ) | sed "s#${anchor}\$##g;s#/# #g" | awk '{print $NF}' | sort -u return 0 } init_progress() { echo 0 > ${LOCKFILE} } progress() { local count ( flock -x 9 count=$(($(< ${LOCKFILE}) + 1)) echo "${count}" >${LOCKFILE} printf "Generating workload... %3d%% (%d of %d)\r" $((count * 100 / $1)) ${count} $1 >&2 ) 9< "${LOCKFILE}" } end_progress() { rm -f ${LOCKFILE} printf "\033[K\r" >&2 } exec_worker_prog() { echo "${PYTHON_PROG}" >/tmp/distro-tool.py python3 /tmp/distro-tool.py "${DOWNLOAD_DIR}" "${TARGET_DIR}" "${DISTRO_SOURCE}" "${DISTRO_MIRROR}" \ "${IS_MIRROR}" "${IGNORE_ERRORS}" "${DRY_RUN}" "${CHECK_NEWER}" \ "${PROGRESS}" "${WORKER_THREADS}" "${GIT_USERNAME}" "${GIT_PASSWORD}" \ "${CHECK_FILTER_CLASS}" "${CHECK_FILTER_TYPE}" ${VERIFY_CHECKSUM} "${VERBOSE}" rm -f /tmp/distro-tool.py } while [ : ]; do [ -z "$1" ] && break case $1 in -d|--download) shift DOWNLOAD_DIR=$1 ;; -l|--libreelec) shift LIBREELEC_DIR=$1 ;; -t|--target) shift TARGET_DIR=$1 ;; -a|--all) IGNORE_ERRORS=yes ;; -p|--package) shift PACKAGE=$1 ;; -r|--revision) shift REVISION=$1 ;; -m|--mirror) IS_MIRROR=yes ;; -s|--source) IS_MIRROR=no ;; --dry-run|--dryrun) DRY_RUN=yes LINE_PREFIX="**DRY-RUN** " ;; --git) DOGIT=yes ;; -n|--notnewer) CHECK_NEWER=no ;; --check-ver) CHECK_FILTER_TYPE=Ver ;; --check-rev) CHECK_FILTER_TYPE=Rev ;; --check-addons) CHECK_FILTER_CLASS=Addons ;; --check-main) CHECK_FILTER_CLASS=Main ;; -T|--threads) shift [ $1 -gt 0 ] && WORKER_THREADS=$1 ;; -U|--gituser) shift GIT_USERNAME=$1 ;; -P|--gitpass) shift GIT_PASSWORD=$1 ;; -v|--verbose) VERBOSE=$((VERBOSE + 1)) ;; --no-progress) PROGRESS=no ;; --path-filter) shift PATH_FILTER=$1 ;; --verify-checksum) VERIFY_CHECKSUM=yes ;; -h|--help) help exit 0 ;; *) help "$1" exit 0 ;; esac shift done [ -n "${PACKAGE}" -a ${VERBOSE} -eq 0 ] && VERBOSE=1 if [ ${DOGIT} = yes ]; then ( if [ -d ${LIBREELEC_DIR}/.git ]; then cd ${LIBREELEC_DIR} git pull else mkdir -p $(dirname "${LIBREELEC_DIR}") 2>/dev/null cd $(dirname "${LIBREELEC_DIR}") git clone https://github.com/LibreELEC/LibreELEC.tv.git $(basename "${LIBREELEC_DIR}") fi ) fi check_exists LIBREELEC_DIR || exit LIBREELEC_DIR="$(get_abs_path "${LIBREELEC_DIR}")" DISTRO_SOURCE="$(get_libreelec_option DISTRO_SRC)" DISTRO_MIRROR="$(get_libreelec_option DISTRO_MIRROR)" LIBREELEC_VER="$(get_libreelec_option LIBREELEC_VERSION)" if [ ${IS_MIRROR} = no ]; then TARGET_DIR="$(get_abs_path "${TARGET_DIR}/${LIBREELEC_VER}")" else TARGET_DIR="$(get_abs_path "${TARGET_DIR}/mirror")" fi check_exists TARGET_DIR || exit if [ ${IS_MIRROR} = no ]; then DOWNLOAD_DIR="$(get_abs_path "${DOWNLOAD_DIR}/${LIBREELEC_VER}")" else DOWNLOAD_DIR="$(get_abs_path "${DOWNLOAD_DIR}/mirror")" fi check_exists DOWNLOAD_DIR || exit if [ -n "${REVISION}" -a -z "${PACKAGE}" ]; then echo "ERROR: A single package must be specified with custom revision" exit 1 fi echo if [ ${IS_MIRROR} = yes ]; then echo "Synchronising LibreELEC.tv (branch: $(get_libreelec_branch), version: ${LIBREELEC_VER}) with MIRROR server ${TARGET_DIR}" else echo "Synchronising LibreELEC.tv (branch: $(get_libreelec_branch), version: ${LIBREELEC_VER}) with SOURCE server ${TARGET_DIR}" fi echo echo "Distro Source is: ${DISTRO_SOURCE}" echo "Distro Mirror is: ${DISTRO_MIRROR}" echo " Syncing against: ${TARGET_DIR}" echo " Downloading to: ${DOWNLOAD_DIR}" if [ -n "${PATH_FILTER}" ]; then echo " Path filter: ${PATH_FILTER}" fi echo " Check Newer: ${CHECK_NEWER^} Class/Type: ${CHECK_FILTER_CLASS} / ${CHECK_FILTER_TYPE}" echo " Dry run: ${DRY_RUN^}" if [ ${VERBOSE} -gt 2 ]; then echo " Debugging level: ${VERBOSE} (${DEBUG_LOG})" echo " Worker Threads: ${WORKER_THREADS}" fi echo if [ ${VERBOSE} -gt 2 -a -n "${DEBUG_LOG}" ]; then generate_work "${PACKAGE}" "${REVISION}" "${PATH_FILTER}" | exec_worker_prog 2>${DEBUG_LOG} else generate_work "${PACKAGE}" "${REVISION}" "${PATH_FILTER}" | exec_worker_prog fi