diff --git a/addons.xml b/addons.xml index 0fd9968..41944b3 100644 --- a/addons.xml +++ b/addons.xml @@ -1,15 +1,17 @@ - + - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/ + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository.midarr/addons.xml + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository.midarr/addons.xml.md5 + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository.midarr/ + false - - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/ + + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/plugin.video.midarr/addons.xml + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/plugin.video.midarr/addons.xml.md5 + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/plugin.video.midarr/ + false @@ -20,4 +22,21 @@ icon.png + + + + + + video + + + Midarr + Official Midarr for Kodi add-on + https://github.com/midarrlabs/repository.midarr + https://github.com/midarrlabs/repository.midarr + + icon.png + fanart.png + + \ No newline at end of file diff --git a/addons.xml.md5 b/addons.xml.md5 index 5e7e8eb..815f3c4 100644 --- a/addons.xml.md5 +++ b/addons.xml.md5 @@ -1 +1 @@ -dd4e4b3079c8b4a87bc32dfa422f19bd \ No newline at end of file +7324f571bd6df1ad4b886a5f1fd67d73 addons.xml diff --git a/nexus/zips/addons.xml b/nexus/zips/addons.xml deleted file mode 100644 index 29192f7..0000000 --- a/nexus/zips/addons.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - video - - - Midarr - Official Midarr for Kodi add-on - https://github.com/midarrlabs/repository.midarr - https://github.com/midarrlabs/repository.midarr - - icon.png - - - \ No newline at end of file diff --git a/nexus/zips/addons.xml.md5 b/nexus/zips/addons.xml.md5 deleted file mode 100644 index c7ac981..0000000 --- a/nexus/zips/addons.xml.md5 +++ /dev/null @@ -1 +0,0 @@ -2e5773f5f179b703e8336768e6bcad99 \ No newline at end of file diff --git a/nexus/zips/plugin.video.midarr/addon.xml b/nexus/zips/plugin.video.midarr/addon.xml deleted file mode 100644 index 644bd83..0000000 --- a/nexus/zips/plugin.video.midarr/addon.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - video - - - Midarr - Official Midarr for Kodi add-on - https://github.com/midarrlabs/repository.midarr - https://github.com/midarrlabs/repository.midarr - - icon.png - - - diff --git a/nexus/plugin.video.midarr/addon.py b/plugin.video.midarr/addon.py similarity index 100% rename from nexus/plugin.video.midarr/addon.py rename to plugin.video.midarr/addon.py diff --git a/nexus/plugin.video.midarr/addon.xml b/plugin.video.midarr/addon.xml similarity index 95% rename from nexus/plugin.video.midarr/addon.xml rename to plugin.video.midarr/addon.xml index 644bd83..2447713 100644 --- a/nexus/plugin.video.midarr/addon.xml +++ b/plugin.video.midarr/addon.xml @@ -13,6 +13,7 @@ https://github.com/midarrlabs/repository.midarr icon.png + fanart.png diff --git a/plugin.video.midarr/fanart.jpg b/plugin.video.midarr/fanart.jpg new file mode 100644 index 0000000..c3fadbd Binary files /dev/null and b/plugin.video.midarr/fanart.jpg differ diff --git a/nexus/plugin.video.midarr/logo.png b/plugin.video.midarr/icon.png similarity index 100% rename from nexus/plugin.video.midarr/logo.png rename to plugin.video.midarr/icon.png diff --git a/nexus/zips/plugin.video.midarr/plugin.video.midarr-1.0.0.zip b/plugin.video.midarr/plugin.video.midarr-1.0.0.zip similarity index 57% rename from nexus/zips/plugin.video.midarr/plugin.video.midarr-1.0.0.zip rename to plugin.video.midarr/plugin.video.midarr-1.0.0.zip index c8a7a89..362da90 100644 Binary files a/nexus/zips/plugin.video.midarr/plugin.video.midarr-1.0.0.zip and b/plugin.video.midarr/plugin.video.midarr-1.0.0.zip differ diff --git a/plugin.video.midarr/plugin.video.midarr-1.0.0.zip.md5 b/plugin.video.midarr/plugin.video.midarr-1.0.0.zip.md5 new file mode 100644 index 0000000..0bcbfdd --- /dev/null +++ b/plugin.video.midarr/plugin.video.midarr-1.0.0.zip.md5 @@ -0,0 +1 @@ +d06c112b4738338a4e507deb9519a1cb *plugin.video.midarr-1.0.0.zip diff --git a/nexus/plugin.video.midarr/resources/settings.xml b/plugin.video.midarr/resources/settings.xml similarity index 100% rename from nexus/plugin.video.midarr/resources/settings.xml rename to plugin.video.midarr/resources/settings.xml diff --git a/repo_generator.py b/repo_generator.py index b25857b..9028933 100644 --- a/repo_generator.py +++ b/repo_generator.py @@ -1,378 +1,465 @@ +#!/usr/bin/env python +r""" +Create a Kodi add-on repository from add-on sources + +This tool extracts Kodi add-ons from their respective locations and +copies the appropriate files into a Kodi add-on repository. Each add-on +is placed in its own directory. Each contains the add-on metadata files +and a zip archive. In addition, the repository catalog "addons.xml" is +placed in the repository folder. + +Each add-on location is either a local path or a URL. If it is a local +path, it can be to either an add-on folder or an add-on ZIP archive. If +it is a URL, it should be to a Git repository and it should use the +format: + REPOSITORY_URL#BRANCH:PATH +The first segment is the Git URL that would be used to clone the +repository, (e.g., +"https://github.com/chadparry/kodi-repository.chad.parry.org.git"). +That is followed by an optional "#" sign and a branch or tag name, +(e.g. "release-1.0"). If no branch name is specified, then the default +is the repository's currently active branch, which is the same default +as git-clone. Next comes an optional ":" sign and path. The path +denotes the location of the add-on within the repository. If no path is +specified, then the default is ".". + +For example, if you are in the directory that should contain addons.xml +and you just copied a new version of the only add-on +"repository.chad.parry.org" to a subdirectory, then you can create or +update the addons.xml file with this command: + + create_repository.py repository.chad.parry.org + +As another example, here is the command that generates Chad Parry's +Repository: + + create_repository.py \ + --datadir=~/html/software/kodi \ + --compressed \ + https://github.com/chadparry\ +/kodi-repository.chad.parry.org.git:repository.chad.parry.org \ + https://github.com/chadparry\ +/kodi-plugin.program.remote.control.browser.git\ +:plugin.program.remote.control.browser + +This script has been tested with Python 2.7.10 and Python 3.6.5. It +depends on the GitPython module. """ - Put this script in the root folder of your repo and it will - zip up all addon folders, create a new zip in your zips folder - and then update the md5 and addons.xml file -""" +__author__ = "Chad Parry" +__contact__ = "github@chad.parry.org" +__copyright__ = "Copyright 2016-2022 Chad Parry" +__license__ = "GNU GENERAL PUBLIC LICENSE. Version 2, June 1991" +__version__ = "2.3.8" + + +import argparse +import collections +import errno +import gzip import hashlib +import io import os +import re import shutil +import stat import sys +import tempfile +import threading +import xml.etree.ElementTree import zipfile -from xml.etree import ElementTree - -SCRIPT_VERSION = 5 -KODI_VERSIONS = ["nexus", "repository"] -IGNORE = [ - ".git", - ".github", - ".gitignore", - ".DS_Store", - "thumbs.db", - ".idea", - "venv", -] -_COLOR_ESCAPE = "\x1b[{}m" -_COLORS = { - "black": "30", - "red": "31", - "green": "4;32", - "yellow": "3;33", - "blue": "34", - "magenta": "35", - "cyan": "1;36", - "grey": "37", - "endc": "0", -} - - -def _setup_colors(): - """ - Return True if the running system's terminal supports color, - and False otherwise. - """ - - def vt_codes_enabled_in_windows_registry(): - """ - Check the Windows registry to see if VT code handling has been enabled by default. - """ - try: - import winreg - except: - return False - else: - reg_key = winreg.OpenKey( - winreg.HKEY_CURRENT_USER, "Console", access=winreg.KEY_ALL_ACCESS - ) - try: - reg_key_value, _ = winreg.QueryValueEx(reg_key, "VirtualTerminalLevel") - except FileNotFoundError: - try: - winreg.SetValueEx( - reg_key, "VirtualTerminalLevel", 0, winreg.KEY_DWORD, 1 - ) - except: - return False - else: - reg_key_value, _ = winreg.QueryValueEx( - reg_key, "VirtualTerminalLevel" - ) - else: - return reg_key_value == 1 - - def is_a_tty(): - return hasattr(sys.stdout, "isatty") and sys.stdout.isatty() - - def legacy_support(): - console = 0 - color = 0 - if sys.platform in ["linux", "linux2", "darwin"]: - pass - elif sys.platform == "win32": - color = os.system("color") - - from ctypes import windll - - k = windll.kernel32 - console = k.SetConsoleMode(k.GetStdHandle(-11), 7) - - return any([color == 1, console == 1]) - - return any( - [ - is_a_tty(), - sys.platform != "win32", - "ANSICON" in os.environ, - "WT_SESSION" in os.environ, - os.environ.get("TERM_PROGRAM") == "vscode", - vt_codes_enabled_in_windows_registry(), - legacy_support(), - ] - ) - - -_SUPPORTS_COLOR = _setup_colors() - - -def color_text(text, color): - """ - Return an ANSI-colored string, if supported. - """ - - return ( - '{}{}{}'.format( - _COLOR_ESCAPE.format(_COLORS[color]), - text, - _COLOR_ESCAPE.format(_COLORS["endc"]), - ) - if _SUPPORTS_COLOR - else text - ) - - -def convert_bytes(num): - """ - this function will convert bytes to MB.... GB... etc - """ - for x in ['bytes', 'KB', 'MB', 'GB', 'TB']: - if num < 1024.0: - return "%3.1f %s" % (num, x) - num /= 1024.0 - - -class Generator: - """ - Generates a new addons.xml file from each addons addon.xml file - and a new addons.xml.md5 hash file. Must be run from the root of - the checked-out repo. - """ - - def __init__(self, release): - self.release_path = release - self.zips_path = os.path.join(self.release_path, "zips") - addons_xml_path = os.path.join(self.zips_path, "addons.xml") - md5_path = os.path.join(self.zips_path, "addons.xml.md5") - - if not os.path.exists(self.zips_path): - os.makedirs(self.zips_path) - - self._remove_binaries() - - if self._generate_addons_file(addons_xml_path): - print( - "Successfully updated {}".format(color_text(addons_xml_path, 'yellow')) - ) - - if self._generate_md5_file(addons_xml_path, md5_path): - print("Successfully updated {}".format(color_text(md5_path, 'yellow'))) - - def _remove_binaries(self): - """ - Removes any and all compiled Python files before operations. - """ - - for parent, dirnames, filenames in os.walk(self.release_path): - for fn in filenames: - if fn.lower().endswith("pyo") or fn.lower().endswith("pyc"): - compiled = os.path.join(parent, fn) - try: - os.remove(compiled) - print( - "Removed compiled python file: {}".format( - color_text(compiled, 'green') - ) - ) - except: - print( - "Failed to remove compiled python file: {}".format( - color_text(compiled, 'red') - ) - ) - for dir in dirnames: - if "pycache" in dir.lower(): - compiled = os.path.join(parent, dir) - try: - shutil.rmtree(compiled) - print( - "Removed __pycache__ cache folder: {}".format( - color_text(compiled, 'green') - ) - ) - except: - print( - "Failed to remove __pycache__ cache folder: {}".format( - color_text(compiled, 'red') - ) - ) - - def _create_zip(self, folder, addon_id, version): - """ - Creates a zip file in the zips directory for the given addon. - """ - addon_folder = os.path.join(self.release_path, folder) - zip_folder = os.path.join(self.zips_path, addon_id) - if not os.path.exists(zip_folder): - os.makedirs(zip_folder) - - final_zip = os.path.join(zip_folder, "{0}-{1}.zip".format(addon_id, version)) - if not os.path.exists(final_zip): - zip = zipfile.ZipFile(final_zip, "w", compression=zipfile.ZIP_DEFLATED) - root_len = len(os.path.dirname(os.path.abspath(addon_folder))) - - for root, dirs, files in os.walk(addon_folder): - # remove any unneeded artifacts - for i in IGNORE: - if i in dirs: - try: - dirs.remove(i) - except: - pass - for f in files: - if f.startswith(i): - try: - files.remove(f) - except: - pass - - archive_root = os.path.abspath(root)[root_len:] - - for f in files: - fullpath = os.path.join(root, f) - archive_name = os.path.join(archive_root, f) - zip.write(fullpath, archive_name, zipfile.ZIP_DEFLATED) - - zip.close() - size = convert_bytes(os.path.getsize(final_zip)) - print( - "Zip created for {} ({}) - {}".format( - color_text(addon_id, 'cyan'), - color_text(version, 'green'), - color_text(size, 'yellow'), - ) - ) - - def _copy_meta_files(self, addon_id, addon_folder): - """ - Copy the addon.xml and relevant art files into the relevant folders in the repository. - """ - - tree = ElementTree.parse(os.path.join(self.release_path, addon_id, "addon.xml")) - root = tree.getroot() - - copyfiles = ["addon.xml"] - for ext in root.findall("extension"): - if ext.get("point") in ["xbmc.addon.metadata", "kodi.addon.metadata"]: - assets = ext.find("assets") - if not assets: - continue - for art in [a for a in assets if a.text]: - copyfiles.append(os.path.normpath(art.text)) - - src_folder = os.path.join(self.release_path, addon_id) - for file in copyfiles: - addon_path = os.path.join(src_folder, file) - if not os.path.exists(addon_path): - continue - - zips_path = os.path.join(addon_folder, file) - asset_path = os.path.split(zips_path)[0] - if not os.path.exists(asset_path): - os.makedirs(asset_path) - - shutil.copy(addon_path, zips_path) - def _generate_addons_file(self, addons_xml_path): - """ - Generates a zip for each found addon, and updates the addons.xml file accordingly. - """ - if not os.path.exists(addons_xml_path): - addons_root = ElementTree.Element('addons') - addons_xml = ElementTree.ElementTree(addons_root) - else: - addons_xml = ElementTree.parse(addons_xml_path) - addons_root = addons_xml.getroot() - - folders = [ - i - for i in os.listdir(self.release_path) - if os.path.isdir(os.path.join(self.release_path, i)) - and i != "zips" - and not i.startswith(".") - and os.path.exists(os.path.join(self.release_path, i, "addon.xml")) - ] - - addon_xpath = "addon[@id='{}']" - changed = False - for addon in folders: - try: - addon_xml_path = os.path.join(self.release_path, addon, "addon.xml") - addon_xml = ElementTree.parse(addon_xml_path) - addon_root = addon_xml.getroot() - id = addon_root.get('id') - version = addon_root.get('version') - - updated = False - addon_entry = addons_root.find(addon_xpath.format(id)) - if addon_entry is not None and addon_entry.get('version') != version: - index = addons_root.findall('addon').index(addon_entry) - addons_root.remove(addon_entry) - addons_root.insert(index, addon_root) - updated = True - changed = True - elif addon_entry is None: - addons_root.append(addon_root) - updated = True - changed = True - - if updated: - # Create the zip files - self._create_zip(addon, id, version) - self._copy_meta_files(addon, os.path.join(self.zips_path, id)) - except Exception as e: - print( - "Excluding {}: {}".format( - color_text(addon, 'yellow'), color_text(e, 'red') - ) - ) - - if changed: - addons_root[:] = sorted(addons_root, key=lambda addon: addon.get('id')) +AddonMetadata = collections.namedtuple( + 'AddonMetadata', ('id', 'version', 'root')) +WorkerResult = collections.namedtuple( + 'WorkerResult', ('addon_metadata', 'exc_info')) +AddonWorker = collections.namedtuple('AddonWorker', ('thread', 'result_slot')) + + +INFO_BASENAME = 'addon.xml' +METADATA_BASENAMES = ( + INFO_BASENAME, + 'icon.png', + 'fanart.jpg', + 'LICENSE.txt') + + +# The specification for version numbers is at http://semver.org/. +# The Kodi documentation at +# http://kodi.wiki/index.php?title=Addon.xml&oldid=128873#How_versioning_works +# adds a twist by recommending a tilde instead of a hyphen. +VERSION_PATTERN = (r'^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)' + r'(?:[-~]((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)' + r'(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?' + r'(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$') + + +def get_archive_basename(addon_metadata): + return '{}-{}.zip'.format(addon_metadata.id, addon_metadata.version) + + +def get_metadata_basenames(addon_metadata): + return ([(basename, basename) for basename in METADATA_BASENAMES] + + [( + 'changelog.txt', + 'changelog-{}.txt'.format(addon_metadata.version))]) + + +def is_url(addon_location): + return bool(re.match(r'[A-Za-z0-9+.-]+://.', addon_location)) + + +def get_posix_path(path): + return path.replace(os.path.sep, '/') + + +def samefile(a, b): + try: + return os.path.samefile(a, b) + except AttributeError: + return (os.path.normcase(os.path.normpath(a)) == + os.path.normcase(os.path.normpath(b))) + + +def parse_metadata(metadata_file): + # Parse the addon.xml metadata. + try: + tree = xml.etree.ElementTree.parse(metadata_file) + except IOError: + raise RuntimeError( + 'Cannot open add-on metadata: {}'.format(metadata_file)) + root = tree.getroot() + addon_metadata = AddonMetadata( + root.get('id'), + root.get('version'), + root) + # Validate the add-on ID. + if (addon_metadata.id is None or + re.search(r'[^a-z0-9._-]', addon_metadata.id)): + raise RuntimeError('Invalid add-on ID: {}'.format(addon_metadata.id)) + if (addon_metadata.version is None or + not re.match(VERSION_PATTERN, addon_metadata.version)): + raise RuntimeError( + 'Invalid add-on verson: {}'.format(addon_metadata.version)) + return addon_metadata + + +def generate_checksum(archive_path, is_binary=True, checksum_path_opt=None): + checksum_path = ('{}.md5'.format(archive_path) + if checksum_path_opt is None else checksum_path_opt) + checksum_dirname = os.path.dirname(checksum_path) + archive_relpath = os.path.relpath(archive_path, checksum_dirname) + + checksum = hashlib.md5() + with open(archive_path, 'rb') as archive_contents: + for chunk in iter(lambda: archive_contents.read(2**12), b''): + checksum.update(chunk) + digest = checksum.hexdigest() + + binary_marker = '*' if is_binary else ' ' + # Force a UNIX line ending, like the md5sum utility. + with io.open(checksum_path, 'w', newline='\n') as sig: + sig.write(u'{} {}{}\n'.format(digest, binary_marker, archive_relpath)) + + +def copy_metadata_files(source_folder, addon_target_folder, addon_metadata): + for (source_basename, target_basename) in get_metadata_basenames( + addon_metadata): + source_path = os.path.join(source_folder, source_basename) + if os.path.isfile(source_path): + shutil.copyfile( + source_path, + os.path.join(addon_target_folder, target_basename)) + + +def on_remove_error(function, path, excinfo): + exc_info_value = excinfo[1] + if (hasattr(exc_info_value, 'errno') and + exc_info_value.errno == errno.EACCES): + os.chmod(path, stat.S_IWUSR) + function(path) + else: + raise + + +def fetch_addon_from_git(addon_location, target_folder): + # Parse the format "REPOSITORY_URL#BRANCH:PATH". The colon is a delimiter + # unless it looks more like a scheme, (e.g., "http://"). + match = re.match( + r'((?:[A-Za-z0-9+.-]+://)?.*?)(?:#([^#]*?))?(?::([^:]*))?$', + addon_location) + (clone_repo, clone_branch, clone_path_option) = match.groups() + clone_path = (get_posix_path(os.path.join('.', '')) + if clone_path_option is None else clone_path_option) + + # Create a temporary folder for the git clone. + clone_folder = tempfile.mkdtemp('-repo') + try: + # Check out the sources. + cloned = git.Repo.clone_from(clone_repo, clone_folder) + if clone_branch is not None: + cloned.git.checkout(clone_branch) + clone_source_folder = os.path.join(clone_folder, clone_path) + + metadata_path = os.path.join(clone_source_folder, INFO_BASENAME) + addon_metadata = parse_metadata(metadata_path) + addon_target_folder = os.path.join(target_folder, addon_metadata.id) + + # Create the compressed add-on archive. + if not os.path.isdir(addon_target_folder): + os.mkdir(addon_target_folder) + archive_path = os.path.join( + addon_target_folder, get_archive_basename(addon_metadata)) + with open(archive_path, 'wb') as archive: + cloned.archive( + archive, + treeish='HEAD:{}'.format(clone_path), + prefix=get_posix_path(os.path.join(addon_metadata.id, '')), + format='zip') + generate_checksum(archive_path) + + copy_metadata_files( + clone_source_folder, addon_target_folder, addon_metadata) + + return addon_metadata + finally: + shutil.rmtree( + clone_folder, + ignore_errors=False, + onerror=on_remove_error) + + +def fetch_addon_from_folder(raw_addon_location, target_folder): + addon_location = os.path.expanduser(raw_addon_location) + metadata_path = os.path.join(addon_location, INFO_BASENAME) + addon_metadata = parse_metadata(metadata_path) + addon_target_folder = os.path.join(target_folder, addon_metadata.id) + + # Create the compressed add-on archive. + if not os.path.isdir(addon_target_folder): + os.mkdir(addon_target_folder) + archive_path = os.path.join( + addon_target_folder, get_archive_basename(addon_metadata)) + with zipfile.ZipFile( + archive_path, + 'w', + compression=zipfile.ZIP_DEFLATED, + ) as archive: + for (root, _, files) in os.walk(addon_location): + relative_root = os.path.join( + addon_metadata.id, os.path.relpath(root, addon_location)) + archive.write(root, relative_root) + for relative_path in files: + source_path = os.path.realpath( + os.path.join(root, relative_path)) + if source_path == archive_path: + # Don't include the archive within itself. + continue + archive.write( + source_path, + os.path.join(relative_root, relative_path)) + generate_checksum(archive_path) + + if not samefile(addon_location, addon_target_folder): + copy_metadata_files( + addon_location, addon_target_folder, addon_metadata) + + return addon_metadata + + +def fetch_addon_from_zip(raw_addon_location, target_folder): + addon_location = os.path.expanduser(raw_addon_location) + with zipfile.ZipFile( + addon_location, + compression=zipfile.ZIP_DEFLATED, + ) as archive: + # Find out the name of the archive's root folder. + roots = frozenset( + next(iter(path.split('/')), '') + for path in archive.namelist()) + if len(roots) != 1: + raise RuntimeError('Archive should contain one directory') + root = next(iter(roots)) + + metadata_file = archive.open( + get_posix_path(os.path.join(root, INFO_BASENAME))) + addon_metadata = parse_metadata(metadata_file) + addon_target_folder = os.path.join(target_folder, addon_metadata.id) + + # Copy the metadata files. + if not os.path.isdir(addon_target_folder): + os.mkdir(addon_target_folder) + for (source_basename, target_basename) in get_metadata_basenames( + addon_metadata): try: - addons_xml.write( - addons_xml_path, encoding="utf-8", xml_declaration=True - ) - - return changed - except Exception as e: - print( - "An error occurred updating {}!\n{}".format( - color_text(addons_xml_path, 'yellow'), color_text(e, 'red') - ) - ) - - def _generate_md5_file(self, addons_xml_path, md5_path): - """ - Generates a new addons.xml.md5 file. - """ - try: - with open(addons_xml_path, "r", encoding="utf-8") as f: - m = hashlib.md5(f.read().encode("utf-8")).hexdigest() - self._save_file(m, file=md5_path) - - return True - except Exception as e: - print( - "An error occurred updating {}!\n{}".format( - color_text(md5_path, 'yellow'), color_text(e, 'red') - ) - ) - - def _save_file(self, data, file): - """ - Saves a file. - """ + source_file = archive.open( + get_posix_path(os.path.join(root, source_basename))) + except KeyError: + continue + with open( + os.path.join(addon_target_folder, target_basename), + 'wb', + ) as target_file: + shutil.copyfileobj(source_file, target_file) + + # Copy the archive. + archive_basename = get_archive_basename(addon_metadata) + archive_path = os.path.join(addon_target_folder, archive_basename) + addon_source_folder = os.path.dirname(addon_location) or '.' + if (not samefile(addon_source_folder, addon_target_folder) or + os.path.basename(addon_location) != archive_basename): + shutil.copyfile(addon_location, archive_path) + generate_checksum(archive_path) + + return addon_metadata + + +def fetch_addon(addon_location, target_folder): + if is_url(addon_location): + addon_metadata = fetch_addon_from_git(addon_location, target_folder) + elif os.path.isdir(addon_location): + addon_metadata = fetch_addon_from_folder(addon_location, target_folder) + elif os.path.isfile(addon_location): + addon_metadata = fetch_addon_from_zip(addon_location, target_folder) + else: + raise RuntimeError('Path not found: {}'.format(addon_location)) + return addon_metadata + + +def fetch_addon_to_result_slot(addon_location, target_folder, result_slot): + try: + addon_metadata = fetch_addon(addon_location, target_folder) + result_slot.append(WorkerResult(addon_metadata, None)) + except Exception: + result_slot.append(WorkerResult(None, sys.exc_info())) + + +def get_addon_worker(addon_location, target_folder): + result_slot = [] + thread = threading.Thread(target=lambda: fetch_addon_to_result_slot( + addon_location, target_folder, result_slot)) + return AddonWorker(thread, result_slot) + + +def create_repository( + addon_locations, + data_path, + info_path, + checksum_path, + is_compressed, + no_parallel): + # Import git lazily. + if any(is_url(addon_location) for addon_location in addon_locations): try: - with open(file, "w") as f: - f.write(data) - except Exception as e: - print( - "An error occurred saving {}!\n{}".format( - color_text(file, 'yellow'), color_text(e, 'red') - ) - ) + global git + import git + except ImportError: + raise RuntimeError( + 'Please install GitPython: pip install gitpython') + + # Create the target folder. + target_folder = os.path.realpath(data_path) + if not os.path.isdir(target_folder): + os.mkdir(target_folder) + + if no_parallel or len(addon_locations) <= 1: + metadata = [ + fetch_addon(addon_location, target_folder) + for addon_location in addon_locations] + else: + # Fetch all the add-on sources in parallel. + workers = [ + get_addon_worker(addon_location, target_folder) + for addon_location in addon_locations] + for worker in workers: + worker.thread.start() + for worker in workers: + worker.thread.join() + + # Collect the results from all the threads. + metadata = [] + for worker in workers: + try: + result = next(iter(worker.result_slot)) + except StopIteration: + raise RuntimeError('Add-on worker did not report result') + if result.exc_info is not None: + raise result.exc_info[1] + metadata.append(result.addon_metadata) + + # Generate the addons.xml file. + root = xml.etree.ElementTree.Element('addons') + for addon_metadata in metadata: + root.append(addon_metadata.root) + tree = xml.etree.ElementTree.ElementTree(root) + if is_compressed: + info_file = gzip.open(info_path, 'wb') + else: + info_file = open(info_path, 'wb') + with info_file: + tree.write(info_file, encoding='UTF-8', xml_declaration=True) + is_binary = is_compressed + generate_checksum(info_path, is_binary, checksum_path) + + +def main(): + parser = argparse.ArgumentParser( + description='Create a Kodi add-on repository from add-on sources') + parser.add_argument( + '--datadir', + '-d', + default='.', + help='Path to place the add-ons [current directory]') + parser.add_argument( + '--info', + '-i', + help='''Path for the addons.xml file [DATADIR/addons.xml or + DATADIR/addons.xml.gz if compressed]''') + parser.add_argument( + '--checksum', + '-c', + help='Path for the addons.xml.md5 file [INFO.md5]') + parser.add_argument( + '--compressed', + '-z', + action='store_true', + help='Compress addons.xml with gzip') + parser.add_argument( + '--no-parallel', + '-n', + action='store_true', + help='''Build add-on sources serially, + which also makes error diagnosis easier''') + parser.add_argument( + 'addon', + nargs='*', + metavar='ADDON', + help='''Location of the add-on: either a path to a local folder or + to a zip archive or a URL for a Git repository with the + format REPOSITORY_URL#BRANCH:PATH''') + args = parser.parse_args() + + data_path = os.path.expanduser(args.datadir) + if args.info is None: + if args.compressed: + info_basename = 'addons.xml.gz' + else: + info_basename = 'addons.xml' + info_path = os.path.join(data_path, info_basename) + else: + info_path = os.path.expanduser(args.info) + checksum_path = ( + os.path.expanduser(args.checksum) if args.checksum is not None + else '{}.md5'.format(info_path)) + create_repository( + args.addon, + data_path, + info_path, + checksum_path, + args.compressed, + args.no_parallel) if __name__ == "__main__": - for release in [r for r in KODI_VERSIONS if os.path.exists(r)]: - Generator(release) \ No newline at end of file + main() \ No newline at end of file diff --git a/repository.midarr-1.0.0.zip b/repository.midarr-1.0.0.zip index e36afcb..a11fb9b 100644 Binary files a/repository.midarr-1.0.0.zip and b/repository.midarr-1.0.0.zip differ diff --git a/repository/zips/repository.midarr/addon.xml b/repository.midarr/addon.xml similarity index 58% rename from repository/zips/repository.midarr/addon.xml rename to repository.midarr/addon.xml index c586569..6330a21 100644 --- a/repository/zips/repository.midarr/addon.xml +++ b/repository.midarr/addon.xml @@ -2,14 +2,16 @@ - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/ + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository.midarr/addons.xml + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository.midarr/addons.xml.md5 + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository.midarr/ + false - - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/ + + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/plugin.video.midarr/addons.xml + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/plugin.video.midarr/addons.xml.md5 + https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/plugin.video.midarr/ + false diff --git a/repository.midarr/fanart.jpg b/repository.midarr/fanart.jpg new file mode 100644 index 0000000..c3fadbd Binary files /dev/null and b/repository.midarr/fanart.jpg differ diff --git a/repository/repository.midarr/logo.png b/repository.midarr/icon.png similarity index 100% rename from repository/repository.midarr/logo.png rename to repository.midarr/icon.png diff --git a/repository/zips/repository.midarr/repository.midarr-1.0.0.zip b/repository.midarr/repository.midarr-1.0.0.zip similarity index 53% rename from repository/zips/repository.midarr/repository.midarr-1.0.0.zip rename to repository.midarr/repository.midarr-1.0.0.zip index 33f4f13..a11fb9b 100644 Binary files a/repository/zips/repository.midarr/repository.midarr-1.0.0.zip and b/repository.midarr/repository.midarr-1.0.0.zip differ diff --git a/repository.midarr/repository.midarr-1.0.0.zip.md5 b/repository.midarr/repository.midarr-1.0.0.zip.md5 new file mode 100644 index 0000000..d6669cf --- /dev/null +++ b/repository.midarr/repository.midarr-1.0.0.zip.md5 @@ -0,0 +1 @@ +2cac180d3eb81cbe7ed1b9b1063e59c3 *repository.midarr-1.0.0.zip diff --git a/repository/repository.midarr/addon.xml b/repository/repository.midarr/addon.xml deleted file mode 100644 index c586569..0000000 --- a/repository/repository.midarr/addon.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/ - - - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/ - - - - Kodi repository for Midarr addons - Kodi repository for Midarr addons - all - - icon.png - - - \ No newline at end of file diff --git a/repository/zips/addons.xml b/repository/zips/addons.xml deleted file mode 100644 index 0fd9968..0000000 --- a/repository/zips/addons.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/repository/zips/ - - - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/addons.xml.md5 - https://raw.githubusercontent.com/midarrlabs/repository.midarr/main/nexus/zips/ - - - - Kodi repository for Midarr addons - Kodi repository for Midarr addons - all - - icon.png - - - \ No newline at end of file diff --git a/repository/zips/addons.xml.md5 b/repository/zips/addons.xml.md5 deleted file mode 100644 index 5e7e8eb..0000000 --- a/repository/zips/addons.xml.md5 +++ /dev/null @@ -1 +0,0 @@ -dd4e4b3079c8b4a87bc32dfa422f19bd \ No newline at end of file