From 4190487d7895cbf687af67018ddfc3022f7500ac Mon Sep 17 00:00:00 2001 From: Emil Svensson Date: Sat, 5 Oct 2024 10:29:31 +0200 Subject: [PATCH] [script.module.inputstreamhelper] 0.7.0 (#2650) --- script.module.inputstreamhelper/README.md | 5 + script.module.inputstreamhelper/addon.xml | 7 +- .../lib/inputstreamhelper/__init__.py | 85 ++-- .../lib/inputstreamhelper/config.py | 8 +- .../lib/inputstreamhelper/unsquash.py | 441 ++++++++++++++++++ .../lib/inputstreamhelper/utils.py | 92 ++-- .../lib/inputstreamhelper/widevine/arm.py | 31 +- .../inputstreamhelper/widevine/arm_lacros.py | 77 +++ .../lib/inputstreamhelper/widevine/repo.py | 76 +++ .../inputstreamhelper/widevine/widevine.py | 82 +--- .../resource.language.en_gb/strings.po | 8 + 11 files changed, 769 insertions(+), 143 deletions(-) create mode 100644 script.module.inputstreamhelper/lib/inputstreamhelper/unsquash.py create mode 100644 script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm_lacros.py create mode 100644 script.module.inputstreamhelper/lib/inputstreamhelper/widevine/repo.py diff --git a/script.module.inputstreamhelper/README.md b/script.module.inputstreamhelper/README.md index 93c5e8eb6..3ae5aaa21 100644 --- a/script.module.inputstreamhelper/README.md +++ b/script.module.inputstreamhelper/README.md @@ -91,6 +91,11 @@ Please report any issues or bug reports on the [GitHub Issues](https://github.co This module is licensed under the **The MIT License**. Please see the [LICENSE.txt](LICENSE.txt) file for details. ## Releases +### v0.7.0 (2024-09-24) +- Get rid of distutils dependency (@horstle, @emilsvennesson) +- Option to get Widevine from lacros image (@horstle) +- Remove support for Python 2 and pre-Matrix Kodi versions (@horstle) + ### v0.6.1 (2023-05-30) - Performance improvements on Linux ARM (@horstle) - This will be the last release for Python 2 i.e. Kodi 18 (Leia) and below. The next release will require Python 3 and Kodi 19 (Matrix) or higher. diff --git a/script.module.inputstreamhelper/addon.xml b/script.module.inputstreamhelper/addon.xml index 610b2a195..991080ac7 100644 --- a/script.module.inputstreamhelper/addon.xml +++ b/script.module.inputstreamhelper/addon.xml @@ -1,5 +1,5 @@ - + @@ -25,6 +25,11 @@ Jednostavan Kodi modul koji olakšava razvijanje dodataka koji se temelje na InputStream dodatku i reprodukciji DRM zaštićenog sadržaja. Простой модуль для Kodi, который облегчает жизнь разработчикам дополнений, с использованием InputStream дополнений и воспроизведения DRM контента. +v0.7.0 (2024-09-24) +- Get rid of distutils dependency +- Option to get Widevine from lacros image +- Remove support for Python 2 and pre-Matrix Kodi versions + v0.6.1 (2023-05-30) - Performance improvements on Linux ARM - This will be the last release for Python 2 i.e. Kodi 18 (Leia) and below. The next release will require Python 3 and Kodi 19 (Matrix) or higher. diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/__init__.py b/script.module.inputstreamhelper/lib/inputstreamhelper/__init__.py index e1260fdb2..6068d79d5 100644 --- a/script.module.inputstreamhelper/lib/inputstreamhelper/__init__.py +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/__init__.py @@ -4,15 +4,20 @@ from __future__ import absolute_import, division, unicode_literals import os +import json from . import config from .kodiutils import (addon_version, browsesingle, delete, exists, get_proxies, get_setting, get_setting_bool, get_setting_float, get_setting_int, jsonrpc, kodi_to_ascii, kodi_version, listdir, localize, log, notification, ok_dialog, progress_dialog, select_dialog, set_setting, set_setting_bool, textviewer, translate_path, yesno_dialog) -from .utils import arch, download_path, http_download, parse_version, remove_tree, store, system_os, temp_path, unzip, userspace64 -from .widevine.arm import dl_extract_widevine, extract_widevine, install_widevine_arm -from .widevine.widevine import (backup_path, cdm_from_repo, choose_widevine_from_repo, has_widevinecdm, ia_cdm_path, install_cdm_from_backup, latest_widevine_available_from_repo, - latest_widevine_version, load_widevine_config, missing_widevine_libs, widevines_available_from_repo, widevine_config_path, widevine_eula, widevinecdm_path) +from .utils import arch, download_path, http_download, parse_version, remove_tree, system_os, temp_path, unzip, userspace64 +from .widevine.arm import dl_extract_widevine_chromeos, extract_widevine_chromeos, install_widevine_arm +from .widevine.arm_lacros import cdm_from_lacros, latest_lacros +from .widevine.widevine import (backup_path, has_widevinecdm, ia_cdm_path, + install_cdm_from_backup, latest_widevine_version, + load_widevine_config, missing_widevine_libs, widevine_config_path, + widevine_eula, widevinecdm_path) +from .widevine.repo import cdm_from_repo, choose_widevine_from_repo, latest_widevine_available_from_repo from .unicodes import compat_path # NOTE: Work around issue caused by platform still using os.popen() @@ -179,17 +184,20 @@ def _install_widevine_from_repo(bpath, choose_version=False): cdm = choose_widevine_from_repo() else: cdm = latest_widevine_available_from_repo() + + if not cdm: + return cdm + cdm_version = cdm.get('version') + dl_path = download_path(cdm.get('url')) - if not exists(download_path(cdm.get('url'))): - downloaded = http_download(cdm.get('url')) - else: - downloaded = True + if not exists(dl_path): + dl_path = http_download(cdm.get('url')) - if downloaded: + if dl_path: progress = progress_dialog() progress.create(heading=localize(30043), message=localize(30044)) # Extracting Widevine CDM - unzip(store('download_path'), os.path.join(bpath, cdm_version, '')) + unzip(dl_path, os.path.join(bpath, cdm_version, '')) return (progress, cdm_version) @@ -243,7 +251,7 @@ def install_widevine(self, choose_version=False): def install_widevine_from(self): """Install Widevine from a given URL or file.""" if yesno_dialog(None, localize(30066)): # download resource with widevine from url? no means specify local - result = dl_extract_widevine(get_setting("image_url"), backup_path()) + result = dl_extract_widevine_chromeos(get_setting("image_url"), backup_path()) if not result: return result @@ -256,7 +264,7 @@ def install_widevine_from(self): return False image_version = os.path.basename(image_path).split("_")[1] - progress = extract_widevine(backup_path(), image_path, image_version) + progress = extract_widevine_chromeos(backup_path(), image_path, image_version) if not progress: return False @@ -293,6 +301,29 @@ def _first_run(): return True return False + @staticmethod + def get_current_wv(): + """Returns which component is used (widevine/chromeos/lacros) and the current version""" + wv_config = load_widevine_config() + component = 'Widevine CDM' + current_version = '0' + + if not wv_config: + log(3, 'Widevine config missing. Could not determine current version, forcing update.') + elif cdm_from_repo(): + current_version = wv_config['version'] + elif cdm_from_lacros(): + component = 'Lacros image' + try: + current_version = wv_config['img_version'] # if lib was installed from chromeos image, there is no img_version + except KeyError: + pass + else: + component = 'Chrome OS' + current_version = wv_config['version'] + + return component, current_version + def _update_widevine(self): """Prompts user to upgrade Widevine CDM when a newer version is available.""" from time import localtime, strftime, time @@ -308,21 +339,9 @@ def _update_widevine(self): log(2, 'Widevine update check was made on {date}', date=strftime('%Y-%m-%d %H:%M', localtime(last_check))) return - wv_config = load_widevine_config() - if not wv_config: - log(3, 'Widevine config missing. Could not determine current version, forcing update.') - current_version = '0' - elif cdm_from_repo(): - component = 'Widevine CDM' - current_version = wv_config['version'] - latest_version = latest_widevine_available_from_repo().get('version') - else: - component = 'Chrome OS' - current_version = wv_config['version'] - latest_version = latest_widevine_version() - if not latest_version: - log(3, 'Updating Widevine CDM failed. Could not determine latest version.') - return + component, current_version = self.get_current_wv() + + latest_version = latest_widevine_version() log(0, 'Latest {component} version is {version}', component=component, version=latest_version) log(0, 'Current {component} version installed is {version}', component=component, version=current_version) @@ -459,9 +478,11 @@ def info_dialog(self): else: wv_updated = 'Never' text += localize(30821, version=self._get_lib_version(widevinecdm_path()), date=wv_updated) + '\n' - if arch() == 'arm' or arch() == 'arm64' and system_os() != 'Darwin': # Chrome OS version + if not cdm_from_repo(): wv_cfg = load_widevine_config() - if wv_cfg: + if wv_cfg and cdm_from_lacros(): # Lacros image version + text += localize(30825, image="Lacros", version=wv_cfg['img_version']) + '\n' + elif wv_cfg: # Chrome OS version text += localize(30822, name=wv_cfg['hwidmatch'].split()[0].lstrip('^'), version=wv_cfg['version']) + '\n' if get_setting_float('last_check', 0.0): wv_check = strftime('%Y-%m-%d %H:%M', localtime(get_setting_float('last_check', 0.0))) @@ -487,7 +508,11 @@ def rollback_libwv(self): notification(localize(30004), localize(30041)) return - installed_version = load_widevine_config()['version'] + try: + installed_version = load_widevine_config()['img_version'] + except KeyError: + installed_version = load_widevine_config()['version'] + del versions[versions.index(installed_version)] if cdm_from_repo(): diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/config.py b/script.module.inputstreamhelper/lib/inputstreamhelper/config.py index 110e60223..bd5ffe3b8 100644 --- a/script.module.inputstreamhelper/lib/inputstreamhelper/config.py +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/config.py @@ -102,12 +102,16 @@ 'trogdor', ] +CHROMEOS_BLOCK_SIZE = 512 + +LACROS_DOWNLOAD_URL = "https://gsdview.appspot.com/chromeos-localmirror/distfiles/chromeos-lacros-{arch}-squash-zstd-{version}" + +LACROS_LATEST = "https://chromiumdash.appspot.com/fetch_releases?channel=Stable&platform=Lacros&num=1" + MINIMUM_INPUTSTREAM_VERSION_ARM64 = { 'inputstream.adaptive': '20.3.5', } -CHROMEOS_BLOCK_SIZE = 512 - HLS_MINIMUM_IA_VERSION = '2.0.10' ISSUE_URL = 'https://github.com/emilsvennesson/script.module.inputstreamhelper/issues' diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/unsquash.py b/script.module.inputstreamhelper/lib/inputstreamhelper/unsquash.py new file mode 100644 index 000000000..5b25e59b7 --- /dev/null +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/unsquash.py @@ -0,0 +1,441 @@ +# -*- coding: utf-8 -*- +# MIT License (see LICENSE.txt or https://opensource.org/licenses/MIT) +""" +Minimal implementation of Squashfs for extracting files from an image. + +Information sourced from: +https://dr-emann.github.io/squashfs/ +https://github.com/plougher/squashfs-tools/blob/master/squashfs-tools/squashfs_fs.h + +Assumptions made: +- Zstd is used for compression. +- Directory table consists of only one metadata block. +- There is only one file with the specific name i.e. no file of the same name in another directory. +- We only need to read inodes of basic files. +""" + +import os + +from ctypes import CDLL, c_void_p, c_size_t, create_string_buffer +from ctypes.util import find_library + +from struct import unpack, calcsize +from dataclasses import dataclass +from math import log2, ceil + +from .kodiutils import log + + +class ZstdDecompressor: # pylint: disable=too-few-public-methods + """ + zstdandard decompressor class + + It's a class to avoid having to load the zstd library for every decompression. + """ + def __init__(self): + libzstd = CDLL(find_library("zstd")) + self.zstddecomp = libzstd.ZSTD_decompress + self.zstddecomp.restype = c_size_t + self.zstddecomp.argtypes = (c_void_p, c_size_t, c_void_p, c_size_t) + self.iserror = libzstd.ZSTD_isError + + def decompress(self, comp_data, comp_size, outsize=8*2**10): + """main function, decompresses binary string """ + if len(comp_data) != comp_size: + raise IOError("Decompression failed! Length of compressed data doesn't match given size.") + + dest = create_string_buffer(outsize) + + actual_outsize = self.zstddecomp(dest, len(dest), comp_data, len(comp_data)) + if self.iserror(actual_outsize): + raise IOError(f"Decompression failed! Error code: {actual_outsize}") + return dest[:actual_outsize] # outsize is always a multiple of 8K, but real size may be smaller + + +@dataclass(frozen=True) +class SBlk: # pylint: disable=too-many-instance-attributes + """superblock as dataclass, does some checks after initialization""" + s_magic: int + inodes: int + mkfs_time: int + block_size: int + fragments: int + compression: int + block_log: int + flags: int + no_ids: int + s_major: int + s_minor: int + root_inode: int + bytes_used: int + id_table_start: int + xattr_id_table_start: int + inode_table_start: int + directory_table_start: int + fragment_table_start: int + lookup_table_start: int + + def __post_init__(self): + """Some sanity checks""" + squashfs_magic = 0x73717368 # Has to be present in every valid squashfs image + if self.s_magic != squashfs_magic: + raise IOError("Squashfs magic doesn't match!") + + if log2(self.block_size) != self.block_log: + raise IOError("block_size and block_log do not match!") + + if bool(self.flags & 0x0004): + raise IOError("Check flag should always be unset!") + + if self.s_major != 4 or self.s_minor != 0: + raise IOError("Unsupported squashfs version!") + + if self.compression != 6: + raise IOError("Image is not compressed using zstd!") + + +@dataclass(frozen=True) +class MetaDataHeader: + """ + header of metadata blocks. + + Most things are contained in metadata blocks, including: + - Compression options + - directory table + - fragment table + - file inodes + """ + compressed: bool + size: int + + +@dataclass(frozen=True) +class InodeHeader: + """squashfs_base_inode_header dataclass""" + inode_type: int + mode: int + uid: int + guid: int + mtime: int + inode_number: int + + +@dataclass(frozen=True) +class BasicFileInode: + """ + This is squashfs_reg_inode_header, but without the base inode header part + """ + start_block: int + fragment: int + offset: int + file_size: int + block_list: tuple # once we remove support for python below 3.9 this can be: tuple[int] + + +@dataclass(frozen=True) +class DirectoryHeader: + """squashfs_dir_header dataclass""" + count: int + start_block: int + inode_number: int + + +@dataclass(frozen=True) +class DirectoryEntry: + """ + Directory entry dataclass. + + This is squashfs_dir_entry in the squashfs-tools source code, + but there "itype" is called "type" and "name_size" is just "size". + + Implements __len__, giving the number of bytes of the whole entry. + """ + offset: int + inode_number: int + itype: int + name_size: int # name is 1 byte longer than given in name_size + name: bytes + + def __len__(self): + """the first four entries are 2 bytes each. name is actually one byte longer than given in name_size""" + return 8 + 1 + self.name_size + + +@dataclass(frozen=True) +class FragmentBlockEntry: + """squashfs_fragment_entry dataclass""" + start_block: int + size: int + unused: int # This field has no meaning + +class SquashFs: + """ + Main class to handle a squashfs image, find and extract files from it. + """ + def __init__(self, fpath): + self.zdecomp = ZstdDecompressor() + self.imfile = open(fpath, "rb") # pylint: disable=consider-using-with # we have our own context manager + self.sblk = self._get_sblk() + self.frag_entries = self._get_fragment_table() + log(0, "squashfs image initialized") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.imfile.close() + + def _get_sblk(self): + """ + Read and check the superblock. + """ + fmt = "<5I6H8Q" + size = calcsize(fmt) + + self.imfile.seek(0) + return SBlk(*unpack(fmt, self.imfile.read(size))) + + @staticmethod + def _fragment_block_entry(chunk): + """ + Interpret as fragment block entry. + """ + fmt = " 0: + entry = self._fragment_block_entry(data) + frag_entries.append(entry) + data = data[16:] # each entry is 16 bytes + + return tuple(frag_entries) + + @staticmethod + def _get_size(csize): + """ + For fragment entries and fragment blocks, the information if the data is compressed or not is contained in the (1 << 24) bit of the size. + """ + compressed = not bool(csize & 0x1000000) + size = csize & 0xffffff + return compressed, size + + @staticmethod + def _metadata_header(chunk): + """ + Interprets as header of a metadata block + """ + header = unpack(" as inode header. + """ + fmt = "<4H2I" + chunk = chunk[:calcsize(fmt)] + return InodeHeader(*unpack(fmt, chunk)) + + def _basic_file_inode(self, chunk): + """ + Interprets as inode of a basic file. + """ + rest_fmt = "<4I" + rest_size = calcsize(rest_fmt) + rest_chunk, block_sizes_chunk = chunk[:rest_size], chunk[rest_size:] + start_block, fragment, offset, file_size = unpack(rest_fmt, rest_chunk) + + num_blocks = ceil(file_size / self.sblk.block_size) + if fragment != 0xffffffff: # There is a fragment. In that case block_sizes is only a list of the full blocks + num_blocks -= 1 + + bsizes_fmt = f"<{num_blocks}I" + bsizes_size = calcsize(bsizes_fmt) + block_sizes_chunk = block_sizes_chunk[:bsizes_size] + block_sizes = unpack(bsizes_fmt, block_sizes_chunk) + return BasicFileInode(start_block, fragment, offset, file_size, block_sizes) + + @staticmethod + def _directory_header(chunk): + """ + Interprets as a header in the directory table. + """ + fmt = "<3I" + chunk = chunk[:calcsize(fmt)] + return DirectoryHeader(*unpack(fmt, chunk)) + + @staticmethod + def _directory_entry(chunk): + """ + Interprets as an entry in the directory table. + """ + rest_fmt = ". + """ + data = self._get_metablock(self.sblk.directory_table_start) + bname = name.encode() + + while len(data) > 0: + header = self._directory_header(data) + data = data[12:] + + for _ in range(header.count+1): + dentry = self._directory_entry(data) + if dentry.name == bname: + log(0, f"found {bname} in dentry {dentry} after dir header {header}") + return header, dentry + + data = data[len(dentry):] + + raise FileNotFoundError(f"{name} not found!") + + def _get_inode_from_pos(self, block_pos, pos_in_block): + """ + Get the inode for a basic file from the starting point of the block and the position in the block. + """ + data = self._get_metablock(block_pos) + data = data[pos_in_block:] + + header = self._inode_header(data) + data = data[16:] + + if header.inode_type == 2: # 2 is a basic file + return self._basic_file_inode(data) + + log(4, "inode types other than basic file are not implemented!") + return None + + def _get_inode(self, name): + """ + Get the inode for a basic file by its name. + """ + head_entry = self._get_dentry(name) + if not head_entry: + return head_entry + + dhead, dentry = head_entry + + block_pos = self.sblk.inode_table_start + dhead.start_block + pos_in_block = dentry.offset + + return self._get_inode_from_pos(block_pos, pos_in_block) + + def read_file_blocks(self, filename): + """ + Generator where each iteration returns a block of file as bytes. + """ + + inode = self._get_inode(filename) + + fragment = self._get_fragment(inode) + file_len = len(fragment) + + self.imfile.seek(inode.start_block) + curr_pos = self.imfile.tell() + + for bsize in inode.block_list: + compressed, size = self._get_size(bsize) + + if curr_pos != self.imfile.tell(): + log(3, "Pointer not at correct position. Moving.") + self.imfile.seek(curr_pos) + + block = self.imfile.read(size) + curr_pos = self.imfile.tell() + + if compressed: + block = self.zdecomp.decompress(block, size, self.sblk.block_size) + + file_len += len(block) + yield block + + if file_len != inode.file_size: + msg = f""" + Size of extracted file not correct. Something went wrong! + calculated file_len: {file_len}, given file_size: {inode.file_size} + """ + raise IOError(msg) + + yield fragment + + def extract_file(self, filename, target_dir): + """ + Extracts file to + """ + with open(os.path.join(target_dir, filename), "wb") as outfile: + for block in self.read_file_blocks(filename): + outfile.write(block) diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/utils.py b/script.module.inputstreamhelper/lib/inputstreamhelper/utils.py index b4b4452fd..16b6bd1b0 100644 --- a/script.module.inputstreamhelper/lib/inputstreamhelper/utils.py +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/utils.py @@ -3,25 +3,50 @@ """Implements various Helper functions""" from __future__ import absolute_import, division, unicode_literals + import os -from time import time +import re +import struct +from functools import total_ordering from socket import timeout from ssl import SSLError -import struct - - -try: # Python 3 - from urllib.error import HTTPError, URLError - from urllib.request import Request, urlopen -except ImportError: # Python 2 - from urllib2 import HTTPError, Request, URLError, urlopen +from time import time +from typing import NamedTuple +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen from . import config -from .kodiutils import (bg_progress_dialog, copy, delete, exists, get_setting, localize, log, mkdirs, - progress_dialog, set_setting, stat_file, translate_path, yesno_dialog) +from .kodiutils import (bg_progress_dialog, copy, delete, exists, get_setting, + localize, log, mkdirs, progress_dialog, set_setting, + stat_file, translate_path, yesno_dialog) from .unicodes import compat_path, from_unicode, to_unicode +@total_ordering +class Version(NamedTuple): + """Minimal version class used for parse_version. Should be enough for our purpose.""" + major: int = 0 + minor: int = 0 + micro: int = 0 + nano: int = 0 + + def __str__(self): + return f"{self.major}.{self.minor}.{self.micro}.{self.nano}" + + def __lt__(self, other): + if self.major != other.major: + return self.major < other.major + if self.minor != other.minor: + return self.minor < other.minor + if self.micro != other.micro: + return self.micro < other.micro + + return self.nano < other.nano + + def __eq__(self, other): + return all((self.major == other.major, self.minor == other.minor, self.micro == other.micro, self.nano == other.nano)) + + def temp_path(): """Return temporary path, usually ~/.kodi/userdata/addon_data/script.module.inputstreamhelper/temp/""" tmp_path = translate_path(os.path.join(get_setting('temp_path', 'special://masterprofile/addon_data/script.module.inputstreamhelper'), 'temp', '')) @@ -96,7 +121,7 @@ def http_head(url): def http_download(url, message=None, checksum=None, hash_alg='sha1', dl_size=None, background=False): # pylint: disable=too-many-statements """Makes HTTP request and displays a progress dialog on download.""" if checksum: - from hashlib import sha1, md5 + from hashlib import md5, sha1 if hash_alg == 'sha1': calc_checksum = sha1() elif hash_alg == 'md5': @@ -184,8 +209,7 @@ def http_download(url, message=None, checksum=None, hash_alg='sha1', dl_size=Non else: return False - store('download_path', dl_path) - return True + return dl_path def unzip(source, destination, file_to_unzip=None, result=[]): # pylint: disable=dangerous-default-value @@ -229,19 +253,6 @@ def system_os(): return sys_name -def store(name, val=None): - """Store arbitrary value across functions""" - - if val is not None: - setattr(store, name, val) - log(0, 'Stored {} in {}'.format(val, name)) - return val - - if not hasattr(store, name): - return None - return getattr(store, name) - - def diskspace(): """Return the free disk space available (in bytes) in temp_path.""" statvfs = os.statvfs(compat_path(temp_path())) @@ -311,7 +322,6 @@ def arch(): sys_arch = 'x86' # else, sys_arch = AMD64 elif 'armv' in sys_arch: - import re arm_version = re.search(r'\d+', sys_arch.split('v')[1]) if arm_version: sys_arch = 'armv' + arm_version.group() @@ -350,11 +360,21 @@ def remove_tree(path): rmtree(compat_path(path)) -def parse_version(version): - """Parse a version string and return a comparable version object""" - try: - from packaging.version import parse - return parse(version) - except ImportError: - from distutils.version import LooseVersion # pylint: disable=deprecated-module - return LooseVersion(version) +def parse_version(vstring): + """Parse a version string and return a comparable version object, properly handling non-numeric prefixes.""" + vstring = vstring.strip('v').lower() + parts = re.split(r'\.', vstring) # split on periods first + + vnums = [] + for part in parts: + # extract numeric part, ignoring non-numeric prefixes + numeric_part = re.search(r'\d+', part) + if numeric_part: + vnums.append(int(numeric_part.group())) + else: + vnums.append(0) # default to 0 if no numeric part found + + # ensure the version tuple always has 4 components + vnums = (vnums + [0] * 4)[:4] + + return Version(*vnums) diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm.py b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm.py index 3cd7bd0e0..c8a0a49c8 100644 --- a/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm.py +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm.py @@ -8,8 +8,9 @@ from .. import config from ..kodiutils import browsesingle, localize, log, ok_dialog, open_file, progress_dialog, yesno_dialog -from ..utils import diskspace, http_download, http_get, parse_version, sizeof_fmt, store, system_os, update_temp_path, userspace64 +from ..utils import diskspace, http_download, http_get, parse_version, sizeof_fmt, system_os, update_temp_path, userspace64 from .arm_chromeos import ChromeOSImage +from .arm_lacros import cdm_from_lacros, install_widevine_arm_lacros def select_best_chromeos_image(devices): @@ -65,8 +66,8 @@ def chromeos_config(): return json.loads(http_get(config.CHROMEOS_RECOVERY_URL)) -def install_widevine_arm(backup_path): - """Installs Widevine CDM on ARM-based architectures.""" +def install_widevine_arm_chromeos(backup_path): + """Installs Widevine CDM extracted from a Chrome OS image on ARM-based architectures.""" # Select newest and smallest ChromeOS image devices = chromeos_config() arm_device = select_best_chromeos_image(devices) @@ -96,7 +97,7 @@ def install_widevine_arm(backup_path): log(2, 'Downloading ChromeOS image for Widevine: {boardname} ({version})'.format(**arm_device)) url = arm_device['url'] - extracted = dl_extract_widevine(url, backup_path, arm_device) + extracted = dl_extract_widevine_chromeos(url, backup_path, arm_device) if extracted: recovery_file = os.path.join(backup_path, arm_device['version'], os.path.basename(config.CHROMEOS_RECOVERY_URL)) with open_file(recovery_file, 'w') as reco_file: # pylint: disable=unspecified-encoding @@ -107,22 +108,20 @@ def install_widevine_arm(backup_path): return False -def dl_extract_widevine(url, backup_path, arm_device=None): +def dl_extract_widevine_chromeos(url, backup_path, arm_device=None): """Download the ChromeOS image and extract Widevine from it""" if arm_device: - downloaded = http_download(url, message=localize(30022), checksum=arm_device['sha1'], hash_alg='sha1', + dl_path = http_download(url, message=localize(30022), checksum=arm_device['sha1'], hash_alg='sha1', dl_size=int(arm_device['zipfilesize'])) # Downloading the recovery image image_version = arm_device['version'] else: - downloaded = http_download(url, message=localize(30022)) + dl_path = http_download(url, message=localize(30022)) image_version = os.path.basename(url).split('_')[1] # minimal info for config.json, "version" is definitely needed e.g. in load_widevine_config: arm_device = {"file": os.path.basename(url), "url": url, "version": image_version} - if downloaded: - image_path = store('download_path') - - progress = extract_widevine(backup_path, image_path, image_version) + if dl_path: + progress = extract_widevine_chromeos(backup_path, dl_path, image_version) if not progress: return False @@ -135,7 +134,7 @@ def dl_extract_widevine(url, backup_path, arm_device=None): return False -def extract_widevine(backup_path, image_path, image_version): +def extract_widevine_chromeos(backup_path, image_path, image_version): """Extract Widevine from the given ChromeOS image""" progress = progress_dialog() progress.create(heading=localize(30043), message=localize(30044)) # Extracting Widevine CDM @@ -150,3 +149,11 @@ def extract_widevine(backup_path, image_path, image_version): return False return progress + + +def install_widevine_arm(backup_path): + """Wrapper for installing widevine either from Chrome browser image or Chrome OS image""" + if cdm_from_lacros(): + return install_widevine_arm_lacros(backup_path) + + return install_widevine_arm_chromeos(backup_path) diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm_lacros.py b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm_lacros.py new file mode 100644 index 000000000..c5c100cc8 --- /dev/null +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/arm_lacros.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# MIT License (see LICENSE.txt or https://opensource.org/licenses/MIT) +"""Implements ARM specific widevine functions for Lacros image""" + +import os +import json +from ctypes.util import find_library + +from .repo import cdm_from_repo +from .. import config +from ..kodiutils import exists, localize, log, mkdirs, open_file, progress_dialog +from ..utils import http_download, http_get, system_os, userspace64 +from ..unsquash import SquashFs + + +def cdm_from_lacros(): + """Whether the Widevine CDM can/should be extracted from a lacros image""" + return not cdm_from_repo() and bool(find_library("zstd")) # The lacros images are compressed with zstd + + +def latest_lacros(): + """Finds the version of the latest stable lacros image""" + latest = json.loads(http_get(config.LACROS_LATEST))[0]["version"] + log(0, f"latest lacros image version is {latest}") + return latest + + +def extract_widevine_lacros(dl_path, backup_path, img_version): + """Extract Widevine from the given Lacros image""" + progress = progress_dialog() + progress.create(heading=localize(30043), message=localize(30044)) # Extracting Widevine CDM, prepping image + + fnames = (config.WIDEVINE_CDM_FILENAME[system_os()], config.WIDEVINE_MANIFEST_FILE, "LICENSE") # Here it's not LICENSE.txt, as defined in the config.py + bpath = os.path.join(backup_path, img_version) + if not exists(bpath): + mkdirs(bpath) + + try: + with SquashFs(dl_path) as sfs: + for num, fname in enumerate(fnames): + sfs.extract_file(fname, bpath) + progress.update(int(90 / len(fnames) * (num + 1)), localize(30048)) # Extracting from image + + except (IOError, FileNotFoundError) as err: + log(4, "SquashFs raised an error") + log(4, err) + return False + + + with open_file(os.path.join(bpath, config.WIDEVINE_MANIFEST_FILE), "r") as manifest_file: + manifest_json = json.load(manifest_file) + + manifest_json.update({"img_version": img_version}) + + with open_file(os.path.join(bpath, config.WIDEVINE_MANIFEST_FILE), "w") as manifest_file: + json.dump(manifest_json, manifest_file, indent=2) + + log(0, f"Successfully extracted all files from lacros image {os.path.basename(dl_path)}") + return progress + + +def install_widevine_arm_lacros(backup_path, img_version=None): + """Installs Widevine CDM extracted from a Chrome browser SquashFS image on ARM-based architectures.""" + + if not img_version: + img_version = latest_lacros() + + url = config.LACROS_DOWNLOAD_URL.format(version=img_version, arch=("arm64" if userspace64() else "arm")) + + dl_path = http_download(url, message=localize(30072)) + + if dl_path: + progress = extract_widevine_lacros(dl_path, backup_path, img_version) + if progress: + return (progress, img_version) + + return False diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/repo.py b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/repo.py new file mode 100644 index 000000000..24cfd8f2a --- /dev/null +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/repo.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# MIT License (see LICENSE.txt or https://opensource.org/licenses/MIT) +"""Implements functions specific to systems where the widevine library is available from Google's repository""" + +from __future__ import absolute_import, division, unicode_literals + +from .. import config +from ..kodiutils import localize, log, select_dialog +from ..utils import arch, http_get, http_head, parse_version, system_os + + +def cdm_from_repo(): + """Whether the Widevine CDM is available from Google's library CDM repository""" + # Based on https://source.chromium.org/chromium/chromium/src/+/master:third_party/widevine/cdm/widevine.gni + if 'x86' in arch() or arch() == 'arm64' and system_os() == 'Darwin': + return True + return False + + +def widevines_available_from_repo(): + """Returns all available Widevine CDM versions and urls from Google's library CDM repository""" + cdm_versions = http_get(config.WIDEVINE_VERSIONS_URL).strip('\n').split('\n') + try: + cdm_os = config.WIDEVINE_OS_MAP[system_os()] + cdm_arch = config.WIDEVINE_ARCH_MAP_REPO[arch()] + except KeyError: + cdm_os = "mac" + cdm_arch = "x64" + available_cdms = [] + for cdm_version in cdm_versions: + cdm_url = config.WIDEVINE_DOWNLOAD_URL.format(version=cdm_version, os=cdm_os, arch=cdm_arch) + http_status = http_head(cdm_url) + if http_status == 200: + available_cdms.append({'version': cdm_version, 'url': cdm_url}) + + if not available_cdms: + log(4, "could not find any available cdm in repo") + + return available_cdms + + +def latest_widevine_available_from_repo(available_cdms=None): + """Returns the latest available Widevine CDM version and url from Google's library CDM repository""" + if not available_cdms: + available_cdms = widevines_available_from_repo() + + try: + latest = available_cdms[-1] # That's probably correct, but the following for loop makes sure + except IndexError: + # widevines_available_from_repo() already logged if there are no available cdms + return None + + for cdm in available_cdms: + if parse_version(cdm['version']) > parse_version(latest['version']): + latest = cdm + + return latest + + +def choose_widevine_from_repo(): + """Choose from the widevine versions available in Google's library CDM repository""" + available_cdms = widevines_available_from_repo() + latest = latest_widevine_available_from_repo(available_cdms) + + opts = tuple(cdm['version'] for cdm in available_cdms) + preselect = opts.index(latest['version']) + + version_index = select_dialog(localize(30069), opts, preselect=preselect) + if version_index == -1: + log(1, 'User did not choose a version to install!') + return False + + cdm = available_cdms[version_index] + log(0, 'User chose to install Widevine version {version} from {url}', version=cdm['version'], url=cdm['url']) + + return cdm diff --git a/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/widevine.py b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/widevine.py index 09f3b379e..a480fd4f5 100644 --- a/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/widevine.py +++ b/script.module.inputstreamhelper/lib/inputstreamhelper/widevine/widevine.py @@ -3,13 +3,19 @@ """Implements generic widevine functions used across architectures""" from __future__ import absolute_import, division, unicode_literals + import os from time import time from .. import config -from ..kodiutils import addon_profile, exists, get_setting_int, listdir, localize, log, mkdirs, ok_dialog, open_file, select_dialog, set_setting, translate_path, yesno_dialog -from ..utils import arch, cmd_exists, hardlink, http_download, http_get, http_head, parse_version, remove_tree, run_cmd, store, system_os +from ..kodiutils import (addon_profile, exists, get_setting_int, listdir, + localize, log, mkdirs, ok_dialog, open_file, + set_setting, translate_path, yesno_dialog) from ..unicodes import compat_path, to_unicode +from ..utils import (arch, cmd_exists, hardlink, http_download, parse_version, + remove_tree, run_cmd, system_os) +from .arm_lacros import cdm_from_lacros, latest_lacros +from .repo import cdm_from_repo, latest_widevine_available_from_repo def install_cdm_from_backup(version): @@ -34,30 +40,23 @@ def widevine_eula(): cdm_arch = config.WIDEVINE_ARCH_MAP_REPO[arch()] else: # Grab the license from the x86 files log(0, 'Acquiring Widevine EULA from x86 files.') - cdm_version = latest_widevine_version(eula=True) + cdm_version = '4.10.2830.0' # fine to hardcode as it's only used for the EULA cdm_os = 'mac' cdm_arch = 'x64' url = config.WIDEVINE_DOWNLOAD_URL.format(version=cdm_version, os=cdm_os, arch=cdm_arch) - downloaded = http_download(url, message=localize(30025), background=True) # Acquiring EULA - if not downloaded: + dl_path = http_download(url, message=localize(30025), background=True) # Acquiring EULA + if not dl_path: return False from zipfile import ZipFile - with ZipFile(compat_path(store('download_path'))) as archive: + with ZipFile(compat_path(dl_path)) as archive: with archive.open(config.WIDEVINE_LICENSE_FILE) as file_obj: eula = file_obj.read().decode().strip().replace('\n', ' ') return yesno_dialog(localize(30026), eula, nolabel=localize(30028), yeslabel=localize(30027)) # Widevine CDM EULA -def cdm_from_repo(): - """Whether the Widevine CDM is available from Google's library CDM repository""" - # Based on https://source.chromium.org/chromium/chromium/src/+/master:third_party/widevine/cdm/widevine.gni - if 'x86' in arch() or arch() == 'arm64' and system_os() == 'Darwin': - return True - return False - def backup_path(): """Return the path to the cdm backups""" path = os.path.join(addon_profile(), 'backup', '') @@ -71,7 +70,7 @@ def widevine_config_path(): iacdm = ia_cdm_path() if iacdm is None: return None - if cdm_from_repo(): + if cdm_from_repo() or cdm_from_lacros(): return os.path.join(iacdm, config.WIDEVINE_CONFIG_NAME) return os.path.join(iacdm, 'config.json') @@ -160,12 +159,13 @@ def missing_widevine_libs(): return None -def latest_widevine_version(eula=False): - """Returns the latest available version of Widevine CDM/Chrome OS.""" - if eula or cdm_from_repo(): - url = config.WIDEVINE_VERSIONS_URL - versions = http_get(url) - return versions.split()[-1] +def latest_widevine_version(): + """Returns the latest available version of Widevine CDM/Chrome OS/Lacros Image.""" + if cdm_from_repo(): + return latest_widevine_available_from_repo().get('version') + + if cdm_from_lacros(): + return latest_lacros() from .arm import chromeos_config, select_best_chromeos_image devices = chromeos_config() @@ -176,48 +176,6 @@ def latest_widevine_version(eula=False): return '' return arm_device.get('version') -def widevines_available_from_repo(): - """Returns all available Widevine CDM versions and urls from Google's library CDM repository""" - cdm_versions = http_get(config.WIDEVINE_VERSIONS_URL).strip('\n').split('\n') - cdm_os = config.WIDEVINE_OS_MAP[system_os()] - cdm_arch = config.WIDEVINE_ARCH_MAP_REPO[arch()] - available_cdms = [] - for cdm_version in cdm_versions: - cdm_url = config.WIDEVINE_DOWNLOAD_URL.format(version=cdm_version, os=cdm_os, arch=cdm_arch) - http_status = http_head(cdm_url) - if http_status == 200: - available_cdms.append({'version': cdm_version, 'url': cdm_url}) - - return available_cdms - -def latest_widevine_available_from_repo(available_cdms=None): - """Returns the latest available Widevine CDM version and url from Google's library CDM repository""" - if not available_cdms: - available_cdms = widevines_available_from_repo() - latest = available_cdms[-1] # That's probably correct, but the following for loop makes sure - for cdm in available_cdms: - if parse_version(cdm['version']) > parse_version(latest['version']): - latest = cdm - - return latest - -def choose_widevine_from_repo(): - """Choose from the widevine versions available in Google's library CDM repository""" - available_cdms = widevines_available_from_repo() - latest = latest_widevine_available_from_repo(available_cdms) - - opts = tuple(cdm['version'] for cdm in available_cdms) - preselect = opts.index(latest['version']) - - version_index = select_dialog(localize(30069), opts, preselect=preselect) - if version_index == -1: - log(1, 'User did not choose a version to install!') - return False - - cdm = available_cdms[version_index] - log(0, 'User chose to install Widevine version {version} from {url}', version=cdm['version'], url=cdm['url']) - - return cdm def remove_old_backups(bpath): """Removes old Widevine backups, if number of allowed backups is exceeded""" diff --git a/script.module.inputstreamhelper/resources/language/resource.language.en_gb/strings.po b/script.module.inputstreamhelper/resources/language/resource.language.en_gb/strings.po index 30e303fd8..a4d080647 100644 --- a/script.module.inputstreamhelper/resources/language/resource.language.en_gb/strings.po +++ b/script.module.inputstreamhelper/resources/language/resource.language.en_gb/strings.po @@ -281,6 +281,10 @@ msgctxt "#30071" msgid "Could not find the Widevine CDM by a quick scan. Now doing a proper search, but this might take very long..." msgstr "" +msgctxt "#30072" +msgid "Downloading the image..." +msgstr "" + ### INFORMATION DIALOG msgctxt "#30800" @@ -315,6 +319,10 @@ msgctxt "#30824" msgid "It is installed at [B]{path}[/B]" msgstr "" +msgctxt "#30825" +msgid "It was extracted from {image} image version [B]{version}[/B]" +msgstr "" + msgctxt "#30830" msgid "Please report issues to: [COLOR yellow]{url}[/COLOR]" msgstr ""