Coverage for aprender.py: 100%

189 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-01 05:56 +0000

1# This file is part of Vallenato.fr. 

2# 

3# Vallenato.fr is free software: you can redistribute it and/or modify 

4# it under the terms of the GNU Affero General Public License as published by 

5# the Free Software Foundation, either version 3 of the License, or 

6# (at your option) any later version. 

7# 

8# Vallenato.fr is distributed in the hope that it will be useful, 

9# but WITHOUT ANY WARRANTY; without even the implied warranty of 

10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

11# GNU Affero General Public License for more details. 

12# 

13# You should have received a copy of the GNU Affero General Public License 

14# along with Vallenato.fr. If not, see <http://www.gnu.org/licenses/>. 

15 

16import json 

17import logging 

18import os 

19import re 

20import readline 

21import shutil 

22import sys 

23import webbrowser 

24from urllib.error import HTTPError 

25 

26from pytube import YouTube 

27from slugify import slugify 

28 

29logger = logging.getLogger(__name__) 

30 

31# File that contains the list of available tutorials 

32TUTORIALES_DATA_FILE = "../website/src/aprender/tutoriales.js" 

33 

34 

35def get_tutorial_info(): 

36 """Retrieve the information of the new tutorial""" 

37 # What is the YouTube tutorial video? 

38 (tutorial_id, tutorial_url) = get_youtube_url("tutorial") 

39 # What is the YouTube full video? 

40 (full_video_id, full_video_url) = get_youtube_url("full") 

41 # Song title, author name and the tutorial creator's name and YouTube channel 

42 (song_title, song_author, tutocreator, tutocreator_channel, yt_tutorial_video) = ( 

43 get_title_author_tutocreator_and_channel(tutorial_url) 

44 ) 

45 # Tutorial's slug 

46 tutorial_slug = get_tutorial_slug(song_title) 

47 return ( 

48 tutorial_id, 

49 tutorial_url, 

50 full_video_id, 

51 full_video_url, 

52 song_title, 

53 song_author, 

54 tutocreator, 

55 tutocreator_channel, 

56 yt_tutorial_video, 

57 tutorial_slug, 

58 ) 

59 

60 

61def get_youtube_url(type): 

62 """Extract video ID and Normalize URL""" 

63 video_id = None 

64 video_url = None 

65 s = input(f"Enter the ID or URL of the {type} video ('q' to quit): ") 

66 while not video_id: 

67 if s.lower() == "q": 

68 print("Exiting...") 

69 sys.exit(10) 

70 video_id = youtube_url_validation(s) 

71 if not video_id: 

72 s = input(f"Invalid {type} video URL, please try again ('q' to quit): ") 

73 video_url = f"https://www.youtube.com/watch?v={video_id}" 

74 return (video_id, video_url) 

75 

76 

77def youtube_url_validation(url): 

78 """Check that it is a valid YouTube URL. 

79 Inspired from https://stackoverflow.com/a/19161373 

80 """ 

81 # Accept just the YouTube ID 

82 if re.match("^[a-zA-Z0-9_-]{11}$", url): 

83 return url 

84 youtube_regex = ( 

85 r"(https?://)?(www\.)?" 

86 r"(youtube|youtu|youtube-nocookie)\.(com|be)/" 

87 r"(watch\?v=|embed/|v/|.+\?v=)?([a-zA-Z0-9_-]{11})" 

88 ) 

89 youtube_regex_match = re.match(youtube_regex, url) 

90 if youtube_regex_match: 

91 return youtube_regex_match.group(6) 

92 return youtube_regex_match 

93 

94 

95def get_title_author_tutocreator_and_channel(url): 

96 logger.debug(f"Downloading information from tutorial video '{url}'.") 

97 yt = YouTube(url) 

98 

99 # Extract the title 

100 song_title = rlinput("Song title ('q' to quit): ", yt.title) 

101 if song_title.lower() == "q": 

102 print("Exiting...") 

103 sys.exit(11) 

104 

105 # Extract the author's name 

106 song_author = rlinput("Song author ('q' to quit): ", yt.title) 

107 if song_author.lower() == "q": 

108 print("Exiting...") 

109 sys.exit(12) 

110 

111 # The name of the creator of the tutorial 

112 tutocreator = yt.author 

113 

114 # The YouTube channel of the creator of the tutorial 

115 # TODO: this broke when migrating to pytube3 

116 tutocreator_channel = "UPDATE MANUALLY" 

117 # tutocreator_channel = yt.player_config_args["player_response"]["videoDetails"]["channelId"] 

118 

119 return (song_title, song_author, tutocreator, tutocreator_channel, yt) 

120 

121 

122def rlinput(prompt, prefill=""): 

123 """Provide an editable input string 

124 Inspired from https://stackoverflow.com/a/36607077 

125 """ 

126 readline.set_startup_hook(lambda: readline.insert_text(prefill)) 

127 try: 

128 return input(prompt) 

129 finally: 

130 readline.set_startup_hook() 

131 

132 

133def get_existing_tutorial_slug(): 

134 # Get the list of existing tutorial slugs 

135 with open(TUTORIALES_DATA_FILE) as in_file: 

136 # Remove the JS bits to keep only the JSON content 

137 tutoriales_json_content = in_file.read()[17:-2] 

138 tutoriales = json.loads(tutoriales_json_content) 

139 tutorials_slugs = [t["slug"] for t in tutoriales] 

140 return tutorials_slugs 

141 

142 

143def get_tutorial_slug(song_title): 

144 tutorials_slugs = get_existing_tutorial_slug() 

145 tutorial_slug = get_suggested_tutorial_slug(song_title, tutorials_slugs) 

146 # Propose the slug to the user 

147 tutorial_slug = rlinput("Tutorial slug/nice URL ('q' to quit): ", tutorial_slug) 

148 if tutorial_slug.lower() == "q": 

149 print("Exiting...") 

150 sys.exit(13) 

151 while tutorial_slug in tutorials_slugs: 

152 logger.debug(f"The slug '{tutorial_slug}' is already used.") 

153 tutorial_slug = rlinput("Tutorial slug/nice URL ('q' to quit): ", tutorial_slug) 

154 if tutorial_slug.lower() == "q": 

155 print("Exiting...") 

156 sys.exit(14) 

157 return tutorial_slug 

158 

159 

160def get_suggested_tutorial_slug(song_title, tutorials_slugs): 

161 # This tutorial's default slug 

162 tutorial_slug_base = slugify(song_title) 

163 tutorial_slug = tutorial_slug_base 

164 

165 i = 1 

166 # Even if this slug is not used, check if maybe the -1 version is already used 

167 # This would be the case if there's already a -1 and -2 version 

168 if ( 

169 tutorial_slug not in tutorials_slugs 

170 and f"{tutorial_slug_base}-{i}" in tutorials_slugs 

171 ): 

172 tutorial_slug = f"{tutorial_slug_base}-{i}" 

173 while tutorial_slug in tutorials_slugs: 

174 logger.debug(f"The slug '{tutorial_slug}' is already used.") 

175 i += 1 

176 tutorial_slug = f"{tutorial_slug_base}-{i}" 

177 return tutorial_slug 

178 

179 

180def determine_output_folder(temp_folder, tutorial_slug): 

181 output_folder = "../website/src/aprender/" 

182 if temp_folder: 

183 # Create a new temporary folder for this new tutorial 

184 output_folder += f"temp/{tutorial_slug}/" 

185 logger.debug( 

186 f"This new tutorial will be created in '{output_folder}' due to --temp-folder parameter." 

187 ) 

188 if os.path.exists(output_folder): 

189 # Ask if the existing temporary folder should be deleted or the script ended 

190 logger.debug("The temporary folder already exists") 

191 s = input( 

192 "The temporary folder already exists, enter 'Y' to delete the folder or 'N' to stop the program: " 

193 ) 

194 valid_entry = False 

195 while not valid_entry: 

196 if s.lower() == "y": 

197 shutil.rmtree(output_folder) 

198 valid_entry = True 

199 elif s.lower() == "n": 

200 print("Exiting...") 

201 sys.exit(15) 

202 else: 

203 s = input( 

204 "Enter 'Y' to delete the folder or 'N' to stop the program: " 

205 ) 

206 

207 if not os.path.exists(output_folder): 

208 os.makedirs(output_folder) 

209 return output_folder 

210 

211 

212def download_videos( 

213 yt_tutorial_video, tutorial_id, full_video_id, videos_output_folder 

214): 

215 # Tutorial video 

216 logger.info(f"Will now download the tutorial video {tutorial_id}...") 

217 download_youtube_video(yt_tutorial_video, tutorial_id, videos_output_folder) 

218 # Full video 

219 logger.info(f"Will now download the full video {full_video_id}...") 

220 # For the tutorial video we already had a Youtube object, not yet for the full video 

221 video_url = f"https://www.youtube.com/watch?v={full_video_id}" 

222 yt_full_video = YouTube(video_url) 

223 download_youtube_video(yt_full_video, full_video_id, videos_output_folder) 

224 

225 

226def download_youtube_video(yt, video_id, videos_output_folder): 

227 # Download stream with itag 18 by default: 

228 # <Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2"> 

229 stream = yt.streams.get_by_itag(18) 

230 if not stream: 

231 logger.debug("No stream available with itag 18") 

232 stream = yt.streams.filter( 

233 res="360p", progressive=True, file_extension="mp4" 

234 ).first() 

235 logger.debug(f"Stream that will be downloaded: {stream}") 

236 logger.debug(f"Download folder: {videos_output_folder}") 

237 try: 

238 download_stream(stream, videos_output_folder, video_id) 

239 except HTTPError as e: 

240 logger.error(f"An HTTP error {e.code} occurred with reason: {e.reason}") 

241 # Propose to download that video manually from the browser 

242 webbrowser.open(f"https://y2mate.com/youtube/{video_id}", new=2, autoraise=True) 

243 return False 

244 return True 

245 

246 

247def download_stream(stream, videos_output_folder, video_id): 

248 stream.download(videos_output_folder, video_id) 

249 

250 

251def generate_new_tutorial_info( 

252 tutorial_slug, song_author, song_title, tutorial_id, full_video_id 

253): 

254 new_tutorial_info = f"""{{ 

255 "slug": "{tutorial_slug}", 

256 "author": "{song_author}", 

257 "title": "{song_title}", 

258 "videos": [ 

259 {{"id": "{tutorial_id}", "start": 0, "end": 999}} 

260 ], 

261 "videos_full_tutorial": [], 

262 "full_version": "{full_video_id}" 

263 }}""" 

264 return new_tutorial_info 

265 

266 

267def update_tutoriales_data_file(tutoriales_data_file, new_tutorial_info): 

268 # Read in the tutoriales data file 

269 with open(tutoriales_data_file, "r") as file: 

270 filedata = file.read() 

271 # Add the new tutorial info to the list of tutorials 

272 filedata = filedata.replace("}\n];", f"}},\n {new_tutorial_info}\n];") 

273 # Save edited file 

274 with open(tutoriales_data_file, "w") as file: 

275 file.write(filedata) 

276 

277 

278def index_new_tutorial_link(tutorial_slug, song_title, song_author): 

279 return f"""\n <div class="card mb-3" style="max-width: 17rem;"> 

280 <div class="card-body"> 

281 <h5 class="card-title">{song_title} - {song_author}</h5> 

282 <a href="{tutorial_slug}" class="stretched-link text-hide">Ver el tutorial</a> 

283 </div> 

284 <div class="card-footer"><small class="text-muted">NNmNNs en NN partes</small></div> 

285 </div>""" 

286 

287 

288def index_new_youtube_links( 

289 song_title, song_author, tutorial_url, tutocreator_channel, tutocreator 

290): 

291 return f'\n <li>{song_title} - {song_author}: <a href="{tutorial_url}">Tutorial en YouTube</a> por <a href="https://www.youtube.com/channel/{tutocreator_channel}">{tutocreator}</a></li>' 

292 

293 

294def dummy_index_update( 

295 tutorial_slug, 

296 song_title, 

297 song_author, 

298 tutorial_url, 

299 tutocreator_channel, 

300 tutocreator, 

301 output_folder, 

302): 

303 dummy_index_page = f"{output_folder}index-dummy.html" 

304 logger.info( 

305 f"Creating a new dummy index page '{dummy_index_page}' with links to be included later in the main index page." 

306 ) 

307 filedata = index_new_tutorial_link(tutorial_slug, song_title, song_author) 

308 filedata += index_new_youtube_links( 

309 song_title, song_author, tutorial_url, tutocreator_channel, tutocreator 

310 ) 

311 with open(dummy_index_page, "w") as file: 

312 file.write(filedata) 

313 

314 

315def dummy_symlink_files(output_folder): 

316 logger.debug( 

317 f"Creating symlinks for the .js and .css files in the dummy folder '{output_folder}'." 

318 ) 

319 os.symlink("../../vallenato.fr.js", f"{output_folder}vallenato.fr.js") 

320 os.symlink("../../vallenato.fr.css", f"{output_folder}vallenato.fr.css") 

321 

322 

323def update_index_page( 

324 tutorial_slug, 

325 song_title, 

326 song_author, 

327 tutorial_url, 

328 tutocreator_channel, 

329 tutocreator, 

330): 

331 logger.info("Updating the index page with links to the new tutorial page.") 

332 # Read in the index page 

333 with open("../website/src/aprender/index.html", "r") as file: 

334 filedata = file.read() 

335 

336 # Add a link to the new tutorial's page 

337 end_section = """ 

338 </div> 

339 </div> 

340 </div> 

341 <div class="row"> 

342 <div class="col-md"> 

343 <h2>Otros recursos</h2>""" 

344 new_link = index_new_tutorial_link(tutorial_slug, song_title, song_author) 

345 tut_number = filedata.count("<!-- Tutorial ") + 1 

346 # TODO: add "wrap every N on ZZ" depending on the tutorial's number 

347 filedata = filedata.replace( 

348 end_section, 

349 f"\n <!-- Tutorial {tut_number} -->{new_link}\n{end_section}", 

350 ) 

351 

352 # Add links to the tutorial and the author's YouTube channel 

353 end_section = "\n </ul>\n </div>\n </div>\n </div>\n </main>\n <!-- End page content -->" 

354 new_link = index_new_youtube_links( 

355 song_title, song_author, tutorial_url, tutocreator_channel, tutocreator 

356 ) 

357 filedata = filedata.replace(end_section, f"{new_link}{end_section}") 

358 

359 # Save edited file 

360 with open("../website/src/aprender/index.html", "w") as file: 

361 file.write(filedata) 

362 

363 

364def aprender(args): 

365 # Get the information about this new tutorial 

366 ( 

367 tutorial_id, 

368 tutorial_url, 

369 full_video_id, 

370 _full_video_url, 

371 song_title, 

372 song_author, 

373 tutocreator, 

374 tutocreator_channel, 

375 yt_tutorial_video, 

376 tutorial_slug, 

377 ) = get_tutorial_info() 

378 

379 # Determine the output folder (depends on the --temp-folder parameter) 

380 output_folder = determine_output_folder(args.temp_folder, tutorial_slug) 

381 

382 # Get the info that will be added for the new tutorial 

383 new_tutorial_info = generate_new_tutorial_info( 

384 tutorial_slug, song_author, song_title, tutorial_id, full_video_id 

385 ) 

386 

387 if args.temp_folder: 

388 # When creating the new tutorial in a temporary folder for later edition, do not update the index page 

389 dummy_index_update( 

390 tutorial_slug, 

391 song_title, 

392 song_author, 

393 tutorial_url, 

394 tutocreator_channel, 

395 tutocreator, 

396 output_folder, 

397 ) 

398 # Symlink files so that the new template can be used from the temp folder 

399 dummy_symlink_files(output_folder) 

400 else: 

401 # Update the index page with the links to the new tutorial and to the tuto's author page 

402 update_index_page( 

403 tutorial_slug, 

404 song_title, 

405 song_author, 

406 tutorial_url, 

407 tutocreator_channel, 

408 tutocreator, 

409 ) 

410 # Add the new tutorial to the list of tutorials 

411 update_tutoriales_data_file(TUTORIALES_DATA_FILE, new_tutorial_info) 

412 

413 # Download the videos (both the tutorial and the full video) 

414 if args.no_download: 

415 logger.info( 

416 "Not downloading the videos from YouTube due to --no-download parameter." 

417 ) 

418 else: 

419 videos_output_folder = f"{output_folder}videos/{tutorial_slug}/" 

420 if not os.path.exists(videos_output_folder): 

421 logger.debug(f"Creating folder '{videos_output_folder}'.") 

422 os.makedirs(videos_output_folder) 

423 download_videos( 

424 yt_tutorial_video, tutorial_id, full_video_id, videos_output_folder 

425 ) 

426 

427 # Open the new tutorial page in the webbrowser (new tab) for edition 

428 new_tutorial_page = f"http://localhost:8000/aprender/?new_tutorial={tutorial_slug}" 

429 logger.debug(f"Opening new tab in web browser to '{new_tutorial_page}'") 

430 webbrowser.open(new_tutorial_page, new=2, autoraise=True)