udemy-userAPI 0.1.6__py3-none-any.whl → 0.1.7__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.
@@ -1,4 +1,4 @@
1
- __version__ = '0.1.6'
1
+ __version__ = '0.1.7'
2
2
  __lib_name__ = 'udemy_userAPI' # local name
3
3
  __repo_name__ = 'udemy-userAPI'
4
4
  __autor__ = 'PauloCesar-dev404'
udemy_userAPI/api.py CHANGED
@@ -1,7 +1,13 @@
1
1
  import json
2
- import requests
3
2
  from .exeptions import UdemyUserApiExceptions, UnhandledExceptions
4
3
  from .authenticate import UdemyAuth
4
+ import os.path
5
+ from pywidevine.cdm import Cdm
6
+ from pywidevine.device import Device
7
+ from pywidevine.pssh import PSSH
8
+ import requests
9
+ import base64
10
+ import logging
5
11
 
6
12
  AUTH = UdemyAuth()
7
13
  COOKIES = AUTH.load_cookies
@@ -38,6 +44,142 @@ HEADERS_octet_stream = {
38
44
  'accept-language': 'en-US,en;q=0.9',
39
45
  }
40
46
 
47
+ logger = logging.getLogger(__name__)
48
+ logger.setLevel(logging.WARNING)
49
+ # Obtém o diretório do arquivo em execução
50
+ locate = os.path.dirname(__file__)
51
+ # Cria o caminho para o arquivo bin.wvd na subpasta bin
52
+ WVD_FILE_PATH = os.path.join(locate, 'bin.wvd')
53
+ device = Device.load(WVD_FILE_PATH)
54
+ cdm = Cdm.from_device(device)
55
+
56
+
57
+ def read_pssh_from_bytes(bytes):
58
+ pssh_offset = bytes.rfind(b'pssh')
59
+ _start = pssh_offset - 4
60
+ _end = pssh_offset - 4 + bytes[pssh_offset - 1]
61
+ pssh = bytes[_start:_end]
62
+ return pssh
63
+
64
+
65
+ def get_pssh(init_url):
66
+ logger.info(f"INIT URL: {init_url}")
67
+ res = requests.get(init_url, headers=HEADERS_octet_stream)
68
+ if not res.ok:
69
+ logger.exception("Could not download init segment: " + res.text)
70
+ return
71
+ pssh = read_pssh_from_bytes(res.content)
72
+ return base64.b64encode(pssh).decode("utf-8")
73
+
74
+
75
+ def get_highest_resolution(resolutions):
76
+ """
77
+ Retorna a maior resolução em uma lista de resoluções.
78
+
79
+ Args:
80
+ resolutions (list of tuple): Lista de resoluções, onde cada tupla representa (largura, altura).
81
+
82
+ Returns:
83
+ tuple: A maior resolução em termos de largura e altura.
84
+ """
85
+ if not resolutions:
86
+ return None
87
+ return max(resolutions, key=lambda res: (res[0], res[1]))
88
+
89
+
90
+ def organize_streams(streams):
91
+ organized_streams = {
92
+ 'dash': [],
93
+ 'hls': []
94
+ }
95
+
96
+ best_video = None
97
+
98
+ for stream in streams:
99
+ # Verifica e adiciona streams DASH
100
+ if stream['type'] == 'application/dash+xml':
101
+ organized_streams['dash'].append({
102
+ 'src': stream['src'],
103
+ 'label': stream.get('label', 'unknown')
104
+ })
105
+
106
+ # Verifica e adiciona streams HLS (m3u8)
107
+ elif stream['type'] == 'application/x-mpegURL':
108
+ organized_streams['hls'].append({
109
+ 'src': stream['src'],
110
+ 'label': stream.get('label', 'auto')
111
+ })
112
+
113
+ # Verifica streams de vídeo (mp4)
114
+ elif stream['type'] == 'video/mp4':
115
+ # Seleciona o vídeo com a maior resolução (baseado no label)
116
+ if best_video is None or int(stream['label']) > int(best_video['label']):
117
+ best_video = {
118
+ 'src': stream['src'],
119
+ 'label': stream['label']
120
+ }
121
+
122
+ # Adiciona o melhor vídeo encontrado na lista 'hls'
123
+ if best_video:
124
+ organized_streams['hls'].append(best_video)
125
+
126
+ return organized_streams
127
+
128
+
129
+ def extract(pssh, license_token):
130
+ license_url = (f"https://www.udemy.com/api-2.0/media-license-server/validate-auth-token?drm_type=widevine"
131
+ f"&auth_token={license_token}")
132
+ logger.info(f"License URL: {license_url}")
133
+ session_id = cdm.open()
134
+ challenge = cdm.get_license_challenge(session_id, PSSH(pssh))
135
+ logger.info("Sending license request now")
136
+ license = requests.post(license_url, headers=HEADERS_octet_stream, data=challenge)
137
+ try:
138
+ str(license.content, "utf-8")
139
+ except:
140
+ base64_license = base64.b64encode(license.content).decode()
141
+ logger.info("[+] Acquired license sucessfully!")
142
+ else:
143
+ if "CAIS" not in license.text:
144
+ logger.exception("[-] Couldn't to get license: [{}]\n{}".format(license.status_code, license.text))
145
+ return
146
+
147
+ logger.info("Trying to get keys now")
148
+ cdm.parse_license(session_id, license.content)
149
+ final_keys = ""
150
+ for key in cdm.get_keys(session_id):
151
+ logger.info(f"[+] Keys: [{key.type}] - {key.kid.hex}:{key.key.hex()}")
152
+ if key.type == "CONTENT":
153
+ final_keys += f"{key.kid.hex}:{key.key.hex()}"
154
+ cdm.close(session_id)
155
+
156
+ if final_keys == "":
157
+ logger.exception("Keys were not extracted sucessfully.")
158
+ return
159
+ return final_keys.strip()
160
+
161
+
162
+ def get_mpd_file(mpd_url):
163
+ try:
164
+ # Faz a solicitação GET com os cabeçalhos
165
+ response = requests.get(mpd_url, headers=HEADERS_USER)
166
+ data = []
167
+ # Exibe o código de status
168
+ if response.status_code == 200:
169
+ return response.content
170
+ else:
171
+ UnhandledExceptions(f"erro ao obter dados de aulas!! {response.status_code}")
172
+ except requests.ConnectionError as e:
173
+ UdemyUserApiExceptions(f"Erro de conexão: {e}")
174
+ except requests.Timeout as e:
175
+ UdemyUserApiExceptions(f"Tempo de requisição excedido: {e}")
176
+ except requests.TooManyRedirects as e:
177
+ UdemyUserApiExceptions(f"Limite de redirecionamentos excedido: {e}")
178
+ except requests.HTTPError as e:
179
+ UdemyUserApiExceptions(f"Erro HTTP: {e}")
180
+ except Exception as e:
181
+ UnhandledExceptions(f"Errro Ao Obter Mídias:{e}")
182
+
41
183
 
42
184
  def parser_chapers(results):
43
185
  """
@@ -76,10 +218,48 @@ def parser_chapers(results):
76
218
  'lecture_id': dictionary.get('id'),
77
219
  'asset_id': asset.get('id')
78
220
  })
79
-
80
221
  return chapters_dict
81
222
 
82
223
 
224
+ def get_add_files(course_id: int):
225
+ url = (f'https://www.udemy.com/api-2.0/courses/{course_id}/subscriber-curriculum-items/?page_size=2000&fields['
226
+ f'lecture]=title,object_index,is_published,sort_order,created,asset,supplementary_assets,is_free&fields['
227
+ f'quiz]=title,object_index,is_published,sort_order,type&fields[practice]=title,object_index,is_published,'
228
+ f'sort_order&fields[chapter]=title,object_index,is_published,sort_order&fields[asset]=title,filename,'
229
+ f'asset_type,status,time_estimation,is_external&caching_intent=True')
230
+ try:
231
+ # Faz a solicitação GET com os cabeçalhos
232
+ response = requests.get(url, headers=HEADERS_USER)
233
+ data = []
234
+ # Exibe o código de status
235
+ if response.status_code == 200:
236
+ a = json.loads(response.text)
237
+ return a
238
+ else:
239
+ UnhandledExceptions(f"erro ao obter dados de aulas!! {response.status_code}")
240
+
241
+ except requests.ConnectionError as e:
242
+ UdemyUserApiExceptions(f"Erro de conexão: {e}")
243
+ except requests.Timeout as e:
244
+ UdemyUserApiExceptions(f"Tempo de requisição excedido: {e}")
245
+ except requests.TooManyRedirects as e:
246
+ UdemyUserApiExceptions(f"Limite de redirecionamentos excedido: {e}")
247
+ except requests.HTTPError as e:
248
+ UdemyUserApiExceptions(f"Erro HTTP: {e}")
249
+ except Exception as e:
250
+ UnhandledExceptions(f"Errro Ao Obter Mídias:{e}")
251
+
252
+
253
+ def get_files_aule(lecture_id_filter, data: list):
254
+ files = []
255
+ # print(f'DEBUG:\n\n{data}')
256
+ for files_data in data:
257
+ lecture_id = files_data.get('lecture_id')
258
+ if lecture_id == lecture_id_filter:
259
+ files.append(files_data)
260
+ return files
261
+
262
+
83
263
  def get_links(course_id: int, id_lecture: int):
84
264
  """
85
265
  :param course_id: id do curso
@@ -120,10 +300,37 @@ def remove_tag(d: str):
120
300
  return new
121
301
 
122
302
 
303
+ def get_external_liks(course_id: int, id_lecture, asset_id):
304
+ url = (f'https://www.udemy.com/api-2.0/users/me/subscribed-courses/{course_id}/lectures/{id_lecture}/'
305
+ f'supplementary-assets/{asset_id}/?fields[asset]=external_url')
306
+ try:
307
+ # Faz a solicitação GET com os cabeçalhos
308
+ response = requests.get(url, headers=HEADERS_USER)
309
+ data = []
310
+ # Exibe o código de status
311
+ if response.status_code == 200:
312
+ a = json.loads(response.text)
313
+ return a
314
+ else:
315
+ UnhandledExceptions(f"erro ao obter dados de aulas!! {response.status_code}")
316
+
317
+ except requests.ConnectionError as e:
318
+ UdemyUserApiExceptions(f"Erro de conexão: {e}")
319
+ except requests.Timeout as e:
320
+ UdemyUserApiExceptions(f"Tempo de requisição excedido: {e}")
321
+ except requests.TooManyRedirects as e:
322
+ UdemyUserApiExceptions(f"Limite de redirecionamentos excedido: {e}")
323
+ except requests.HTTPError as e:
324
+ UdemyUserApiExceptions(f"Erro HTTP: {e}")
325
+ except Exception as e:
326
+ UnhandledExceptions(f"Errro Ao Obter Mídias:{e}")
327
+
328
+
123
329
  def extract_files(supplementary_assets: list) -> list:
124
330
  """Obtém o ID da lecture, o ID do asset, o asset_type e o filename."""
125
331
  files = []
126
332
  for item in supplementary_assets:
333
+ # print(f'DEBUG files:\n{item}\n\n')
127
334
  lecture_title = item.get('lecture_title')
128
335
  lecture_id = item.get('lecture_id')
129
336
  asset = item.get('asset', {})
@@ -131,14 +338,15 @@ def extract_files(supplementary_assets: list) -> list:
131
338
  asset_type = asset.get('asset_type')
132
339
  filename = asset.get('filename')
133
340
  title = asset.get('title')
134
-
341
+ external_url = asset.get('is_external', None)
135
342
  files.append({
136
343
  'lecture_id': lecture_id,
137
344
  'asset_id': asset_id,
138
345
  'asset_type': asset_type,
139
346
  'filename': filename,
140
347
  'title': title,
141
- 'lecture_title': lecture_title
348
+ 'lecture_title': lecture_title,
349
+ 'ExternalLink': external_url
142
350
  })
143
351
  return files
144
352
 
@@ -42,7 +42,7 @@ class UdemyAuth:
42
42
  cookies_dict = {cookie.name: cookie.value for cookie in cookies}
43
43
  cookies_str = "; ".join([f"{key}={value}" for key, value in cookies_dict.items()])
44
44
  return cookies_str
45
- except FileNotFoundError:
45
+ except Exception as e:
46
46
  return False
47
47
 
48
48
  log = verif_config()
@@ -89,81 +89,94 @@ class UdemyAuth:
89
89
 
90
90
  def login(self, email: str, password: str):
91
91
  """efetuar login na udemy"""
92
- # Inicializa uma sessão usando cloudscraper para contornar a proteção Cloudflare
93
- s = cloudscraper.create_scraper()
94
-
95
- # Faz uma requisição GET à página de inscrição para obter o token CSRF
96
- r = s.get(
97
- "https://www.udemy.com/join/signup-popup/",
98
- headers={"User-Agent": "okhttp/4.9.2 UdemyAndroid 8.9.2(499) (phone)"},
99
- )
100
-
101
- # Extrai o token CSRF dos cookies
102
- csrf_token = r.cookies["csrftoken"]
103
-
104
- # Prepara os dados para o login
105
- data = {
106
- "csrfmiddlewaretoken": csrf_token,
107
- "locale": "pt_BR",
108
- "email": email,
109
- "password": password,
110
- }
111
-
112
- # Atualiza os cookies e cabeçalhos da sessão
113
- s.cookies.update(r.cookies)
114
- s.headers.update(
115
- {
116
- "User-Agent": "okhttp/4.9.2 UdemyAndroid 8.9.2(499) (phone)",
117
- "Accept": "application/json, text/plain, */*",
118
- "Accept-Language": "en-GB,en;q=0.5",
119
- "Referer": "https://www.udemy.com/join/login-popup/?locale=en_US&response_type=html&next=https%3A%2F"
120
- "%2Fwww.udemy.com%2F",
121
- "Origin": "https://www.udemy.com",
122
- "DNT": "1",
123
- "Connection": "keep-alive",
124
- "Sec-Fetch-Dest": "empty",
125
- "Sec-Fetch-Mode": "cors",
126
- "Sec-Fetch-Site": "same-origin",
127
- "Pragma": "no-cache",
128
- "Cache-Control": "no-cache",
92
+ try:
93
+ # Inicializa uma sessão usando cloudscraper para contornar a proteção Cloudflare
94
+ s = cloudscraper.create_scraper()
95
+
96
+ # Faz uma requisição GET à página de inscrição para obter o token CSRF
97
+ r = s.get(
98
+ "https://www.udemy.com/join/signup-popup/",
99
+ headers={"User-Agent": "okhttp/4.9.2 UdemyAndroid 8.9.2(499) (phone)"},
100
+ )
101
+
102
+ # Extrai o token CSRF dos cookies
103
+ csrf_token = r.cookies["csrftoken"]
104
+
105
+ # Prepara os dados para o login
106
+ data = {
107
+ "csrfmiddlewaretoken": csrf_token,
108
+ "locale": "pt_BR",
109
+ "email": email,
110
+ "password": password,
129
111
  }
130
- )
131
-
132
- # Tenta fazer login com as credenciais fornecidas
133
- r = s.post(
134
- "https://www.udemy.com/join/login-popup/?response_type=json",
135
- data=data,
136
- allow_redirects=False,
137
- )
138
-
139
- # Verifica a resposta para determinar se o login foi bem-sucedido
140
- if "returnUrl" in r.text:
141
- self.__make_cookies(r.cookies.get("client_id"), r.cookies.get("access_token"), csrf_token)
142
- self.__save_cookies(s.cookies)
143
- else:
144
- login_error = r.json().get("error", {}).get("data", {}).get("formErrors", [])[0]
145
- if login_error[0] == "Y":
146
- raise LoginException("Você excedeu o número máximo de solicitações por hora.")
147
- elif login_error[0] == "T":
148
- raise LoginException("Email ou senha incorretos")
149
- else:
150
- raise UnhandledExceptions(login_error)
151
112
 
152
- return s
113
+ # Atualiza os cookies e cabeçalhos da sessão
114
+ s.cookies.update(r.cookies)
115
+ s.headers.update(
116
+ {
117
+ "User-Agent": "okhttp/4.9.2 UdemyAndroid 8.9.2(499) (phone)",
118
+ "Accept": "application/json, text/plain, */*",
119
+ "Accept-Language": "en-GB,en;q=0.5",
120
+ "Referer": "https://www.udemy.com/join/login-popup/?locale=en_US&response_type=html&next=https%3A%2F"
121
+ "%2Fwww.udemy.com%2F",
122
+ "Origin": "https://www.udemy.com",
123
+ "DNT": "1",
124
+ "Connection": "keep-alive",
125
+ "Sec-Fetch-Dest": "empty",
126
+ "Sec-Fetch-Mode": "cors",
127
+ "Sec-Fetch-Site": "same-origin",
128
+ "Pragma": "no-cache",
129
+ "Cache-Control": "no-cache",
130
+ }
131
+ )
132
+
133
+ # Tenta fazer login com as credenciais fornecidas
134
+ r = s.post(
135
+ "https://www.udemy.com/join/login-popup/?response_type=json",
136
+ data=data,
137
+ allow_redirects=False,
138
+ )
139
+
140
+ # Verifica a resposta para determinar se o login foi bem-sucedido
141
+ if "returnUrl" in r.text:
142
+ self.__make_cookies(r.cookies.get("client_id"), r.cookies.get("access_token"), csrf_token)
143
+ self.__save_cookies(s.cookies)
144
+ else:
145
+ login_error = r.json().get("error", {}).get("data", {}).get("formErrors", [])[0]
146
+ if login_error[0] == "Y":
147
+ raise LoginException("Você excedeu o número máximo de solicitações por hora.")
148
+ elif login_error[0] == "T":
149
+ raise LoginException("Email ou senha incorretos")
150
+ else:
151
+ raise UnhandledExceptions(login_error)
152
+
153
+ return s
154
+ except Exception as e:
155
+ LoginException(e)
153
156
 
154
157
  def __save_cookies(self, cookies):
155
- with open(fr'{self.__file_path}', 'wb') as f:
156
- pickle.dump(cookies, f)
158
+ try:
159
+ with open(fr'{self.__file_path}', 'wb') as f:
160
+ pickle.dump(cookies, f)
161
+ except Exception as e:
162
+ LoginException(e)
157
163
 
158
164
  @property
159
165
  def load_cookies(self) -> str:
160
166
  """carrega cookies e retorna-os em uma string formatada"""
161
- file = os.path.join(fr'{self.__file_path}')
162
- if os.path.exists(file):
163
- with open(fr'{self.__file_path}', 'rb') as f:
164
- cookies = pickle.load(f)
165
- cookies_dict = {cookie.name: cookie.value for cookie in cookies}
166
- cookies_str = "; ".join([f"{key}={value}" for key, value in cookies_dict.items()])
167
- return cookies_str
168
- else:
169
- return 'None'
167
+ try:
168
+ file = os.path.join(fr'{self.__file_path}')
169
+ if os.path.exists(file):
170
+ with open(fr'{self.__file_path}', 'rb') as f:
171
+ cookies = pickle.load(f)
172
+ cookies_dict = {cookie.name: cookie.value for cookie in cookies}
173
+ cookies_str = "; ".join([f"{key}={value}" for key, value in cookies_dict.items()])
174
+ return cookies_str
175
+ else:
176
+ return 'None'
177
+ except Exception as e:
178
+ LoginException(e)
179
+
180
+ def remove_cookies(self):
181
+ if os.path.exists(self.__file_path):
182
+ os.remove(self.__file_path)
udemy_userAPI/bultins.py CHANGED
@@ -1,7 +1,33 @@
1
- import requests
2
1
  import json
2
+ from typing import Any
3
+ import requests
4
+ from .api import get_links, remove_tag, parser_chapers, extract_files, HEADERS_USER, assets_infor, get_add_files, \
5
+ get_files_aule, get_external_liks, extract, get_pssh, organize_streams, get_mpd_file, get_highest_resolution
3
6
  from .sections import get_course_infor
4
- from .api import get_links, remove_tag, parser_chapers, extract_files, HEADERS_USER, assets_infor
7
+ from .mpd_analyzer import MPDParser
8
+
9
+
10
+ class DRM:
11
+ def __init__(self, license_token: str, get_media_sources: list):
12
+ self.__mpd_content = None
13
+ self.__dash_url = organize_streams(streams=get_media_sources).get('dash')
14
+ self.__token = license_token
15
+ self.get_key_for_lesson()
16
+
17
+ def get_key_for_lesson(self):
18
+ """get keys for lesson"""
19
+ if self.__dash_url:
20
+ self.__mpd_content = get_mpd_file(mpd_url=self.__dash_url[0].get('src'))
21
+ parser = MPDParser(mpd_file_path=self.__mpd_content, is_file=True, headers=HEADERS_USER)
22
+ resolutions = get_highest_resolution(parser.get_all_video_resolutions())
23
+ parser.set_selected_resolution(resolution=resolutions)
24
+ init_url = parser.get_selected_video_init_url()
25
+ if init_url:
26
+ pssh = get_pssh(init_url=init_url)
27
+ if pssh:
28
+ keys = extract(pssh=pssh, license_token=self.__token)
29
+ if keys:
30
+ return keys
5
31
 
6
32
 
7
33
  class Files:
@@ -10,30 +36,44 @@ class Files:
10
36
  self.__id_course = id_course
11
37
 
12
38
  @property
13
- def get_download_url(self) -> dict[list]:
39
+ def get_download_url(self) -> dict[str, Any | None] | list[dict[str, Any | None]]:
14
40
  """obter url de download de um arquivo quando disponivel(geralemnete para arquivos esta opção é valida"""
15
41
  da = {}
16
- download_urls = ''
42
+ download_urls = []
17
43
  for files in self.__data:
18
44
  lecture_id = files.get('lecture_id', None)
19
45
  asset_id = files.get('asset_id', None)
20
46
  title = files.get("title", None)
21
- if asset_id and title and lecture_id:
47
+ lecture_title = files.get('lecture_title')
48
+ external_link = files.get('ExternalLink')
49
+ if external_link:
50
+ lnk = get_external_liks(course_id=self.__id_course, id_lecture=lecture_id, asset_id=asset_id)
51
+ dt_file = {'title-file': title, 'lecture_title': lecture_title, 'lecture_id': lecture_id,
52
+ 'external_link': external_link,
53
+ 'data-file': lnk.get('external_url', None)}
54
+ return dt_file
55
+ if asset_id and title and lecture_id and not external_link:
22
56
  resp = requests.get(
23
- f"https://www.udemy.com/api-2.0/users/me/subscribed-courses/{self.__id_course}/lectures/{lecture_id}/supplementary-assets/{asset_id}/?fields[asset]=download_urls",
57
+ f"https://www.udemy.com/api-2.0/users/me/subscribed-courses/{self.__id_course}/lectures/"
58
+ f"{lecture_id}/supplementary-assets/{asset_id}/?fields[asset]=download_urls",
24
59
  headers=HEADERS_USER)
25
60
  if resp.status_code == 200:
26
61
  da = json.loads(resp.text)
27
- download_urls = da['download_urls']
62
+ ## para cdaa dict de um fle colocar seu titulo:
63
+ dt_file = {'title-file': title, 'lecture_title': lecture_title, 'lecture_id': lecture_id,
64
+ 'external_link': external_link,
65
+ 'data-file': da['download_urls']}
66
+ download_urls.append(dt_file)
28
67
  return download_urls
29
68
 
30
69
 
31
70
  class Lecture:
32
71
  """CRIAR objetos aula(lecture) do curso e extrair os dados.."""
33
72
 
34
- def __init__(self, data: dict, course_id: int):
73
+ def __init__(self, data: dict, course_id: int, additional_files):
35
74
  self.__course_id = course_id
36
75
  self.__data = data
76
+ self.__additional_files = additional_files
37
77
  self.__asset = self.__data.get("asset")
38
78
 
39
79
  @property
@@ -86,9 +126,15 @@ class Lecture:
86
126
  return self.__asset.get('media_license_token')
87
127
 
88
128
  @property
89
- def course_is_drmed(self) -> bool:
90
- """verifica se possui DRM.."""
91
- return self.__asset.get('course_is_drmed')
129
+ def course_is_drmed(self):
130
+ """verifica se a aula possui DRM se sim retorna as keys da aula...
131
+ retorna 'kid:key' or None"""
132
+ if self.__asset.get('course_is_drmed'):
133
+ d = DRM(license_token=self.get_media_license_token,
134
+ get_media_sources=self.get_media_sources)
135
+ return d
136
+ else:
137
+ return self.__asset.get('course_is_drmed')
92
138
 
93
139
  @property
94
140
  def get_download_urls(self) -> list:
@@ -110,6 +156,13 @@ class Lecture:
110
156
  d = assets_infor(course_id=self.__course_id, id_lecture=self.get_lecture_id, assets_id=self.__asset.get("id"))
111
157
  return d
112
158
 
159
+ @property
160
+ def get_resources(self):
161
+ """obtem os recurso adconais reaioanddos a aula"""
162
+ files_add = get_files_aule(lecture_id_filter=self.get_lecture_id, data=self.__additional_files)
163
+ f = Files(files=files_add, id_course=self.__course_id).get_download_url
164
+ return f
165
+
113
166
 
114
167
  class Course:
115
168
  """receb um dict com os dados do curso"""
@@ -119,6 +172,7 @@ class Course:
119
172
  self.__data = self.__parser_chapers
120
173
  self.__course_id = course_id
121
174
  self.__results = results
175
+ self.__additional_files_data = get_add_files(course_id)
122
176
  self.__information = self.__load_infor_course()
123
177
 
124
178
  def __load_infor_course(self) -> dict:
@@ -179,34 +233,40 @@ class Course:
179
233
  def get_lectures(self) -> list:
180
234
  """Obtém uma lista com todas as aulas."""
181
235
  videos = []
236
+ section_order = 1 # Iniciar a numeração das seções (capítulos)
237
+
182
238
  for chapter in self.__data.values():
183
- for index, video in enumerate(chapter.get('videos_in_chapter', [])): # Corrigido o loop
184
- section = chapter.get('title_chapter')
239
+ for index, video in enumerate(chapter.get('videos_in_chapter', [])):
240
+ section = f"{section_order} {chapter.get('title_chapter')}" # Adicionar numeração da seção
185
241
  title = video.get('video_title')
186
242
  id_lecture = video.get('lecture_id')
187
243
  id_asset = video.get('asset_id')
188
- section_order = index + 1 # Adiciona 1 para começar a numeração em 1
189
244
  dt = {
190
245
  'section': section,
191
246
  'title': title,
192
247
  'lecture_id': id_lecture,
193
248
  'asset_id': id_asset,
194
- 'section_order': section_order
249
+ 'section_order': section_order,
250
+ 'lecture_order': index + 1 # Numeração da seção e vídeo
195
251
  }
196
252
  videos.append(dt)
253
+ section_order += 1 # Incrementar o número da seção após processar os vídeos do capítulo
197
254
  return videos
198
255
 
199
256
  def get_details_lecture(self, lecture_id: int) -> Lecture:
200
257
  """obter detalhes de uma aula específica, irá retornar o objeto Lecture"""
201
258
  links = get_links(course_id=self.__course_id, id_lecture=lecture_id)
202
- lecture = Lecture(data=links, course_id=self.__course_id)
259
+ additional_files = self.__load_assets()
260
+ # print(f'DEBUG files passados a lecture:\n{additional_files}\n\n')
261
+ lecture = Lecture(data=links, course_id=self.__course_id, additional_files=additional_files)
203
262
  return lecture
204
263
 
205
264
  @property
206
- def get_additional_files(self) -> dict[list]:
265
+ def get_additional_files(self) -> list[Any]:
207
266
  """Retorna a lista de arquivos adcionais de um curso."""
208
267
  supplementary_assets = []
209
- for item in self.__results.get('results', []):
268
+ files_downloader = []
269
+ for item in self.__additional_files_data.get('results', []):
210
270
  # Check if the item is a lecture with supplementary assets
211
271
  if item.get('_class') == 'lecture':
212
272
  id = item.get('id')
@@ -219,5 +279,25 @@ class Course:
219
279
  'asset': asset
220
280
  })
221
281
  files = extract_files(supplementary_assets)
282
+ # print(f'DEBUG files:\n{files}\n\n')
222
283
  files_objt = Files(files=files, id_course=self.__course_id).get_download_url
223
284
  return files_objt
285
+
286
+ def __load_assets(self):
287
+ """Retorna a lista de arquivos adcionais de um curso."""
288
+ supplementary_assets = []
289
+ files_downloader = []
290
+ for item in self.__additional_files_data.get('results', []):
291
+ # Check if the item is a lecture with supplementary assets
292
+ if item.get('_class') == 'lecture':
293
+ id = item.get('id')
294
+ title = item.get('title')
295
+ assets = item.get('supplementary_assets', [])
296
+ for asset in assets:
297
+ supplementary_assets.append({
298
+ 'lecture_id': id,
299
+ 'lecture_title': title,
300
+ 'asset': asset
301
+ })
302
+ files = extract_files(supplementary_assets)
303
+ return files
@@ -0,0 +1,3 @@
1
+ # Biblioteca para análise de arquivos MPD
2
+ from .mpd_parser import MPDParser
3
+ __version__ = '0.1.0'
@@ -0,0 +1,357 @@
1
+ import re
2
+ import xml.etree.ElementTree as ET
3
+
4
+
5
+ class MPDParser:
6
+ """
7
+ Classe para analisar e extrair informações de manifestos MPD (Media Presentation Description),
8
+ com foco em arquivos VOD (Video on Demand). Atualmente, não oferece suporte para transmissões ao vivo
9
+ """
10
+
11
+ def __init__(self, mpd_file_path: str, headers=None,is_file=None):
12
+ """
13
+ Inicializa o parser para um arquivo MPD a partir de um caminho de arquivo.
14
+
15
+ Args:
16
+ mpd_file_path (str): Caminho do arquivo MPD.
17
+ headers (dict, opcional): Headers HTTP adicionais para requisição, caso necessário.
18
+ """
19
+ self.is_file = is_file
20
+ self.availability_start_time = None
21
+ self.mpd_file_path = mpd_file_path
22
+ self.headers = headers if headers else {}
23
+ self.video_representations = {} # Armazena representações de vídeo, organizadas por resolução
24
+ self.audio_representations = {} # Armazena representações de áudio, organizadas por taxa de bits
25
+ self.content_protection = {} # Armazena informações de proteção de conteúdo
26
+ self.selected_resolution = None # Resolução selecionada para recuperação de segmentos de vídeo
27
+ self.initi = self.__parse_mpd2()
28
+ if not self.initi:
29
+ initi = self.__parse_mpd()
30
+
31
+ def __parse_mpd(self):
32
+ """
33
+ Faz o parsing do arquivo MPD localizado no caminho especificado e
34
+ extrai informações sobre segmentos de vídeo e áudio, além de proteção de conteúdo.
35
+ """
36
+ mpd_content = ''
37
+ if not self.is_file:
38
+ try:
39
+ with open(self.mpd_file_path, 'r', encoding='utf-8') as file:
40
+ mpd_content = file.read()
41
+ except FileNotFoundError:
42
+ print(f"Erro: Arquivo '{self.mpd_file_path}' não encontrado.")
43
+ return
44
+ except IOError:
45
+ print(f"Erro ao ler o arquivo '{self.mpd_file_path}'.")
46
+ return
47
+ else:
48
+ mpd_content = self.mpd_file_path
49
+ # Analisa o conteúdo MPD usando namespaces XML para acessar nós DASH
50
+ root = ET.fromstring(mpd_content)
51
+ ns = {'dash': 'urn:mpeg:dash:schema:mpd:2011'}
52
+
53
+ # Processa cada AdaptationSet para extração de representações de áudio e vídeo
54
+ for adaptation_set in root.findall('.//dash:AdaptationSet', ns):
55
+ mime_type = adaptation_set.attrib.get('mimeType', '')
56
+ # Extrai informações de proteção de conteúdo, se presentes
57
+ for content_protection in adaptation_set.findall('dash:ContentProtection', ns):
58
+ scheme_id_uri = content_protection.attrib.get('schemeIdUri', '')
59
+ value = content_protection.attrib.get('value', '')
60
+ self.content_protection[scheme_id_uri] = value
61
+
62
+ # Extrai informações de cada representação de mídia
63
+ for representation in adaptation_set.findall('dash:Representation', ns):
64
+ rep_id = representation.attrib.get('id')
65
+ width = int(representation.attrib.get('width', 0))
66
+ height = int(representation.attrib.get('height', 0))
67
+ resolution = (width, height) if width and height else None
68
+ bandwidth = int(representation.attrib.get('bandwidth', 0))
69
+ # Obtém a quantidade de canais de áudio, se disponível
70
+ audio_channels = representation.find('dash:AudioChannelConfiguration', ns)
71
+ audio_channels_count = int(audio_channels.attrib.get('value', 0)) if audio_channels is not None else 0
72
+
73
+ # Processa SegmentTemplate para URLs de inicialização e segmentos
74
+ segment_template = representation.find('dash:SegmentTemplate', ns)
75
+ if segment_template is not None:
76
+ init_template = segment_template.get('initialization')
77
+ init_url = self.__build_url(init_template, rep_id, bandwidth) if init_template else None
78
+
79
+ media_url_template = segment_template.get('media')
80
+ timescale = int(segment_template.get('timescale', 1))
81
+
82
+ # Processa SegmentTimeline para obtenção de segmentos individuais
83
+ segment_timeline = segment_template.find('dash:SegmentTimeline', ns)
84
+ segments = []
85
+ if segment_timeline is not None:
86
+ segment_number = int(segment_template.get('startNumber', 1))
87
+ start_time = 0
88
+ for segment in segment_timeline.findall('dash:S', ns):
89
+ t = int(segment.get('t', start_time))
90
+ d = int(segment.get('d'))
91
+ r = int(segment.get('r', 0))
92
+
93
+ # Adiciona segmentos repetidos se necessário
94
+ for _ in range(r + 1):
95
+ segments.append(
96
+ self.__calculate_segment_url(media_url_template, segment_number, t, rep_id,
97
+ bandwidth)
98
+ )
99
+ t += d
100
+ segment_number += 1
101
+
102
+ # Armazena informações de representação com resolução ou taxa de bits como chave
103
+ representation_info = {
104
+ 'id': rep_id,
105
+ 'resolution': resolution,
106
+ 'bandwidth': bandwidth,
107
+ 'audio_channels': audio_channels_count,
108
+ 'init_url': init_url,
109
+ 'segments': segments,
110
+ }
111
+ if 'video' in mime_type:
112
+ self.video_representations[resolution] = representation_info
113
+ elif 'audio' in mime_type:
114
+ self.audio_representations[bandwidth] = representation_info
115
+
116
+ def __parse_mpd2(self):
117
+ """
118
+ Faz o parsing do arquivo MPD localizado no caminho especificado e
119
+ extrai informações sobre segmentos de vídeo e áudio, além de proteção de conteúdo.
120
+ """
121
+ mpd_content = ''
122
+ if not self.is_file:
123
+ try:
124
+ with open(self.mpd_file_path, 'r', encoding='utf-8') as file:
125
+ mpd_content = file.read()
126
+ except FileNotFoundError:
127
+ print(f"Erro: Arquivo '{self.mpd_file_path}' não encontrado.")
128
+ return
129
+ except IOError:
130
+ print(f"Erro ao ler o arquivo '{self.mpd_file_path}'.")
131
+ return
132
+ else:
133
+ mpd_content = self.mpd_file_path
134
+
135
+ # Analisar o conteúdo MPD
136
+ root = ET.fromstring(mpd_content)
137
+ ns = {'dash': 'urn:mpeg:dash:schema:mpd:2011'}
138
+
139
+ # Extrai a duração total da apresentação em segundos
140
+ self.media_presentation_duration = self.parse_duration(root.attrib.get('mediaPresentationDuration', 'PT0S'))
141
+
142
+ for adaptation_set in root.findall('.//dash:AdaptationSet', ns):
143
+ mime_type = adaptation_set.attrib.get('mimeType', '')
144
+
145
+ # Extrai proteção de conteúdo
146
+ for content_protection in adaptation_set.findall('dash:ContentProtection', ns):
147
+ scheme_id_uri = content_protection.attrib.get('schemeIdUri', '')
148
+ value = content_protection.attrib.get('value', '')
149
+ self.content_protection[scheme_id_uri] = value
150
+
151
+ # Extrai representações de vídeo e áudio
152
+ for representation in adaptation_set.findall('dash:Representation', ns):
153
+ rep_id = representation.attrib.get('id')
154
+ width = int(representation.attrib.get('width', 0))
155
+ height = int(representation.attrib.get('height', 0))
156
+ resolution = (width, height) if width and height else None
157
+ bandwidth = int(representation.attrib.get('bandwidth', 0))
158
+
159
+ # SegmentTemplate e SegmentTimeline
160
+ segment_template = adaptation_set.find('dash:SegmentTemplate', ns)
161
+ if segment_template is not None:
162
+ init_template = segment_template.get('initialization')
163
+ media_template = segment_template.get('media')
164
+ timescale = int(segment_template.get('timescale', 1))
165
+ start_number = int(segment_template.get('startNumber', 1))
166
+
167
+ # Processa SegmentTimeline
168
+ segment_timeline = segment_template.find('dash:SegmentTimeline', ns)
169
+ segments = []
170
+ if segment_timeline is not None:
171
+ segment_number = start_number
172
+ for segment in segment_timeline.findall('dash:S', ns):
173
+ t = int(segment.get('t', 0))
174
+ d = int(segment.get('d'))
175
+ r = int(segment.get('r', 0)) # Quantidade de repetições
176
+
177
+ for i in range(r + 1): # Inclui o segmento e suas repetições
178
+ segment_time = t + i * d
179
+ segment_url = self.__calculate_segment_url2(media_template, segment_number, segment_time,
180
+ rep_id)
181
+ segments.append(segment_url)
182
+ segment_number += 1
183
+ else:
184
+ # No SegmentTimeline, gera segmentos contínuos
185
+ duration = int(segment_template.get('duration', 1))
186
+ total_segments = int((self.media_presentation_duration * timescale) // duration)
187
+ for segment_number in range(start_number, start_number + total_segments):
188
+ segment_time = (segment_number - 1) * duration
189
+ segment_url = self.__calculate_segment_url2(media_template, segment_number, segment_time,
190
+ rep_id)
191
+ segments.append(segment_url)
192
+
193
+ # Armazena representações de vídeo e áudio com URLs de segmentos
194
+ representation_info = {
195
+ 'id': rep_id,
196
+ 'resolution': resolution,
197
+ 'bandwidth': bandwidth,
198
+ 'init_url': self.__build_url2(init_template, rep_id),
199
+ 'segments': segments,
200
+ }
201
+ if 'video' in mime_type:
202
+ self.video_representations[resolution] = representation_info
203
+ elif 'audio' in mime_type:
204
+ self.audio_representations[bandwidth] = representation_info
205
+ def parse_duration(self, duration_str):
206
+ """
207
+ Converte uma duração em formato ISO 8601 (ex: "PT163.633S") para segundos (float).
208
+ """
209
+ match = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?', duration_str)
210
+ if match:
211
+ hours = int(match.group(1)) if match.group(1) else 0
212
+ minutes = int(match.group(2)) if match.group(2) else 0
213
+ seconds = float(match.group(3)) if match.group(3) else 0.0
214
+ return hours * 3600 + minutes * 60 + seconds
215
+ return 0.0
216
+
217
+ @staticmethod
218
+ def __build_url(template, rep_id, bandwidth):
219
+ """
220
+ Constrói uma URL substituindo placeholders em um template de URL.
221
+
222
+ Args:
223
+ template (str): Template de URL com placeholders.
224
+ rep_id (str): ID da representação.
225
+ bandwidth (int): Largura de banda da representação.
226
+
227
+ Returns:
228
+ str: URL formatada com placeholders substituídos.
229
+ """
230
+ if '$RepresentationID$' in template:
231
+ template = template.replace('$RepresentationID$', rep_id)
232
+ if '$Bandwidth$' in template:
233
+ template = template.replace('$Bandwidth$', str(bandwidth))
234
+ return template
235
+
236
+ def __build_url2(self, template, rep_id):
237
+ """
238
+ Constrói a URL substituindo variáveis no template com base nos atributos.
239
+ """
240
+ if '$RepresentationID$' in template:
241
+ template = template.replace('$RepresentationID$', rep_id)
242
+ return template
243
+
244
+ @staticmethod
245
+ def __calculate_segment_url(media_template, segment_number, segment_time, rep_id, bandwidth):
246
+ """
247
+ Constrói a URL de um segmento substituindo placeholders por valores reais.
248
+
249
+ Args:
250
+ media_template (str): Template de URL do segmento.
251
+ segment_number (int): Número do segmento.
252
+ segment_time (int): Timestamp do segmento.
253
+ rep_id (str): ID da representação.
254
+ bandwidth (int): Largura de banda da representação.
255
+
256
+ Returns:
257
+ str: URL do segmento com placeholders substituídos.
258
+ """
259
+ url = media_template.replace('$Number$', str(segment_number))
260
+ url = url.replace('$RepresentationID$', rep_id).replace('$Bandwidth$', str(bandwidth))
261
+ if '$Time$' in url:
262
+ url = url.replace('$Time$', str(segment_time))
263
+ return url
264
+
265
+ def __calculate_segment_url2(self, media_template, segment_number, segment_time, rep_id):
266
+ """
267
+ Calcula a URL de um segmento específico, substituindo variáveis no template.
268
+ """
269
+ url = media_template.replace('$Number$', str(segment_number))
270
+ url = url.replace('$RepresentationID$', rep_id)
271
+ if '$Time$' in url:
272
+ url = url.replace('$Time$', str(segment_time))
273
+ return url
274
+
275
+ def get_video_representations(self):
276
+ """
277
+ Retorna as representações de vídeo extraídas do arquivo MPD.
278
+
279
+ Returns:
280
+ dict: Representações de vídeo com resoluções como chaves.
281
+ """
282
+ return self.video_representations
283
+
284
+ def get_audio_representations(self):
285
+ """
286
+ Retorna as representações de áudio extraídas do arquivo MPD.
287
+
288
+ Returns:
289
+ dict: Representações de áudio com taxas de bits como chaves.
290
+ """
291
+ return self.audio_representations
292
+
293
+ def get_content_protection_info(self):
294
+ """
295
+ Retorna as informações de proteção de conteúdo extraídas do MPD.
296
+
297
+ Returns:
298
+ dict: Dados de proteção de conteúdo com URI do esquema como chaves.
299
+ """
300
+ return self.content_protection
301
+
302
+ def set_selected_resolution(self, resolution: tuple):
303
+ """
304
+ Define a resolução selecionada para a recuperação de segmentos de vídeo.
305
+
306
+ Args:
307
+ resolution (tuple): Resolução desejada (largura, altura).
308
+
309
+ Raises:
310
+ Exception: Se a resolução não estiver disponível no manifesto.
311
+ """
312
+ if resolution in self.video_representations:
313
+ self.selected_resolution = resolution
314
+ else:
315
+ raise Exception(
316
+ f'A resolução {resolution} não está disponível!\n\n\t=> Resoluções disponíveis no arquivo: {self.get_all_video_resolutions()}')
317
+
318
+ def get_selected_video_segments(self):
319
+ """
320
+ Retorna os URLs dos segmentos de vídeo para a resolução selecionada.
321
+
322
+ Returns:
323
+ list: URLs dos segmentos de vídeo para a resolução selecionada.
324
+ """
325
+ if self.selected_resolution:
326
+ return self.video_representations[self.selected_resolution].get('segments', [])
327
+ else:
328
+ raise Exception(f'Você deve selecioanar uma resolução no método self.set_selected_resolution()')
329
+
330
+ def get_selected_video_init_url(self):
331
+ """
332
+ Retorna o URL de inicialização para a resolução de vídeo selecionada.
333
+
334
+ Returns:
335
+ str: URL de inicialização do vídeo, ou None se não houver resolução selecionada.
336
+ """
337
+ if self.selected_resolution:
338
+ return self.video_representations[self.selected_resolution].get('init_url')
339
+ return None
340
+
341
+ def get_all_video_resolutions(self):
342
+ """
343
+ Retorna uma lista de todas as resoluções de vídeo disponíveis.
344
+
345
+ Returns:
346
+ list: Lista de tuplas com resoluções de vídeo (largura, altura).
347
+ """
348
+ return list(self.video_representations.keys())
349
+
350
+ def get_audio_channels_count(self):
351
+ """
352
+ Retorna um dicionário com a quantidade de canais de áudio para cada taxa de bits de áudio.
353
+
354
+ Returns:
355
+ dict: Quantidade de canais de áudio para cada taxa de bits.
356
+ """
357
+ return {bandwidth: info['audio_channels'] for bandwidth, info in self.audio_representations.items()}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: udemy_userAPI
3
- Version: 0.1.6
3
+ Version: 0.1.7
4
4
  Summary: Obtenha detalhes de cursos que o usuário esteja inscrito da plataforma Udemy,usando o EndPoint de usuário o mesmo que o navegador utiliza para acessar e redenrizar os cursos.
5
5
  Author: PauloCesar-dev404
6
6
  Author-email: paulocesar0073dev404@gmail.com
@@ -0,0 +1,15 @@
1
+ udemy_userAPI/__init__.py,sha256=BPle89xE_CMTKKe_Lw6jioYLgpH-q_Lpho2S-n1PIUA,206
2
+ udemy_userAPI/__version__.py,sha256=LQkqzSPXq6RAMSeuzbyVp9NNPiqy1dPbWwBYG106raU,404
3
+ udemy_userAPI/api.py,sha256=7wWkuXwBvTpBLsAl7LaZnJKA6If0cL942CMbjY4GZJs,18782
4
+ udemy_userAPI/authenticate.py,sha256=inKtOzVEDExmo88-hq1_WQKrZmPsfx9r93AqCq0vnLo,7720
5
+ udemy_userAPI/bultins.py,sha256=bvgYC8cYMH26kqNzqhFlrCHR3M9aGQV5UsuemjKdM0k,12310
6
+ udemy_userAPI/exeptions.py,sha256=nuZoAt4i-ctrW8zx9LZtejrngpFXDHOVE5cEXM4RtrY,508
7
+ udemy_userAPI/sections.py,sha256=zPyDhvTIQCL0nbf7OJZG28Kax_iooILQ_hywUwvHoL8,4043
8
+ udemy_userAPI/udemy.py,sha256=ceaXVTbQhSYkHnPIpYWUVtT6IT6jBvzYO_k4B7EFyj8,2154
9
+ udemy_userAPI/mpd_analyzer/__init__.py,sha256=i3JVWyvcFLaj5kPmx8c1PgjsLht7OUIQQClD4yqYbo8,102
10
+ udemy_userAPI/mpd_analyzer/mpd_parser.py,sha256=_vw1feJXDjw5fQLOmA5-H3UklX_30Pbl__HtDUqvp3c,17283
11
+ udemy_userAPI-0.1.7.dist-info/LICENSE,sha256=l4jdKYt8gSdDFOGr09vCKnMn_Im55XIcQKqTDEtFfNs,1095
12
+ udemy_userAPI-0.1.7.dist-info/METADATA,sha256=VyI2ApvWAQ3MAMyAyCylR1xlPy8WsxRtPBAHBtRVewU,1368
13
+ udemy_userAPI-0.1.7.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91
14
+ udemy_userAPI-0.1.7.dist-info/top_level.txt,sha256=ijTINaSDRKhdahY_X7dmSRFTxBIwQErWv9ATCG55mog,14
15
+ udemy_userAPI-0.1.7.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.1.0)
2
+ Generator: setuptools (75.3.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,13 +0,0 @@
1
- udemy_userAPI/__init__.py,sha256=BPle89xE_CMTKKe_Lw6jioYLgpH-q_Lpho2S-n1PIUA,206
2
- udemy_userAPI/__version__.py,sha256=mAEr0zlmSEAjfDqxvgeci1fw5fg0S1-Kiprgz_SpQlE,404
3
- udemy_userAPI/api.py,sha256=ttjrMsEa2WTn1TdR1YyfzD_aFnd3ePAQClCptoV8uL4,10728
4
- udemy_userAPI/authenticate.py,sha256=RKq4CTKj1tufjyFQ-DTvuUhllcdPvNzJ4qLtEKUkXLA,7112
5
- udemy_userAPI/bultins.py,sha256=1VMem8VNie85kSrwqiKsQ7iNYdbFHDtyRKAIZyTS_KE,8154
6
- udemy_userAPI/exeptions.py,sha256=nuZoAt4i-ctrW8zx9LZtejrngpFXDHOVE5cEXM4RtrY,508
7
- udemy_userAPI/sections.py,sha256=zPyDhvTIQCL0nbf7OJZG28Kax_iooILQ_hywUwvHoL8,4043
8
- udemy_userAPI/udemy.py,sha256=ceaXVTbQhSYkHnPIpYWUVtT6IT6jBvzYO_k4B7EFyj8,2154
9
- udemy_userAPI-0.1.6.dist-info/LICENSE,sha256=l4jdKYt8gSdDFOGr09vCKnMn_Im55XIcQKqTDEtFfNs,1095
10
- udemy_userAPI-0.1.6.dist-info/METADATA,sha256=2et7KgSg1hTE85oPFci5uDSroMqbG0mMjs7JmvSQ4eM,1368
11
- udemy_userAPI-0.1.6.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
12
- udemy_userAPI-0.1.6.dist-info/top_level.txt,sha256=ijTINaSDRKhdahY_X7dmSRFTxBIwQErWv9ATCG55mog,14
13
- udemy_userAPI-0.1.6.dist-info/RECORD,,