ps5-updates 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ps5_updates/LICENSES.txt +23 -0
- ps5_updates/__init__.py +22 -0
- ps5_updates/data.py +240 -0
- ps5_updates/pkg.py +538 -0
- ps5_updates/title.py +488 -0
- ps5_updates-0.9.0.dist-info/METADATA +140 -0
- ps5_updates-0.9.0.dist-info/RECORD +9 -0
- ps5_updates-0.9.0.dist-info/WHEEL +4 -0
- ps5_updates-0.9.0.dist-info/licenses/LICENSE +22 -0
ps5_updates/LICENSES.txt
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
https://github.com/andshrew/PS4-Updates-Python/blob/v1.1.0/LICENSE
|
|
2
|
+
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2023, 2025 andshrew
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
ps5_updates/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025 andshrew
|
|
4
|
+
# https://github.com/andshrew/PS5-Updates-Python
|
|
5
|
+
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
# copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
# SOFTWARE.
|
ps5_updates/data.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2026 andshrew
|
|
4
|
+
# https://github.com/andshrew/PS5-Updates-Python
|
|
5
|
+
|
|
6
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
# in the Software without restriction, including without limitation the rights
|
|
9
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
# furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
# copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
# SOFTWARE.
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import socket
|
|
26
|
+
import ssl
|
|
27
|
+
from urllib.parse import urlparse, ParseResult
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Optional, Union
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class HTTPSocket:
|
|
33
|
+
"""A socket based HTTP downloader
|
|
34
|
+
|
|
35
|
+
Intended for partially downloading a file from a web server using a
|
|
36
|
+
manually created HTTP/1.1 request.
|
|
37
|
+
|
|
38
|
+
Warning: HTTPS server validation is disabled to enable downloading
|
|
39
|
+
from servers using certificates issued from non-public CAs.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
port: Remote server port
|
|
43
|
+
url: URL as a ParseResult of the file to download
|
|
44
|
+
timeout: Socket timeout in seconds
|
|
45
|
+
"""
|
|
46
|
+
port: int
|
|
47
|
+
url: ParseResult
|
|
48
|
+
timeout: Optional[int] = 60
|
|
49
|
+
connection: Optional[socket.socket] = None
|
|
50
|
+
no_more_data: Optional[bool] = False
|
|
51
|
+
_is_connected: Optional[bool] = False
|
|
52
|
+
|
|
53
|
+
def __post_init__(self):
|
|
54
|
+
self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
55
|
+
self.connection.settimeout(self.timeout)
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
self.connect()
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
62
|
+
self.close()
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_url(cls, url: Union[str, ParseResult], port: int=0, timeout: int=30) -> 'HTTPSocket':
|
|
67
|
+
"""Creates an instance from a URL
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
url: URL as a String or ParseResult of the file to download
|
|
71
|
+
port: Specific remote server port
|
|
72
|
+
timeout: Socket timeout in seconds
|
|
73
|
+
"""
|
|
74
|
+
if isinstance(url, str):
|
|
75
|
+
url = urlparse(url)
|
|
76
|
+
if not isinstance(url, ParseResult):
|
|
77
|
+
raise TypeError('Url should be of type ParseResult')
|
|
78
|
+
if url.scheme != 'http' and url.scheme != 'https':
|
|
79
|
+
raise ValueError('Url should be HTTP or HTTPS')
|
|
80
|
+
if url.port is not None:
|
|
81
|
+
port = url.port
|
|
82
|
+
if url.port is None and port == 0:
|
|
83
|
+
if url.scheme == 'http':
|
|
84
|
+
port = 80
|
|
85
|
+
if url.scheme == 'https':
|
|
86
|
+
port = 443
|
|
87
|
+
|
|
88
|
+
return cls(url=url, port=port, timeout=timeout)
|
|
89
|
+
|
|
90
|
+
def connect(self):
|
|
91
|
+
"""Initiates connection to the remote server
|
|
92
|
+
"""
|
|
93
|
+
if self._is_connected:
|
|
94
|
+
logger.debug(f'This socket is already connected. Close the socket and create a new one ' +
|
|
95
|
+
f' before calling connect again')
|
|
96
|
+
return
|
|
97
|
+
self._is_connected = False
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
self.connection.connect((self.url.hostname, self.port))
|
|
101
|
+
except socket.timeout as ex:
|
|
102
|
+
logger.error(f'Socket timeout connecting to: {self.url.hostname} port {self.port}')
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
if self.url.scheme == "https":
|
|
106
|
+
# ssl.CERT_NONE will disable validating server cert (ie. no requirement for issuing CA to be in our trusted CAs)
|
|
107
|
+
# When HTTPS is used by the update servers they are typically using certificates issued by an internal CA, so
|
|
108
|
+
# this validation would fail.
|
|
109
|
+
# Ref: https://docs.python.org/3/library/ssl.html
|
|
110
|
+
context = ssl.create_default_context()
|
|
111
|
+
context.check_hostname = False
|
|
112
|
+
context.verify_mode = ssl.CERT_NONE
|
|
113
|
+
try:
|
|
114
|
+
self.connection = context.wrap_socket(self.connection, server_hostname=self.url.hostname)
|
|
115
|
+
except Exception as ex:
|
|
116
|
+
logger.error(f'Socket error {self.url.hostname} port {self.port}: {ex.args}')
|
|
117
|
+
self._is_connected = False
|
|
118
|
+
return
|
|
119
|
+
self._is_connected = True
|
|
120
|
+
|
|
121
|
+
def close(self):
|
|
122
|
+
"""Closes the socket
|
|
123
|
+
|
|
124
|
+
If a new socket is to subsequently be opened using this object then first create a
|
|
125
|
+
new socket by calling _recreate_socket
|
|
126
|
+
"""
|
|
127
|
+
if self._is_connected:
|
|
128
|
+
try:
|
|
129
|
+
self.connection.close()
|
|
130
|
+
except Exception as ex:
|
|
131
|
+
logger.error(f'Error when closing socket: {self.url.hostname} port {self.port}: {ex.args}')
|
|
132
|
+
self._is_connected = False
|
|
133
|
+
|
|
134
|
+
def initial_receive(self, magic: bytes, byte_limit: int=16384, redirect_count: int=0,
|
|
135
|
+
redirect_limit: int=5) -> 'bytes':
|
|
136
|
+
"""Starts downloading a file
|
|
137
|
+
|
|
138
|
+
Creates the initial HTTP request and handles CDN-type redirects
|
|
139
|
+
If magic bytes are found in the response the response bytes are returned aligned to the
|
|
140
|
+
location of the magic (ie. the initial response is discarded)
|
|
141
|
+
|
|
142
|
+
Attributes:
|
|
143
|
+
magic: Bytes of the files 'magic' signature
|
|
144
|
+
byte_limit: Maximum number of bytes that will be downloaded for finding the files signature
|
|
145
|
+
redirect_limit: If the connection is redirected to another server this is the maximum number of times it will be followed
|
|
146
|
+
"""
|
|
147
|
+
url = self.url
|
|
148
|
+
request = f'GET {url.path} HTTP/1.1\r\nHost:{url.hostname}\r\nConnection: close\r\n\r\n'
|
|
149
|
+
self.connection.send(request.encode())
|
|
150
|
+
|
|
151
|
+
received_data = b''
|
|
152
|
+
while len(received_data) < byte_limit:
|
|
153
|
+
response = self.receive(4096)
|
|
154
|
+
if magic in response:
|
|
155
|
+
break
|
|
156
|
+
if magic not in response:
|
|
157
|
+
# Sometimes downloads are redirected to a specific CDN URL
|
|
158
|
+
if any(s in response[0:100].decode() for s in ('302 Moved Temporarily', '302 Found') ):
|
|
159
|
+
logger.debug(f'302 redirect response received, current redirect count: {redirect_count}')
|
|
160
|
+
if redirect_count > redirect_limit:
|
|
161
|
+
self.close()
|
|
162
|
+
logger.error(f'Too many 302 redirect responses have been received, aborting')
|
|
163
|
+
break
|
|
164
|
+
# Parse the responses HTTP headers to find the redirect location
|
|
165
|
+
response_headers = response.decode().splitlines()
|
|
166
|
+
for i, header in enumerate(response_headers):
|
|
167
|
+
if "Location: " in header:
|
|
168
|
+
redirect_url = urlparse(header.replace("Location: ", ""))
|
|
169
|
+
logger.debug(f'Trying again with URL: {redirect_url.geturl()}')
|
|
170
|
+
# Close and recreate the socket, and then call
|
|
171
|
+
# this method agian using the redirected URL
|
|
172
|
+
self.close()
|
|
173
|
+
redirect_count = redirect_count + 1
|
|
174
|
+
self.url = redirect_url
|
|
175
|
+
# Validate if the port has changed
|
|
176
|
+
if redirect_url.port is None:
|
|
177
|
+
if url.scheme == 'http':
|
|
178
|
+
self.port = 80
|
|
179
|
+
if url.scheme == 'https':
|
|
180
|
+
self.port = 443
|
|
181
|
+
else:
|
|
182
|
+
# New URL is using a non-standard port
|
|
183
|
+
self.port = url.port
|
|
184
|
+
self._recreate_socket()
|
|
185
|
+
self.connect()
|
|
186
|
+
return self.initial_receive(magic=magic, byte_limit=byte_limit,
|
|
187
|
+
redirect_count=redirect_count, redirect_limit=redirect_limit)
|
|
188
|
+
if self.no_more_data:
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
if magic not in response:
|
|
192
|
+
# File signaure not found within receive byte limit
|
|
193
|
+
logger.error(f'File magic NOT found and the byte limit {byte_limit} has been reached - actual bytes downloaded {len(response)}')
|
|
194
|
+
response = b''
|
|
195
|
+
self.connection.close()
|
|
196
|
+
|
|
197
|
+
if magic in response:
|
|
198
|
+
# File signaure has been found
|
|
199
|
+
# Discard bytes prior to the location of the magic
|
|
200
|
+
magic_offset = response.find(magic)
|
|
201
|
+
response = response[magic_offset:]
|
|
202
|
+
|
|
203
|
+
return response
|
|
204
|
+
|
|
205
|
+
def receive(self, buffer: int=4096, length: int=0) -> 'bytes':
|
|
206
|
+
"""Requests bytes from the remote server
|
|
207
|
+
|
|
208
|
+
Attributes:
|
|
209
|
+
buffer: The number of bytes to receive in each request
|
|
210
|
+
length: The maximum number of bytes to receive
|
|
211
|
+
"""
|
|
212
|
+
received_data = b''
|
|
213
|
+
if length == 0:
|
|
214
|
+
length = buffer
|
|
215
|
+
if length < buffer:
|
|
216
|
+
buffer = length
|
|
217
|
+
if self.no_more_data is False:
|
|
218
|
+
while len(received_data) <= length:
|
|
219
|
+
response = self.connection.recv(buffer)
|
|
220
|
+
if len(response) == 0:
|
|
221
|
+
# No more data received in the request
|
|
222
|
+
self.no_more_data = True
|
|
223
|
+
break
|
|
224
|
+
received_data = received_data + response
|
|
225
|
+
return received_data
|
|
226
|
+
|
|
227
|
+
def _recreate_socket(self):
|
|
228
|
+
"""
|
|
229
|
+
Internal function for recreating the underlying socket
|
|
230
|
+
"""
|
|
231
|
+
self.close()
|
|
232
|
+
self.no_more_data = False
|
|
233
|
+
self._is_connected = False
|
|
234
|
+
self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
235
|
+
self.connection.settimeout(self.timeout)
|
|
236
|
+
|
|
237
|
+
logger = logging.getLogger(__name__)
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
print('https://github.com/andshrew/PS5-Updates-Python')
|