hbmartin commited on
Commit
d9674b7
·
unverified ·
2 Parent(s): ea14b7b f9d7147

Merge pull request #27 from swiftyy-mage/master

Browse files
Files changed (2) hide show
  1. pytube/cli.py +191 -3
  2. tests/test_cli.py +47 -0
pytube/cli.py CHANGED
@@ -1,5 +1,4 @@
1
  #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
  """A simple command line application to download youtube videos."""
4
 
5
  import argparse
@@ -10,6 +9,7 @@ import logging
10
  import os
11
  import shutil
12
  import sys
 
13
  from io import BufferedWriter
14
  from typing import Any, Optional, List
15
 
@@ -64,6 +64,10 @@ def _perform_args_on_youtube(youtube: YouTube, args: argparse.Namespace) -> None
64
  download_by_resolution(
65
  youtube=youtube, resolution=args.resolution, target=args.target
66
  )
 
 
 
 
67
 
68
 
69
  def _parse_args(
@@ -120,6 +124,27 @@ def _parse_args(
120
  "Default is current working directory"
121
  ),
122
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  return parser.parse_args(args)
125
 
@@ -192,13 +217,141 @@ def on_progress(
192
  display_progress_bar(bytes_received, filesize)
193
 
194
 
195
- def _download(stream: Stream, target: Optional[str] = None) -> None:
 
 
196
  filesize_megabytes = stream.filesize // 1048576
197
  print(f"{stream.default_filename} | {filesize_megabytes} MB")
198
- stream.download(output_path=target)
199
  sys.stdout.write("\n")
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  def download_by_itag(youtube: YouTube, itag: int, target: Optional[str] = None) -> None:
203
  """Start downloading a YouTube video.
204
 
@@ -236,6 +389,7 @@ def download_by_resolution(
236
  :param str target:
237
  Target directory for download
238
  """
 
239
  stream = youtube.streams.get_by_resolution(resolution)
240
  if stream is None:
241
  print(f"Could not find a stream with resolution: {resolution}")
@@ -293,5 +447,39 @@ def download_caption(
293
  _print_available_captions(youtube.captions)
294
 
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  if __name__ == "__main__":
297
  main()
 
1
  #!/usr/bin/env python3
 
2
  """A simple command line application to download youtube videos."""
3
 
4
  import argparse
 
9
  import os
10
  import shutil
11
  import sys
12
+ import subprocess # nosec
13
  from io import BufferedWriter
14
  from typing import Any, Optional, List
15
 
 
64
  download_by_resolution(
65
  youtube=youtube, resolution=args.resolution, target=args.target
66
  )
67
+ if args.audio:
68
+ download_audio(youtube=youtube, filetype=args.audio, target=args.target)
69
+ if args.ffmpeg:
70
+ ffmpeg_process(youtube=youtube, resolution=args.ffmpeg, target=args.target)
71
 
72
 
73
  def _parse_args(
 
124
  "Default is current working directory"
125
  ),
126
  )
127
+ parser.add_argument(
128
+ "-a",
129
+ "--audio",
130
+ const="mp4",
131
+ nargs="?",
132
+ help=(
133
+ "Download the audio for a given URL at the highest bitrate available"
134
+ "Defaults to mp4 format if none is specified"
135
+ ),
136
+ )
137
+ parser.add_argument(
138
+ "-f",
139
+ "--ffmpeg",
140
+ const="best",
141
+ nargs="?",
142
+ help=(
143
+ "Downloads the audio and video stream for resolution provided"
144
+ "If no resolution is provided, downloads the best resolution"
145
+ "Runs the command line program ffmpeg to combine the audio and video"
146
+ ),
147
+ )
148
 
149
  return parser.parse_args(args)
150
 
 
217
  display_progress_bar(bytes_received, filesize)
218
 
219
 
220
+ def _download(
221
+ stream: Stream, target: Optional[str] = None, filename: Optional[str] = None
222
+ ) -> None:
223
  filesize_megabytes = stream.filesize // 1048576
224
  print(f"{stream.default_filename} | {filesize_megabytes} MB")
225
+ stream.download(output_path=target, filename=filename)
226
  sys.stdout.write("\n")
227
 
228
 
229
+ def unique_name(
230
+ base: str, subtype: Optional[str], video_audio: str, target: str
231
+ ) -> str:
232
+ """
233
+ Given a base name, the file format, and the target directory, will generate
234
+ a filename unique for that directory and file format.
235
+ :param str base:
236
+ The given base-name.
237
+ :param str subtype:
238
+ The filetype of the video which will be downloaded.
239
+ :param Path target:
240
+ Target directory for download.
241
+ """
242
+ counter = 0
243
+ while True:
244
+ name = f"{base}_{video_audio}_{counter}"
245
+ unique = os.path.join(target, f"{name}.{subtype}")
246
+ if not os.path.exists(unique):
247
+ return str(name)
248
+ counter += 1
249
+
250
+
251
+ def ffmpeg_process(
252
+ youtube: YouTube, resolution: str, target: Optional[str] = None
253
+ ) -> None:
254
+ """
255
+ Decides the correct video stream to download, then calls ffmpeg_downloader.
256
+
257
+ :param YouTube youtube:
258
+ A valid YouTube object.
259
+ :param str resolution:
260
+ YouTube video resolution.
261
+ :param str target:
262
+ Target directory for download
263
+ """
264
+ youtube.register_on_progress_callback(on_progress)
265
+ if target is None:
266
+ target = os.getcwd()
267
+
268
+ if resolution == "best":
269
+ highest_quality = (
270
+ youtube.streams.filter(progressive=False)
271
+ .order_by("resolution")
272
+ .desc()
273
+ .first()
274
+ )
275
+
276
+ video_stream = (
277
+ youtube.streams.filter(progressive=False, subtype="mp4")
278
+ .order_by("resolution")
279
+ .desc()
280
+ .first()
281
+ )
282
+
283
+ if highest_quality.resolution == video_stream.resolution:
284
+ ffmpeg_downloader(youtube=youtube, stream=video_stream, target=target)
285
+ else:
286
+ ffmpeg_downloader(youtube=youtube, stream=highest_quality, target=target)
287
+ else:
288
+ video_stream = youtube.streams.filter(
289
+ progressive=False, resolution=resolution, subtype="mp4"
290
+ ).first()
291
+ if video_stream is not None:
292
+ ffmpeg_downloader(youtube=youtube, stream=video_stream, target=target)
293
+ else:
294
+ video_stream = youtube.streams.filter(
295
+ progressive=False, resolution=resolution
296
+ ).first()
297
+ if video_stream is None:
298
+ print(f"Could not find a stream with resolution: {resolution}")
299
+ print("Try one of these:")
300
+ display_streams(youtube)
301
+ sys.exit()
302
+ ffmpeg_downloader(youtube=youtube, stream=video_stream, target=target)
303
+
304
+
305
+ def ffmpeg_downloader(youtube: YouTube, stream: Stream, target: str) -> None:
306
+ """
307
+ Given a YouTube Stream object, finds the correct audio stream, downloads them both
308
+ giving them a unique name, them uses ffmpeg to create a new file with the audio
309
+ and video from the previously downloaded files. Then deletes the original adaptive
310
+ streams, leaving the combination.
311
+
312
+ :param YouTube youtube:
313
+ A valid YouTube object
314
+ :param Stream stream:
315
+ A valid Stream object
316
+ :param Path target:
317
+ A valid Path object
318
+ """
319
+ audio_stream = (
320
+ youtube.streams.filter(only_audio=True, subtype=stream.subtype)
321
+ .order_by("abr")
322
+ .desc()
323
+ .first()
324
+ )
325
+
326
+ video_unique_name = unique_name(
327
+ safe_filename(stream.title), stream.subtype, "video", target=target
328
+ )
329
+ audio_unique_name = unique_name(
330
+ safe_filename(stream.title), stream.subtype, "audio", target=target
331
+ )
332
+ _download(stream=stream, target=target, filename=video_unique_name)
333
+ _download(stream=audio_stream, target=target, filename=audio_unique_name)
334
+
335
+ video_path = os.path.join(target, f"{video_unique_name}.{stream.subtype}")
336
+ audio_path = os.path.join(target, f"{audio_unique_name}.{stream.subtype}")
337
+ final_path = os.path.join(target, f"{safe_filename(stream.title)}.{stream.subtype}")
338
+
339
+ subprocess.run( # nosec
340
+ [
341
+ "ffmpeg",
342
+ "-i",
343
+ f"{video_path}",
344
+ "-i",
345
+ f"{audio_path}",
346
+ "-codec",
347
+ "copy",
348
+ f"{final_path}",
349
+ ]
350
+ )
351
+ os.unlink(video_path)
352
+ os.unlink(audio_path)
353
+
354
+
355
  def download_by_itag(youtube: YouTube, itag: int, target: Optional[str] = None) -> None:
356
  """Start downloading a YouTube video.
357
 
 
389
  :param str target:
390
  Target directory for download
391
  """
392
+ # TODO(nficano): allow dash itags to be selected
393
  stream = youtube.streams.get_by_resolution(resolution)
394
  if stream is None:
395
  print(f"Could not find a stream with resolution: {resolution}")
 
447
  _print_available_captions(youtube.captions)
448
 
449
 
450
+ def download_audio(
451
+ youtube: YouTube, filetype: str, target: Optional[str] = None
452
+ ) -> None:
453
+ """
454
+ Given a filetype, downloads the highest quality available audio stream for a
455
+ YouTube video.
456
+
457
+ :param YouTube youtube:
458
+ A valid YouTube object.
459
+ :param str filetype:
460
+ Desired file format to download.
461
+ :param str target:
462
+ Target directory for download
463
+ """
464
+ audio = (
465
+ youtube.streams.filter(only_audio=True, subtype=filetype)
466
+ .order_by("abr")
467
+ .desc()
468
+ .first()
469
+ )
470
+
471
+ if audio is None:
472
+ print("No audio only stream found. Try one of these:")
473
+ display_streams(youtube)
474
+ sys.exit()
475
+
476
+ youtube.register_on_progress_callback(on_progress)
477
+
478
+ try:
479
+ _download(audio, target=target)
480
+ except KeyboardInterrupt:
481
+ sys.exit()
482
+
483
+
484
  if __name__ == "__main__":
485
  main()
tests/test_cli.py CHANGED
@@ -217,3 +217,50 @@ def test_download_by_resolution_not_exists(youtube, stream_query):
217
  youtube=youtube, resolution="DOESNT EXIST", target="test_target"
218
  )
219
  cli._download.assert_not_called()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  youtube=youtube, resolution="DOESNT EXIST", target="test_target"
218
  )
219
  cli._download.assert_not_called()
220
+
221
+
222
+ @mock.patch("pytube.cli.YouTube")
223
+ @mock.patch("pytube.cli.Stream")
224
+ def test_ffmpeg_downloader(youtube, stream):
225
+ parser = argparse.ArgumentParser()
226
+ args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-f", "best"])
227
+ cli._parse_args = MagicMock(return_value=args)
228
+ cli.safe_filename = MagicMock(return_value="PSY - GANGNAM STYLE(강남스타일) MV")
229
+ cli.subprocess.run = MagicMock()
230
+ cli.os.unlink = MagicMock()
231
+ cli.ffmpeg_downloader = MagicMock()
232
+ cli.main()
233
+ cli.ffmpeg_downloader.assert_called()
234
+
235
+
236
+ @mock.patch("pytube.cli.YouTube.__init__", return_value=None)
237
+ def test_download_audio(youtube):
238
+ parser = argparse.ArgumentParser()
239
+ args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-a", "mp4"])
240
+ cli._parse_args = MagicMock(return_value=args)
241
+ cli.download_audio = MagicMock()
242
+ cli.main()
243
+ youtube.assert_called()
244
+ cli.download_audio.assert_called()
245
+
246
+
247
+ @mock.patch("pytube.cli.YouTube.__init__", return_value=None)
248
+ def test_ffmpeg_process(youtube):
249
+ parser = argparse.ArgumentParser()
250
+ args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-f", "2160p"])
251
+ cli._parse_args = MagicMock(return_value=args)
252
+ cli.ffmpeg_process = MagicMock()
253
+ cli.main()
254
+ youtube.assert_called()
255
+ cli.ffmpeg_process.assert_called()
256
+
257
+
258
+ @mock.patch("pytube.cli.YouTube.__init__", return_value=None)
259
+ def test_perform_args_on_youtube(youtube):
260
+ parser = argparse.ArgumentParser()
261
+ args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0"])
262
+ cli._parse_args = MagicMock(return_value=args)
263
+ cli._perform_args_on_youtube = MagicMock()
264
+ cli.main()
265
+ youtube.assert_called()
266
+ cli._perform_args_on_youtube.assert_called()