diff --git a/.github/workflows/publish-test-pypi.yml b/.github/workflows/publish-test-pypi.yml index c43388c..edbcf94 100644 --- a/.github/workflows/publish-test-pypi.yml +++ b/.github/workflows/publish-test-pypi.yml @@ -36,5 +36,5 @@ jobs: - name: Publish to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - repository_url: https://test.pypi.org/legacy/ + repository-url: https://test.pypi.org/legacy/ password: ${{ secrets.TEST_PYPI_API_TOKEN }} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 702d333..9557e09 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 andshrew +Copyright (c) 2023, 2025 andshrew Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 6cc5d17..d101a28 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ pip install ps4-updates ``` ## Typical Usage -Create a `Ps4TitleUpdate` object by specifying a PS4 Title Id (eg. `CUSA00001_00` or `CUSA00001`). +Create a `Ps4TitleUpdate` object by specifying a PS4 Title Id (eg. `CUSA00001_00` or `CUSA00001`). A list of known Title Ids is [available here](https://andshrew.github.io/PlayStation-Titles/?platform=ps4&hasContentId). Invoke `get_update()` on the object to begin retrieving information about the update. If an update is available it will try to retrieve the following: @@ -19,11 +19,12 @@ Invoke `get_update()` on the object to begin retrieving information about the up * Download Size * Update Creation Date * changeinfo.xml (developer included update notes) +* param.sfo update pkg parameters ## Limitations Only information about the current update version can be retrieved. -It is not a guarantee that changeinfo.xml will be included within the updates pkg file. The file is typically located at the start of the file, however it does not appear to be in a fixed location. This package attempts to locate it by downloading up to the first 30MB of the pkg file. You can increase (or decrease) this search range by setting `byte_limit` when creating a `Ps4TitleUpdate` object. +It is not a guarantee that `changeinfo.xml` will be included within the updates pkg file. This package attempts to locate it (and `param.sfo`) by parsing the update PKG file header. By default it will download up to the first 50MB of the PKG file, but if either the `changeinfo.xml` or `param.sfo` are located beyond this range then neither will be downloaded and no more data will be downloaded beyond the range of the PKG header. You can increase (or decrease) this limit by setting the `byte_limit` when creating a `Ps4TitleUpdate` object. ## Usage Examples @@ -49,7 +50,6 @@ title.print_update_info() #### Console Output ``` -vscode ➜ /workspaces/PS4-Updates-Python (main) $ python app.py Title Id: CUSA00001 Update Url: http://gs-sec.ww.np.dl.playstation.net/plo/np/CUSA00001/1123f23c1f00810a5e43fcb409ada7823bc5ad21b357817e314b6c4832cf6f9f/CUSA00001-ver.xml Title Name: THE PLAYROOM @@ -122,6 +122,8 @@ You can manually invoke `_get_partial_pkg_file()`, which makes the following ava | update_pkg_exists | `True` if some data was found in pkg file at `update_pkg_url` | | update_pkg_cdate | Update creation date as string YYYYMMDD | | update_pkg_cdate_as_date | Update creation date as datetime | +| update_pkg_param_sfo | An `SFO` object if the pkg param.sfo file was found and parsed | +| update_pkg_bytes_exceeded | `True` if the pkg file header was parsed but data exists beyond the range of the specified `bytes_limit` | | changeinfo_exists | `True` if changeinfo.xml was found | | changeinfo | List of dicts for each change in changeinfo.xml | | changeinfo_count | Number of changes in changeinfo.xml | @@ -129,5 +131,59 @@ You can manually invoke `_get_partial_pkg_file()`, which makes the following ava | changeinfo_current | List of dicts for change matching the current version | | changeinfo_xml | Full XML for changeinfo.xml +## `SFO` and `SFO_Entry` Object Reference +By default, if an update exists then `get_update()` will download and attempt to parse the beginning of the updates pkg file. If the [`param.sfo`](https://www.psdevwiki.com/ps4/Param.sfo) file is located then the entries that it contains will be parsed into `SFO_Entry` objects and attached to a single `SFO` object that is then accessible from the `update_pkg_param_sfo` attribute of a `Ps4TitleUpdate` object. + +For details of the information that might be in these objects, see the [Parameter Descriptions on psdevwiki.com](https://www.psdevwiki.com/ps4/Param.sfo#Parameters_Descriptions). + +The entries data (as bytes) is saved in the `SFO_Entry` objects `data_bytes` attribute. Your application will need to further parse this information into a usable format. + +### Example for parsing the SAVE_DATA_TRANSFER_TITLE_ID_LIST parameter +There will be an entry in the param.sfo file named `SAVE_DATA_TRANSFER_TITLE_ID_LIST` if the title supports reading another titles save data files (ie. for save transfer between games). + +This example will print a titles update information to the screen, and then additionally print information from this specific SFO entry (if it exists). + +```python +from ps4_updates import title as ps4up + +title = ps4up.Ps4TitleUpdate('CUSA00897') +title.get_update() +title.print_update_info() + +if title.update_pkg_exists is True: + entry = next((x for x in title.update_pkg_param_sfo.entries if x.name == "SAVE_DATA_TRANSFER_TITLE_ID_LIST"), None) + if entry is not None: + entry = entry.data_bytes.decode().strip('\x00').split('\n') + if len(entry) == 1: + print(f'This title shares save data with {len(entry)} title:') + if len(entry) > 1: + print(f'This title shares save data with {len(entry)} titles:') + if len(entry) > 0: + entry = sorted(entry, key=lambda x: x) + for i in entry: + print(f'\t{i}') +``` +#### Console Output +``` +Title Id: CUSA00897 +Update Url: http://gs-sec.ww.np.dl.playstation.net/plo/np/CUSA00897/7d49cb7e0fd38b63970664874c3f4149fd86446456cc020a6555afaa79a10239/CUSA00897-ver.xml +Title Name: inFAMOUS™ First Light +Content Id: EP9000-CUSA00897_00-FIRSTLIGHTSHIP00 +Current Version: 01.04 +Download Size: 2.38 GB +Creation Date: Fri, 18-Nov-2016 + +01.04 +- Graphics Bug Fixes + +This title shares save data with 5 titles: + CUSA00004 + CUSA00223 + CUSA00263 + CUSA00305 + CUSA00309 +``` + ## Additional Thanks -[Zer0xFF](https://gist.github.com/Zer0xFF/d94818f15e3e85b0b4d48000a4be1c73) - sharing the method for generating a title update URL \ No newline at end of file +[Zer0xFF](https://gist.github.com/Zer0xFF/d94818f15e3e85b0b4d48000a4be1c73) - sharing the method for generating a title update URL +[psdevwiki](https://www.psdevwiki.com/ps4) - documentation on [PKG file format](https://www.psdevwiki.com/ps4/PKG_files) and [param.sfo files](https://www.psdevwiki.com/ps4/Param.sfo) \ No newline at end of file diff --git a/ps4_updates/pkg.py b/ps4_updates/pkg.py new file mode 100644 index 0000000..2398bf7 --- /dev/null +++ b/ps4_updates/pkg.py @@ -0,0 +1,290 @@ +# MIT License + +# Copyright (c) 2025 andshrew +# https://github.com/andshrew/PS4-Updates-Python + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# With thanks to PS4 Developer Wiki for the information on PKG files +# PKG: https://www.psdevwiki.com/ps4/PKG_files +# param.sfo: https://www.psdevwiki.com/ps4/Param.sfo + +import logging +import struct +from dataclasses import dataclass, field +from typing import List, Optional + +def read_int8(b): + return struct.unpack('I', b.read(struct.calcsize('>I')))[0] + +def read_uint64_be(b): + return struct.unpack('>Q', b.read(struct.calcsize('>Q')))[0] + +@dataclass +class PKG_File: + """PKG File object. + + Create a PKG File object to store information about a specific file within a PKG file. + + This object should be appended to the files attribute of a PKG object. + + For more information on the data structure see: + + https://www.psdevwiki.com/ps4/PKG_files#Files + """ + id: Optional[int] = None + filename_offset: Optional[int] = None + flags1: Optional[int] = None + flags2: Optional[int] = None + offset: Optional[int] = None + size: Optional[int] = None + padding: Optional[int] = None + + def set_from_bytes(self, b): + """Set object attributes from a ReadableBuffer of bytes. + + The PKG file table is of a fixed size. This will read the supplied ReadableBuffer + and assign the values to the appropriate attributes. This data is stored in the + PKG file as big-endian. + + Attributes: + b: A ReadableBuffer of bytes pre-positioned at the location of + a PKG file table entry + """ + + self.id = read_uint32_be(b) + self.filename_offset = read_uint32_be(b) + self.flags1 = read_uint32_be(b) + self.flags2 = read_uint32_be(b) + self.offset = read_uint32_be(b) + self.size = read_uint32_be(b) + self.padding = read_uint64_be(b) + + def unpack_from_bytes(self, b): + """Set object attributes from a ReadableBuffer of bytes. + + The PKG file table is of a fixed size. This will read the supplied ReadableBuffer + and assign the values to the appropriate attributes. + + This is the same as set_from_bytes except this uses struct.unpack to read and assign + the attributes. + + Attributes: + b: A ReadableBuffer of bytes pre-positioned at the location of + a PKG file table entry + """ + + format = '>IIIIIIQ' + self.id, self.filename_offset, self.flags1, \ + self.flags2, self.offset, self.size, \ + self.padding = struct.unpack(format, b.read(struct.calcsize(format))) + +@dataclass +class PKG: + """PKG header object. + + Create a PKG header object to store a selection of the header entries from a PKG + file. The files attribute stores a list of the files found within the PKG file. + + For more information on the data structure see: + + https://www.psdevwiki.com/ps4/PKG_files#File_Header + """ + + offset: int + magic: Optional[str] = None + file_count: Optional[int] = None + entry_count: Optional[int] = None + table_offset: Optional[int] = None + body_offset: Optional[int] = None + content_offset: Optional[int] = None + files: List[PKG_File] = field(default_factory=list) + + def set_from_bytes(self, b): + """Set object attributes from a ReadableBuffer of bytes. + + The PKG file header entries are at fixed locations offset from the start of the file. + This will read the supplied ReadableBuffer and assign the values of interest to + the appropriate attributes. This data is stored in the PKG file as big-endian. + + Attributes: + b: A ReadableBuffer of bytes pre-positioned at the location of + a PKG file + """ + + self.magic = b.read(4) + if self.magic != b'\x7fCNT': + logging.error(f'The supplied ReadableBuffer is not a PKG file') + self.magic = None + return + b.seek(0x10) + self.file_count = read_uint32_be(b) + b.seek(0x18) + self.table_offset = read_uint32_be(b) + b.seek(0x20) + self.body_offset = read_uint64_be(b) + b.seek(0x30) + self.content_offset = read_uint64_be(b) + +@dataclass +class SFO_Entry: + """SFO Entry object. + + Create a SFO Entry object to store information about a specific data item within a param.sfo file. + + This object should be appended to the entries attribute of a SFO object. The data_bytes attribute + stores the raw data bytes for the entry. + + For more information on the data structure see: + + https://www.psdevwiki.com/ps4/Param.sfo#Data_table + """ + + key_table_offset: Optional[int] = None + data_format: Optional[int] = None + param_length: Optional[int] = None + param_max_length: Optional[int] = None + data_table_offset: Optional[int] = None + name: Optional[str] = None + data_bytes: Optional[bytes] = None + + def set_from_bytes(self, b): + """Set object attributes from a ReadableBuffer of bytes. + + The SFO entry is of a fixed size. This will read the supplied ReadableBuffer + and assign the values to the appropriate attributes. This data is stored in the + param.sfo file as little-endian. + + Attributes: + b: A ReadableBuffer of bytes pre-positioned at the location of + a SFO entry table item + """ + + self.key_table_offset = read_int16(b) + self.data_format = read_int16(b) + self.param_length = read_int32(b) + self.param_max_length = read_int32(b) + self.data_table_offset = read_int32(b) + + def unpack_from_bytes(self, b): + """Set object attributes from a ReadableBuffer of bytes. + + The SFO entry is of a fixed size. This will read the supplied ReadableBuffer + and assign the values to the appropriate attributes. This data is stored in the + param.sfo file as little-endian. + + This is the same as set_from_bytes except this uses struct.unpack to read and assign + the attributes. + + Attributes: + b: A ReadableBuffer of bytes pre-positioned at the location of + a SFO entry table item + """ + + format = '= 0 and cdate_found is False: - cdate_end_idx = response.find(','.encode(), cdate_start_idx, cdate_start_idx + 16) - if cdate_end_idx != -1: - logger.debug(f'cdate found at {len(response)}') - cdate_found = True - cdate = response[cdate_start_idx+7:cdate_end_idx].decode() - # Find 'changeinfo.xml' - Patch Update Notes - if changeinfo_start_idx == -1: - changeinfo_start_idx = response.find(changeinfo_start) - if changeinfo_start_idx >= 0 and changeinfo_found is False: - changeinfo_end_idx = response.find(changeinfo_end, changeinfo_start_idx) - if changeinfo_end_idx >= 0 and changeinfo_found is False: - logger.debug(f'changeinfo.xml found at {len(response)}') - changeinfo_found = True - changeinfo = response[changeinfo_start_idx:changeinfo_end_idx + len(changeinfo_end)].decode() - # Stop downloading if we have found all required information - if cdate_found == changeinfo_found == True: - logger.debug(f'cdate and changeinfo.xml found - exit download early at {len(response)}') + + if pkg_magic not in response: + # Sometimes downloads are redirected to specific CDN URL + if "302 Moved Temporarily" in response[0:100].decode(): + logger.debug(f'302 Moved Temporarily') + response_headers = response.decode().splitlines() + for i, c in enumerate(response_headers): + if "Location: " in c: + redirect_url = urlparse(response_headers[i].replace("Location: ", "")) + logger.debug(f'Trying again with URL: {redirect_url.geturl()}') + # Call this method again to try and download using the CDN URL + return self._get_partial_pkg_file(url=redirect_url.geturl(), byte_limit=byte_limit) + if pkg_magic in response: break - # Stop downloading if we have reached the byte download limit - if len(response) >= byte_limit: - logger.debug(f'changeinfo.xml NOT found - exit download at byte download limit {byte_limit} - actual bytes downloaded {len(response)}') + if bytes_rcvd >= byte_limit: + logger.error(f'PKG file header NOT found - byte limit {byte_limit} reached - actual bytes downloaded {bytes_rcvd}') + response = None break + + # The PKG file has been found in the response + if pkg_magic in response: + # Discard the initial part of the response, so that response now starts with the PKG file + offset = response.find(pkg_magic) + response = response[offset:] + pkg = PKG(offset = 0) + + # Parse the PKG header + with io.BytesIO(response) as b: + pkg.set_from_bytes(b) + + # Continue downloading data up to the end of the PKG files table + pkg_table_end = pkg.table_offset + (pkg.file_count * 32) + while pkg_table_end > len(response): + # Need more bytes + chunk = s.recv(4096) + bytes_rcvd += len(chunk) + if len(chunk) == 0: + # No more data + logger.debug(f'No more data') + break + response = response + chunk + + # Parse the PKG files table + with io.BytesIO(response) as b: + b.seek(pkg.table_offset) + for i in range(pkg.file_count): + file = PKG_File() + file.unpack_from_bytes(b) + pkg.files.append(file) + + # We can now locate files of interest, namely: + # id | File + # == | ==== + # 4096 | param.sfo (various PKG metadata including cdate) + # 4704 | changeinfo.xml (Developer patch notes) + + # Check that downloading the param.sfo and changeinfo.xml won't result in exceeding the byte_limit + check_end_location = 0 + check = file = next((x for x in pkg.files if x.id == 4704), None) + if check is not None: + if check.offset + check.size > check_end_location: + check_end_location = check.offset + check.size + check = file = next((x for x in pkg.files if x.id == 4096), None) + if check is not None: + if check.offset + check.size > check_end_location: + check_end_location = check.offset + check.size + if check_end_location == 0: + logging.info(f'Neither changeinfo.xml or param.sfo are in this PKG file') + s.close() + return + if check_end_location + bytes_rcvd >= byte_limit: + logger.error(f'changeinfo.xml or param.sfo are located beyond the current byte limit {byte_limit}. ' + \ + f'Increase {byte_limit} to at least {check_end_location + bytes_rcvd + 1} ' + \ + f'to download') + logger.debug(f'In total {bytes_to_formatted_filesize(bytes_rcvd)} of the PKG file was downloaded') + self.update_pkg_bytes_exceeded = True + s.close() + return + + # Locate and parse changeinfo.xml + file = next((x for x in pkg.files if x.id == 4704), None) + if file is None: + logger.debug(f'changeinfo.xml does not exist within the PKG file table') + if file is not None: + logger.debug(f'changeinfo.xml is located at offset {file.offset}') + changeinfo_end = file.offset + file.size + while changeinfo_end > len(response): + # Continue downloading up to the end of the changeinfo.xml files location + chunk = s.recv(16384) + bytes_rcvd += len(chunk) + if len(chunk) == 0: + logger.error(f'The server has no more data to transfer, but the end of the changeinfo.xml file has not been received yet') + s.close() + return + response = response + chunk + try: + changeinfo = response[file.offset:changeinfo_end].decode() + except Exception as ex: + logger.error(f'Unable to decode the changeinfo.xml file from the response data: {ex.args}') + changeinfo = None + + self.update_pkg_exists = True + if changeinfo is not None: + changeinfo_found = True + self.changeinfo_xml = changeinfo + self.changeinfo_exists = True + self.changeinfo = self._parse_changeinfo_xml(self.changeinfo_xml) + # When changeinfo.xml contains update notes for multiple versions, there is + # no guarantee on the order. Some developers have theirs ascending, some descending. + # Try to sort the list so that the first entry is for the latest version + self.changeinfo = sorted(self.changeinfo, key=lambda x: x['app_version'], reverse=True) + # There is no guarantee that there are notes for the current version. + # Try and find that if it exists. + current_change = list(filter(lambda x: x['app_version'] == self.version, self.changeinfo)) + if len(current_change) > 0: + self.changeinfo_current = current_change + self.changeinfo_current_exists = True + + # Locate and parse param.sfo + file = next((x for x in pkg.files if x.id == 4096), None) + if file is None: + logger.debug(f'param.sfo does not exist within the PKG file table') + if file is not None: + logger.debug(f'param.sfo is located at offset {file.offset}') + sfo_end = file.offset + file.size + while sfo_end > len(response): + # Continue downloading up to the end of the param.sfo files location + chunk = s.recv(16384) + bytes_rcvd += len(chunk) + if len(chunk) == 0: + logger.error(f'The server has no more data to transfer, but the end of the param.sfo file has not been received yet') + s.close() + return + response = response + chunk + sfo_data = response[file.offset:sfo_end] + sfo = SFO(offset_relative=file.offset) + with io.BytesIO(sfo_data) as b: + sfo.set_from_bytes(b) + b.seek(20) + # Parse the entry index + for i in range(sfo.number_of_entries): + entry = SFO_Entry() + entry.set_from_bytes(b) + sfo.entries.append(entry) + sfo.set_entries_name(b) + # Parse the entry data + for i in range(sfo.number_of_entries): + entry = sfo.entries[i] + b.seek(sfo.data_table_offset + entry.data_table_offset) + entry.data_bytes = b.read(entry.param_length) + self.update_pkg_param_sfo = sfo + self.update_pkg_exists = True + # The cdate is within param.sfo entry "PUBTOOLINFO" + # This is a comma seperated NULL terminated string of + # key pair values + pub_data = next((x for x in sfo.entries if x.name == "PUBTOOLINFO"), None) + if pub_data is None: + logger.debug(f'PUBTOOLINFO does not exist within the param.sfo entries') + if pub_data is not None: + try: + data = pub_data.data_bytes.decode() + except Exception as ex: + logger.error(f'Unable to decode PUBTOOLINFO data: {ex.args}') + data = None + + if data is not None: + data = data.split(',') + cdate = next((x for x in data if "c_date" in x), None) + if cdate is not None: + cdate = cdate[7:] + cdate_found = True + self.update_pkg_exists = True + self.update_pkg_cdate = cdate + try: + self.update_pkg_cdate_as_date = datetime.strptime(self.update_pkg_cdate, '%Y%m%d') + except Exception as ex: + logger.error(f'Unable to parse cdate into datetime: {self.update_pkg_cdate}') + self.update_pkg_cdate = None s.close() - # Sometimes downloads are redirected to specific CDN URL - if "302 Moved Temporarily" in response[0:100].decode(): - logger.debug(f'302 Moved Temporarily') - response_headers = response.decode().splitlines() - for i, c in enumerate(response_headers): - if "Location: " in c: - redirect_url = urlparse(response_headers[i].replace("Location: ", "")) - logger.debug(f'Trying again with URL: {redirect_url.geturl()}') - # Call this method again to try and download using the CDN URL - return self._get_partial_pkg_file(url=redirect_url.geturl(), byte_limit=byte_limit) - - if cdate_found is True: - self.update_pkg_exists = True - self.update_pkg_cdate = cdate - try: - self.update_pkg_cdate_as_date = datetime.strptime(self.update_pkg_cdate, '%Y%m%d') - except Exception as ex: - logger.error(f'Unable to parse cdate into datetime: {self.update_pkg_cdate}') - self.update_pkg_cdate = None - - if changeinfo_found is True: - self.update_pkg_exists = True - self.changeinfo_xml = changeinfo - self.changeinfo_exists = True - self.changeinfo = self._parse_changeinfo_xml(self.changeinfo_xml) - # When changeinfo.xml contains update notes for multiple versions, there is - # no guarantee on the order. Some developers have theirs ascending, some descending. - # Try to sort the list so that the first entry is for the latest version - self.changeinfo = sorted(self.changeinfo, key=lambda x: x['app_version'], reverse=True) - # There is no guarantee that there are notes for the current version. - # Try and find that if it exists. - current_change = list(filter(lambda x: x['app_version'] == self.version, self.changeinfo)) - if len(current_change) > 0: - self.changeinfo_current = current_change - self.changeinfo_current_exists = True + if cdate_found == changeinfo_found == True: + logger.debug(f'cdate and changeinfo.xml were found') + logger.debug(f'In total {bytes_to_formatted_filesize(bytes_rcvd)} of the PKG file was downloaded') return diff --git a/pyproject.toml b/pyproject.toml index c1fe004..c28bd6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,14 @@ build-backend = "hatchling.build" [project] name = "ps4_updates" -version = "1.0.0" +version = "1.1.0" authors = [ { name="andshrew", email="7409326+andshrew@users.noreply.github.com" }, ] description = "A simple package for accessing PS4 title update information" readme = "README.md" +license = "MIT" +license-files = ["LICEN[CS]E.*"] requires-python = ">=3.8" dependencies = [ "requests>=2.31", @@ -34,4 +36,7 @@ exclude = [ [project.urls] "Homepage" = "https://github.com/andshrew/PS4-Updates-Python" -"Bug Tracker" = "https://github.com/andshrew/PS4-Updates-Python/issues" \ No newline at end of file +"Repository" = "https://github.com/andshrew/PS4-Updates-Python.git" +"GitHub" = "https://github.com/andshrew/PS4-Updates-Python" +"Issues" = "https://github.com/andshrew/PS4-Updates-Python/issues" +"Changelog" = "https://github.com/andshrew/PS4-Updates-Python/releases" \ No newline at end of file