CaesarCloudSync commited on
Commit
b467181
·
0 Parent(s):

youtube oauth and url usage

Browse files
.gitattributes ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tflite filter=lfs diff=lfs merge=lfs -text
29
+ *.tgz filter=lfs diff=lfs merge=lfs -text
30
+ *.wasm filter=lfs diff=lfs merge=lfs -text
31
+ *.xz filter=lfs diff=lfs merge=lfs -text
32
+ *.zip filter=lfs diff=lfs merge=lfs -text
33
+ *.zst filter=lfs diff=lfs merge=lfs -text
34
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
CaesarAILogo.png ADDED
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use the official Python 3.9 image
2
+ FROM python:3.10
3
+ RUN export PYTHONPATH=$PWD
4
+ RUN apt-get update && apt-get install curl ffmpeg libsm6 libxext6 uvicorn libopencv-dev python3-opencv libgirepository1.0-dev tesseract-ocr -y
5
+
6
+ RUN pip install uvicorn
7
+ # Set the working directory to /code
8
+ WORKDIR /code
9
+ #VOLUME /home/amari/Desktop/CaesarAI/CaesarFastAPI /code
10
+ # Copy the current directory contents into the container at /code
11
+ COPY ./requirements.txt /code/requirements.txt
12
+
13
+ # Install requirements.txt
14
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
15
+
16
+ # Set up a new user named "user" with user ID 1000
17
+ RUN useradd -m -u 1000 user
18
+ # Switch to the "user" user
19
+ USER user
20
+ # Set home to the user's home directory
21
+ ENV HOME=/home/user \
22
+ PATH=/home/user/.local/bin:$PATH
23
+
24
+ # Set the working directory to the user's home directory
25
+ WORKDIR $HOME/app
26
+
27
+ # Copy the current directory contents into the container at $HOME/app setting the owner to the user
28
+ COPY --chown=user . $HOME/app
29
+
30
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860","--reload"]
31
+
32
+
README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: CaesarMusic
3
+ emoji: 📚
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: docker
7
+ pinned: false
8
+ ---
__pycache__/caesarmusic.cpython-310.pyc ADDED
Binary file (6.52 kB). View file
 
__pycache__/main.cpython-310.pyc ADDED
Binary file (5.03 kB). View file
 
caesarmusic.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Download youtube videos in python using pytube library
2
+ import os
3
+ # paste the YouTube video URL here
4
+ import io
5
+ import re
6
+ import zipfile
7
+ from io import BytesIO
8
+ from youtubesearchpython import VideosSearch,PlaylistsSearch
9
+ from fastapi.responses import StreamingResponse
10
+ from moviepy.editor import *
11
+ from caesarpytube import YouTube
12
+ #from pytube import YouTube
13
+ #from caesarpytube import YouTube
14
+ from pytube.exceptions import PytubeError
15
+ from typing import Generator
16
+ #from youtubesearchpython import Playlist
17
+ from pytube.contrib.playlist import Playlist
18
+ import time
19
+ import base64
20
+ class CaesarMusic:
21
+ def __init__(self,download_dir=f"{os.getcwd()}/Songs",first_time_running=True) -> None:
22
+ self.first_time_running = first_time_running
23
+ self.download_dir= download_dir
24
+ def fetch_playslist_songs(self,artist,playlisturl):
25
+
26
+ playlist = Playlist(playlisturl)
27
+
28
+ #songs = [song for song in playlist.videos if artist in song["channel"]["name"].lower()]
29
+ #print(songs)
30
+ #return songs
31
+ return [{"link":url} for url in playlist]
32
+ def download_mp4_song(self,url):
33
+ # create a YouTube object and get the video stream
34
+ youtube = YouTube(url)
35
+ song_title = youtube.title + ".mp4"
36
+ print(song_title)
37
+ video_streams = youtube.streams.filter(res="720p")
38
+ print(video_streams)
39
+
40
+ # set the download path and download the video
41
+ download_path = "D:\CaesarAI\CaesarAIAPI\CaesarAI\CaesarAIMusicLoad"
42
+ video_streams.first().download(download_path)
43
+
44
+
45
+ def download_mp3_song(self,url,DOWNLOAD_DIR=f"{os.getcwd()}/Songs"):
46
+ if "Songs" not in os.listdir(os.getcwd()):
47
+ os.mkdir(DOWNLOAD_DIR)
48
+ # importing packages
49
+ try:
50
+ # url input from user
51
+ #str(input("Enter the URL of the video you want to download: \n>> "))
52
+ #print("Starting...")
53
+
54
+ yt = YouTube(url,use_oauth=True, allow_oauth_cache=True)
55
+ if self.first_time_running == True:
56
+ #time.sleep(40)
57
+ self.first_time_running = False
58
+ print('Downloading : {} with url : {}'.format(yt.title, yt.watch_url))
59
+
60
+ # extract only audio
61
+ video = yt.streams.filter(only_audio=True).first()
62
+
63
+ # check for destination to save file
64
+ #print("Enter the destination (leave blank for current directory)")
65
+
66
+ # download the file
67
+ out_file = video.download(output_path=DOWNLOAD_DIR)
68
+
69
+ # save the file
70
+ base, ext = os.path.splitext(out_file)
71
+ new_file = base + '.mp3'
72
+ os.rename(out_file, new_file)
73
+
74
+ # result of success
75
+ #print(yt.title + " has been successfully downloaded.")
76
+ return new_file#yt.title
77
+ except (FileExistsError,Exception) as fex:
78
+ print(type(fex),fex)
79
+
80
+ def zipfiles(self,filenames):
81
+ iosongs = BytesIO()
82
+ zip_filename = "songs.zip"
83
+ with zipfile.ZipFile(iosongs, 'w') as zipMe:
84
+ for f in filenames:
85
+ if "mp3" in f:
86
+ zipMe.write(f, compress_type=zipfile.ZIP_DEFLATED)
87
+ zipMe.close()
88
+ return StreamingResponse(
89
+ iter([iosongs.getvalue()]),
90
+ media_type="application/x-zip-compressed",
91
+ headers = { "Content-Disposition":f"attachment;filename=%s" % zip_filename}
92
+ )
93
+ def clean_up_dir(self,download_dir,ext=None):
94
+ try:
95
+ if ext:
96
+ filelist = [i for i in os.listdir(download_dir) if ext in i]
97
+ elif not ext:
98
+ filelist =list(os.listdir(download_dir))
99
+ for f in filelist:
100
+ os.remove(os.path.join(download_dir, f))
101
+ except FileNotFoundError as fex:
102
+ pass
103
+ def caesarmusicfetch(self,artist,album):
104
+ self.clean_up_dir(self.download_dir)
105
+
106
+ artist = artist.lower().strip()
107
+ album = album.lower().strip()
108
+ query = f"{artist} {album}"
109
+ videosSearch = VideosSearch(query,limit=4)
110
+
111
+ videos = []
112
+ for video in videosSearch.result()["result"]:
113
+ if artist in video["channel"]["name"].lower():
114
+ if album in video["title"].lower():
115
+ videos.append(video)
116
+ videosunique = []
117
+ [videosunique.append(item) for item in videos if item not in videosunique]
118
+ return videosunique
119
+ def caesarmusicextract(self,videos):
120
+ songtitles = []
121
+ if videos != []:
122
+ for video in videos:
123
+ while True:
124
+ try:
125
+ songtitle = self.download_mp3_song(video["link"],self.download_dir)
126
+ songtitles.append(songtitle)
127
+ break
128
+ except PytubeError as pex:
129
+ continue
130
+ songtitles= [f"Songs/{i}" for i in os.listdir(self.download_dir) if "mp3" in i]
131
+ songzipresponse = self.zipfiles(songtitles)
132
+ self.clean_up_dir(self.download_dir,"mp3")
133
+ self.clean_up_dir(self.download_dir,"mp4")
134
+ return songzipresponse
135
+ elif videos == []:
136
+ return "No song detected"
137
+ def caesarmusicextractgenerator(self,videos) -> Generator:
138
+ songtitles = []
139
+ if videos != []:
140
+ for video in videos:
141
+ while True:
142
+ try:
143
+ songtitle = self.download_mp3_song(video["link"],self.download_dir)
144
+ songtitles.append(songtitle)
145
+ break
146
+ except PytubeError as pex:
147
+ continue
148
+ #print(songtitle)
149
+ try:
150
+
151
+ with open(f'{songtitle}', 'rb') as f:
152
+ data = f.read()
153
+ encoded_string = base64.b64encode(data).decode("utf-8")
154
+ yield encoded_string,songtitle.replace(f"{os.getcwd()}/Songs\\","").replace(f"{os.getcwd()}/Songs/","") #data
155
+ except FileNotFoundError as fex:
156
+
157
+ print(fex)
158
+ continue
159
+ #songtitles= [f"Songs/{i}" for i in os.listdir(self.download_dir) if "mp3" in i]
160
+ #songzipresponse = self.zipfiles(songtitles)
161
+ #self.clean_up_dir(self.download_dir,"mp3")
162
+ #self.clean_up_dir(self.download_dir,"mp4")
163
+ elif videos == []:
164
+ yield "No song detected"
165
+
166
+
167
+
168
+
169
+ if __name__ == "__main__":
170
+ def test2():
171
+
172
+ artist = artist.lower().strip()
173
+ album = album.lower().strip()
174
+ query = f"{artist} {album}"
175
+ caesarmusic = CaesarMusic()
176
+ videosSearch = PlaylistsSearch(query,limit=4)
177
+ #print(videosSearch.result()["result"][1])
178
+ resultnum,result = [],[]
179
+ for playlist in videosSearch.result()["result"]:
180
+ if "playlist" in playlist["link"]:
181
+ songs = caesarmusic.fetch_playslist_songs(artist,playlist["link"])
182
+ print(songs)
183
+ resultnum.append(len(songs))
184
+ result.append(songs)
185
+
186
+ biggest_number = max(resultnum)
187
+ #songs = result[resultnum.index(biggest_number)]
188
+ #for song in caesarmusic.caesarmusicextractgenerator(songs):
189
+ # pass
190
+
191
+
192
+
193
+
194
+ #playlisturl = [playlist["link"] for playlist in videosSearch.result()["result"] if "playlist" in playlist["link"]][0]
195
+ #songs = caesarmusic.fetch_playslist_songs(artist,playlisturl)
196
+ #print(len(songs))
197
+ #print(songs[0])
198
+ #for song in caesarmusic.caesarmusicextractgenerator(songs):
199
+ # pass
200
+ #print(song)
201
+ download_dir = f"{os.getcwd()}/Songs"
202
+ artist = "brent faiyaz"
203
+ album = "sonder son"
204
+ album_or_song = "album"
205
+ query = f"{artist} {album}"
206
+ caesarmusic = CaesarMusic()
207
+ if album_or_song == "album":
208
+ videosSearch = PlaylistsSearch(query,limit=1)
209
+ playlisturl = [playlist["link"] for playlist in videosSearch.result()["result"] if "playlist" in playlist["link"]][0]
210
+ songs = caesarmusic.fetch_playslist_songs(artist,playlisturl)
211
+ #print(songs[0])
212
+ songzipresponse = caesarmusic.caesarmusicextract(songs)
213
+ if songzipresponse == "No song detected":
214
+ print({"message":"No song detected"})
215
+ elif songzipresponse != "No song detected":
216
+ print(songzipresponse)
217
+ elif album_or_song == "song":
218
+ songs = caesarmusic.caesarmusicfetch(artist,album)
219
+ #print(songs)
220
+ songzipresponse = caesarmusic.caesarmusicextract(songs)
221
+ if songzipresponse == "No song detected":
222
+ print({"message":"No song detected"})
223
+ elif songzipresponse != "No song detected":
224
+ print(songzipresponse)
225
+
226
+
227
+
228
+
caesarpytube/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # flake8: noqa: F401
2
+ # noreorder
3
+ """
4
+ Pytube: a very serious Python library for downloading YouTube Videos.
5
+ """
6
+ __title__ = "pytube"
7
+ __author__ = "Ronnie Ghose, Taylor Fox Dahlin, Nick Ficano"
8
+ __license__ = "The Unlicense (Unlicense)"
9
+ __js__ = None
10
+ __js_url__ = None
11
+
12
+ from pytube.version import __version__
13
+ from pytube.streams import Stream
14
+ from pytube.captions import Caption
15
+ from pytube.query import CaptionQuery, StreamQuery
16
+ from pytube.__main__ import YouTube
17
+ from pytube.contrib.playlist import Playlist
18
+ from pytube.contrib.channel import Channel
19
+ from pytube.contrib.search import Search
caesarpytube/__main__.py ADDED
@@ -0,0 +1,479 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This module implements the core developer interface for pytube.
3
+
4
+ The problem domain of the :class:`YouTube <YouTube> class focuses almost
5
+ exclusively on the developer interface. Pytube offloads the heavy lifting to
6
+ smaller peripheral modules and functions.
7
+
8
+ """
9
+ import logging
10
+ from typing import Any, Callable, Dict, List, Optional
11
+
12
+ import pytube
13
+ import pytube.exceptions as exceptions
14
+ from pytube import extract, request
15
+ from pytube import Stream, StreamQuery
16
+ from pytube.helpers import install_proxy
17
+ from pytube.innertube import InnerTube
18
+ from pytube.metadata import YouTubeMetadata
19
+ from pytube.monostate import Monostate
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class YouTube:
25
+ """Core developer interface for pytube."""
26
+
27
+ def __init__(
28
+ self,
29
+ url: str,
30
+ on_progress_callback: Optional[Callable[[Any, bytes, int], None]] = None,
31
+ on_complete_callback: Optional[Callable[[Any, Optional[str]], None]] = None,
32
+ proxies: Dict[str, str] = None,
33
+ use_oauth: bool = False,
34
+ allow_oauth_cache: bool = True
35
+ ):
36
+ """Construct a :class:`YouTube <YouTube>`.
37
+
38
+ :param str url:
39
+ A valid YouTube watch URL.
40
+ :param func on_progress_callback:
41
+ (Optional) User defined callback function for stream download
42
+ progress events.
43
+ :param func on_complete_callback:
44
+ (Optional) User defined callback function for stream download
45
+ complete events.
46
+ :param dict proxies:
47
+ (Optional) A dict mapping protocol to proxy address which will be used by pytube.
48
+ :param bool use_oauth:
49
+ (Optional) Prompt the user to authenticate to YouTube.
50
+ If allow_oauth_cache is set to True, the user should only be prompted once.
51
+ :param bool allow_oauth_cache:
52
+ (Optional) Cache OAuth tokens locally on the machine. Defaults to True.
53
+ These tokens are only generated if use_oauth is set to True as well.
54
+ """
55
+ self._js: Optional[str] = None # js fetched by js_url
56
+ self._js_url: Optional[str] = None # the url to the js, parsed from watch html
57
+
58
+ self._vid_info: Optional[Dict] = None # content fetched from innertube/player
59
+
60
+ self._watch_html: Optional[str] = None # the html of /watch?v=<video_id>
61
+ self._embed_html: Optional[str] = None
62
+ self._player_config_args: Optional[Dict] = None # inline js in the html containing
63
+ self._age_restricted: Optional[bool] = None
64
+
65
+ self._fmt_streams: Optional[List[Stream]] = None
66
+
67
+ self._initial_data = None
68
+ self._metadata: Optional[YouTubeMetadata] = None
69
+
70
+ # video_id part of /watch?v=<video_id>
71
+ self.video_id = extract.video_id(url)
72
+
73
+ self.watch_url = f"https://youtube.com/watch?v={self.video_id}"
74
+ self.embed_url = f"https://www.youtube.com/embed/{self.video_id}"
75
+
76
+ # Shared between all instances of `Stream` (Borg pattern).
77
+ self.stream_monostate = Monostate(
78
+ on_progress=on_progress_callback, on_complete=on_complete_callback
79
+ )
80
+
81
+ if proxies:
82
+ install_proxy(proxies)
83
+
84
+ self._author = None
85
+ self._title = None
86
+ self._publish_date = None
87
+
88
+ self.use_oauth = use_oauth
89
+ self.allow_oauth_cache = allow_oauth_cache
90
+
91
+ def __repr__(self):
92
+ return f'<pytube.__main__.YouTube object: videoId={self.video_id}>'
93
+
94
+ def __eq__(self, o: object) -> bool:
95
+ # Compare types and urls, if they're same return true, else return false.
96
+ return type(o) == type(self) and o.watch_url == self.watch_url
97
+
98
+ @property
99
+ def watch_html(self):
100
+ if self._watch_html:
101
+ return self._watch_html
102
+ self._watch_html = request.get(url=self.watch_url)
103
+ return self._watch_html
104
+
105
+ @property
106
+ def embed_html(self):
107
+ if self._embed_html:
108
+ return self._embed_html
109
+ self._embed_html = request.get(url=self.embed_url)
110
+ return self._embed_html
111
+
112
+ @property
113
+ def age_restricted(self):
114
+ if self._age_restricted:
115
+ return self._age_restricted
116
+ self._age_restricted = extract.is_age_restricted(self.watch_html)
117
+ return self._age_restricted
118
+
119
+ @property
120
+ def js_url(self):
121
+ if self._js_url:
122
+ return self._js_url
123
+
124
+ if self.age_restricted:
125
+ self._js_url = extract.js_url(self.embed_html)
126
+ else:
127
+ self._js_url = extract.js_url(self.watch_html)
128
+
129
+ return self._js_url
130
+
131
+ @property
132
+ def js(self):
133
+ if self._js:
134
+ return self._js
135
+
136
+ # If the js_url doesn't match the cached url, fetch the new js and update
137
+ # the cache; otherwise, load the cache.
138
+ if pytube.__js_url__ != self.js_url:
139
+ self._js = request.get(self.js_url)
140
+ pytube.__js__ = self._js
141
+ pytube.__js_url__ = self.js_url
142
+ else:
143
+ self._js = pytube.__js__
144
+
145
+ return self._js
146
+
147
+ @property
148
+ def initial_data(self):
149
+ if self._initial_data:
150
+ return self._initial_data
151
+ self._initial_data = extract.initial_data(self.watch_html)
152
+ return self._initial_data
153
+
154
+ @property
155
+ def streaming_data(self):
156
+ """Return streamingData from video info."""
157
+ if 'streamingData' in self.vid_info:
158
+ return self.vid_info['streamingData']
159
+ else:
160
+ self.bypass_age_gate()
161
+ return self.vid_info['streamingData']
162
+
163
+ @property
164
+ def fmt_streams(self):
165
+ """Returns a list of streams if they have been initialized.
166
+
167
+ If the streams have not been initialized, finds all relevant
168
+ streams and initializes them.
169
+ """
170
+ self.check_availability()
171
+ if self._fmt_streams:
172
+ return self._fmt_streams
173
+
174
+ self._fmt_streams = []
175
+
176
+ stream_manifest = extract.apply_descrambler(self.streaming_data)
177
+
178
+ # If the cached js doesn't work, try fetching a new js file
179
+ # https://github.com/pytube/pytube/issues/1054
180
+ try:
181
+ extract.apply_signature(stream_manifest, self.vid_info, self.js)
182
+ except exceptions.ExtractError:
183
+ # To force an update to the js file, we clear the cache and retry
184
+ self._js = None
185
+ self._js_url = None
186
+ pytube.__js__ = None
187
+ pytube.__js_url__ = None
188
+ extract.apply_signature(stream_manifest, self.vid_info, self.js)
189
+
190
+ # build instances of :class:`Stream <Stream>`
191
+ # Initialize stream objects
192
+ for stream in stream_manifest:
193
+ video = Stream(
194
+ stream=stream,
195
+ monostate=self.stream_monostate,
196
+ )
197
+ self._fmt_streams.append(video)
198
+
199
+ self.stream_monostate.title = self.title
200
+ self.stream_monostate.duration = self.length
201
+
202
+ return self._fmt_streams
203
+
204
+ def check_availability(self):
205
+ """Check whether the video is available.
206
+
207
+ Raises different exceptions based on why the video is unavailable,
208
+ otherwise does nothing.
209
+ """
210
+ status, messages = extract.playability_status(self.watch_html)
211
+
212
+ for reason in messages:
213
+ if status == 'UNPLAYABLE':
214
+ if reason == (
215
+ 'Join this channel to get access to members-only content '
216
+ 'like this video, and other exclusive perks.'
217
+ ):
218
+ raise exceptions.MembersOnly(video_id=self.video_id)
219
+ elif reason == 'This live stream recording is not available.':
220
+ raise exceptions.RecordingUnavailable(video_id=self.video_id)
221
+ else:
222
+ raise exceptions.VideoUnavailable(video_id=self.video_id)
223
+ elif status == 'LOGIN_REQUIRED':
224
+ if reason == (
225
+ 'This is a private video. '
226
+ 'Please sign in to verify that you may see it.'
227
+ ):
228
+ raise exceptions.VideoPrivate(video_id=self.video_id)
229
+ elif status == 'ERROR':
230
+ if reason == 'Video unavailable':
231
+ raise exceptions.VideoUnavailable(video_id=self.video_id)
232
+ elif status == 'LIVE_STREAM':
233
+ raise exceptions.LiveStreamError(video_id=self.video_id)
234
+
235
+ @property
236
+ def vid_info(self):
237
+ """Parse the raw vid info and return the parsed result.
238
+
239
+ :rtype: Dict[Any, Any]
240
+ """
241
+ if self._vid_info:
242
+ return self._vid_info
243
+
244
+ innertube = InnerTube(use_oauth=self.use_oauth, allow_cache=self.allow_oauth_cache)
245
+
246
+ innertube_response = innertube.player(self.video_id)
247
+ self._vid_info = innertube_response
248
+ return self._vid_info
249
+
250
+ def bypass_age_gate(self):
251
+ """Attempt to update the vid_info by bypassing the age gate."""
252
+ innertube = InnerTube(
253
+ client='ANDROID_EMBED',
254
+ use_oauth=self.use_oauth,
255
+ allow_cache=self.allow_oauth_cache
256
+ )
257
+ innertube_response = innertube.player(self.video_id)
258
+
259
+ playability_status = innertube_response['playabilityStatus'].get('status', None)
260
+
261
+ # If we still can't access the video, raise an exception
262
+ # (tier 3 age restriction)
263
+ if playability_status == 'UNPLAYABLE':
264
+ raise exceptions.AgeRestrictedError(self.video_id)
265
+
266
+ self._vid_info = innertube_response
267
+
268
+ @property
269
+ def caption_tracks(self) -> List[pytube.Caption]:
270
+ """Get a list of :class:`Caption <Caption>`.
271
+
272
+ :rtype: List[Caption]
273
+ """
274
+ raw_tracks = (
275
+ self.vid_info.get("captions", {})
276
+ .get("playerCaptionsTracklistRenderer", {})
277
+ .get("captionTracks", [])
278
+ )
279
+ return [pytube.Caption(track) for track in raw_tracks]
280
+
281
+ @property
282
+ def captions(self) -> pytube.CaptionQuery:
283
+ """Interface to query caption tracks.
284
+
285
+ :rtype: :class:`CaptionQuery <CaptionQuery>`.
286
+ """
287
+ return pytube.CaptionQuery(self.caption_tracks)
288
+
289
+ @property
290
+ def streams(self) -> StreamQuery:
291
+ """Interface to query both adaptive (DASH) and progressive streams.
292
+
293
+ :rtype: :class:`StreamQuery <StreamQuery>`.
294
+ """
295
+ self.check_availability()
296
+ return StreamQuery(self.fmt_streams)
297
+
298
+ @property
299
+ def thumbnail_url(self) -> str:
300
+ """Get the thumbnail url image.
301
+
302
+ :rtype: str
303
+ """
304
+ thumbnail_details = (
305
+ self.vid_info.get("videoDetails", {})
306
+ .get("thumbnail", {})
307
+ .get("thumbnails")
308
+ )
309
+ if thumbnail_details:
310
+ thumbnail_details = thumbnail_details[-1] # last item has max size
311
+ return thumbnail_details["url"]
312
+
313
+ return f"https://img.youtube.com/vi/{self.video_id}/maxresdefault.jpg"
314
+
315
+ @property
316
+ def publish_date(self):
317
+ """Get the publish date.
318
+
319
+ :rtype: datetime
320
+ """
321
+ if self._publish_date:
322
+ return self._publish_date
323
+ self._publish_date = extract.publish_date(self.watch_html)
324
+ return self._publish_date
325
+
326
+ @publish_date.setter
327
+ def publish_date(self, value):
328
+ """Sets the publish date."""
329
+ self._publish_date = value
330
+
331
+ @property
332
+ def title(self) -> str:
333
+ """Get the video title.
334
+
335
+ :rtype: str
336
+ """
337
+ if self._title:
338
+ return self._title
339
+
340
+ try:
341
+ self._title = self.vid_info['videoDetails']['title']
342
+ except KeyError:
343
+ # Check_availability will raise the correct exception in most cases
344
+ # if it doesn't, ask for a report.
345
+ self.check_availability()
346
+ raise exceptions.PytubeError(
347
+ (
348
+ f'Exception while accessing title of {self.watch_url}. '
349
+ 'Please file a bug report at https://github.com/pytube/pytube'
350
+ )
351
+ )
352
+
353
+ return self._title
354
+
355
+ @title.setter
356
+ def title(self, value):
357
+ """Sets the title value."""
358
+ self._title = value
359
+
360
+ @property
361
+ def description(self) -> str:
362
+ """Get the video description.
363
+
364
+ :rtype: str
365
+ """
366
+ return self.vid_info.get("videoDetails", {}).get("shortDescription")
367
+
368
+ @property
369
+ def rating(self) -> float:
370
+ """Get the video average rating.
371
+
372
+ :rtype: float
373
+
374
+ """
375
+ return self.vid_info.get("videoDetails", {}).get("averageRating")
376
+
377
+ @property
378
+ def length(self) -> int:
379
+ """Get the video length in seconds.
380
+
381
+ :rtype: int
382
+ """
383
+ return int(self.vid_info.get('videoDetails', {}).get('lengthSeconds'))
384
+
385
+ @property
386
+ def views(self) -> int:
387
+ """Get the number of the times the video has been viewed.
388
+
389
+ :rtype: int
390
+ """
391
+ return int(self.vid_info.get("videoDetails", {}).get("viewCount"))
392
+
393
+ @property
394
+ def author(self) -> str:
395
+ """Get the video author.
396
+ :rtype: str
397
+ """
398
+ if self._author:
399
+ return self._author
400
+ self._author = self.vid_info.get("videoDetails", {}).get(
401
+ "author", "unknown"
402
+ )
403
+ return self._author
404
+
405
+ @author.setter
406
+ def author(self, value):
407
+ """Set the video author."""
408
+ self._author = value
409
+
410
+ @property
411
+ def keywords(self) -> List[str]:
412
+ """Get the video keywords.
413
+
414
+ :rtype: List[str]
415
+ """
416
+ return self.vid_info.get('videoDetails', {}).get('keywords', [])
417
+
418
+ @property
419
+ def channel_id(self) -> str:
420
+ """Get the video poster's channel id.
421
+
422
+ :rtype: str
423
+ """
424
+ return self.vid_info.get('videoDetails', {}).get('channelId', None)
425
+
426
+ @property
427
+ def channel_url(self) -> str:
428
+ """Construct the channel url for the video's poster from the channel id.
429
+
430
+ :rtype: str
431
+ """
432
+ return f'https://www.youtube.com/channel/{self.channel_id}'
433
+
434
+ @property
435
+ def metadata(self) -> Optional[YouTubeMetadata]:
436
+ """Get the metadata for the video.
437
+
438
+ :rtype: YouTubeMetadata
439
+ """
440
+ if self._metadata:
441
+ return self._metadata
442
+ else:
443
+ self._metadata = extract.metadata(self.initial_data)
444
+ return self._metadata
445
+
446
+ def register_on_progress_callback(self, func: Callable[[Any, bytes, int], None]):
447
+ """Register a download progress callback function post initialization.
448
+
449
+ :param callable func:
450
+ A callback function that takes ``stream``, ``chunk``,
451
+ and ``bytes_remaining`` as parameters.
452
+
453
+ :rtype: None
454
+
455
+ """
456
+ self.stream_monostate.on_progress = func
457
+
458
+ def register_on_complete_callback(self, func: Callable[[Any, Optional[str]], None]):
459
+ """Register a download complete callback function post initialization.
460
+
461
+ :param callable func:
462
+ A callback function that takes ``stream`` and ``file_path``.
463
+
464
+ :rtype: None
465
+
466
+ """
467
+ self.stream_monostate.on_complete = func
468
+
469
+ @staticmethod
470
+ def from_id(video_id: str) -> "YouTube":
471
+ """Construct a :class:`YouTube <YouTube>` object from a video id.
472
+
473
+ :param str video_id:
474
+ The video id of the YouTube video.
475
+
476
+ :rtype: :class:`YouTube <YouTube>`
477
+
478
+ """
479
+ return YouTube(f"https://www.youtube.com/watch?v={video_id}")
caesarpytube/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (824 Bytes). View file
 
caesarpytube/captions.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import os
3
+ import time
4
+ import json
5
+ import xml.etree.ElementTree as ElementTree
6
+ from html import unescape
7
+ from typing import Dict, Optional
8
+
9
+ from pytube import request
10
+ from pytube.helpers import safe_filename, target_directory
11
+
12
+
13
+ class Caption:
14
+ """Container for caption tracks."""
15
+
16
+ def __init__(self, caption_track: Dict):
17
+ """Construct a :class:`Caption <Caption>`.
18
+
19
+ :param dict caption_track:
20
+ Caption track data extracted from ``watch_html``.
21
+ """
22
+ self.url = caption_track.get("baseUrl")
23
+
24
+ # Certain videos have runs instead of simpleText
25
+ # this handles that edge case
26
+ name_dict = caption_track['name']
27
+ if 'simpleText' in name_dict:
28
+ self.name = name_dict['simpleText']
29
+ else:
30
+ for el in name_dict['runs']:
31
+ if 'text' in el:
32
+ self.name = el['text']
33
+
34
+ # Use "vssId" instead of "languageCode", fix issue #779
35
+ self.code = caption_track["vssId"]
36
+ # Remove preceding '.' for backwards compatibility, e.g.:
37
+ # English -> vssId: .en, languageCode: en
38
+ # English (auto-generated) -> vssId: a.en, languageCode: en
39
+ self.code = self.code.strip('.')
40
+
41
+ @property
42
+ def xml_captions(self) -> str:
43
+ """Download the xml caption tracks."""
44
+ return request.get(self.url)
45
+
46
+ @property
47
+ def json_captions(self) -> dict:
48
+ """Download and parse the json caption tracks."""
49
+ json_captions_url = self.url.replace('fmt=srv3','fmt=json3')
50
+ text = request.get(json_captions_url)
51
+ parsed = json.loads(text)
52
+ assert parsed['wireMagic'] == 'pb3', 'Unexpected captions format'
53
+ return parsed
54
+
55
+ def generate_srt_captions(self) -> str:
56
+ """Generate "SubRip Subtitle" captions.
57
+
58
+ Takes the xml captions from :meth:`~pytube.Caption.xml_captions` and
59
+ recompiles them into the "SubRip Subtitle" format.
60
+ """
61
+ return self.xml_caption_to_srt(self.xml_captions)
62
+
63
+ @staticmethod
64
+ def float_to_srt_time_format(d: float) -> str:
65
+ """Convert decimal durations into proper srt format.
66
+
67
+ :rtype: str
68
+ :returns:
69
+ SubRip Subtitle (str) formatted time duration.
70
+
71
+ float_to_srt_time_format(3.89) -> '00:00:03,890'
72
+ """
73
+ fraction, whole = math.modf(d)
74
+ time_fmt = time.strftime("%H:%M:%S,", time.gmtime(whole))
75
+ ms = f"{fraction:.3f}".replace("0.", "")
76
+ return time_fmt + ms
77
+
78
+ def xml_caption_to_srt(self, xml_captions: str) -> str:
79
+ """Convert xml caption tracks to "SubRip Subtitle (srt)".
80
+
81
+ :param str xml_captions:
82
+ XML formatted caption tracks.
83
+ """
84
+ segments = []
85
+ root = ElementTree.fromstring(xml_captions)
86
+ for i, child in enumerate(list(root)):
87
+ text = child.text or ""
88
+ caption = unescape(text.replace("\n", " ").replace(" ", " "),)
89
+ try:
90
+ duration = float(child.attrib["dur"])
91
+ except KeyError:
92
+ duration = 0.0
93
+ start = float(child.attrib["start"])
94
+ end = start + duration
95
+ sequence_number = i + 1 # convert from 0-indexed to 1.
96
+ line = "{seq}\n{start} --> {end}\n{text}\n".format(
97
+ seq=sequence_number,
98
+ start=self.float_to_srt_time_format(start),
99
+ end=self.float_to_srt_time_format(end),
100
+ text=caption,
101
+ )
102
+ segments.append(line)
103
+ return "\n".join(segments).strip()
104
+
105
+ def download(
106
+ self,
107
+ title: str,
108
+ srt: bool = True,
109
+ output_path: Optional[str] = None,
110
+ filename_prefix: Optional[str] = None,
111
+ ) -> str:
112
+ """Write the media stream to disk.
113
+
114
+ :param title:
115
+ Output filename (stem only) for writing media file.
116
+ If one is not specified, the default filename is used.
117
+ :type title: str
118
+ :param srt:
119
+ Set to True to download srt, false to download xml. Defaults to True.
120
+ :type srt bool
121
+ :param output_path:
122
+ (optional) Output path for writing media file. If one is not
123
+ specified, defaults to the current working directory.
124
+ :type output_path: str or None
125
+ :param filename_prefix:
126
+ (optional) A string that will be prepended to the filename.
127
+ For example a number in a playlist or the name of a series.
128
+ If one is not specified, nothing will be prepended
129
+ This is separate from filename so you can use the default
130
+ filename but still add a prefix.
131
+ :type filename_prefix: str or None
132
+
133
+ :rtype: str
134
+ """
135
+ if title.endswith(".srt") or title.endswith(".xml"):
136
+ filename = ".".join(title.split(".")[:-1])
137
+ else:
138
+ filename = title
139
+
140
+ if filename_prefix:
141
+ filename = f"{safe_filename(filename_prefix)}{filename}"
142
+
143
+ filename = safe_filename(filename)
144
+
145
+ filename += f" ({self.code})"
146
+
147
+ if srt:
148
+ filename += ".srt"
149
+ else:
150
+ filename += ".xml"
151
+
152
+ file_path = os.path.join(target_directory(output_path), filename)
153
+
154
+ with open(file_path, "w", encoding="utf-8") as file_handle:
155
+ if srt:
156
+ file_handle.write(self.generate_srt_captions())
157
+ else:
158
+ file_handle.write(self.xml_captions)
159
+
160
+ return file_path
161
+
162
+ def __repr__(self):
163
+ """Printable object representation."""
164
+ return '<Caption lang="{s.name}" code="{s.code}">'.format(s=self)
caesarpytube/cipher.py ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This module contains all logic necessary to decipher the signature.
3
+
4
+ YouTube's strategy to restrict downloading videos is to send a ciphered version
5
+ of the signature to the client, along with the decryption algorithm obfuscated
6
+ in JavaScript. For the clients to play the videos, JavaScript must take the
7
+ ciphered version, cycle it through a series of "transform functions," and then
8
+ signs the media URL with the output.
9
+
10
+ This module is responsible for (1) finding and extracting those "transform
11
+ functions" (2) maps them to Python equivalents and (3) taking the ciphered
12
+ signature and decoding it.
13
+
14
+ """
15
+ import logging
16
+ import re
17
+ from itertools import chain
18
+ from typing import Any, Callable, Dict, List, Optional, Tuple
19
+
20
+ from pytube.exceptions import ExtractError, RegexMatchError
21
+ from pytube.helpers import cache, regex_search
22
+ from pytube.parser import find_object_from_startpoint, throttling_array_split
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class Cipher:
28
+ def __init__(self, js: str):
29
+ self.transform_plan: List[str] = get_transform_plan(js)
30
+ var_regex = re.compile(r"^\w+\W")
31
+ var_match = var_regex.search(self.transform_plan[0])
32
+ if not var_match:
33
+ raise RegexMatchError(
34
+ caller="__init__", pattern=var_regex.pattern
35
+ )
36
+ var = var_match.group(0)[:-1]
37
+ self.transform_map = get_transform_map(js, var)
38
+ self.js_func_patterns = [
39
+ r"\w+\.(\w+)\(\w,(\d+)\)",
40
+ r"\w+\[(\"\w+\")\]\(\w,(\d+)\)"
41
+ ]
42
+
43
+ self.throttling_plan = get_throttling_plan(js)
44
+ self.throttling_array = get_throttling_function_array(js)
45
+
46
+ self.calculated_n = None
47
+
48
+ def calculate_n(self, initial_n: list):
49
+ """Converts n to the correct value to prevent throttling."""
50
+ if self.calculated_n:
51
+ return self.calculated_n
52
+
53
+ # First, update all instances of 'b' with the list(initial_n)
54
+ for i in range(len(self.throttling_array)):
55
+ if self.throttling_array[i] == 'b':
56
+ self.throttling_array[i] = initial_n
57
+
58
+ for step in self.throttling_plan:
59
+ curr_func = self.throttling_array[int(step[0])]
60
+ if not callable(curr_func):
61
+ logger.debug(f'{curr_func} is not callable.')
62
+ logger.debug(f'Throttling array:\n{self.throttling_array}\n')
63
+ raise ExtractError(f'{curr_func} is not callable.')
64
+
65
+ first_arg = self.throttling_array[int(step[1])]
66
+
67
+ if len(step) == 2:
68
+ curr_func(first_arg)
69
+ elif len(step) == 3:
70
+ second_arg = self.throttling_array[int(step[2])]
71
+ curr_func(first_arg, second_arg)
72
+
73
+ self.calculated_n = ''.join(initial_n)
74
+ return self.calculated_n
75
+
76
+ def get_signature(self, ciphered_signature: str) -> str:
77
+ """Decipher the signature.
78
+
79
+ Taking the ciphered signature, applies the transform functions.
80
+
81
+ :param str ciphered_signature:
82
+ The ciphered signature sent in the ``player_config``.
83
+ :rtype: str
84
+ :returns:
85
+ Decrypted signature required to download the media content.
86
+ """
87
+ signature = list(ciphered_signature)
88
+
89
+ for js_func in self.transform_plan:
90
+ name, argument = self.parse_function(js_func) # type: ignore
91
+ signature = self.transform_map[name](signature, argument)
92
+ logger.debug(
93
+ "applied transform function\n"
94
+ "output: %s\n"
95
+ "js_function: %s\n"
96
+ "argument: %d\n"
97
+ "function: %s",
98
+ "".join(signature),
99
+ name,
100
+ argument,
101
+ self.transform_map[name],
102
+ )
103
+
104
+ return "".join(signature)
105
+
106
+ @cache
107
+ def parse_function(self, js_func: str) -> Tuple[str, int]:
108
+ """Parse the Javascript transform function.
109
+
110
+ Break a JavaScript transform function down into a two element ``tuple``
111
+ containing the function name and some integer-based argument.
112
+
113
+ :param str js_func:
114
+ The JavaScript version of the transform function.
115
+ :rtype: tuple
116
+ :returns:
117
+ two element tuple containing the function name and an argument.
118
+
119
+ **Example**:
120
+
121
+ parse_function('DE.AJ(a,15)')
122
+ ('AJ', 15)
123
+
124
+ """
125
+ logger.debug("parsing transform function")
126
+ for pattern in self.js_func_patterns:
127
+ regex = re.compile(pattern)
128
+ parse_match = regex.search(js_func)
129
+ if parse_match:
130
+ fn_name, fn_arg = parse_match.groups()
131
+ return fn_name, int(fn_arg)
132
+
133
+ raise RegexMatchError(
134
+ caller="parse_function", pattern="js_func_patterns"
135
+ )
136
+
137
+
138
+ def get_initial_function_name(js: str) -> str:
139
+ """Extract the name of the function responsible for computing the signature.
140
+ :param str js:
141
+ The contents of the base.js asset file.
142
+ :rtype: str
143
+ :returns:
144
+ Function name from regex match
145
+ """
146
+
147
+ function_patterns = [
148
+ r"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
149
+ r"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
150
+ r'(?:\b|[^a-zA-Z0-9$])(?P<sig>[a-zA-Z0-9$]{2})\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)', # noqa: E501
151
+ r'(?P<sig>[a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)', # noqa: E501
152
+ r'(["\'])signature\1\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
153
+ r"\.sig\|\|(?P<sig>[a-zA-Z0-9$]+)\(",
154
+ r"yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
155
+ r"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
156
+ r"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
157
+ r"\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
158
+ r"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
159
+ r"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
160
+ ]
161
+ logger.debug("finding initial function name")
162
+ for pattern in function_patterns:
163
+ regex = re.compile(pattern)
164
+ function_match = regex.search(js)
165
+ if function_match:
166
+ logger.debug("finished regex search, matched: %s", pattern)
167
+ return function_match.group(1)
168
+
169
+ raise RegexMatchError(
170
+ caller="get_initial_function_name", pattern="multiple"
171
+ )
172
+
173
+
174
+ def get_transform_plan(js: str) -> List[str]:
175
+ """Extract the "transform plan".
176
+
177
+ The "transform plan" is the functions that the ciphered signature is
178
+ cycled through to obtain the actual signature.
179
+
180
+ :param str js:
181
+ The contents of the base.js asset file.
182
+
183
+ **Example**:
184
+
185
+ ['DE.AJ(a,15)',
186
+ 'DE.VR(a,3)',
187
+ 'DE.AJ(a,51)',
188
+ 'DE.VR(a,3)',
189
+ 'DE.kT(a,51)',
190
+ 'DE.kT(a,8)',
191
+ 'DE.VR(a,3)',
192
+ 'DE.kT(a,21)']
193
+ """
194
+ name = re.escape(get_initial_function_name(js))
195
+ pattern = r"%s=function\(\w\){[a-z=\.\(\"\)]*;(.*);(?:.+)}" % name
196
+ logger.debug("getting transform plan")
197
+ return regex_search(pattern, js, group=1).split(";")
198
+
199
+
200
+ def get_transform_object(js: str, var: str) -> List[str]:
201
+ """Extract the "transform object".
202
+
203
+ The "transform object" contains the function definitions referenced in the
204
+ "transform plan". The ``var`` argument is the obfuscated variable name
205
+ which contains these functions, for example, given the function call
206
+ ``DE.AJ(a,15)`` returned by the transform plan, "DE" would be the var.
207
+
208
+ :param str js:
209
+ The contents of the base.js asset file.
210
+ :param str var:
211
+ The obfuscated variable name that stores an object with all functions
212
+ that descrambles the signature.
213
+
214
+ **Example**:
215
+
216
+ >>> get_transform_object(js, 'DE')
217
+ ['AJ:function(a){a.reverse()}',
218
+ 'VR:function(a,b){a.splice(0,b)}',
219
+ 'kT:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}']
220
+
221
+ """
222
+ pattern = r"var %s={(.*?)};" % re.escape(var)
223
+ logger.debug("getting transform object")
224
+ regex = re.compile(pattern, flags=re.DOTALL)
225
+ transform_match = regex.search(js)
226
+ if not transform_match:
227
+ raise RegexMatchError(caller="get_transform_object", pattern=pattern)
228
+
229
+ return transform_match.group(1).replace("\n", " ").split(", ")
230
+
231
+
232
+ def get_transform_map(js: str, var: str) -> Dict:
233
+ """Build a transform function lookup.
234
+
235
+ Build a lookup table of obfuscated JavaScript function names to the
236
+ Python equivalents.
237
+
238
+ :param str js:
239
+ The contents of the base.js asset file.
240
+ :param str var:
241
+ The obfuscated variable name that stores an object with all functions
242
+ that descrambles the signature.
243
+
244
+ """
245
+ transform_object = get_transform_object(js, var)
246
+ mapper = {}
247
+ for obj in transform_object:
248
+ # AJ:function(a){a.reverse()} => AJ, function(a){a.reverse()}
249
+ name, function = obj.split(":", 1)
250
+ fn = map_functions(function)
251
+ mapper[name] = fn
252
+ return mapper
253
+
254
+
255
+ def get_throttling_function_name(js: str) -> str:
256
+ """Extract the name of the function that computes the throttling parameter.
257
+
258
+ :param str js:
259
+ The contents of the base.js asset file.
260
+ :rtype: str
261
+ :returns:
262
+ The name of the function used to compute the throttling parameter.
263
+ """
264
+ function_patterns = [
265
+ # https://github.com/ytdl-org/youtube-dl/issues/29326#issuecomment-865985377
266
+ # https://github.com/yt-dlp/yt-dlp/commit/48416bc4a8f1d5ff07d5977659cb8ece7640dcd8
267
+ # var Bpa = [iha];
268
+ # ...
269
+ # a.C && (b = a.get("n")) && (b = Bpa[0](b), a.set("n", b),
270
+ # Bpa.length || iha("")) }};
271
+ # In the above case, `iha` is the relevant function name
272
+ r'a\.[a-zA-Z]\s*&&\s*\([a-z]\s*=\s*a\.get\("n"\)\)\s*&&\s*'
273
+ r'\([a-z]\s*=\s*([a-zA-Z0-9$]+)(\[\d+\])?\([a-z]\)',
274
+ ]
275
+ logger.debug('Finding throttling function name')
276
+ for pattern in function_patterns:
277
+ regex = re.compile(pattern)
278
+ function_match = regex.search(js)
279
+ if function_match:
280
+ logger.debug("finished regex search, matched: %s", pattern)
281
+ if len(function_match.groups()) == 1:
282
+ return function_match.group(1)
283
+ idx = function_match.group(2)
284
+ if idx:
285
+ idx = idx.strip("[]")
286
+ array = re.search(
287
+ r'var {nfunc}\s*=\s*(\[.+?\]);'.format(
288
+ nfunc=re.escape(function_match.group(1))),
289
+ js
290
+ )
291
+ if array:
292
+ array = array.group(1).strip("[]").split(",")
293
+ array = [x.strip() for x in array]
294
+ return array[int(idx)]
295
+
296
+ raise RegexMatchError(
297
+ caller="get_throttling_function_name", pattern="multiple"
298
+ )
299
+
300
+
301
+ def get_throttling_function_code(js: str) -> str:
302
+ """Extract the raw code for the throttling function.
303
+
304
+ :param str js:
305
+ The contents of the base.js asset file.
306
+ :rtype: str
307
+ :returns:
308
+ The name of the function used to compute the throttling parameter.
309
+ """
310
+ # Begin by extracting the correct function name
311
+ name = re.escape(get_throttling_function_name(js))
312
+
313
+ # Identify where the function is defined
314
+ pattern_start = r"%s=function\(\w\)" % name
315
+ regex = re.compile(pattern_start)
316
+ match = regex.search(js)
317
+
318
+ # Extract the code within curly braces for the function itself, and merge any split lines
319
+ code_lines_list = find_object_from_startpoint(js, match.span()[1]).split('\n')
320
+ joined_lines = "".join(code_lines_list)
321
+
322
+ # Prepend function definition (e.g. `Dea=function(a)`)
323
+ return match.group(0) + joined_lines
324
+
325
+
326
+ def get_throttling_function_array(js: str) -> List[Any]:
327
+ """Extract the "c" array.
328
+
329
+ :param str js:
330
+ The contents of the base.js asset file.
331
+ :returns:
332
+ The array of various integers, arrays, and functions.
333
+ """
334
+ raw_code = get_throttling_function_code(js)
335
+
336
+ array_start = r",c=\["
337
+ array_regex = re.compile(array_start)
338
+ match = array_regex.search(raw_code)
339
+
340
+ array_raw = find_object_from_startpoint(raw_code, match.span()[1] - 1)
341
+ str_array = throttling_array_split(array_raw)
342
+
343
+ converted_array = []
344
+ for el in str_array:
345
+ try:
346
+ converted_array.append(int(el))
347
+ continue
348
+ except ValueError:
349
+ # Not an integer value.
350
+ pass
351
+
352
+ if el == 'null':
353
+ converted_array.append(None)
354
+ continue
355
+
356
+ if el.startswith('"') and el.endswith('"'):
357
+ # Convert e.g. '"abcdef"' to string without quotation marks, 'abcdef'
358
+ converted_array.append(el[1:-1])
359
+ continue
360
+
361
+ if el.startswith('function'):
362
+ mapper = (
363
+ (r"{for\(\w=\(\w%\w\.length\+\w\.length\)%\w\.length;\w--;\)\w\.unshift\(\w.pop\(\)\)}", throttling_unshift), # noqa:E501
364
+ (r"{\w\.reverse\(\)}", throttling_reverse),
365
+ (r"{\w\.push\(\w\)}", throttling_push),
366
+ (r";var\s\w=\w\[0\];\w\[0\]=\w\[\w\];\w\[\w\]=\w}", throttling_swap),
367
+ (r"case\s\d+", throttling_cipher_function),
368
+ (r"\w\.splice\(0,1,\w\.splice\(\w,1,\w\[0\]\)\[0\]\)", throttling_nested_splice), # noqa:E501
369
+ (r";\w\.splice\(\w,1\)}", js_splice),
370
+ (r"\w\.splice\(-\w\)\.reverse\(\)\.forEach\(function\(\w\){\w\.unshift\(\w\)}\)", throttling_prepend), # noqa:E501
371
+ (r"for\(var \w=\w\.length;\w;\)\w\.push\(\w\.splice\(--\w,1\)\[0\]\)}", throttling_reverse), # noqa:E501
372
+ )
373
+
374
+ found = False
375
+ for pattern, fn in mapper:
376
+ if re.search(pattern, el):
377
+ converted_array.append(fn)
378
+ found = True
379
+ if found:
380
+ continue
381
+
382
+ converted_array.append(el)
383
+
384
+ # Replace null elements with array itself
385
+ for i in range(len(converted_array)):
386
+ if converted_array[i] is None:
387
+ converted_array[i] = converted_array
388
+
389
+ return converted_array
390
+
391
+
392
+ def get_throttling_plan(js: str):
393
+ """Extract the "throttling plan".
394
+
395
+ The "throttling plan" is a list of tuples used for calling functions
396
+ in the c array. The first element of the tuple is the index of the
397
+ function to call, and any remaining elements of the tuple are arguments
398
+ to pass to that function.
399
+
400
+ :param str js:
401
+ The contents of the base.js asset file.
402
+ :returns:
403
+ The full function code for computing the throttlign parameter.
404
+ """
405
+ raw_code = get_throttling_function_code(js)
406
+
407
+ transform_start = r"try{"
408
+ plan_regex = re.compile(transform_start)
409
+ match = plan_regex.search(raw_code)
410
+
411
+ transform_plan_raw = find_object_from_startpoint(raw_code, match.span()[1] - 1)
412
+
413
+ # Steps are either c[x](c[y]) or c[x](c[y],c[z])
414
+ step_start = r"c\[(\d+)\]\(c\[(\d+)\](,c(\[(\d+)\]))?\)"
415
+ step_regex = re.compile(step_start)
416
+ matches = step_regex.findall(transform_plan_raw)
417
+ transform_steps = []
418
+ for match in matches:
419
+ if match[4] != '':
420
+ transform_steps.append((match[0],match[1],match[4]))
421
+ else:
422
+ transform_steps.append((match[0],match[1]))
423
+
424
+ return transform_steps
425
+
426
+
427
+ def reverse(arr: List, _: Optional[Any]):
428
+ """Reverse elements in a list.
429
+
430
+ This function is equivalent to:
431
+
432
+ .. code-block:: javascript
433
+
434
+ function(a, b) { a.reverse() }
435
+
436
+ This method takes an unused ``b`` variable as their transform functions
437
+ universally sent two arguments.
438
+
439
+ **Example**:
440
+
441
+ >>> reverse([1, 2, 3, 4])
442
+ [4, 3, 2, 1]
443
+ """
444
+ return arr[::-1]
445
+
446
+
447
+ def splice(arr: List, b: int):
448
+ """Add/remove items to/from a list.
449
+
450
+ This function is equivalent to:
451
+
452
+ .. code-block:: javascript
453
+
454
+ function(a, b) { a.splice(0, b) }
455
+
456
+ **Example**:
457
+
458
+ >>> splice([1, 2, 3, 4], 2)
459
+ [1, 2]
460
+ """
461
+ return arr[b:]
462
+
463
+
464
+ def swap(arr: List, b: int):
465
+ """Swap positions at b modulus the list length.
466
+
467
+ This function is equivalent to:
468
+
469
+ .. code-block:: javascript
470
+
471
+ function(a, b) { var c=a[0];a[0]=a[b%a.length];a[b]=c }
472
+
473
+ **Example**:
474
+
475
+ >>> swap([1, 2, 3, 4], 2)
476
+ [3, 2, 1, 4]
477
+ """
478
+ r = b % len(arr)
479
+ return list(chain([arr[r]], arr[1:r], [arr[0]], arr[r + 1 :]))
480
+
481
+
482
+ def throttling_reverse(arr: list):
483
+ """Reverses the input list.
484
+
485
+ Needs to do an in-place reversal so that the passed list gets changed.
486
+ To accomplish this, we create a reversed copy, and then change each
487
+ indvidual element.
488
+ """
489
+ reverse_copy = arr.copy()[::-1]
490
+ for i in range(len(reverse_copy)):
491
+ arr[i] = reverse_copy[i]
492
+
493
+
494
+ def throttling_push(d: list, e: Any):
495
+ """Pushes an element onto a list."""
496
+ d.append(e)
497
+
498
+
499
+ def throttling_mod_func(d: list, e: int):
500
+ """Perform the modular function from the throttling array functions.
501
+
502
+ In the javascript, the modular operation is as follows:
503
+ e = (e % d.length + d.length) % d.length
504
+
505
+ We simply translate this to python here.
506
+ """
507
+ return (e % len(d) + len(d)) % len(d)
508
+
509
+
510
+ def throttling_unshift(d: list, e: int):
511
+ """Rotates the elements of the list to the right.
512
+
513
+ In the javascript, the operation is as follows:
514
+ for(e=(e%d.length+d.length)%d.length;e--;)d.unshift(d.pop())
515
+ """
516
+ e = throttling_mod_func(d, e)
517
+ new_arr = d[-e:] + d[:-e]
518
+ d.clear()
519
+ for el in new_arr:
520
+ d.append(el)
521
+
522
+
523
+ def throttling_cipher_function(d: list, e: str):
524
+ """This ciphers d with e to generate a new list.
525
+
526
+ In the javascript, the operation is as follows:
527
+ var h = [A-Za-z0-9-_], f = 96; // simplified from switch-case loop
528
+ d.forEach(
529
+ function(l,m,n){
530
+ this.push(
531
+ n[m]=h[
532
+ (h.indexOf(l)-h.indexOf(this[m])+m-32+f--)%h.length
533
+ ]
534
+ )
535
+ },
536
+ e.split("")
537
+ )
538
+ """
539
+ h = list('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_')
540
+ f = 96
541
+ # by naming it "this" we can more closely reflect the js
542
+ this = list(e)
543
+
544
+ # This is so we don't run into weirdness with enumerate while
545
+ # we change the input list
546
+ copied_list = d.copy()
547
+
548
+ for m, l in enumerate(copied_list):
549
+ bracket_val = (h.index(l) - h.index(this[m]) + m - 32 + f) % len(h)
550
+ this.append(
551
+ h[bracket_val]
552
+ )
553
+ d[m] = h[bracket_val]
554
+ f -= 1
555
+
556
+
557
+ def throttling_nested_splice(d: list, e: int):
558
+ """Nested splice function in throttling js.
559
+
560
+ In the javascript, the operation is as follows:
561
+ function(d,e){
562
+ e=(e%d.length+d.length)%d.length;
563
+ d.splice(
564
+ 0,
565
+ 1,
566
+ d.splice(
567
+ e,
568
+ 1,
569
+ d[0]
570
+ )[0]
571
+ )
572
+ }
573
+
574
+ While testing, all this seemed to do is swap element 0 and e,
575
+ but the actual process is preserved in case there was an edge
576
+ case that was not considered.
577
+ """
578
+ e = throttling_mod_func(d, e)
579
+ inner_splice = js_splice(
580
+ d,
581
+ e,
582
+ 1,
583
+ d[0]
584
+ )
585
+ js_splice(
586
+ d,
587
+ 0,
588
+ 1,
589
+ inner_splice[0]
590
+ )
591
+
592
+
593
+ def throttling_prepend(d: list, e: int):
594
+ """
595
+
596
+ In the javascript, the operation is as follows:
597
+ function(d,e){
598
+ e=(e%d.length+d.length)%d.length;
599
+ d.splice(-e).reverse().forEach(
600
+ function(f){
601
+ d.unshift(f)
602
+ }
603
+ )
604
+ }
605
+
606
+ Effectively, this moves the last e elements of d to the beginning.
607
+ """
608
+ start_len = len(d)
609
+ # First, calculate e
610
+ e = throttling_mod_func(d, e)
611
+
612
+ # Then do the prepending
613
+ new_arr = d[-e:] + d[:-e]
614
+
615
+ # And update the input list
616
+ d.clear()
617
+ for el in new_arr:
618
+ d.append(el)
619
+
620
+ end_len = len(d)
621
+ assert start_len == end_len
622
+
623
+
624
+ def throttling_swap(d: list, e: int):
625
+ """Swap positions of the 0'th and e'th elements in-place."""
626
+ e = throttling_mod_func(d, e)
627
+ f = d[0]
628
+ d[0] = d[e]
629
+ d[e] = f
630
+
631
+
632
+ def js_splice(arr: list, start: int, delete_count=None, *items):
633
+ """Implementation of javascript's splice function.
634
+
635
+ :param list arr:
636
+ Array to splice
637
+ :param int start:
638
+ Index at which to start changing the array
639
+ :param int delete_count:
640
+ Number of elements to delete from the array
641
+ :param *items:
642
+ Items to add to the array
643
+
644
+ Reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice # noqa:E501
645
+ """
646
+ # Special conditions for start value
647
+ try:
648
+ if start > len(arr):
649
+ start = len(arr)
650
+ # If start is negative, count backwards from end
651
+ if start < 0:
652
+ start = len(arr) - start
653
+ except TypeError:
654
+ # Non-integer start values are treated as 0 in js
655
+ start = 0
656
+
657
+ # Special condition when delete_count is greater than remaining elements
658
+ if not delete_count or delete_count >= len(arr) - start:
659
+ delete_count = len(arr) - start # noqa: N806
660
+
661
+ deleted_elements = arr[start:start + delete_count]
662
+
663
+ # Splice appropriately.
664
+ new_arr = arr[:start] + list(items) + arr[start + delete_count:]
665
+
666
+ # Replace contents of input array
667
+ arr.clear()
668
+ for el in new_arr:
669
+ arr.append(el)
670
+
671
+ return deleted_elements
672
+
673
+
674
+ def map_functions(js_func: str) -> Callable:
675
+ """For a given JavaScript transform function, return the Python equivalent.
676
+
677
+ :param str js_func:
678
+ The JavaScript version of the transform function.
679
+ """
680
+ mapper = (
681
+ # function(a){a.reverse()}
682
+ (r"{\w\.reverse\(\)}", reverse),
683
+ # function(a,b){a.splice(0,b)}
684
+ (r"{\w\.splice\(0,\w\)}", splice),
685
+ # function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}
686
+ (r"{var\s\w=\w\[0\];\w\[0\]=\w\[\w\%\w.length\];\w\[\w\]=\w}", swap),
687
+ # function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}
688
+ (
689
+ r"{var\s\w=\w\[0\];\w\[0\]=\w\[\w\%\w.length\];\w\[\w\%\w.length\]=\w}",
690
+ swap,
691
+ ),
692
+ )
693
+
694
+ for pattern, fn in mapper:
695
+ if re.search(pattern, js_func):
696
+ return fn
697
+ raise RegexMatchError(caller="map_functions", pattern="multiple")
caesarpytube/cli.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """A simple command line application to download youtube videos."""
3
+ import argparse
4
+ import gzip
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import sys
10
+ import datetime as dt
11
+ import subprocess # nosec
12
+ from typing import List, Optional
13
+
14
+ import pytube.exceptions as exceptions
15
+ from pytube import __version__
16
+ from pytube import CaptionQuery, Playlist, Stream, YouTube
17
+ from pytube.helpers import safe_filename, setup_logger
18
+
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def main():
24
+ """Command line application to download youtube videos."""
25
+ # noinspection PyTypeChecker
26
+ parser = argparse.ArgumentParser(description=main.__doc__)
27
+ args = _parse_args(parser)
28
+ if args.verbose:
29
+ log_filename = None
30
+ if args.logfile:
31
+ log_filename = args.logfile
32
+ setup_logger(logging.DEBUG, log_filename=log_filename)
33
+ logger.debug(f'Pytube version: {__version__}')
34
+
35
+ if not args.url or "youtu" not in args.url:
36
+ parser.print_help()
37
+ sys.exit(1)
38
+
39
+ if "/playlist" in args.url:
40
+ print("Loading playlist...")
41
+ playlist = Playlist(args.url)
42
+ if not args.target:
43
+ args.target = safe_filename(playlist.title)
44
+ for youtube_video in playlist.videos:
45
+ try:
46
+ _perform_args_on_youtube(youtube_video, args)
47
+ except exceptions.PytubeError as e:
48
+ print(f"There was an error with video: {youtube_video}")
49
+ print(e)
50
+ else:
51
+ print("Loading video...")
52
+ youtube = YouTube(args.url)
53
+ _perform_args_on_youtube(youtube, args)
54
+
55
+
56
+ def _perform_args_on_youtube(
57
+ youtube: YouTube, args: argparse.Namespace
58
+ ) -> None:
59
+ if len(sys.argv) == 2 : # no arguments parsed
60
+ download_highest_resolution_progressive(
61
+ youtube=youtube, resolution="highest", target=args.target
62
+ )
63
+ if args.list_captions:
64
+ _print_available_captions(youtube.captions)
65
+ if args.list:
66
+ display_streams(youtube)
67
+ if args.build_playback_report:
68
+ build_playback_report(youtube)
69
+ if args.itag:
70
+ download_by_itag(youtube=youtube, itag=args.itag, target=args.target)
71
+ if args.caption_code:
72
+ download_caption(
73
+ youtube=youtube, lang_code=args.caption_code, target=args.target
74
+ )
75
+ if args.resolution:
76
+ download_by_resolution(
77
+ youtube=youtube, resolution=args.resolution, target=args.target
78
+ )
79
+ if args.audio:
80
+ download_audio(
81
+ youtube=youtube, filetype=args.audio, target=args.target
82
+ )
83
+ if args.ffmpeg:
84
+ ffmpeg_process(
85
+ youtube=youtube, resolution=args.ffmpeg, target=args.target
86
+ )
87
+
88
+
89
+ def _parse_args(
90
+ parser: argparse.ArgumentParser, args: Optional[List] = None
91
+ ) -> argparse.Namespace:
92
+ parser.add_argument(
93
+ "url", help="The YouTube /watch or /playlist url", nargs="?"
94
+ )
95
+ parser.add_argument(
96
+ "--version", action="version", version="%(prog)s " + __version__,
97
+ )
98
+ parser.add_argument(
99
+ "--itag", type=int, help="The itag for the desired stream",
100
+ )
101
+ parser.add_argument(
102
+ "-r",
103
+ "--resolution",
104
+ type=str,
105
+ help="The resolution for the desired stream",
106
+ )
107
+ parser.add_argument(
108
+ "-l",
109
+ "--list",
110
+ action="store_true",
111
+ help=(
112
+ "The list option causes pytube cli to return a list of streams "
113
+ "available to download"
114
+ ),
115
+ )
116
+ parser.add_argument(
117
+ "-v",
118
+ "--verbose",
119
+ action="store_true",
120
+ dest="verbose",
121
+ help="Set logger output to verbose output.",
122
+ )
123
+ parser.add_argument(
124
+ "--logfile",
125
+ action="store",
126
+ help="logging debug and error messages into a log file",
127
+ )
128
+ parser.add_argument(
129
+ "--build-playback-report",
130
+ action="store_true",
131
+ help="Save the html and js to disk",
132
+ )
133
+ parser.add_argument(
134
+ "-c",
135
+ "--caption-code",
136
+ type=str,
137
+ help=(
138
+ "Download srt captions for given language code. "
139
+ "Prints available language codes if no argument given"
140
+ ),
141
+ )
142
+ parser.add_argument(
143
+ '-lc',
144
+ '--list-captions',
145
+ action='store_true',
146
+ help=(
147
+ "List available caption codes for a video"
148
+ )
149
+ )
150
+ parser.add_argument(
151
+ "-t",
152
+ "--target",
153
+ help=(
154
+ "The output directory for the downloaded stream. "
155
+ "Default is current working directory"
156
+ ),
157
+ )
158
+ parser.add_argument(
159
+ "-a",
160
+ "--audio",
161
+ const="mp4",
162
+ nargs="?",
163
+ help=(
164
+ "Download the audio for a given URL at the highest bitrate available"
165
+ "Defaults to mp4 format if none is specified"
166
+ ),
167
+ )
168
+ parser.add_argument(
169
+ "-f",
170
+ "--ffmpeg",
171
+ const="best",
172
+ nargs="?",
173
+ help=(
174
+ "Downloads the audio and video stream for resolution provided"
175
+ "If no resolution is provided, downloads the best resolution"
176
+ "Runs the command line program ffmpeg to combine the audio and video"
177
+ ),
178
+ )
179
+
180
+ return parser.parse_args(args)
181
+
182
+
183
+ def build_playback_report(youtube: YouTube) -> None:
184
+ """Serialize the request data to json for offline debugging.
185
+
186
+ :param YouTube youtube:
187
+ A YouTube object.
188
+ """
189
+ ts = int(dt.datetime.utcnow().timestamp())
190
+ fp = os.path.join(os.getcwd(), f"yt-video-{youtube.video_id}-{ts}.json.gz")
191
+
192
+ js = youtube.js
193
+ watch_html = youtube.watch_html
194
+ vid_info = youtube.vid_info
195
+
196
+ with gzip.open(fp, "wb") as fh:
197
+ fh.write(
198
+ json.dumps(
199
+ {
200
+ "url": youtube.watch_url,
201
+ "js": js,
202
+ "watch_html": watch_html,
203
+ "video_info": vid_info,
204
+ }
205
+ ).encode("utf8"),
206
+ )
207
+
208
+
209
+ def display_progress_bar(
210
+ bytes_received: int, filesize: int, ch: str = "█", scale: float = 0.55
211
+ ) -> None:
212
+ """Display a simple, pretty progress bar.
213
+
214
+ Example:
215
+ ~~~~~~~~
216
+ PSY - GANGNAM STYLE(강남스타일) MV.mp4
217
+ ↳ |███████████████████████████████████████| 100.0%
218
+
219
+ :param int bytes_received:
220
+ The delta between the total file size (bytes) and bytes already
221
+ written to disk.
222
+ :param int filesize:
223
+ File size of the media stream in bytes.
224
+ :param str ch:
225
+ Character to use for presenting progress segment.
226
+ :param float scale:
227
+ Scale multiplier to reduce progress bar size.
228
+
229
+ """
230
+ columns = shutil.get_terminal_size().columns
231
+ max_width = int(columns * scale)
232
+
233
+ filled = int(round(max_width * bytes_received / float(filesize)))
234
+ remaining = max_width - filled
235
+ progress_bar = ch * filled + " " * remaining
236
+ percent = round(100.0 * bytes_received / float(filesize), 1)
237
+ text = f" ↳ |{progress_bar}| {percent}%\r"
238
+ sys.stdout.write(text)
239
+ sys.stdout.flush()
240
+
241
+
242
+ # noinspection PyUnusedLocal
243
+ def on_progress(
244
+ stream: Stream, chunk: bytes, bytes_remaining: int
245
+ ) -> None: # pylint: disable=W0613
246
+ filesize = stream.filesize
247
+ bytes_received = filesize - bytes_remaining
248
+ display_progress_bar(bytes_received, filesize)
249
+
250
+
251
+ def _download(
252
+ stream: Stream,
253
+ target: Optional[str] = None,
254
+ filename: Optional[str] = None,
255
+ ) -> None:
256
+ filesize_megabytes = stream.filesize // 1048576
257
+ print(f"{filename or stream.default_filename} | {filesize_megabytes} MB")
258
+ file_path = stream.get_file_path(filename=filename, output_path=target)
259
+ if stream.exists_at_path(file_path):
260
+ print(f"Already downloaded at:\n{file_path}")
261
+ return
262
+
263
+ stream.download(output_path=target, filename=filename)
264
+ sys.stdout.write("\n")
265
+
266
+
267
+ def _unique_name(base: str, subtype: str, media_type: str, target: str) -> str:
268
+ """
269
+ Given a base name, the file format, and the target directory, will generate
270
+ a filename unique for that directory and file format.
271
+ :param str base:
272
+ The given base-name.
273
+ :param str subtype:
274
+ The filetype of the video which will be downloaded.
275
+ :param str media_type:
276
+ The media_type of the file, ie. "audio" or "video"
277
+ :param Path target:
278
+ Target directory for download.
279
+ """
280
+ counter = 0
281
+ while True:
282
+ file_name = f"{base}_{media_type}_{counter}"
283
+ file_path = os.path.join(target, f"{file_name}.{subtype}")
284
+ if not os.path.exists(file_path):
285
+ return file_name
286
+ counter += 1
287
+
288
+
289
+ def ffmpeg_process(
290
+ youtube: YouTube, resolution: str, target: Optional[str] = None
291
+ ) -> None:
292
+ """
293
+ Decides the correct video stream to download, then calls _ffmpeg_downloader.
294
+
295
+ :param YouTube youtube:
296
+ A valid YouTube object.
297
+ :param str resolution:
298
+ YouTube video resolution.
299
+ :param str target:
300
+ Target directory for download
301
+ """
302
+ youtube.register_on_progress_callback(on_progress)
303
+ target = target or os.getcwd()
304
+
305
+ if resolution == "best":
306
+ highest_quality_stream = (
307
+ youtube.streams.filter(progressive=False)
308
+ .order_by("resolution")
309
+ .last()
310
+ )
311
+ mp4_stream = (
312
+ youtube.streams.filter(progressive=False, subtype="mp4")
313
+ .order_by("resolution")
314
+ .last()
315
+ )
316
+ if highest_quality_stream.resolution == mp4_stream.resolution:
317
+ video_stream = mp4_stream
318
+ else:
319
+ video_stream = highest_quality_stream
320
+ else:
321
+ video_stream = youtube.streams.filter(
322
+ progressive=False, resolution=resolution, subtype="mp4"
323
+ ).first()
324
+ if not video_stream:
325
+ video_stream = youtube.streams.filter(
326
+ progressive=False, resolution=resolution
327
+ ).first()
328
+ if video_stream is None:
329
+ print(f"Could not find a stream with resolution: {resolution}")
330
+ print("Try one of these:")
331
+ display_streams(youtube)
332
+ sys.exit()
333
+
334
+ audio_stream = youtube.streams.get_audio_only(video_stream.subtype)
335
+ if not audio_stream:
336
+ audio_stream = (
337
+ youtube.streams.filter(only_audio=True).order_by("abr").last()
338
+ )
339
+ if not audio_stream:
340
+ print("Could not find an audio only stream")
341
+ sys.exit()
342
+ _ffmpeg_downloader(
343
+ audio_stream=audio_stream, video_stream=video_stream, target=target
344
+ )
345
+
346
+
347
+ def _ffmpeg_downloader(
348
+ audio_stream: Stream, video_stream: Stream, target: str
349
+ ) -> None:
350
+ """
351
+ Given a YouTube Stream object, finds the correct audio stream, downloads them both
352
+ giving them a unique name, them uses ffmpeg to create a new file with the audio
353
+ and video from the previously downloaded files. Then deletes the original adaptive
354
+ streams, leaving the combination.
355
+
356
+ :param Stream audio_stream:
357
+ A valid Stream object representing the audio to download
358
+ :param Stream video_stream:
359
+ A valid Stream object representing the video to download
360
+ :param Path target:
361
+ A valid Path object
362
+ """
363
+ video_unique_name = _unique_name(
364
+ safe_filename(video_stream.title),
365
+ video_stream.subtype,
366
+ "video",
367
+ target=target,
368
+ )
369
+ audio_unique_name = _unique_name(
370
+ safe_filename(video_stream.title),
371
+ audio_stream.subtype,
372
+ "audio",
373
+ target=target,
374
+ )
375
+ _download(stream=video_stream, target=target, filename=video_unique_name)
376
+ print("Loading audio...")
377
+ _download(stream=audio_stream, target=target, filename=audio_unique_name)
378
+
379
+ video_path = os.path.join(
380
+ target, f"{video_unique_name}.{video_stream.subtype}"
381
+ )
382
+ audio_path = os.path.join(
383
+ target, f"{audio_unique_name}.{audio_stream.subtype}"
384
+ )
385
+ final_path = os.path.join(
386
+ target, f"{safe_filename(video_stream.title)}.{video_stream.subtype}"
387
+ )
388
+
389
+ subprocess.run( # nosec
390
+ [
391
+ "ffmpeg",
392
+ "-i",
393
+ video_path,
394
+ "-i",
395
+ audio_path,
396
+ "-codec",
397
+ "copy",
398
+ final_path,
399
+ ]
400
+ )
401
+ os.unlink(video_path)
402
+ os.unlink(audio_path)
403
+
404
+
405
+ def download_by_itag(
406
+ youtube: YouTube, itag: int, target: Optional[str] = None
407
+ ) -> None:
408
+ """Start downloading a YouTube video.
409
+
410
+ :param YouTube youtube:
411
+ A valid YouTube object.
412
+ :param int itag:
413
+ YouTube format identifier code.
414
+ :param str target:
415
+ Target directory for download
416
+ """
417
+ stream = youtube.streams.get_by_itag(itag)
418
+ if stream is None:
419
+ print(f"Could not find a stream with itag: {itag}")
420
+ print("Try one of these:")
421
+ display_streams(youtube)
422
+ sys.exit()
423
+
424
+ youtube.register_on_progress_callback(on_progress)
425
+
426
+ try:
427
+ _download(stream, target=target)
428
+ except KeyboardInterrupt:
429
+ sys.exit()
430
+
431
+
432
+ def download_by_resolution(
433
+ youtube: YouTube, resolution: str, target: Optional[str] = None
434
+ ) -> None:
435
+ """Start downloading a YouTube video.
436
+
437
+ :param YouTube youtube:
438
+ A valid YouTube object.
439
+ :param str resolution:
440
+ YouTube video resolution.
441
+ :param str target:
442
+ Target directory for download
443
+ """
444
+ # TODO(nficano): allow dash itags to be selected
445
+ stream = youtube.streams.get_by_resolution(resolution)
446
+ if stream is None:
447
+ print(f"Could not find a stream with resolution: {resolution}")
448
+ print("Try one of these:")
449
+ display_streams(youtube)
450
+ sys.exit()
451
+
452
+ youtube.register_on_progress_callback(on_progress)
453
+
454
+ try:
455
+ _download(stream, target=target)
456
+ except KeyboardInterrupt:
457
+ sys.exit()
458
+
459
+
460
+ def download_highest_resolution_progressive(
461
+ youtube: YouTube, resolution: str, target: Optional[str] = None
462
+ ) -> None:
463
+ """Start downloading the highest resolution progressive stream.
464
+
465
+ :param YouTube youtube:
466
+ A valid YouTube object.
467
+ :param str resolution:
468
+ YouTube video resolution.
469
+ :param str target:
470
+ Target directory for download
471
+ """
472
+ youtube.register_on_progress_callback(on_progress)
473
+ try:
474
+ stream = youtube.streams.get_highest_resolution()
475
+ except exceptions.VideoUnavailable as err:
476
+ print(f"No video streams available: {err}")
477
+ else:
478
+ try:
479
+ _download(stream, target=target)
480
+ except KeyboardInterrupt:
481
+ sys.exit()
482
+
483
+
484
+ def display_streams(youtube: YouTube) -> None:
485
+ """Probe YouTube video and lists its available formats.
486
+
487
+ :param YouTube youtube:
488
+ A valid YouTube watch URL.
489
+
490
+ """
491
+ for stream in youtube.streams:
492
+ print(stream)
493
+
494
+
495
+ def _print_available_captions(captions: CaptionQuery) -> None:
496
+ print(
497
+ f"Available caption codes are: {', '.join(c.code for c in captions)}"
498
+ )
499
+
500
+
501
+ def download_caption(
502
+ youtube: YouTube, lang_code: Optional[str], target: Optional[str] = None
503
+ ) -> None:
504
+ """Download a caption for the YouTube video.
505
+
506
+ :param YouTube youtube:
507
+ A valid YouTube object.
508
+ :param str lang_code:
509
+ Language code desired for caption file.
510
+ Prints available codes if the value is None
511
+ or the desired code is not available.
512
+ :param str target:
513
+ Target directory for download
514
+ """
515
+ try:
516
+ caption = youtube.captions[lang_code]
517
+ downloaded_path = caption.download(
518
+ title=youtube.title, output_path=target
519
+ )
520
+ print(f"Saved caption file to: {downloaded_path}")
521
+ except KeyError:
522
+ print(f"Unable to find caption with code: {lang_code}")
523
+ _print_available_captions(youtube.captions)
524
+
525
+
526
+ def download_audio(
527
+ youtube: YouTube, filetype: str, target: Optional[str] = None
528
+ ) -> None:
529
+ """
530
+ Given a filetype, downloads the highest quality available audio stream for a
531
+ YouTube video.
532
+
533
+ :param YouTube youtube:
534
+ A valid YouTube object.
535
+ :param str filetype:
536
+ Desired file format to download.
537
+ :param str target:
538
+ Target directory for download
539
+ """
540
+ audio = (
541
+ youtube.streams.filter(only_audio=True, subtype=filetype)
542
+ .order_by("abr")
543
+ .last()
544
+ )
545
+
546
+ if audio is None:
547
+ print("No audio only stream found. Try one of these:")
548
+ display_streams(youtube)
549
+ sys.exit()
550
+
551
+ youtube.register_on_progress_callback(on_progress)
552
+
553
+ try:
554
+ _download(audio, target=target)
555
+ except KeyboardInterrupt:
556
+ sys.exit()
557
+
558
+
559
+ if __name__ == "__main__":
560
+ main()
caesarpytube/contrib/__init__.py ADDED
File without changes
caesarpytube/contrib/channel.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Module for interacting with a user's youtube channel."""
3
+ import json
4
+ import logging
5
+ from typing import Dict, List, Optional, Tuple
6
+
7
+ from pytube import extract, Playlist, request
8
+ from pytube.helpers import uniqueify
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Channel(Playlist):
14
+ def __init__(self, url: str, proxies: Optional[Dict[str, str]] = None):
15
+ """Construct a :class:`Channel <Channel>`.
16
+
17
+ :param str url:
18
+ A valid YouTube channel URL.
19
+ :param proxies:
20
+ (Optional) A dictionary of proxies to use for web requests.
21
+ """
22
+ super().__init__(url, proxies)
23
+
24
+ self.channel_uri = extract.channel_name(url)
25
+
26
+ self.channel_url = (
27
+ f"https://www.youtube.com{self.channel_uri}"
28
+ )
29
+
30
+ self.videos_url = self.channel_url + '/videos'
31
+ self.playlists_url = self.channel_url + '/playlists'
32
+ self.community_url = self.channel_url + '/community'
33
+ self.featured_channels_url = self.channel_url + '/channels'
34
+ self.about_url = self.channel_url + '/about'
35
+
36
+ # Possible future additions
37
+ self._playlists_html = None
38
+ self._community_html = None
39
+ self._featured_channels_html = None
40
+ self._about_html = None
41
+
42
+ @property
43
+ def channel_name(self):
44
+ """Get the name of the YouTube channel.
45
+
46
+ :rtype: str
47
+ """
48
+ return self.initial_data['metadata']['channelMetadataRenderer']['title']
49
+
50
+ @property
51
+ def channel_id(self):
52
+ """Get the ID of the YouTube channel.
53
+
54
+ This will return the underlying ID, not the vanity URL.
55
+
56
+ :rtype: str
57
+ """
58
+ return self.initial_data['metadata']['channelMetadataRenderer']['externalId']
59
+
60
+ @property
61
+ def vanity_url(self):
62
+ """Get the vanity URL of the YouTube channel.
63
+
64
+ Returns None if it doesn't exist.
65
+
66
+ :rtype: str
67
+ """
68
+ return self.initial_data['metadata']['channelMetadataRenderer'].get('vanityChannelUrl', None) # noqa:E501
69
+
70
+ @property
71
+ def html(self):
72
+ """Get the html for the /videos page.
73
+
74
+ :rtype: str
75
+ """
76
+ if self._html:
77
+ return self._html
78
+ self._html = request.get(self.videos_url)
79
+ return self._html
80
+
81
+ @property
82
+ def playlists_html(self):
83
+ """Get the html for the /playlists page.
84
+
85
+ Currently unused for any functionality.
86
+
87
+ :rtype: str
88
+ """
89
+ if self._playlists_html:
90
+ return self._playlists_html
91
+ else:
92
+ self._playlists_html = request.get(self.playlists_url)
93
+ return self._playlists_html
94
+
95
+ @property
96
+ def community_html(self):
97
+ """Get the html for the /community page.
98
+
99
+ Currently unused for any functionality.
100
+
101
+ :rtype: str
102
+ """
103
+ if self._community_html:
104
+ return self._community_html
105
+ else:
106
+ self._community_html = request.get(self.community_url)
107
+ return self._community_html
108
+
109
+ @property
110
+ def featured_channels_html(self):
111
+ """Get the html for the /channels page.
112
+
113
+ Currently unused for any functionality.
114
+
115
+ :rtype: str
116
+ """
117
+ if self._featured_channels_html:
118
+ return self._featured_channels_html
119
+ else:
120
+ self._featured_channels_html = request.get(self.featured_channels_url)
121
+ return self._featured_channels_html
122
+
123
+ @property
124
+ def about_html(self):
125
+ """Get the html for the /about page.
126
+
127
+ Currently unused for any functionality.
128
+
129
+ :rtype: str
130
+ """
131
+ if self._about_html:
132
+ return self._about_html
133
+ else:
134
+ self._about_html = request.get(self.about_url)
135
+ return self._about_html
136
+
137
+ @staticmethod
138
+ def _extract_videos(raw_json: str) -> Tuple[List[str], Optional[str]]:
139
+ """Extracts videos from a raw json page
140
+
141
+ :param str raw_json: Input json extracted from the page or the last
142
+ server response
143
+ :rtype: Tuple[List[str], Optional[str]]
144
+ :returns: Tuple containing a list of up to 100 video watch ids and
145
+ a continuation token, if more videos are available
146
+ """
147
+ initial_data = json.loads(raw_json)
148
+ # this is the json tree structure, if the json was extracted from
149
+ # html
150
+ try:
151
+ videos = initial_data["contents"][
152
+ "twoColumnBrowseResultsRenderer"][
153
+ "tabs"][1]["tabRenderer"]["content"][
154
+ "sectionListRenderer"]["contents"][0][
155
+ "itemSectionRenderer"]["contents"][0][
156
+ "gridRenderer"]["items"]
157
+ except (KeyError, IndexError, TypeError):
158
+ try:
159
+ # this is the json tree structure, if the json was directly sent
160
+ # by the server in a continuation response
161
+ important_content = initial_data[1]['response']['onResponseReceivedActions'][
162
+ 0
163
+ ]['appendContinuationItemsAction']['continuationItems']
164
+ videos = important_content
165
+ except (KeyError, IndexError, TypeError):
166
+ try:
167
+ # this is the json tree structure, if the json was directly sent
168
+ # by the server in a continuation response
169
+ # no longer a list and no longer has the "response" key
170
+ important_content = initial_data['onResponseReceivedActions'][0][
171
+ 'appendContinuationItemsAction']['continuationItems']
172
+ videos = important_content
173
+ except (KeyError, IndexError, TypeError) as p:
174
+ logger.info(p)
175
+ return [], None
176
+
177
+ try:
178
+ continuation = videos[-1]['continuationItemRenderer'][
179
+ 'continuationEndpoint'
180
+ ]['continuationCommand']['token']
181
+ videos = videos[:-1]
182
+ except (KeyError, IndexError):
183
+ # if there is an error, no continuation is available
184
+ continuation = None
185
+
186
+ # remove duplicates
187
+ return (
188
+ uniqueify(
189
+ list(
190
+ # only extract the video ids from the video data
191
+ map(
192
+ lambda x: (
193
+ f"/watch?v="
194
+ f"{x['gridVideoRenderer']['videoId']}"
195
+ ),
196
+ videos
197
+ )
198
+ ),
199
+ ),
200
+ continuation,
201
+ )
caesarpytube/contrib/playlist.py ADDED
@@ -0,0 +1,419 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module to download a complete playlist from a youtube channel."""
2
+ import json
3
+ import logging
4
+ from collections.abc import Sequence
5
+ from datetime import date, datetime
6
+ from typing import Dict, Iterable, List, Optional, Tuple, Union
7
+
8
+ from pytube import extract, request, YouTube
9
+ from pytube.helpers import cache, DeferredGeneratorList, install_proxy, uniqueify
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class Playlist(Sequence):
15
+ """Load a YouTube playlist with URL"""
16
+
17
+ def __init__(self, url: str, proxies: Optional[Dict[str, str]] = None):
18
+ if proxies:
19
+ install_proxy(proxies)
20
+
21
+ self._input_url = url
22
+
23
+ # These need to be initialized as None for the properties.
24
+ self._html = None
25
+ self._ytcfg = None
26
+ self._initial_data = None
27
+ self._sidebar_info = None
28
+
29
+ self._playlist_id = None
30
+
31
+ @property
32
+ def playlist_id(self):
33
+ """Get the playlist id.
34
+
35
+ :rtype: str
36
+ """
37
+ if self._playlist_id:
38
+ return self._playlist_id
39
+ self._playlist_id = extract.playlist_id(self._input_url)
40
+ return self._playlist_id
41
+
42
+ @property
43
+ def playlist_url(self):
44
+ """Get the base playlist url.
45
+
46
+ :rtype: str
47
+ """
48
+ return f"https://www.youtube.com/playlist?list={self.playlist_id}"
49
+
50
+ @property
51
+ def html(self):
52
+ """Get the playlist page html.
53
+
54
+ :rtype: str
55
+ """
56
+ if self._html:
57
+ return self._html
58
+ self._html = request.get(self.playlist_url)
59
+ return self._html
60
+
61
+ @property
62
+ def ytcfg(self):
63
+ """Extract the ytcfg from the playlist page html.
64
+
65
+ :rtype: dict
66
+ """
67
+ if self._ytcfg:
68
+ return self._ytcfg
69
+ self._ytcfg = extract.get_ytcfg(self.html)
70
+ return self._ytcfg
71
+
72
+ @property
73
+ def initial_data(self):
74
+ """Extract the initial data from the playlist page html.
75
+
76
+ :rtype: dict
77
+ """
78
+ if self._initial_data:
79
+ return self._initial_data
80
+ else:
81
+ self._initial_data = extract.initial_data(self.html)
82
+ return self._initial_data
83
+
84
+ @property
85
+ def sidebar_info(self):
86
+ """Extract the sidebar info from the playlist page html.
87
+
88
+ :rtype: dict
89
+ """
90
+ if self._sidebar_info:
91
+ return self._sidebar_info
92
+ else:
93
+ self._sidebar_info = self.initial_data['sidebar'][
94
+ 'playlistSidebarRenderer']['items']
95
+ return self._sidebar_info
96
+
97
+ @property
98
+ def yt_api_key(self):
99
+ """Extract the INNERTUBE_API_KEY from the playlist ytcfg.
100
+
101
+ :rtype: str
102
+ """
103
+ return self.ytcfg['INNERTUBE_API_KEY']
104
+
105
+ def _paginate(
106
+ self, until_watch_id: Optional[str] = None
107
+ ) -> Iterable[List[str]]:
108
+ """Parse the video links from the page source, yields the /watch?v=
109
+ part from video link
110
+
111
+ :param until_watch_id Optional[str]: YouTube Video watch id until
112
+ which the playlist should be read.
113
+
114
+ :rtype: Iterable[List[str]]
115
+ :returns: Iterable of lists of YouTube watch ids
116
+ """
117
+ videos_urls, continuation = self._extract_videos(
118
+ json.dumps(extract.initial_data(self.html))
119
+ )
120
+ if until_watch_id:
121
+ try:
122
+ trim_index = videos_urls.index(f"/watch?v={until_watch_id}")
123
+ yield videos_urls[:trim_index]
124
+ return
125
+ except ValueError:
126
+ pass
127
+ yield videos_urls
128
+
129
+ # Extraction from a playlist only returns 100 videos at a time
130
+ # if self._extract_videos returns a continuation there are more
131
+ # than 100 songs inside a playlist, so we need to add further requests
132
+ # to gather all of them
133
+ if continuation:
134
+ load_more_url, headers, data = self._build_continuation_url(continuation)
135
+ else:
136
+ load_more_url, headers, data = None, None, None
137
+
138
+ while load_more_url and headers and data: # there is an url found
139
+ logger.debug("load more url: %s", load_more_url)
140
+ # requesting the next page of videos with the url generated from the
141
+ # previous page, needs to be a post
142
+ req = request.post(load_more_url, extra_headers=headers, data=data)
143
+ # extract up to 100 songs from the page loaded
144
+ # returns another continuation if more videos are available
145
+ videos_urls, continuation = self._extract_videos(req)
146
+ if until_watch_id:
147
+ try:
148
+ trim_index = videos_urls.index(f"/watch?v={until_watch_id}")
149
+ yield videos_urls[:trim_index]
150
+ return
151
+ except ValueError:
152
+ pass
153
+ yield videos_urls
154
+
155
+ if continuation:
156
+ load_more_url, headers, data = self._build_continuation_url(
157
+ continuation
158
+ )
159
+ else:
160
+ load_more_url, headers, data = None, None, None
161
+
162
+ def _build_continuation_url(self, continuation: str) -> Tuple[str, dict, dict]:
163
+ """Helper method to build the url and headers required to request
164
+ the next page of videos
165
+
166
+ :param str continuation: Continuation extracted from the json response
167
+ of the last page
168
+ :rtype: Tuple[str, dict, dict]
169
+ :returns: Tuple of an url and required headers for the next http
170
+ request
171
+ """
172
+ return (
173
+ (
174
+ # was changed to this format (and post requests)
175
+ # between 2021.03.02 and 2021.03.03
176
+ "https://www.youtube.com/youtubei/v1/browse?key="
177
+ f"{self.yt_api_key}"
178
+ ),
179
+ {
180
+ "X-YouTube-Client-Name": "1",
181
+ "X-YouTube-Client-Version": "2.20200720.00.02",
182
+ },
183
+ # extra data required for post request
184
+ {
185
+ "continuation": continuation,
186
+ "context": {
187
+ "client": {
188
+ "clientName": "WEB",
189
+ "clientVersion": "2.20200720.00.02"
190
+ }
191
+ }
192
+ }
193
+ )
194
+
195
+ @staticmethod
196
+ def _extract_videos(raw_json: str) -> Tuple[List[str], Optional[str]]:
197
+ """Extracts videos from a raw json page
198
+
199
+ :param str raw_json: Input json extracted from the page or the last
200
+ server response
201
+ :rtype: Tuple[List[str], Optional[str]]
202
+ :returns: Tuple containing a list of up to 100 video watch ids and
203
+ a continuation token, if more videos are available
204
+ """
205
+ initial_data = json.loads(raw_json)
206
+ try:
207
+ # this is the json tree structure, if the json was extracted from
208
+ # html
209
+ section_contents = initial_data["contents"][
210
+ "twoColumnBrowseResultsRenderer"][
211
+ "tabs"][0]["tabRenderer"]["content"][
212
+ "sectionListRenderer"]["contents"]
213
+ try:
214
+ # Playlist without submenus
215
+ important_content = section_contents[
216
+ 0]["itemSectionRenderer"][
217
+ "contents"][0]["playlistVideoListRenderer"]
218
+ except (KeyError, IndexError, TypeError):
219
+ # Playlist with submenus
220
+ important_content = section_contents[
221
+ 1]["itemSectionRenderer"][
222
+ "contents"][0]["playlistVideoListRenderer"]
223
+ videos = important_content["contents"]
224
+ except (KeyError, IndexError, TypeError):
225
+ try:
226
+ # this is the json tree structure, if the json was directly sent
227
+ # by the server in a continuation response
228
+ # no longer a list and no longer has the "response" key
229
+ important_content = initial_data['onResponseReceivedActions'][0][
230
+ 'appendContinuationItemsAction']['continuationItems']
231
+ videos = important_content
232
+ except (KeyError, IndexError, TypeError) as p:
233
+ logger.info(p)
234
+ return [], None
235
+
236
+ try:
237
+ continuation = videos[-1]['continuationItemRenderer'][
238
+ 'continuationEndpoint'
239
+ ]['continuationCommand']['token']
240
+ videos = videos[:-1]
241
+ except (KeyError, IndexError):
242
+ # if there is an error, no continuation is available
243
+ continuation = None
244
+
245
+ # remove duplicates
246
+ return (
247
+ uniqueify(
248
+ list(
249
+ # only extract the video ids from the video data
250
+ map(
251
+ lambda x: (
252
+ f"/watch?v="
253
+ f"{x['playlistVideoRenderer']['videoId']}"
254
+ ),
255
+ videos
256
+ )
257
+ ),
258
+ ),
259
+ continuation,
260
+ )
261
+
262
+ def trimmed(self, video_id: str) -> Iterable[str]:
263
+ """Retrieve a list of YouTube video URLs trimmed at the given video ID
264
+
265
+ i.e. if the playlist has video IDs 1,2,3,4 calling trimmed(3) returns
266
+ [1,2]
267
+ :type video_id: str
268
+ video ID to trim the returned list of playlist URLs at
269
+ :rtype: List[str]
270
+ :returns:
271
+ List of video URLs from the playlist trimmed at the given ID
272
+ """
273
+ for page in self._paginate(until_watch_id=video_id):
274
+ yield from (self._video_url(watch_path) for watch_path in page)
275
+
276
+ def url_generator(self):
277
+ """Generator that yields video URLs.
278
+
279
+ :Yields: Video URLs
280
+ """
281
+ for page in self._paginate():
282
+ for video in page:
283
+ yield self._video_url(video)
284
+
285
+ @property # type: ignore
286
+ @cache
287
+ def video_urls(self) -> DeferredGeneratorList:
288
+ """Complete links of all the videos in playlist
289
+
290
+ :rtype: List[str]
291
+ :returns: List of video URLs
292
+ """
293
+ return DeferredGeneratorList(self.url_generator())
294
+
295
+ def videos_generator(self):
296
+ for url in self.video_urls:
297
+ yield YouTube(url)
298
+
299
+ @property
300
+ def videos(self) -> Iterable[YouTube]:
301
+ """Yields YouTube objects of videos in this playlist
302
+
303
+ :rtype: List[YouTube]
304
+ :returns: List of YouTube
305
+ """
306
+ return DeferredGeneratorList(self.videos_generator())
307
+
308
+ def __getitem__(self, i: Union[slice, int]) -> Union[str, List[str]]:
309
+ return self.video_urls[i]
310
+
311
+ def __len__(self) -> int:
312
+ return len(self.video_urls)
313
+
314
+ def __repr__(self) -> str:
315
+ return f"{repr(self.video_urls)}"
316
+
317
+ @property
318
+ @cache
319
+ def last_updated(self) -> Optional[date]:
320
+ """Extract the date that the playlist was last updated.
321
+
322
+ For some playlists, this will be a specific date, which is returned as a datetime
323
+ object. For other playlists, this is an estimate such as "1 week ago". Due to the
324
+ fact that this value is returned as a string, pytube does a best-effort parsing
325
+ where possible, and returns the raw string where it is not possible.
326
+
327
+ :return: Date of last playlist update where possible, else the string provided
328
+ :rtype: datetime.date
329
+ """
330
+ last_updated_text = self.sidebar_info[0]['playlistSidebarPrimaryInfoRenderer'][
331
+ 'stats'][2]['runs'][1]['text']
332
+ try:
333
+ date_components = last_updated_text.split()
334
+ month = date_components[0]
335
+ day = date_components[1].strip(',')
336
+ year = date_components[2]
337
+ return datetime.strptime(
338
+ f"{month} {day:0>2} {year}", "%b %d %Y"
339
+ ).date()
340
+ except (IndexError, KeyError):
341
+ return last_updated_text
342
+
343
+ @property
344
+ @cache
345
+ def title(self) -> Optional[str]:
346
+ """Extract playlist title
347
+
348
+ :return: playlist title (name)
349
+ :rtype: Optional[str]
350
+ """
351
+ return self.sidebar_info[0]['playlistSidebarPrimaryInfoRenderer'][
352
+ 'title']['runs'][0]['text']
353
+
354
+ @property
355
+ def description(self) -> str:
356
+ return self.sidebar_info[0]['playlistSidebarPrimaryInfoRenderer'][
357
+ 'description']['simpleText']
358
+
359
+ @property
360
+ def length(self):
361
+ """Extract the number of videos in the playlist.
362
+
363
+ :return: Playlist video count
364
+ :rtype: int
365
+ """
366
+ count_text = self.sidebar_info[0]['playlistSidebarPrimaryInfoRenderer'][
367
+ 'stats'][0]['runs'][0]['text']
368
+ count_text = count_text.replace(',','')
369
+ return int(count_text)
370
+
371
+ @property
372
+ def views(self):
373
+ """Extract view count for playlist.
374
+
375
+ :return: Playlist view count
376
+ :rtype: int
377
+ """
378
+ # "1,234,567 views"
379
+ views_text = self.sidebar_info[0]['playlistSidebarPrimaryInfoRenderer'][
380
+ 'stats'][1]['simpleText']
381
+ # "1,234,567"
382
+ count_text = views_text.split()[0]
383
+ # "1234567"
384
+ count_text = count_text.replace(',', '')
385
+ return int(count_text)
386
+
387
+ @property
388
+ def owner(self):
389
+ """Extract the owner of the playlist.
390
+
391
+ :return: Playlist owner name.
392
+ :rtype: str
393
+ """
394
+ return self.sidebar_info[1]['playlistSidebarSecondaryInfoRenderer'][
395
+ 'videoOwner']['videoOwnerRenderer']['title']['runs'][0]['text']
396
+
397
+ @property
398
+ def owner_id(self):
399
+ """Extract the channel_id of the owner of the playlist.
400
+
401
+ :return: Playlist owner's channel ID.
402
+ :rtype: str
403
+ """
404
+ return self.sidebar_info[1]['playlistSidebarSecondaryInfoRenderer'][
405
+ 'videoOwner']['videoOwnerRenderer']['title']['runs'][0][
406
+ 'navigationEndpoint']['browseEndpoint']['browseId']
407
+
408
+ @property
409
+ def owner_url(self):
410
+ """Create the channel url of the owner of the playlist.
411
+
412
+ :return: Playlist owner's channel url.
413
+ :rtype: str
414
+ """
415
+ return f'https://www.youtube.com/channel/{self.owner_id}'
416
+
417
+ @staticmethod
418
+ def _video_url(watch_path: str):
419
+ return f"https://www.youtube.com{watch_path}"
caesarpytube/contrib/search.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for interacting with YouTube search."""
2
+ # Native python imports
3
+ import logging
4
+
5
+ # Local imports
6
+ from pytube import YouTube
7
+ from pytube.innertube import InnerTube
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Search:
14
+ def __init__(self, query):
15
+ """Initialize Search object.
16
+
17
+ :param str query:
18
+ Search query provided by the user.
19
+ """
20
+ self.query = query
21
+ self._innertube_client = InnerTube(client='WEB')
22
+
23
+ # The first search, without a continuation, is structured differently
24
+ # and contains completion suggestions, so we must store this separately
25
+ self._initial_results = None
26
+
27
+ self._results = None
28
+ self._completion_suggestions = None
29
+
30
+ # Used for keeping track of query continuations so that new results
31
+ # are always returned when get_next_results() is called
32
+ self._current_continuation = None
33
+
34
+ @property
35
+ def completion_suggestions(self):
36
+ """Return query autocompletion suggestions for the query.
37
+
38
+ :rtype: list
39
+ :returns:
40
+ A list of autocomplete suggestions provided by YouTube for the query.
41
+ """
42
+ if self._completion_suggestions:
43
+ return self._completion_suggestions
44
+ if self.results:
45
+ self._completion_suggestions = self._initial_results['refinements']
46
+ return self._completion_suggestions
47
+
48
+ @property
49
+ def results(self):
50
+ """Return search results.
51
+
52
+ On first call, will generate and return the first set of results.
53
+ Additional results can be generated using ``.get_next_results()``.
54
+
55
+ :rtype: list
56
+ :returns:
57
+ A list of YouTube objects.
58
+ """
59
+ if self._results:
60
+ return self._results
61
+
62
+ videos, continuation = self.fetch_and_parse()
63
+ self._results = videos
64
+ self._current_continuation = continuation
65
+ return self._results
66
+
67
+ def get_next_results(self):
68
+ """Use the stored continuation string to fetch the next set of results.
69
+
70
+ This method does not return the results, but instead updates the results property.
71
+ """
72
+ if self._current_continuation:
73
+ videos, continuation = self.fetch_and_parse(self._current_continuation)
74
+ self._results.extend(videos)
75
+ self._current_continuation = continuation
76
+ else:
77
+ raise IndexError
78
+
79
+ def fetch_and_parse(self, continuation=None):
80
+ """Fetch from the innertube API and parse the results.
81
+
82
+ :param str continuation:
83
+ Continuation string for fetching results.
84
+ :rtype: tuple
85
+ :returns:
86
+ A tuple of a list of YouTube objects and a continuation string.
87
+ """
88
+ # Begin by executing the query and identifying the relevant sections
89
+ # of the results
90
+ raw_results = self.fetch_query(continuation)
91
+
92
+ # Initial result is handled by try block, continuations by except block
93
+ try:
94
+ sections = raw_results['contents']['twoColumnSearchResultsRenderer'][
95
+ 'primaryContents']['sectionListRenderer']['contents']
96
+ except KeyError:
97
+ sections = raw_results['onResponseReceivedCommands'][0][
98
+ 'appendContinuationItemsAction']['continuationItems']
99
+ item_renderer = None
100
+ continuation_renderer = None
101
+ for s in sections:
102
+ if 'itemSectionRenderer' in s:
103
+ item_renderer = s['itemSectionRenderer']
104
+ if 'continuationItemRenderer' in s:
105
+ continuation_renderer = s['continuationItemRenderer']
106
+
107
+ # If the continuationItemRenderer doesn't exist, assume no further results
108
+ if continuation_renderer:
109
+ next_continuation = continuation_renderer['continuationEndpoint'][
110
+ 'continuationCommand']['token']
111
+ else:
112
+ next_continuation = None
113
+
114
+ # If the itemSectionRenderer doesn't exist, assume no results.
115
+ if item_renderer:
116
+ videos = []
117
+ raw_video_list = item_renderer['contents']
118
+ for video_details in raw_video_list:
119
+ # Skip over ads
120
+ if video_details.get('searchPyvRenderer', {}).get('ads', None):
121
+ continue
122
+
123
+ # Skip "recommended" type videos e.g. "people also watched" and "popular X"
124
+ # that break up the search results
125
+ if 'shelfRenderer' in video_details:
126
+ continue
127
+
128
+ # Skip auto-generated "mix" playlist results
129
+ if 'radioRenderer' in video_details:
130
+ continue
131
+
132
+ # Skip playlist results
133
+ if 'playlistRenderer' in video_details:
134
+ continue
135
+
136
+ # Skip channel results
137
+ if 'channelRenderer' in video_details:
138
+ continue
139
+
140
+ # Skip 'people also searched for' results
141
+ if 'horizontalCardListRenderer' in video_details:
142
+ continue
143
+
144
+ # Can't seem to reproduce, probably related to typo fix suggestions
145
+ if 'didYouMeanRenderer' in video_details:
146
+ continue
147
+
148
+ # Seems to be the renderer used for the image shown on a no results page
149
+ if 'backgroundPromoRenderer' in video_details:
150
+ continue
151
+
152
+ if 'videoRenderer' not in video_details:
153
+ logger.warn('Unexpected renderer encountered.')
154
+ logger.warn(f'Renderer name: {video_details.keys()}')
155
+ logger.warn(f'Search term: {self.query}')
156
+ logger.warn(
157
+ 'Please open an issue at '
158
+ 'https://github.com/pytube/pytube/issues '
159
+ 'and provide this log output.'
160
+ )
161
+ continue
162
+
163
+ # Extract relevant video information from the details.
164
+ # Some of this can be used to pre-populate attributes of the
165
+ # YouTube object.
166
+ vid_renderer = video_details['videoRenderer']
167
+ vid_id = vid_renderer['videoId']
168
+ vid_url = f'https://www.youtube.com/watch?v={vid_id}'
169
+ vid_title = vid_renderer['title']['runs'][0]['text']
170
+ vid_channel_name = vid_renderer['ownerText']['runs'][0]['text']
171
+ vid_channel_uri = vid_renderer['ownerText']['runs'][0][
172
+ 'navigationEndpoint']['commandMetadata']['webCommandMetadata']['url']
173
+ # Livestreams have "runs", non-livestreams have "simpleText",
174
+ # and scheduled releases do not have 'viewCountText'
175
+ if 'viewCountText' in vid_renderer:
176
+ if 'runs' in vid_renderer['viewCountText']:
177
+ vid_view_count_text = vid_renderer['viewCountText']['runs'][0]['text']
178
+ else:
179
+ vid_view_count_text = vid_renderer['viewCountText']['simpleText']
180
+ # Strip ' views' text, then remove commas
181
+ stripped_text = vid_view_count_text.split()[0].replace(',','')
182
+ if stripped_text == 'No':
183
+ vid_view_count = 0
184
+ else:
185
+ vid_view_count = int(stripped_text)
186
+ else:
187
+ vid_view_count = 0
188
+ if 'lengthText' in vid_renderer:
189
+ vid_length = vid_renderer['lengthText']['simpleText']
190
+ else:
191
+ vid_length = None
192
+
193
+ vid_metadata = {
194
+ 'id': vid_id,
195
+ 'url': vid_url,
196
+ 'title': vid_title,
197
+ 'channel_name': vid_channel_name,
198
+ 'channel_url': vid_channel_uri,
199
+ 'view_count': vid_view_count,
200
+ 'length': vid_length
201
+ }
202
+
203
+ # Construct YouTube object from metadata and append to results
204
+ vid = YouTube(vid_metadata['url'])
205
+ vid.author = vid_metadata['channel_name']
206
+ vid.title = vid_metadata['title']
207
+ videos.append(vid)
208
+ else:
209
+ videos = None
210
+
211
+ return videos, next_continuation
212
+
213
+ def fetch_query(self, continuation=None):
214
+ """Fetch raw results from the innertube API.
215
+
216
+ :param str continuation:
217
+ Continuation string for fetching results.
218
+ :rtype: dict
219
+ :returns:
220
+ The raw json object returned by the innertube API.
221
+ """
222
+ query_results = self._innertube_client.search(self.query, continuation)
223
+ if not self._initial_results:
224
+ self._initial_results = query_results
225
+ return query_results # noqa:R504
caesarpytube/exceptions.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Library specific exception definitions."""
2
+ from typing import Pattern, Union
3
+
4
+
5
+ class PytubeError(Exception):
6
+ """Base pytube exception that all others inherit.
7
+
8
+ This is done to not pollute the built-in exceptions, which *could* result
9
+ in unintended errors being unexpectedly and incorrectly handled within
10
+ implementers code.
11
+ """
12
+
13
+
14
+ class MaxRetriesExceeded(PytubeError):
15
+ """Maximum number of retries exceeded."""
16
+
17
+
18
+ class HTMLParseError(PytubeError):
19
+ """HTML could not be parsed"""
20
+
21
+
22
+ class ExtractError(PytubeError):
23
+ """Data extraction based exception."""
24
+
25
+
26
+ class RegexMatchError(ExtractError):
27
+ """Regex pattern did not return any matches."""
28
+
29
+ def __init__(self, caller: str, pattern: Union[str, Pattern]):
30
+ """
31
+ :param str caller:
32
+ Calling function
33
+ :param str pattern:
34
+ Pattern that failed to match
35
+ """
36
+ super().__init__(f"{caller}: could not find match for {pattern}")
37
+ self.caller = caller
38
+ self.pattern = pattern
39
+
40
+
41
+ class VideoUnavailable(PytubeError):
42
+ """Base video unavailable error."""
43
+ def __init__(self, video_id: str):
44
+ """
45
+ :param str video_id:
46
+ A YouTube video identifier.
47
+ """
48
+ self.video_id = video_id
49
+ super().__init__(self.error_string)
50
+
51
+ @property
52
+ def error_string(self):
53
+ return f'{self.video_id} is unavailable'
54
+
55
+
56
+ class AgeRestrictedError(VideoUnavailable):
57
+ """Video is age restricted, and cannot be accessed without OAuth."""
58
+ def __init__(self, video_id: str):
59
+ """
60
+ :param str video_id:
61
+ A YouTube video identifier.
62
+ """
63
+ self.video_id = video_id
64
+ super().__init__(self.video_id)
65
+
66
+ @property
67
+ def error_string(self):
68
+ return f"{self.video_id} is age restricted, and can't be accessed without logging in."
69
+
70
+
71
+ class LiveStreamError(VideoUnavailable):
72
+ """Video is a live stream."""
73
+ def __init__(self, video_id: str):
74
+ """
75
+ :param str video_id:
76
+ A YouTube video identifier.
77
+ """
78
+ self.video_id = video_id
79
+ super().__init__(self.video_id)
80
+
81
+ @property
82
+ def error_string(self):
83
+ return f'{self.video_id} is streaming live and cannot be loaded'
84
+
85
+
86
+ class VideoPrivate(VideoUnavailable):
87
+ def __init__(self, video_id: str):
88
+ """
89
+ :param str video_id:
90
+ A YouTube video identifier.
91
+ """
92
+ self.video_id = video_id
93
+ super().__init__(self.video_id)
94
+
95
+ @property
96
+ def error_string(self):
97
+ return f'{self.video_id} is a private video'
98
+
99
+
100
+ class RecordingUnavailable(VideoUnavailable):
101
+ def __init__(self, video_id: str):
102
+ """
103
+ :param str video_id:
104
+ A YouTube video identifier.
105
+ """
106
+ self.video_id = video_id
107
+ super().__init__(self.video_id)
108
+
109
+ @property
110
+ def error_string(self):
111
+ return f'{self.video_id} does not have a live stream recording available'
112
+
113
+
114
+ class MembersOnly(VideoUnavailable):
115
+ """Video is members-only.
116
+
117
+ YouTube has special videos that are only viewable to users who have
118
+ subscribed to a content creator.
119
+ ref: https://support.google.com/youtube/answer/7544492?hl=en
120
+ """
121
+ def __init__(self, video_id: str):
122
+ """
123
+ :param str video_id:
124
+ A YouTube video identifier.
125
+ """
126
+ self.video_id = video_id
127
+ super().__init__(self.video_id)
128
+
129
+ @property
130
+ def error_string(self):
131
+ return f'{self.video_id} is a members-only video'
132
+
133
+
134
+ class VideoRegionBlocked(VideoUnavailable):
135
+ def __init__(self, video_id: str):
136
+ """
137
+ :param str video_id:
138
+ A YouTube video identifier.
139
+ """
140
+ self.video_id = video_id
141
+ super().__init__(self.video_id)
142
+
143
+ @property
144
+ def error_string(self):
145
+ return f'{self.video_id} is not available in your region'
caesarpytube/extract.py ADDED
@@ -0,0 +1,579 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module contains all non-cipher related data extraction logic."""
2
+ import logging
3
+ import urllib.parse
4
+ import re
5
+ from collections import OrderedDict
6
+ from datetime import datetime
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+ from urllib.parse import parse_qs, quote, urlencode, urlparse
9
+
10
+ from pytube.cipher import Cipher
11
+ from pytube.exceptions import HTMLParseError, LiveStreamError, RegexMatchError
12
+ from pytube.helpers import regex_search
13
+ from pytube.metadata import YouTubeMetadata
14
+ from pytube.parser import parse_for_object, parse_for_all_objects
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def publish_date(watch_html: str):
21
+ """Extract publish date
22
+ :param str watch_html:
23
+ The html contents of the watch page.
24
+ :rtype: str
25
+ :returns:
26
+ Publish date of the video.
27
+ """
28
+ try:
29
+ result = regex_search(
30
+ r"(?<=itemprop=\"datePublished\" content=\")\d{4}-\d{2}-\d{2}",
31
+ watch_html, group=0
32
+ )
33
+ except RegexMatchError:
34
+ return None
35
+ return datetime.strptime(result, '%Y-%m-%d')
36
+
37
+
38
+ def recording_available(watch_html):
39
+ """Check if live stream recording is available.
40
+
41
+ :param str watch_html:
42
+ The html contents of the watch page.
43
+ :rtype: bool
44
+ :returns:
45
+ Whether or not the content is private.
46
+ """
47
+ unavailable_strings = [
48
+ 'This live stream recording is not available.'
49
+ ]
50
+ for string in unavailable_strings:
51
+ if string in watch_html:
52
+ return False
53
+ return True
54
+
55
+
56
+ def is_private(watch_html):
57
+ """Check if content is private.
58
+
59
+ :param str watch_html:
60
+ The html contents of the watch page.
61
+ :rtype: bool
62
+ :returns:
63
+ Whether or not the content is private.
64
+ """
65
+ private_strings = [
66
+ "This is a private video. Please sign in to verify that you may see it.",
67
+ "\"simpleText\":\"Private video\"",
68
+ "This video is private."
69
+ ]
70
+ for string in private_strings:
71
+ if string in watch_html:
72
+ return True
73
+ return False
74
+
75
+
76
+ def is_age_restricted(watch_html: str) -> bool:
77
+ """Check if content is age restricted.
78
+
79
+ :param str watch_html:
80
+ The html contents of the watch page.
81
+ :rtype: bool
82
+ :returns:
83
+ Whether or not the content is age restricted.
84
+ """
85
+ try:
86
+ regex_search(r"og:restrictions:age", watch_html, group=0)
87
+ except RegexMatchError:
88
+ return False
89
+ return True
90
+
91
+
92
+ def playability_status(watch_html: str) -> (str, str):
93
+ """Return the playability status and status explanation of a video.
94
+
95
+ For example, a video may have a status of LOGIN_REQUIRED, and an explanation
96
+ of "This is a private video. Please sign in to verify that you may see it."
97
+
98
+ This explanation is what gets incorporated into the media player overlay.
99
+
100
+ :param str watch_html:
101
+ The html contents of the watch page.
102
+ :rtype: bool
103
+ :returns:
104
+ Playability status and reason of the video.
105
+ """
106
+ player_response = initial_player_response(watch_html)
107
+ status_dict = player_response.get('playabilityStatus', {})
108
+ if 'liveStreamability' in status_dict:
109
+ return 'LIVE_STREAM', 'Video is a live stream.'
110
+ if 'status' in status_dict:
111
+ if 'reason' in status_dict:
112
+ return status_dict['status'], [status_dict['reason']]
113
+ if 'messages' in status_dict:
114
+ return status_dict['status'], status_dict['messages']
115
+ return None, [None]
116
+
117
+
118
+ def video_id(url: str) -> str:
119
+ """Extract the ``video_id`` from a YouTube url.
120
+
121
+ This function supports the following patterns:
122
+
123
+ - :samp:`https://youtube.com/watch?v={video_id}`
124
+ - :samp:`https://youtube.com/embed/{video_id}`
125
+ - :samp:`https://youtu.be/{video_id}`
126
+
127
+ :param str url:
128
+ A YouTube url containing a video id.
129
+ :rtype: str
130
+ :returns:
131
+ YouTube video id.
132
+ """
133
+ return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
134
+
135
+
136
+ def playlist_id(url: str) -> str:
137
+ """Extract the ``playlist_id`` from a YouTube url.
138
+
139
+ This function supports the following patterns:
140
+
141
+ - :samp:`https://youtube.com/playlist?list={playlist_id}`
142
+ - :samp:`https://youtube.com/watch?v={video_id}&list={playlist_id}`
143
+
144
+ :param str url:
145
+ A YouTube url containing a playlist id.
146
+ :rtype: str
147
+ :returns:
148
+ YouTube playlist id.
149
+ """
150
+ parsed = urllib.parse.urlparse(url)
151
+ return parse_qs(parsed.query)['list'][0]
152
+
153
+
154
+ def channel_name(url: str) -> str:
155
+ """Extract the ``channel_name`` or ``channel_id`` from a YouTube url.
156
+
157
+ This function supports the following patterns:
158
+
159
+ - :samp:`https://youtube.com/c/{channel_name}/*`
160
+ - :samp:`https://youtube.com/channel/{channel_id}/*
161
+ - :samp:`https://youtube.com/u/{channel_name}/*`
162
+ - :samp:`https://youtube.com/user/{channel_id}/*
163
+
164
+ :param str url:
165
+ A YouTube url containing a channel name.
166
+ :rtype: str
167
+ :returns:
168
+ YouTube channel name.
169
+ """
170
+ patterns = [
171
+ r"(?:\/(c)\/([%\d\w_\-]+)(\/.*)?)",
172
+ r"(?:\/(channel)\/([%\w\d_\-]+)(\/.*)?)",
173
+ r"(?:\/(u)\/([%\d\w_\-]+)(\/.*)?)",
174
+ r"(?:\/(user)\/([%\w\d_\-]+)(\/.*)?)"
175
+ ]
176
+ for pattern in patterns:
177
+ regex = re.compile(pattern)
178
+ function_match = regex.search(url)
179
+ if function_match:
180
+ logger.debug("finished regex search, matched: %s", pattern)
181
+ uri_style = function_match.group(1)
182
+ uri_identifier = function_match.group(2)
183
+ return f'/{uri_style}/{uri_identifier}'
184
+
185
+ raise RegexMatchError(
186
+ caller="channel_name", pattern="patterns"
187
+ )
188
+
189
+
190
+ def video_info_url(video_id: str, watch_url: str) -> str:
191
+ """Construct the video_info url.
192
+
193
+ :param str video_id:
194
+ A YouTube video identifier.
195
+ :param str watch_url:
196
+ A YouTube watch url.
197
+ :rtype: str
198
+ :returns:
199
+ :samp:`https://youtube.com/get_video_info` with necessary GET
200
+ parameters.
201
+ """
202
+ params = OrderedDict(
203
+ [
204
+ ("video_id", video_id),
205
+ ("ps", "default"),
206
+ ("eurl", quote(watch_url)),
207
+ ("hl", "en_US"),
208
+ ("html5", "1"),
209
+ ("c", "TVHTML5"),
210
+ ("cver", "7.20201028"),
211
+ ]
212
+ )
213
+ return _video_info_url(params)
214
+
215
+
216
+ def video_info_url_age_restricted(video_id: str, embed_html: str) -> str:
217
+ """Construct the video_info url.
218
+
219
+ :param str video_id:
220
+ A YouTube video identifier.
221
+ :param str embed_html:
222
+ The html contents of the embed page (for age restricted videos).
223
+ :rtype: str
224
+ :returns:
225
+ :samp:`https://youtube.com/get_video_info` with necessary GET
226
+ parameters.
227
+ """
228
+ try:
229
+ sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
230
+ except RegexMatchError:
231
+ sts = ""
232
+ # Here we use ``OrderedDict`` so that the output is consistent between
233
+ # Python 2.7+.
234
+ eurl = f"https://youtube.googleapis.com/v/{video_id}"
235
+ params = OrderedDict(
236
+ [
237
+ ("video_id", video_id),
238
+ ("eurl", eurl),
239
+ ("sts", sts),
240
+ ("html5", "1"),
241
+ ("c", "TVHTML5"),
242
+ ("cver", "7.20201028"),
243
+ ]
244
+ )
245
+ return _video_info_url(params)
246
+
247
+
248
+ def _video_info_url(params: OrderedDict) -> str:
249
+ return "https://www.youtube.com/get_video_info?" + urlencode(params)
250
+
251
+
252
+ def js_url(html: str) -> str:
253
+ """Get the base JavaScript url.
254
+
255
+ Construct the base JavaScript url, which contains the decipher
256
+ "transforms".
257
+
258
+ :param str html:
259
+ The html contents of the watch page.
260
+ """
261
+ try:
262
+ base_js = get_ytplayer_config(html)['assets']['js']
263
+ except (KeyError, RegexMatchError):
264
+ base_js = get_ytplayer_js(html)
265
+ return "https://youtube.com" + base_js
266
+
267
+
268
+ def mime_type_codec(mime_type_codec: str) -> Tuple[str, List[str]]:
269
+ """Parse the type data.
270
+
271
+ Breaks up the data in the ``type`` key of the manifest, which contains the
272
+ mime type and codecs serialized together, and splits them into separate
273
+ elements.
274
+
275
+ **Example**:
276
+
277
+ mime_type_codec('audio/webm; codecs="opus"') -> ('audio/webm', ['opus'])
278
+
279
+ :param str mime_type_codec:
280
+ String containing mime type and codecs.
281
+ :rtype: tuple
282
+ :returns:
283
+ The mime type and a list of codecs.
284
+
285
+ """
286
+ pattern = r"(\w+\/\w+)\;\scodecs=\"([a-zA-Z-0-9.,\s]*)\""
287
+ regex = re.compile(pattern)
288
+ results = regex.search(mime_type_codec)
289
+ if not results:
290
+ raise RegexMatchError(caller="mime_type_codec", pattern=pattern)
291
+ mime_type, codecs = results.groups()
292
+ return mime_type, [c.strip() for c in codecs.split(",")]
293
+
294
+
295
+ def get_ytplayer_js(html: str) -> Any:
296
+ """Get the YouTube player base JavaScript path.
297
+
298
+ :param str html
299
+ The html contents of the watch page.
300
+ :rtype: str
301
+ :returns:
302
+ Path to YouTube's base.js file.
303
+ """
304
+ js_url_patterns = [
305
+ r"(/s/player/[\w\d]+/[\w\d_/.]+/base\.js)"
306
+ ]
307
+ for pattern in js_url_patterns:
308
+ regex = re.compile(pattern)
309
+ function_match = regex.search(html)
310
+ if function_match:
311
+ logger.debug("finished regex search, matched: %s", pattern)
312
+ yt_player_js = function_match.group(1)
313
+ return yt_player_js
314
+
315
+ raise RegexMatchError(
316
+ caller="get_ytplayer_js", pattern="js_url_patterns"
317
+ )
318
+
319
+
320
+ def get_ytplayer_config(html: str) -> Any:
321
+ """Get the YouTube player configuration data from the watch html.
322
+
323
+ Extract the ``ytplayer_config``, which is json data embedded within the
324
+ watch html and serves as the primary source of obtaining the stream
325
+ manifest data.
326
+
327
+ :param str html:
328
+ The html contents of the watch page.
329
+ :rtype: str
330
+ :returns:
331
+ Substring of the html containing the encoded manifest data.
332
+ """
333
+ logger.debug("finding initial function name")
334
+ config_patterns = [
335
+ r"ytplayer\.config\s*=\s*",
336
+ r"ytInitialPlayerResponse\s*=\s*"
337
+ ]
338
+ for pattern in config_patterns:
339
+ # Try each pattern consecutively if they don't find a match
340
+ try:
341
+ return parse_for_object(html, pattern)
342
+ except HTMLParseError as e:
343
+ logger.debug(f'Pattern failed: {pattern}')
344
+ logger.debug(e)
345
+ continue
346
+
347
+ # setConfig() needs to be handled a little differently.
348
+ # We want to parse the entire argument to setConfig()
349
+ # and use then load that as json to find PLAYER_CONFIG
350
+ # inside of it.
351
+ setconfig_patterns = [
352
+ r"yt\.setConfig\(.*['\"]PLAYER_CONFIG['\"]:\s*"
353
+ ]
354
+ for pattern in setconfig_patterns:
355
+ # Try each pattern consecutively if they don't find a match
356
+ try:
357
+ return parse_for_object(html, pattern)
358
+ except HTMLParseError:
359
+ continue
360
+
361
+ raise RegexMatchError(
362
+ caller="get_ytplayer_config", pattern="config_patterns, setconfig_patterns"
363
+ )
364
+
365
+
366
+ def get_ytcfg(html: str) -> str:
367
+ """Get the entirety of the ytcfg object.
368
+
369
+ This is built over multiple pieces, so we have to find all matches and
370
+ combine the dicts together.
371
+
372
+ :param str html:
373
+ The html contents of the watch page.
374
+ :rtype: str
375
+ :returns:
376
+ Substring of the html containing the encoded manifest data.
377
+ """
378
+ ytcfg = {}
379
+ ytcfg_patterns = [
380
+ r"ytcfg\s=\s",
381
+ r"ytcfg\.set\("
382
+ ]
383
+ for pattern in ytcfg_patterns:
384
+ # Try each pattern consecutively and try to build a cohesive object
385
+ try:
386
+ found_objects = parse_for_all_objects(html, pattern)
387
+ for obj in found_objects:
388
+ ytcfg.update(obj)
389
+ except HTMLParseError:
390
+ continue
391
+
392
+ if len(ytcfg) > 0:
393
+ return ytcfg
394
+
395
+ raise RegexMatchError(
396
+ caller="get_ytcfg", pattern="ytcfg_pattenrs"
397
+ )
398
+
399
+
400
+ def apply_signature(stream_manifest: Dict, vid_info: Dict, js: str) -> None:
401
+ """Apply the decrypted signature to the stream manifest.
402
+
403
+ :param dict stream_manifest:
404
+ Details of the media streams available.
405
+ :param str js:
406
+ The contents of the base.js asset file.
407
+
408
+ """
409
+ cipher = Cipher(js=js)
410
+
411
+ for i, stream in enumerate(stream_manifest):
412
+ try:
413
+ url: str = stream["url"]
414
+ except KeyError:
415
+ live_stream = (
416
+ vid_info.get("playabilityStatus", {},)
417
+ .get("liveStreamability")
418
+ )
419
+ if live_stream:
420
+ raise LiveStreamError("UNKNOWN")
421
+ # 403 Forbidden fix.
422
+ if "signature" in url or (
423
+ "s" not in stream and ("&sig=" in url or "&lsig=" in url)
424
+ ):
425
+ # For certain videos, YouTube will just provide them pre-signed, in
426
+ # which case there's no real magic to download them and we can skip
427
+ # the whole signature descrambling entirely.
428
+ logger.debug("signature found, skip decipher")
429
+ continue
430
+
431
+ signature = cipher.get_signature(ciphered_signature=stream["s"])
432
+
433
+ logger.debug(
434
+ "finished descrambling signature for itag=%s", stream["itag"]
435
+ )
436
+ parsed_url = urlparse(url)
437
+
438
+ # Convert query params off url to dict
439
+ query_params = parse_qs(urlparse(url).query)
440
+ query_params = {
441
+ k: v[0] for k,v in query_params.items()
442
+ }
443
+ query_params['sig'] = signature
444
+ if 'ratebypass' not in query_params.keys():
445
+ # Cipher n to get the updated value
446
+
447
+ initial_n = list(query_params['n'])
448
+ new_n = cipher.calculate_n(initial_n)
449
+ query_params['n'] = new_n
450
+
451
+ url = f'{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}?{urlencode(query_params)}' # noqa:E501
452
+
453
+ # 403 forbidden fix
454
+ stream_manifest[i]["url"] = url
455
+
456
+
457
+ def apply_descrambler(stream_data: Dict) -> None:
458
+ """Apply various in-place transforms to YouTube's media stream data.
459
+
460
+ Creates a ``list`` of dictionaries by string splitting on commas, then
461
+ taking each list item, parsing it as a query string, converting it to a
462
+ ``dict`` and unquoting the value.
463
+
464
+ :param dict stream_data:
465
+ Dictionary containing query string encoded values.
466
+
467
+ **Example**:
468
+
469
+ >>> d = {'foo': 'bar=1&var=test,em=5&t=url%20encoded'}
470
+ >>> apply_descrambler(d, 'foo')
471
+ >>> print(d)
472
+ {'foo': [{'bar': '1', 'var': 'test'}, {'em': '5', 't': 'url encoded'}]}
473
+
474
+ """
475
+ if 'url' in stream_data:
476
+ return None
477
+
478
+ # Merge formats and adaptiveFormats into a single list
479
+ formats = []
480
+ if 'formats' in stream_data.keys():
481
+ formats.extend(stream_data['formats'])
482
+ if 'adaptiveFormats' in stream_data.keys():
483
+ formats.extend(stream_data['adaptiveFormats'])
484
+
485
+ # Extract url and s from signatureCiphers as necessary
486
+ for data in formats:
487
+ if 'url' not in data:
488
+ if 'signatureCipher' in data:
489
+ cipher_url = parse_qs(data['signatureCipher'])
490
+ data['url'] = cipher_url['url'][0]
491
+ data['s'] = cipher_url['s'][0]
492
+ data['is_otf'] = data.get('type') == 'FORMAT_STREAM_TYPE_OTF'
493
+
494
+ logger.debug("applying descrambler")
495
+ return formats
496
+
497
+
498
+ def initial_data(watch_html: str) -> str:
499
+ """Extract the ytInitialData json from the watch_html page.
500
+
501
+ This mostly contains metadata necessary for rendering the page on-load,
502
+ such as video information, copyright notices, etc.
503
+
504
+ @param watch_html: Html of the watch page
505
+ @return:
506
+ """
507
+ patterns = [
508
+ r"window\[['\"]ytInitialData['\"]]\s*=\s*",
509
+ r"ytInitialData\s*=\s*"
510
+ ]
511
+ for pattern in patterns:
512
+ try:
513
+ return parse_for_object(watch_html, pattern)
514
+ except HTMLParseError:
515
+ pass
516
+
517
+ raise RegexMatchError(caller='initial_data', pattern='initial_data_pattern')
518
+
519
+
520
+ def initial_player_response(watch_html: str) -> str:
521
+ """Extract the ytInitialPlayerResponse json from the watch_html page.
522
+
523
+ This mostly contains metadata necessary for rendering the page on-load,
524
+ such as video information, copyright notices, etc.
525
+
526
+ @param watch_html: Html of the watch page
527
+ @return:
528
+ """
529
+ patterns = [
530
+ r"window\[['\"]ytInitialPlayerResponse['\"]]\s*=\s*",
531
+ r"ytInitialPlayerResponse\s*=\s*"
532
+ ]
533
+ for pattern in patterns:
534
+ try:
535
+ return parse_for_object(watch_html, pattern)
536
+ except HTMLParseError:
537
+ pass
538
+
539
+ raise RegexMatchError(
540
+ caller='initial_player_response',
541
+ pattern='initial_player_response_pattern'
542
+ )
543
+
544
+
545
+ def metadata(initial_data) -> Optional[YouTubeMetadata]:
546
+ """Get the informational metadata for the video.
547
+
548
+ e.g.:
549
+ [
550
+ {
551
+ 'Song': '강남스타일(Gangnam Style)',
552
+ 'Artist': 'PSY',
553
+ 'Album': 'PSY SIX RULES Pt.1',
554
+ 'Licensed to YouTube by': 'YG Entertainment Inc. [...]'
555
+ }
556
+ ]
557
+
558
+ :rtype: YouTubeMetadata
559
+ """
560
+ try:
561
+ metadata_rows: List = initial_data["contents"]["twoColumnWatchNextResults"][
562
+ "results"]["results"]["contents"][1]["videoSecondaryInfoRenderer"][
563
+ "metadataRowContainer"]["metadataRowContainerRenderer"]["rows"]
564
+ except (KeyError, IndexError):
565
+ # If there's an exception accessing this data, it probably doesn't exist.
566
+ return YouTubeMetadata([])
567
+
568
+ # Rows appear to only have "metadataRowRenderer" or "metadataRowHeaderRenderer"
569
+ # and we only care about the former, so we filter the others
570
+ metadata_rows = filter(
571
+ lambda x: "metadataRowRenderer" in x.keys(),
572
+ metadata_rows
573
+ )
574
+
575
+ # We then access the metadataRowRenderer key in each element
576
+ # and build a metadata object from this new list
577
+ metadata_rows = [x["metadataRowRenderer"] for x in metadata_rows]
578
+
579
+ return YouTubeMetadata(metadata_rows)
caesarpytube/helpers.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Various helper functions implemented by pytube."""
2
+ import functools
3
+ import gzip
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import warnings
9
+ from typing import Any, Callable, Dict, List, Optional, TypeVar
10
+ from urllib import request
11
+
12
+ from pytube.exceptions import RegexMatchError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class DeferredGeneratorList:
18
+ """A wrapper class for deferring list generation.
19
+
20
+ Pytube has some continuation generators that create web calls, which means
21
+ that any time a full list is requested, all of those web calls must be
22
+ made at once, which could lead to slowdowns. This will allow individual
23
+ elements to be queried, so that slowdowns only happen as necessary. For
24
+ example, you can iterate over elements in the list without accessing them
25
+ all simultaneously. This should allow for speed improvements for playlist
26
+ and channel interactions.
27
+ """
28
+ def __init__(self, generator):
29
+ """Construct a :class:`DeferredGeneratorList <DeferredGeneratorList>`.
30
+
31
+ :param generator generator:
32
+ The deferrable generator to create a wrapper for.
33
+ :param func func:
34
+ (Optional) A function to call on the generator items to produce the list.
35
+ """
36
+ self.gen = generator
37
+ self._elements = []
38
+
39
+ def __eq__(self, other):
40
+ """We want to mimic list behavior for comparison."""
41
+ return list(self) == other
42
+
43
+ def __getitem__(self, key) -> Any:
44
+ """Only generate items as they're asked for."""
45
+ # We only allow querying with indexes.
46
+ if not isinstance(key, (int, slice)):
47
+ raise TypeError('Key must be either a slice or int.')
48
+
49
+ # Convert int keys to slice
50
+ key_slice = key
51
+ if isinstance(key, int):
52
+ key_slice = slice(key, key + 1, 1)
53
+
54
+ # Generate all elements up to the final item
55
+ while len(self._elements) < key_slice.stop:
56
+ try:
57
+ next_item = next(self.gen)
58
+ except StopIteration:
59
+ # If we can't find enough elements for the slice, raise an IndexError
60
+ raise IndexError
61
+ else:
62
+ self._elements.append(next_item)
63
+
64
+ return self._elements[key]
65
+
66
+ def __iter__(self):
67
+ """Custom iterator for dynamically generated list."""
68
+ iter_index = 0
69
+ while True:
70
+ try:
71
+ curr_item = self[iter_index]
72
+ except IndexError:
73
+ return
74
+ else:
75
+ yield curr_item
76
+ iter_index += 1
77
+
78
+ def __next__(self) -> Any:
79
+ """Fetch next element in iterator."""
80
+ try:
81
+ curr_element = self[self.iter_index]
82
+ except IndexError:
83
+ raise StopIteration
84
+ self.iter_index += 1
85
+ return curr_element # noqa:R504
86
+
87
+ def __len__(self) -> int:
88
+ """Return length of list of all items."""
89
+ self.generate_all()
90
+ return len(self._elements)
91
+
92
+ def __repr__(self) -> str:
93
+ """String representation of all items."""
94
+ self.generate_all()
95
+ return str(self._elements)
96
+
97
+ def __reversed__(self):
98
+ self.generate_all()
99
+ return self._elements[::-1]
100
+
101
+ def generate_all(self):
102
+ """Generate all items."""
103
+ while True:
104
+ try:
105
+ next_item = next(self.gen)
106
+ except StopIteration:
107
+ break
108
+ else:
109
+ self._elements.append(next_item)
110
+
111
+
112
+ def regex_search(pattern: str, string: str, group: int) -> str:
113
+ """Shortcut method to search a string for a given pattern.
114
+
115
+ :param str pattern:
116
+ A regular expression pattern.
117
+ :param str string:
118
+ A target string to search.
119
+ :param int group:
120
+ Index of group to return.
121
+ :rtype:
122
+ str or tuple
123
+ :returns:
124
+ Substring pattern matches.
125
+ """
126
+ regex = re.compile(pattern)
127
+ results = regex.search(string)
128
+ if not results:
129
+ raise RegexMatchError(caller="regex_search", pattern=pattern)
130
+
131
+ logger.debug("matched regex search: %s", pattern)
132
+
133
+ return results.group(group)
134
+
135
+
136
+ def safe_filename(s: str, max_length: int = 255) -> str:
137
+ """Sanitize a string making it safe to use as a filename.
138
+
139
+ This function was based off the limitations outlined here:
140
+ https://en.wikipedia.org/wiki/Filename.
141
+
142
+ :param str s:
143
+ A string to make safe for use as a file name.
144
+ :param int max_length:
145
+ The maximum filename character length.
146
+ :rtype: str
147
+ :returns:
148
+ A sanitized string.
149
+ """
150
+ # Characters in range 0-31 (0x00-0x1F) are not allowed in ntfs filenames.
151
+ ntfs_characters = [chr(i) for i in range(0, 31)]
152
+ characters = [
153
+ r'"',
154
+ r"\#",
155
+ r"\$",
156
+ r"\%",
157
+ r"'",
158
+ r"\*",
159
+ r"\,",
160
+ r"\.",
161
+ r"\/",
162
+ r"\:",
163
+ r'"',
164
+ r"\;",
165
+ r"\<",
166
+ r"\>",
167
+ r"\?",
168
+ r"\\",
169
+ r"\^",
170
+ r"\|",
171
+ r"\~",
172
+ r"\\\\",
173
+ ]
174
+ pattern = "|".join(ntfs_characters + characters)
175
+ regex = re.compile(pattern, re.UNICODE)
176
+ filename = regex.sub("", s)
177
+ return filename[:max_length].rsplit(" ", 0)[0]
178
+
179
+
180
+ def setup_logger(level: int = logging.ERROR, log_filename: Optional[str] = None) -> None:
181
+ """Create a configured instance of logger.
182
+
183
+ :param int level:
184
+ Describe the severity level of the logs to handle.
185
+ """
186
+ fmt = "[%(asctime)s] %(levelname)s in %(module)s: %(message)s"
187
+ date_fmt = "%H:%M:%S"
188
+ formatter = logging.Formatter(fmt, datefmt=date_fmt)
189
+
190
+ # https://github.com/pytube/pytube/issues/163
191
+ logger = logging.getLogger("pytube")
192
+ logger.setLevel(level)
193
+
194
+ stream_handler = logging.StreamHandler()
195
+ stream_handler.setFormatter(formatter)
196
+ logger.addHandler(stream_handler)
197
+
198
+ if log_filename is not None:
199
+ file_handler = logging.FileHandler(log_filename)
200
+ file_handler.setFormatter(formatter)
201
+ logger.addHandler(file_handler)
202
+
203
+
204
+ GenericType = TypeVar("GenericType")
205
+
206
+
207
+ def cache(func: Callable[..., GenericType]) -> GenericType:
208
+ """ mypy compatible annotation wrapper for lru_cache"""
209
+ return functools.lru_cache()(func) # type: ignore
210
+
211
+
212
+ def deprecated(reason: str) -> Callable:
213
+ """
214
+ This is a decorator which can be used to mark functions
215
+ as deprecated. It will result in a warning being emitted
216
+ when the function is used.
217
+ """
218
+
219
+ def decorator(func1):
220
+ message = "Call to deprecated function {name} ({reason})."
221
+
222
+ @functools.wraps(func1)
223
+ def new_func1(*args, **kwargs):
224
+ warnings.simplefilter("always", DeprecationWarning)
225
+ warnings.warn(
226
+ message.format(name=func1.__name__, reason=reason),
227
+ category=DeprecationWarning,
228
+ stacklevel=2,
229
+ )
230
+ warnings.simplefilter("default", DeprecationWarning)
231
+ return func1(*args, **kwargs)
232
+
233
+ return new_func1
234
+
235
+ return decorator
236
+
237
+
238
+ def target_directory(output_path: Optional[str] = None) -> str:
239
+ """
240
+ Function for determining target directory of a download.
241
+ Returns an absolute path (if relative one given) or the current
242
+ path (if none given). Makes directory if it does not exist.
243
+
244
+ :type output_path: str
245
+ :rtype: str
246
+ :returns:
247
+ An absolute directory path as a string.
248
+ """
249
+ if output_path:
250
+ if not os.path.isabs(output_path):
251
+ output_path = os.path.join(os.getcwd(), output_path)
252
+ else:
253
+ output_path = os.getcwd()
254
+ os.makedirs(output_path, exist_ok=True)
255
+ return output_path
256
+
257
+
258
+ def install_proxy(proxy_handler: Dict[str, str]) -> None:
259
+ proxy_support = request.ProxyHandler(proxy_handler)
260
+ opener = request.build_opener(proxy_support)
261
+ request.install_opener(opener)
262
+
263
+
264
+ def uniqueify(duped_list: List) -> List:
265
+ """Remove duplicate items from a list, while maintaining list order.
266
+
267
+ :param List duped_list
268
+ List to remove duplicates from
269
+
270
+ :return List result
271
+ De-duplicated list
272
+ """
273
+ seen: Dict[Any, bool] = {}
274
+ result = []
275
+ for item in duped_list:
276
+ if item in seen:
277
+ continue
278
+ seen[item] = True
279
+ result.append(item)
280
+ return result
281
+
282
+
283
+ def generate_all_html_json_mocks():
284
+ """Regenerate the video mock json files for all current test videos.
285
+
286
+ This should automatically output to the test/mocks directory.
287
+ """
288
+ test_vid_ids = [
289
+ '2lAe1cqCOXo',
290
+ '5YceQ8YqYMc',
291
+ 'irauhITDrsE',
292
+ 'm8uHb5jIGN8',
293
+ 'QRS8MkLhQmM',
294
+ 'WXxV9g7lsFE'
295
+ ]
296
+ for vid_id in test_vid_ids:
297
+ create_mock_html_json(vid_id)
298
+
299
+
300
+ def create_mock_html_json(vid_id) -> Dict[str, Any]:
301
+ """Generate a json.gz file with sample html responses.
302
+
303
+ :param str vid_id
304
+ YouTube video id
305
+
306
+ :return dict data
307
+ Dict used to generate the json.gz file
308
+ """
309
+ from pytube import YouTube
310
+ gzip_filename = 'yt-video-%s-html.json.gz' % vid_id
311
+
312
+ # Get the pytube directory in order to navigate to /tests/mocks
313
+ pytube_dir_path = os.path.abspath(
314
+ os.path.join(
315
+ os.path.dirname(__file__),
316
+ os.path.pardir
317
+ )
318
+ )
319
+ pytube_mocks_path = os.path.join(pytube_dir_path, 'tests', 'mocks')
320
+ gzip_filepath = os.path.join(pytube_mocks_path, gzip_filename)
321
+
322
+ yt = YouTube(f'https://www.youtube.com/watch?v={vid_id}')
323
+ html_data = {
324
+ 'url': yt.watch_url,
325
+ 'js': yt.js,
326
+ 'embed_html': yt.embed_html,
327
+ 'watch_html': yt.watch_html,
328
+ 'vid_info': yt.vid_info
329
+ }
330
+
331
+ logger.info(f'Outputing json.gz file to {gzip_filepath}')
332
+ with gzip.open(gzip_filepath, 'wb') as f:
333
+ f.write(json.dumps(html_data).encode('utf-8'))
334
+
335
+ return html_data
caesarpytube/innertube.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module is designed to interact with the innertube API.
2
+
3
+ This module is NOT intended to be used directly by end users, as each of the
4
+ interfaces returns raw results. These should instead be parsed to extract
5
+ the useful information for the end user.
6
+ """
7
+ # Native python imports
8
+ import json
9
+ import os
10
+ import pathlib
11
+ import time
12
+ from urllib import parse
13
+
14
+ # Local imports
15
+ from pytube import request
16
+
17
+ # YouTube on TV client secrets
18
+ _client_id = '861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com'
19
+ _client_secret = 'SboVhoG9s0rNafixCSGGKXAT'
20
+
21
+ # Extracted API keys -- unclear what these are linked to.
22
+ _api_keys = [
23
+ 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
24
+ 'AIzaSyCtkvNIR1HCEwzsqK6JuE6KqpyjusIRI30',
25
+ 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
26
+ 'AIzaSyC8UYZpvA2eknNex0Pjid0_eTLJoDu6los',
27
+ 'AIzaSyCjc_pVEDi4qsv5MtC2dMXzpIaDoRFLsxw',
28
+ 'AIzaSyDHQ9ipnphqTzDqZsbtd8_Ru4_kiKVQe2k'
29
+ ]
30
+
31
+ _default_clients = {
32
+ 'WEB': {
33
+ 'context': {
34
+ 'client': {
35
+ 'clientName': 'WEB',
36
+ 'clientVersion': '2.20200720.00.02'
37
+ }
38
+ },
39
+ 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
40
+ },
41
+ 'ANDROID': {
42
+ 'context': {
43
+ 'client': {
44
+ 'clientName': 'ANDROID',
45
+ 'clientVersion': '16.20'
46
+ }
47
+ },
48
+ 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
49
+ },
50
+ 'WEB_EMBED': {
51
+ 'context': {
52
+ 'client': {
53
+ 'clientName': 'WEB',
54
+ 'clientVersion': '2.20210721.00.00',
55
+ 'clientScreen': 'EMBED'
56
+ }
57
+ },
58
+ 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
59
+ },
60
+ 'ANDROID_EMBED': {
61
+ 'context': {
62
+ 'client': {
63
+ 'clientName': 'ANDROID',
64
+ 'clientVersion': '16.20',
65
+ 'clientScreen': 'EMBED'
66
+ }
67
+ },
68
+ 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
69
+ }
70
+ }
71
+ _token_timeout = 1800
72
+ _cache_dir = pathlib.Path(__file__).parent.resolve() / '__cache__'
73
+ _token_file = os.path.join(_cache_dir, 'tokens.json')
74
+
75
+
76
+ class InnerTube:
77
+ """Object for interacting with the innertube API."""
78
+ def __init__(self, client='ANDROID', use_oauth=False, allow_cache=True):
79
+ """Initialize an InnerTube object.
80
+
81
+ :param str client:
82
+ Client to use for the object.
83
+ Default to web because it returns the most playback types.
84
+ :param bool use_oauth:
85
+ Whether or not to authenticate to YouTube.
86
+ :param bool allow_cache:
87
+ Allows caching of oauth tokens on the machine.
88
+ """
89
+ self.context = _default_clients[client]['context']
90
+ self.api_key = _default_clients[client]['api_key']
91
+ self.access_token = None
92
+ self.refresh_token = None
93
+ self.use_oauth = use_oauth
94
+ self.allow_cache = allow_cache
95
+
96
+ # Stored as epoch time
97
+ self.expires = None
98
+
99
+ # Try to load from file if specified
100
+ if self.use_oauth and self.allow_cache:
101
+ # Try to load from file if possible
102
+ if os.path.exists(_token_file):
103
+ with open(_token_file) as f:
104
+ data = json.load(f)
105
+ self.access_token = data['access_token']
106
+ self.refresh_token = data['refresh_token']
107
+ self.expires = data['expires']
108
+ self.refresh_bearer_token()
109
+
110
+ def cache_tokens(self):
111
+ """Cache tokens to file if allowed."""
112
+ if not self.allow_cache:
113
+ return
114
+
115
+ data = {
116
+ 'access_token': self.access_token,
117
+ 'refresh_token': self.refresh_token,
118
+ 'expires': self.expires
119
+ }
120
+ if not os.path.exists(_cache_dir):
121
+ os.mkdir(_cache_dir)
122
+ with open(_token_file, 'w') as f:
123
+ json.dump(data, f)
124
+
125
+ def refresh_bearer_token(self, force=False):
126
+ """Refreshes the OAuth token if necessary.
127
+
128
+ :param bool force:
129
+ Force-refresh the bearer token.
130
+ """
131
+ if not self.use_oauth:
132
+ return
133
+ # Skip refresh if it's not necessary and not forced
134
+ if self.expires > time.time() and not force:
135
+ return
136
+
137
+ # Subtracting 30 seconds is arbitrary to avoid potential time discrepencies
138
+ start_time = int(time.time() - 30)
139
+ data = {
140
+ 'client_id': _client_id,
141
+ 'client_secret': _client_secret,
142
+ 'grant_type': 'refresh_token',
143
+ 'refresh_token': self.refresh_token
144
+ }
145
+ response = request._execute_request(
146
+ 'https://oauth2.googleapis.com/token',
147
+ 'POST',
148
+ headers={
149
+ 'Content-Type': 'application/json'
150
+ },
151
+ data=data
152
+ )
153
+ response_data = json.loads(response.read())
154
+
155
+ self.access_token = response_data['access_token']
156
+ self.expires = start_time + response_data['expires_in']
157
+ self.cache_tokens()
158
+
159
+ def fetch_bearer_token(self):
160
+ """Fetch an OAuth token."""
161
+ # Subtracting 30 seconds is arbitrary to avoid potential time discrepencies
162
+ start_time = int(time.time() - 30)
163
+ data = {
164
+ 'client_id': _client_id,
165
+ 'scope': 'https://www.googleapis.com/auth/youtube'
166
+ }
167
+ response = request._execute_request(
168
+ 'https://oauth2.googleapis.com/device/code',
169
+ 'POST',
170
+ headers={
171
+ 'Content-Type': 'application/json'
172
+ },
173
+ data=data
174
+ )
175
+ response_data = json.loads(response.read())
176
+ verification_url = response_data['verification_url']
177
+ user_code = response_data['user_code']
178
+ print(f'Please open {verification_url} and input code {user_code}')
179
+ print("Waiting 40 seconds...")
180
+ time.sleep(40)
181
+ #input('Press enter when you have completed this step.')
182
+
183
+ data = {
184
+ 'client_id': _client_id,
185
+ 'client_secret': _client_secret,
186
+ 'device_code': response_data['device_code'],
187
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code'
188
+ }
189
+ response = request._execute_request(
190
+ 'https://oauth2.googleapis.com/token',
191
+ 'POST',
192
+ headers={
193
+ 'Content-Type': 'application/json'
194
+ },
195
+ data=data
196
+ )
197
+ response_data = json.loads(response.read())
198
+
199
+ self.access_token = response_data['access_token']
200
+ self.refresh_token = response_data['refresh_token']
201
+ self.expires = start_time + response_data['expires_in']
202
+ self.cache_tokens()
203
+
204
+ @property
205
+ def base_url(self):
206
+ """Return the base url endpoint for the innertube API."""
207
+ return 'https://www.youtube.com/youtubei/v1'
208
+
209
+ @property
210
+ def base_data(self):
211
+ """Return the base json data to transmit to the innertube API."""
212
+ return {
213
+ 'context': self.context
214
+ }
215
+
216
+ @property
217
+ def base_params(self):
218
+ """Return the base query parameters to transmit to the innertube API."""
219
+ return {
220
+ 'key': self.api_key,
221
+ 'contentCheckOk': True,
222
+ 'racyCheckOk': True
223
+ }
224
+
225
+ def _call_api(self, endpoint, query, data):
226
+ """Make a request to a given endpoint with the provided query parameters and data."""
227
+ # Remove the API key if oauth is being used.
228
+ if self.use_oauth:
229
+ del query['key']
230
+
231
+ endpoint_url = f'{endpoint}?{parse.urlencode(query)}'
232
+ headers = {
233
+ 'Content-Type': 'application/json',
234
+ }
235
+ # Add the bearer token if applicable
236
+ if self.use_oauth:
237
+ if self.access_token:
238
+ self.refresh_bearer_token()
239
+ headers['Authorization'] = f'Bearer {self.access_token}'
240
+ else:
241
+ self.fetch_bearer_token()
242
+ headers['Authorization'] = f'Bearer {self.access_token}'
243
+
244
+ response = request._execute_request(
245
+ endpoint_url,
246
+ 'POST',
247
+ headers=headers,
248
+ data=data
249
+ )
250
+ return json.loads(response.read())
251
+
252
+ def browse(self):
253
+ """Make a request to the browse endpoint.
254
+
255
+ TODO: Figure out how we can use this
256
+ """
257
+ # endpoint = f'{self.base_url}/browse' # noqa:E800
258
+ ...
259
+ # return self._call_api(endpoint, query, self.base_data) # noqa:E800
260
+
261
+ def config(self):
262
+ """Make a request to the config endpoint.
263
+
264
+ TODO: Figure out how we can use this
265
+ """
266
+ # endpoint = f'{self.base_url}/config' # noqa:E800
267
+ ...
268
+ # return self._call_api(endpoint, query, self.base_data) # noqa:E800
269
+
270
+ def guide(self):
271
+ """Make a request to the guide endpoint.
272
+
273
+ TODO: Figure out how we can use this
274
+ """
275
+ # endpoint = f'{self.base_url}/guide' # noqa:E800
276
+ ...
277
+ # return self._call_api(endpoint, query, self.base_data) # noqa:E800
278
+
279
+ def next(self):
280
+ """Make a request to the next endpoint.
281
+
282
+ TODO: Figure out how we can use this
283
+ """
284
+ # endpoint = f'{self.base_url}/next' # noqa:E800
285
+ ...
286
+ # return self._call_api(endpoint, query, self.base_data) # noqa:E800
287
+
288
+ def player(self, video_id):
289
+ """Make a request to the player endpoint.
290
+
291
+ :param str video_id:
292
+ The video id to get player info for.
293
+ :rtype: dict
294
+ :returns:
295
+ Raw player info results.
296
+ """
297
+ endpoint = f'{self.base_url}/player'
298
+ query = {
299
+ 'videoId': video_id,
300
+ }
301
+ query.update(self.base_params)
302
+ return self._call_api(endpoint, query, self.base_data)
303
+
304
+ def search(self, search_query, continuation=None):
305
+ """Make a request to the search endpoint.
306
+
307
+ :param str search_query:
308
+ The query to search.
309
+ :rtype: dict
310
+ :returns:
311
+ Raw search query results.
312
+ """
313
+ endpoint = f'{self.base_url}/search'
314
+ query = {
315
+ 'query': search_query
316
+ }
317
+ query.update(self.base_params)
318
+ data = {}
319
+ if continuation:
320
+ data['continuation'] = continuation
321
+ data.update(self.base_data)
322
+ return self._call_api(endpoint, query, data)
323
+
324
+ def verify_age(self, video_id):
325
+ """Make a request to the age_verify endpoint.
326
+
327
+ Notable examples of the types of video this verification step is for:
328
+ * https://www.youtube.com/watch?v=QLdAhwSBZ3w
329
+ * https://www.youtube.com/watch?v=hc0ZDaAZQT0
330
+
331
+ :param str video_id:
332
+ The video id to get player info for.
333
+ :rtype: dict
334
+ :returns:
335
+ Returns information that includes a URL for bypassing certain restrictions.
336
+ """
337
+ endpoint = f'{self.base_url}/verify_age'
338
+ data = {
339
+ 'nextEndpoint': {
340
+ 'urlEndpoint': {
341
+ 'url': f'/watch?v={video_id}'
342
+ }
343
+ },
344
+ 'setControvercy': True
345
+ }
346
+ data.update(self.base_data)
347
+ result = self._call_api(endpoint, self.base_params, data)
348
+ return result
349
+
350
+ def get_transcript(self, video_id):
351
+ """Make a request to the get_transcript endpoint.
352
+
353
+ This is likely related to captioning for videos, but is currently untested.
354
+ """
355
+ endpoint = f'{self.base_url}/get_transcript'
356
+ query = {
357
+ 'videoId': video_id,
358
+ }
359
+ query.update(self.base_params)
360
+ result = self._call_api(endpoint, query, self.base_data)
361
+ return result
caesarpytube/itags.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module contains a lookup table of YouTube's itag values."""
2
+ from typing import Dict
3
+
4
+ PROGRESSIVE_VIDEO = {
5
+ 5: ("240p", "64kbps"),
6
+ 6: ("270p", "64kbps"),
7
+ 13: ("144p", None),
8
+ 17: ("144p", "24kbps"),
9
+ 18: ("360p", "96kbps"),
10
+ 22: ("720p", "192kbps"),
11
+ 34: ("360p", "128kbps"),
12
+ 35: ("480p", "128kbps"),
13
+ 36: ("240p", None),
14
+ 37: ("1080p", "192kbps"),
15
+ 38: ("3072p", "192kbps"),
16
+ 43: ("360p", "128kbps"),
17
+ 44: ("480p", "128kbps"),
18
+ 45: ("720p", "192kbps"),
19
+ 46: ("1080p", "192kbps"),
20
+ 59: ("480p", "128kbps"),
21
+ 78: ("480p", "128kbps"),
22
+ 82: ("360p", "128kbps"),
23
+ 83: ("480p", "128kbps"),
24
+ 84: ("720p", "192kbps"),
25
+ 85: ("1080p", "192kbps"),
26
+ 91: ("144p", "48kbps"),
27
+ 92: ("240p", "48kbps"),
28
+ 93: ("360p", "128kbps"),
29
+ 94: ("480p", "128kbps"),
30
+ 95: ("720p", "256kbps"),
31
+ 96: ("1080p", "256kbps"),
32
+ 100: ("360p", "128kbps"),
33
+ 101: ("480p", "192kbps"),
34
+ 102: ("720p", "192kbps"),
35
+ 132: ("240p", "48kbps"),
36
+ 151: ("720p", "24kbps"),
37
+ 300: ("720p", "128kbps"),
38
+ 301: ("1080p", "128kbps"),
39
+ }
40
+
41
+ DASH_VIDEO = {
42
+ # DASH Video
43
+ 133: ("240p", None), # MP4
44
+ 134: ("360p", None), # MP4
45
+ 135: ("480p", None), # MP4
46
+ 136: ("720p", None), # MP4
47
+ 137: ("1080p", None), # MP4
48
+ 138: ("2160p", None), # MP4
49
+ 160: ("144p", None), # MP4
50
+ 167: ("360p", None), # WEBM
51
+ 168: ("480p", None), # WEBM
52
+ 169: ("720p", None), # WEBM
53
+ 170: ("1080p", None), # WEBM
54
+ 212: ("480p", None), # MP4
55
+ 218: ("480p", None), # WEBM
56
+ 219: ("480p", None), # WEBM
57
+ 242: ("240p", None), # WEBM
58
+ 243: ("360p", None), # WEBM
59
+ 244: ("480p", None), # WEBM
60
+ 245: ("480p", None), # WEBM
61
+ 246: ("480p", None), # WEBM
62
+ 247: ("720p", None), # WEBM
63
+ 248: ("1080p", None), # WEBM
64
+ 264: ("1440p", None), # MP4
65
+ 266: ("2160p", None), # MP4
66
+ 271: ("1440p", None), # WEBM
67
+ 272: ("4320p", None), # WEBM
68
+ 278: ("144p", None), # WEBM
69
+ 298: ("720p", None), # MP4
70
+ 299: ("1080p", None), # MP4
71
+ 302: ("720p", None), # WEBM
72
+ 303: ("1080p", None), # WEBM
73
+ 308: ("1440p", None), # WEBM
74
+ 313: ("2160p", None), # WEBM
75
+ 315: ("2160p", None), # WEBM
76
+ 330: ("144p", None), # WEBM
77
+ 331: ("240p", None), # WEBM
78
+ 332: ("360p", None), # WEBM
79
+ 333: ("480p", None), # WEBM
80
+ 334: ("720p", None), # WEBM
81
+ 335: ("1080p", None), # WEBM
82
+ 336: ("1440p", None), # WEBM
83
+ 337: ("2160p", None), # WEBM
84
+ 394: ("144p", None), # MP4
85
+ 395: ("240p", None), # MP4
86
+ 396: ("360p", None), # MP4
87
+ 397: ("480p", None), # MP4
88
+ 398: ("720p", None), # MP4
89
+ 399: ("1080p", None), # MP4
90
+ 400: ("1440p", None), # MP4
91
+ 401: ("2160p", None), # MP4
92
+ 402: ("4320p", None), # MP4
93
+ 571: ("4320p", None), # MP4
94
+ 694: ("144p", None), # MP4
95
+ 695: ("240p", None), # MP4
96
+ 696: ("360p", None), # MP4
97
+ 697: ("480p", None), # MP4
98
+ 698: ("720p", None), # MP4
99
+ 699: ("1080p", None), # MP4
100
+ 700: ("1440p", None), # MP4
101
+ 701: ("2160p", None), # MP4
102
+ 702: ("4320p", None), # MP4
103
+ }
104
+
105
+ DASH_AUDIO = {
106
+ # DASH Audio
107
+ 139: (None, "48kbps"), # MP4
108
+ 140: (None, "128kbps"), # MP4
109
+ 141: (None, "256kbps"), # MP4
110
+ 171: (None, "128kbps"), # WEBM
111
+ 172: (None, "256kbps"), # WEBM
112
+ 249: (None, "50kbps"), # WEBM
113
+ 250: (None, "70kbps"), # WEBM
114
+ 251: (None, "160kbps"), # WEBM
115
+ 256: (None, "192kbps"), # MP4
116
+ 258: (None, "384kbps"), # MP4
117
+ 325: (None, None), # MP4
118
+ 328: (None, None), # MP4
119
+ }
120
+
121
+ ITAGS = {
122
+ **PROGRESSIVE_VIDEO,
123
+ **DASH_VIDEO,
124
+ **DASH_AUDIO,
125
+ }
126
+
127
+ HDR = [330, 331, 332, 333, 334, 335, 336, 337]
128
+ _3D = [82, 83, 84, 85, 100, 101, 102]
129
+ LIVE = [91, 92, 93, 94, 95, 96, 132, 151]
130
+
131
+
132
+ def get_format_profile(itag: int) -> Dict:
133
+ """Get additional format information for a given itag.
134
+
135
+ :param str itag:
136
+ YouTube format identifier code.
137
+ """
138
+ itag = int(itag)
139
+ if itag in ITAGS:
140
+ res, bitrate = ITAGS[itag]
141
+ else:
142
+ res, bitrate = None, None
143
+ return {
144
+ "resolution": res,
145
+ "abr": bitrate,
146
+ "is_live": itag in LIVE,
147
+ "is_3d": itag in _3D,
148
+ "is_hdr": itag in HDR,
149
+ "is_dash": (
150
+ itag in DASH_AUDIO
151
+ or itag in DASH_VIDEO
152
+ ),
153
+ }
caesarpytube/metadata.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module contains the YouTubeMetadata class."""
2
+ import json
3
+ from typing import Dict, List, Optional
4
+
5
+
6
+ class YouTubeMetadata:
7
+ def __init__(self, metadata: List):
8
+ self._raw_metadata: List = metadata
9
+ self._metadata = [{}]
10
+
11
+ for el in metadata:
12
+ # We only add metadata to the dict if it has a simpleText title.
13
+ if 'title' in el and 'simpleText' in el['title']:
14
+ metadata_title = el['title']['simpleText']
15
+ else:
16
+ continue
17
+
18
+ contents = el['contents'][0]
19
+ if 'simpleText' in contents:
20
+ self._metadata[-1][metadata_title] = contents['simpleText']
21
+ elif 'runs' in contents:
22
+ self._metadata[-1][metadata_title] = contents['runs'][0]['text']
23
+
24
+ # Upon reaching a dividing line, create a new grouping
25
+ if el.get('hasDividerLine', False):
26
+ self._metadata.append({})
27
+
28
+ # If we happen to create an empty dict at the end, drop it
29
+ if self._metadata[-1] == {}:
30
+ self._metadata = self._metadata[:-1]
31
+
32
+ def __getitem__(self, key):
33
+ return self._metadata[key]
34
+
35
+ def __iter__(self):
36
+ for el in self._metadata:
37
+ yield el
38
+
39
+ def __str__(self):
40
+ return json.dumps(self._metadata)
41
+
42
+ @property
43
+ def raw_metadata(self) -> Optional[Dict]:
44
+ return self._raw_metadata
45
+
46
+ @property
47
+ def metadata(self):
48
+ return self._metadata
caesarpytube/monostate.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Callable, Optional
2
+
3
+
4
+ class Monostate:
5
+ def __init__(
6
+ self,
7
+ on_progress: Optional[Callable[[Any, bytes, int], None]],
8
+ on_complete: Optional[Callable[[Any, Optional[str]], None]],
9
+ title: Optional[str] = None,
10
+ duration: Optional[int] = None,
11
+ ):
12
+ self.on_progress = on_progress
13
+ self.on_complete = on_complete
14
+ self.title = title
15
+ self.duration = duration
caesarpytube/parser.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import json
3
+ import re
4
+ from pytube.exceptions import HTMLParseError
5
+
6
+
7
+ def parse_for_all_objects(html, preceding_regex):
8
+ """Parses input html to find all matches for the input starting point.
9
+
10
+ :param str html:
11
+ HTML to be parsed for an object.
12
+ :param str preceding_regex:
13
+ Regex to find the string preceding the object.
14
+ :rtype list:
15
+ :returns:
16
+ A list of dicts created from parsing the objects.
17
+ """
18
+ result = []
19
+ regex = re.compile(preceding_regex)
20
+ match_iter = regex.finditer(html)
21
+ for match in match_iter:
22
+ if match:
23
+ start_index = match.end()
24
+ try:
25
+ obj = parse_for_object_from_startpoint(html, start_index)
26
+ except HTMLParseError:
27
+ # Some of the instances might fail because set is technically
28
+ # a method of the ytcfg object. We'll skip these since they
29
+ # don't seem relevant at the moment.
30
+ continue
31
+ else:
32
+ result.append(obj)
33
+
34
+ if len(result) == 0:
35
+ raise HTMLParseError(f'No matches for regex {preceding_regex}')
36
+
37
+ return result
38
+
39
+
40
+ def parse_for_object(html, preceding_regex):
41
+ """Parses input html to find the end of a JavaScript object.
42
+
43
+ :param str html:
44
+ HTML to be parsed for an object.
45
+ :param str preceding_regex:
46
+ Regex to find the string preceding the object.
47
+ :rtype dict:
48
+ :returns:
49
+ A dict created from parsing the object.
50
+ """
51
+ regex = re.compile(preceding_regex)
52
+ result = regex.search(html)
53
+ if not result:
54
+ raise HTMLParseError(f'No matches for regex {preceding_regex}')
55
+
56
+ start_index = result.end()
57
+ return parse_for_object_from_startpoint(html, start_index)
58
+
59
+
60
+ def find_object_from_startpoint(html, start_point):
61
+ """Parses input html to find the end of a JavaScript object.
62
+
63
+ :param str html:
64
+ HTML to be parsed for an object.
65
+ :param int start_point:
66
+ Index of where the object starts.
67
+ :rtype dict:
68
+ :returns:
69
+ A dict created from parsing the object.
70
+ """
71
+ html = html[start_point:]
72
+ if html[0] not in ['{','[']:
73
+ raise HTMLParseError(f'Invalid start point. Start of HTML:\n{html[:20]}')
74
+
75
+ # First letter MUST be a open brace, so we put that in the stack,
76
+ # and skip the first character.
77
+ last_char = '{'
78
+ curr_char = None
79
+ stack = [html[0]]
80
+ i = 1
81
+
82
+ context_closers = {
83
+ '{': '}',
84
+ '[': ']',
85
+ '"': '"',
86
+ '/': '/' # javascript regex
87
+ }
88
+
89
+ while i < len(html):
90
+ if len(stack) == 0:
91
+ break
92
+ if curr_char not in [' ', '\n']:
93
+ last_char = curr_char
94
+ curr_char = html[i]
95
+ curr_context = stack[-1]
96
+
97
+ # If we've reached a context closer, we can remove an element off the stack
98
+ if curr_char == context_closers[curr_context]:
99
+ stack.pop()
100
+ i += 1
101
+ continue
102
+
103
+ # Strings and regex expressions require special context handling because they can contain
104
+ # context openers *and* closers
105
+ if curr_context in ['"', '/']:
106
+ # If there's a backslash in a string or regex expression, we skip a character
107
+ if curr_char == '\\':
108
+ i += 2
109
+ continue
110
+ else:
111
+ # Non-string contexts are when we need to look for context openers.
112
+ if curr_char in context_closers.keys():
113
+ # Slash starts a regular expression depending on context
114
+ if not (curr_char == '/' and last_char not in ['(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';']):
115
+ stack.append(curr_char)
116
+
117
+ i += 1
118
+
119
+ full_obj = html[:i]
120
+ return full_obj # noqa: R504
121
+
122
+
123
+ def parse_for_object_from_startpoint(html, start_point):
124
+ """JSONifies an object parsed from HTML.
125
+
126
+ :param str html:
127
+ HTML to be parsed for an object.
128
+ :param int start_point:
129
+ Index of where the object starts.
130
+ :rtype dict:
131
+ :returns:
132
+ A dict created from parsing the object.
133
+ """
134
+ full_obj = find_object_from_startpoint(html, start_point)
135
+ try:
136
+ return json.loads(full_obj)
137
+ except json.decoder.JSONDecodeError:
138
+ try:
139
+ return ast.literal_eval(full_obj)
140
+ except (ValueError, SyntaxError):
141
+ raise HTMLParseError('Could not parse object.')
142
+
143
+
144
+ def throttling_array_split(js_array):
145
+ """Parses the throttling array into a python list of strings.
146
+
147
+ Expects input to begin with `[` and close with `]`.
148
+
149
+ :param str js_array:
150
+ The javascript array, as a string.
151
+ :rtype: list:
152
+ :returns:
153
+ A list of strings representing splits on `,` in the throttling array.
154
+ """
155
+ results = []
156
+ curr_substring = js_array[1:]
157
+
158
+ comma_regex = re.compile(r",")
159
+ func_regex = re.compile(r"function\([^)]*\)")
160
+
161
+ while len(curr_substring) > 0:
162
+ if curr_substring.startswith('function'):
163
+ # Handle functions separately. These can contain commas
164
+ match = func_regex.search(curr_substring)
165
+ match_start, match_end = match.span()
166
+
167
+ function_text = find_object_from_startpoint(curr_substring, match.span()[1])
168
+ full_function_def = curr_substring[:match_end + len(function_text)]
169
+ results.append(full_function_def)
170
+ curr_substring = curr_substring[len(full_function_def) + 1:]
171
+ else:
172
+ match = comma_regex.search(curr_substring)
173
+
174
+ # Try-catch to capture end of array
175
+ try:
176
+ match_start, match_end = match.span()
177
+ except AttributeError:
178
+ match_start = len(curr_substring) - 1
179
+ match_end = match_start + 1
180
+
181
+ curr_el = curr_substring[:match_start]
182
+ results.append(curr_el)
183
+ curr_substring = curr_substring[match_end:]
184
+
185
+ return results
caesarpytube/query.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module provides a query interface for media streams and captions."""
2
+ from collections.abc import Mapping, Sequence
3
+ from typing import Callable, List, Optional, Union
4
+
5
+ from pytube import Caption, Stream
6
+ from pytube.helpers import deprecated
7
+
8
+
9
+ class StreamQuery(Sequence):
10
+ """Interface for querying the available media streams."""
11
+
12
+ def __init__(self, fmt_streams):
13
+ """Construct a :class:`StreamQuery <StreamQuery>`.
14
+
15
+ param list fmt_streams:
16
+ list of :class:`Stream <Stream>` instances.
17
+ """
18
+ self.fmt_streams = fmt_streams
19
+ self.itag_index = {int(s.itag): s for s in fmt_streams}
20
+
21
+ def filter(
22
+ self,
23
+ fps=None,
24
+ res=None,
25
+ resolution=None,
26
+ mime_type=None,
27
+ type=None,
28
+ subtype=None,
29
+ file_extension=None,
30
+ abr=None,
31
+ bitrate=None,
32
+ video_codec=None,
33
+ audio_codec=None,
34
+ only_audio=None,
35
+ only_video=None,
36
+ progressive=None,
37
+ adaptive=None,
38
+ is_dash=None,
39
+ custom_filter_functions=None,
40
+ ):
41
+ """Apply the given filtering criterion.
42
+
43
+ :param fps:
44
+ (optional) The frames per second.
45
+ :type fps:
46
+ int or None
47
+
48
+ :param resolution:
49
+ (optional) Alias to ``res``.
50
+ :type res:
51
+ str or None
52
+
53
+ :param res:
54
+ (optional) The video resolution.
55
+ :type resolution:
56
+ str or None
57
+
58
+ :param mime_type:
59
+ (optional) Two-part identifier for file formats and format contents
60
+ composed of a "type", a "subtype".
61
+ :type mime_type:
62
+ str or None
63
+
64
+ :param type:
65
+ (optional) Type part of the ``mime_type`` (e.g.: audio, video).
66
+ :type type:
67
+ str or None
68
+
69
+ :param subtype:
70
+ (optional) Sub-type part of the ``mime_type`` (e.g.: mp4, mov).
71
+ :type subtype:
72
+ str or None
73
+
74
+ :param file_extension:
75
+ (optional) Alias to ``sub_type``.
76
+ :type file_extension:
77
+ str or None
78
+
79
+ :param abr:
80
+ (optional) Average bitrate (ABR) refers to the average amount of
81
+ data transferred per unit of time (e.g.: 64kbps, 192kbps).
82
+ :type abr:
83
+ str or None
84
+
85
+ :param bitrate:
86
+ (optional) Alias to ``abr``.
87
+ :type bitrate:
88
+ str or None
89
+
90
+ :param video_codec:
91
+ (optional) Video compression format.
92
+ :type video_codec:
93
+ str or None
94
+
95
+ :param audio_codec:
96
+ (optional) Audio compression format.
97
+ :type audio_codec:
98
+ str or None
99
+
100
+ :param bool progressive:
101
+ Excludes adaptive streams (one file contains both audio and video
102
+ tracks).
103
+
104
+ :param bool adaptive:
105
+ Excludes progressive streams (audio and video are on separate
106
+ tracks).
107
+
108
+ :param bool is_dash:
109
+ Include/exclude dash streams.
110
+
111
+ :param bool only_audio:
112
+ Excludes streams with video tracks.
113
+
114
+ :param bool only_video:
115
+ Excludes streams with audio tracks.
116
+
117
+ :param custom_filter_functions:
118
+ (optional) Interface for defining complex filters without
119
+ subclassing.
120
+ :type custom_filter_functions:
121
+ list or None
122
+
123
+ """
124
+ filters = []
125
+ if res or resolution:
126
+ if isinstance(res, str) or isinstance(resolution, str):
127
+ filters.append(lambda s: s.resolution == (res or resolution))
128
+ elif isinstance(res, list) or isinstance(resolution, list):
129
+ filters.append(lambda s: s.resolution in (res or resolution))
130
+
131
+ if fps:
132
+ filters.append(lambda s: s.fps == fps)
133
+
134
+ if mime_type:
135
+ filters.append(lambda s: s.mime_type == mime_type)
136
+
137
+ if type:
138
+ filters.append(lambda s: s.type == type)
139
+
140
+ if subtype or file_extension:
141
+ filters.append(lambda s: s.subtype == (subtype or file_extension))
142
+
143
+ if abr or bitrate:
144
+ filters.append(lambda s: s.abr == (abr or bitrate))
145
+
146
+ if video_codec:
147
+ filters.append(lambda s: s.video_codec == video_codec)
148
+
149
+ if audio_codec:
150
+ filters.append(lambda s: s.audio_codec == audio_codec)
151
+
152
+ if only_audio:
153
+ filters.append(
154
+ lambda s: (
155
+ s.includes_audio_track and not s.includes_video_track
156
+ ),
157
+ )
158
+
159
+ if only_video:
160
+ filters.append(
161
+ lambda s: (
162
+ s.includes_video_track and not s.includes_audio_track
163
+ ),
164
+ )
165
+
166
+ if progressive:
167
+ filters.append(lambda s: s.is_progressive)
168
+
169
+ if adaptive:
170
+ filters.append(lambda s: s.is_adaptive)
171
+
172
+ if custom_filter_functions:
173
+ filters.extend(custom_filter_functions)
174
+
175
+ if is_dash is not None:
176
+ filters.append(lambda s: s.is_dash == is_dash)
177
+
178
+ return self._filter(filters)
179
+
180
+ def _filter(self, filters: List[Callable]) -> "StreamQuery":
181
+ fmt_streams = self.fmt_streams
182
+ for filter_lambda in filters:
183
+ fmt_streams = filter(filter_lambda, fmt_streams)
184
+ return StreamQuery(list(fmt_streams))
185
+
186
+ def order_by(self, attribute_name: str) -> "StreamQuery":
187
+ """Apply a sort order. Filters out stream the do not have the attribute.
188
+
189
+ :param str attribute_name:
190
+ The name of the attribute to sort by.
191
+ """
192
+ has_attribute = [
193
+ s
194
+ for s in self.fmt_streams
195
+ if getattr(s, attribute_name) is not None
196
+ ]
197
+ # Check that the attributes have string values.
198
+ if has_attribute and isinstance(
199
+ getattr(has_attribute[0], attribute_name), str
200
+ ):
201
+ # Try to return a StreamQuery sorted by the integer representations
202
+ # of the values.
203
+ try:
204
+ return StreamQuery(
205
+ sorted(
206
+ has_attribute,
207
+ key=lambda s: int(
208
+ "".join(
209
+ filter(str.isdigit, getattr(s, attribute_name))
210
+ )
211
+ ), # type: ignore # noqa: E501
212
+ )
213
+ )
214
+ except ValueError:
215
+ pass
216
+
217
+ return StreamQuery(
218
+ sorted(has_attribute, key=lambda s: getattr(s, attribute_name))
219
+ )
220
+
221
+ def desc(self) -> "StreamQuery":
222
+ """Sort streams in descending order.
223
+
224
+ :rtype: :class:`StreamQuery <StreamQuery>`
225
+
226
+ """
227
+ return StreamQuery(self.fmt_streams[::-1])
228
+
229
+ def asc(self) -> "StreamQuery":
230
+ """Sort streams in ascending order.
231
+
232
+ :rtype: :class:`StreamQuery <StreamQuery>`
233
+
234
+ """
235
+ return self
236
+
237
+ def get_by_itag(self, itag: int) -> Optional[Stream]:
238
+ """Get the corresponding :class:`Stream <Stream>` for a given itag.
239
+
240
+ :param int itag:
241
+ YouTube format identifier code.
242
+ :rtype: :class:`Stream <Stream>` or None
243
+ :returns:
244
+ The :class:`Stream <Stream>` matching the given itag or None if
245
+ not found.
246
+
247
+ """
248
+ return self.itag_index.get(int(itag))
249
+
250
+ def get_by_resolution(self, resolution: str) -> Optional[Stream]:
251
+ """Get the corresponding :class:`Stream <Stream>` for a given resolution.
252
+
253
+ Stream must be a progressive mp4.
254
+
255
+ :param str resolution:
256
+ Video resolution i.e. "720p", "480p", "360p", "240p", "144p"
257
+ :rtype: :class:`Stream <Stream>` or None
258
+ :returns:
259
+ The :class:`Stream <Stream>` matching the given itag or None if
260
+ not found.
261
+
262
+ """
263
+ return self.filter(
264
+ progressive=True, subtype="mp4", resolution=resolution
265
+ ).first()
266
+
267
+ def get_lowest_resolution(self) -> Optional[Stream]:
268
+ """Get lowest resolution stream that is a progressive mp4.
269
+
270
+ :rtype: :class:`Stream <Stream>` or None
271
+ :returns:
272
+ The :class:`Stream <Stream>` matching the given itag or None if
273
+ not found.
274
+
275
+ """
276
+ return (
277
+ self.filter(progressive=True, subtype="mp4")
278
+ .order_by("resolution")
279
+ .first()
280
+ )
281
+
282
+ def get_highest_resolution(self) -> Optional[Stream]:
283
+ """Get highest resolution stream that is a progressive video.
284
+
285
+ :rtype: :class:`Stream <Stream>` or None
286
+ :returns:
287
+ The :class:`Stream <Stream>` matching the given itag or None if
288
+ not found.
289
+
290
+ """
291
+ return self.filter(progressive=True).order_by("resolution").last()
292
+
293
+ def get_audio_only(self, subtype: str = "mp4") -> Optional[Stream]:
294
+ """Get highest bitrate audio stream for given codec (defaults to mp4)
295
+
296
+ :param str subtype:
297
+ Audio subtype, defaults to mp4
298
+ :rtype: :class:`Stream <Stream>` or None
299
+ :returns:
300
+ The :class:`Stream <Stream>` matching the given itag or None if
301
+ not found.
302
+ """
303
+ return (
304
+ self.filter(only_audio=True, subtype=subtype)
305
+ .order_by("abr")
306
+ .last()
307
+ )
308
+
309
+ def otf(self, is_otf: bool = False) -> "StreamQuery":
310
+ """Filter stream by OTF, useful if some streams have 404 URLs
311
+
312
+ :param bool is_otf: Set to False to retrieve only non-OTF streams
313
+ :rtype: :class:`StreamQuery <StreamQuery>`
314
+ :returns: A StreamQuery object with otf filtered streams
315
+ """
316
+ return self._filter([lambda s: s.is_otf == is_otf])
317
+
318
+ def first(self) -> Optional[Stream]:
319
+ """Get the first :class:`Stream <Stream>` in the results.
320
+
321
+ :rtype: :class:`Stream <Stream>` or None
322
+ :returns:
323
+ the first result of this query or None if the result doesn't
324
+ contain any streams.
325
+
326
+ """
327
+ try:
328
+ return self.fmt_streams[0]
329
+ except IndexError:
330
+ return None
331
+
332
+ def last(self):
333
+ """Get the last :class:`Stream <Stream>` in the results.
334
+
335
+ :rtype: :class:`Stream <Stream>` or None
336
+ :returns:
337
+ Return the last result of this query or None if the result
338
+ doesn't contain any streams.
339
+
340
+ """
341
+ try:
342
+ return self.fmt_streams[-1]
343
+ except IndexError:
344
+ pass
345
+
346
+ @deprecated("Get the size of this list directly using len()")
347
+ def count(self, value: Optional[str] = None) -> int: # pragma: no cover
348
+ """Get the count of items in the list.
349
+
350
+ :rtype: int
351
+ """
352
+ if value:
353
+ return self.fmt_streams.count(value)
354
+
355
+ return len(self)
356
+
357
+ @deprecated("This object can be treated as a list, all() is useless")
358
+ def all(self) -> List[Stream]: # pragma: no cover
359
+ """Get all the results represented by this query as a list.
360
+
361
+ :rtype: list
362
+
363
+ """
364
+ return self.fmt_streams
365
+
366
+ def __getitem__(self, i: Union[slice, int]):
367
+ return self.fmt_streams[i]
368
+
369
+ def __len__(self) -> int:
370
+ return len(self.fmt_streams)
371
+
372
+ def __repr__(self) -> str:
373
+ return f"{self.fmt_streams}"
374
+
375
+
376
+ class CaptionQuery(Mapping):
377
+ """Interface for querying the available captions."""
378
+
379
+ def __init__(self, captions: List[Caption]):
380
+ """Construct a :class:`Caption <Caption>`.
381
+
382
+ param list captions:
383
+ list of :class:`Caption <Caption>` instances.
384
+
385
+ """
386
+ self.lang_code_index = {c.code: c for c in captions}
387
+
388
+ @deprecated(
389
+ "This object can be treated as a dictionary, i.e. captions['en']"
390
+ )
391
+ def get_by_language_code(
392
+ self, lang_code: str
393
+ ) -> Optional[Caption]: # pragma: no cover
394
+ """Get the :class:`Caption <Caption>` for a given ``lang_code``.
395
+
396
+ :param str lang_code:
397
+ The code that identifies the caption language.
398
+ :rtype: :class:`Caption <Caption>` or None
399
+ :returns:
400
+ The :class:`Caption <Caption>` matching the given ``lang_code`` or
401
+ None if it does not exist.
402
+ """
403
+ return self.lang_code_index.get(lang_code)
404
+
405
+ @deprecated("This object can be treated as a dictionary")
406
+ def all(self) -> List[Caption]: # pragma: no cover
407
+ """Get all the results represented by this query as a list.
408
+
409
+ :rtype: list
410
+
411
+ """
412
+ return list(self.lang_code_index.values())
413
+
414
+ def __getitem__(self, i: str):
415
+ return self.lang_code_index[i]
416
+
417
+ def __len__(self) -> int:
418
+ return len(self.lang_code_index)
419
+
420
+ def __iter__(self):
421
+ return iter(self.lang_code_index.values())
422
+
423
+ def __repr__(self) -> str:
424
+ return f"{self.lang_code_index}"
caesarpytube/request.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Implements a simple wrapper around urlopen."""
2
+ import http.client
3
+ import json
4
+ import logging
5
+ import re
6
+ import socket
7
+ from functools import lru_cache
8
+ from urllib import parse
9
+ from urllib.error import URLError
10
+ from urllib.request import Request, urlopen
11
+
12
+ from pytube.exceptions import RegexMatchError, MaxRetriesExceeded
13
+ from pytube.helpers import regex_search
14
+
15
+ logger = logging.getLogger(__name__)
16
+ default_range_size = 9437184 # 9MB
17
+
18
+
19
+ def _execute_request(
20
+ url,
21
+ method=None,
22
+ headers=None,
23
+ data=None,
24
+ timeout=socket._GLOBAL_DEFAULT_TIMEOUT
25
+ ):
26
+ base_headers = {"User-Agent": "Mozilla/5.0", "accept-language": "en-US,en"}
27
+ if headers:
28
+ base_headers.update(headers)
29
+ if data:
30
+ # encode data for request
31
+ if not isinstance(data, bytes):
32
+ data = bytes(json.dumps(data), encoding="utf-8")
33
+ if url.lower().startswith("http"):
34
+ request = Request(url, headers=base_headers, method=method, data=data)
35
+ else:
36
+ raise ValueError("Invalid URL")
37
+ return urlopen(request, timeout=timeout) # nosec
38
+
39
+
40
+ def get(url, extra_headers=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
41
+ """Send an http GET request.
42
+
43
+ :param str url:
44
+ The URL to perform the GET request for.
45
+ :param dict extra_headers:
46
+ Extra headers to add to the request
47
+ :rtype: str
48
+ :returns:
49
+ UTF-8 encoded string of response
50
+ """
51
+ if extra_headers is None:
52
+ extra_headers = {}
53
+ response = _execute_request(url, headers=extra_headers, timeout=timeout)
54
+ return response.read().decode("utf-8")
55
+
56
+
57
+ def post(url, extra_headers=None, data=None, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
58
+ """Send an http POST request.
59
+
60
+ :param str url:
61
+ The URL to perform the POST request for.
62
+ :param dict extra_headers:
63
+ Extra headers to add to the request
64
+ :param dict data:
65
+ The data to send on the POST request
66
+ :rtype: str
67
+ :returns:
68
+ UTF-8 encoded string of response
69
+ """
70
+ # could technically be implemented in get,
71
+ # but to avoid confusion implemented like this
72
+ if extra_headers is None:
73
+ extra_headers = {}
74
+ if data is None:
75
+ data = {}
76
+ # required because the youtube servers are strict on content type
77
+ # raises HTTPError [400]: Bad Request otherwise
78
+ extra_headers.update({"Content-Type": "application/json"})
79
+ response = _execute_request(
80
+ url,
81
+ headers=extra_headers,
82
+ data=data,
83
+ timeout=timeout
84
+ )
85
+ return response.read().decode("utf-8")
86
+
87
+
88
+ def seq_stream(
89
+ url,
90
+ timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
91
+ max_retries=0
92
+ ):
93
+ """Read the response in sequence.
94
+ :param str url: The URL to perform the GET request for.
95
+ :rtype: Iterable[bytes]
96
+ """
97
+ # YouTube expects a request sequence number as part of the parameters.
98
+ split_url = parse.urlsplit(url)
99
+ base_url = '%s://%s/%s?' % (split_url.scheme, split_url.netloc, split_url.path)
100
+
101
+ querys = dict(parse.parse_qsl(split_url.query))
102
+
103
+ # The 0th sequential request provides the file headers, which tell us
104
+ # information about how the file is segmented.
105
+ querys['sq'] = 0
106
+ url = base_url + parse.urlencode(querys)
107
+
108
+ segment_data = b''
109
+ for chunk in stream(url, timeout=timeout, max_retries=max_retries):
110
+ yield chunk
111
+ segment_data += chunk
112
+
113
+ # We can then parse the header to find the number of segments
114
+ stream_info = segment_data.split(b'\r\n')
115
+ segment_count_pattern = re.compile(b'Segment-Count: (\\d+)')
116
+ for line in stream_info:
117
+ match = segment_count_pattern.search(line)
118
+ if match:
119
+ segment_count = int(match.group(1).decode('utf-8'))
120
+
121
+ # We request these segments sequentially to build the file.
122
+ seq_num = 1
123
+ while seq_num <= segment_count:
124
+ # Create sequential request URL
125
+ querys['sq'] = seq_num
126
+ url = base_url + parse.urlencode(querys)
127
+
128
+ yield from stream(url, timeout=timeout, max_retries=max_retries)
129
+ seq_num += 1
130
+ return # pylint: disable=R1711
131
+
132
+
133
+ def stream(
134
+ url,
135
+ timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
136
+ max_retries=0
137
+ ):
138
+ """Read the response in chunks.
139
+ :param str url: The URL to perform the GET request for.
140
+ :rtype: Iterable[bytes]
141
+ """
142
+ file_size: int = default_range_size # fake filesize to start
143
+ downloaded = 0
144
+ while downloaded < file_size:
145
+ stop_pos = min(downloaded + default_range_size, file_size) - 1
146
+ range_header = f"bytes={downloaded}-{stop_pos}"
147
+ tries = 0
148
+
149
+ # Attempt to make the request multiple times as necessary.
150
+ while True:
151
+ # If the max retries is exceeded, raise an exception
152
+ if tries >= 1 + max_retries:
153
+ raise MaxRetriesExceeded()
154
+
155
+ # Try to execute the request, ignoring socket timeouts
156
+ try:
157
+ response = _execute_request(
158
+ url,
159
+ method="GET",
160
+ headers={"Range": range_header},
161
+ timeout=timeout
162
+ )
163
+ except URLError as e:
164
+ # We only want to skip over timeout errors, and
165
+ # raise any other URLError exceptions
166
+ if isinstance(e.reason, socket.timeout):
167
+ pass
168
+ else:
169
+ raise
170
+ except http.client.IncompleteRead:
171
+ # Allow retries on IncompleteRead errors for unreliable connections
172
+ pass
173
+ else:
174
+ # On a successful request, break from loop
175
+ break
176
+ tries += 1
177
+
178
+ if file_size == default_range_size:
179
+ try:
180
+ content_range = response.info()["Content-Range"]
181
+ file_size = int(content_range.split("/")[1])
182
+ except (KeyError, IndexError, ValueError) as e:
183
+ logger.error(e)
184
+ while True:
185
+ chunk = response.read()
186
+ if not chunk:
187
+ break
188
+ downloaded += len(chunk)
189
+ yield chunk
190
+ return # pylint: disable=R1711
191
+
192
+
193
+ @lru_cache()
194
+ def filesize(url):
195
+ """Fetch size in bytes of file at given URL
196
+
197
+ :param str url: The URL to get the size of
198
+ :returns: int: size in bytes of remote file
199
+ """
200
+ return int(head(url)["content-length"])
201
+
202
+
203
+ @lru_cache()
204
+ def seq_filesize(url):
205
+ """Fetch size in bytes of file at given URL from sequential requests
206
+
207
+ :param str url: The URL to get the size of
208
+ :returns: int: size in bytes of remote file
209
+ """
210
+ total_filesize = 0
211
+ # YouTube expects a request sequence number as part of the parameters.
212
+ split_url = parse.urlsplit(url)
213
+ base_url = '%s://%s/%s?' % (split_url.scheme, split_url.netloc, split_url.path)
214
+ querys = dict(parse.parse_qsl(split_url.query))
215
+
216
+ # The 0th sequential request provides the file headers, which tell us
217
+ # information about how the file is segmented.
218
+ querys['sq'] = 0
219
+ url = base_url + parse.urlencode(querys)
220
+ response = _execute_request(
221
+ url, method="GET"
222
+ )
223
+
224
+ response_value = response.read()
225
+ # The file header must be added to the total filesize
226
+ total_filesize += len(response_value)
227
+
228
+ # We can then parse the header to find the number of segments
229
+ segment_count = 0
230
+ stream_info = response_value.split(b'\r\n')
231
+ segment_regex = b'Segment-Count: (\\d+)'
232
+ for line in stream_info:
233
+ # One of the lines should contain the segment count, but we don't know
234
+ # which, so we need to iterate through the lines to find it
235
+ try:
236
+ segment_count = int(regex_search(segment_regex, line, 1))
237
+ except RegexMatchError:
238
+ pass
239
+
240
+ if segment_count == 0:
241
+ raise RegexMatchError('seq_filesize', segment_regex)
242
+
243
+ # We make HEAD requests to the segments sequentially to find the total filesize.
244
+ seq_num = 1
245
+ while seq_num <= segment_count:
246
+ # Create sequential request URL
247
+ querys['sq'] = seq_num
248
+ url = base_url + parse.urlencode(querys)
249
+
250
+ total_filesize += int(head(url)['content-length'])
251
+ seq_num += 1
252
+ return total_filesize
253
+
254
+
255
+ def head(url):
256
+ """Fetch headers returned http GET request.
257
+
258
+ :param str url:
259
+ The URL to perform the GET request for.
260
+ :rtype: dict
261
+ :returns:
262
+ dictionary of lowercase headers
263
+ """
264
+ response_headers = _execute_request(url, method="HEAD").info()
265
+ return {k.lower(): v for k, v in response_headers.items()}
caesarpytube/streams.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This module contains a container for stream manifest data.
3
+
4
+ A container object for the media stream (video only / audio only / video+audio
5
+ combined). This was referred to as ``Video`` in the legacy pytube version, but
6
+ has been renamed to accommodate DASH (which serves the audio and video
7
+ separately).
8
+ """
9
+ import logging
10
+ import os
11
+ from math import ceil
12
+
13
+ from datetime import datetime
14
+ from typing import BinaryIO, Dict, Optional, Tuple
15
+ from urllib.error import HTTPError
16
+ from urllib.parse import parse_qs
17
+
18
+ from pytube import extract, request
19
+ from pytube.helpers import safe_filename, target_directory
20
+ from pytube.itags import get_format_profile
21
+ from pytube.monostate import Monostate
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class Stream:
27
+ """Container for stream manifest data."""
28
+
29
+ def __init__(
30
+ self, stream: Dict, monostate: Monostate
31
+ ):
32
+ """Construct a :class:`Stream <Stream>`.
33
+
34
+ :param dict stream:
35
+ The unscrambled data extracted from YouTube.
36
+ :param dict monostate:
37
+ Dictionary of data shared across all instances of
38
+ :class:`Stream <Stream>`.
39
+ """
40
+ # A dictionary shared between all instances of :class:`Stream <Stream>`
41
+ # (Borg pattern).
42
+ self._monostate = monostate
43
+
44
+ self.url = stream["url"] # signed download url
45
+ self.itag = int(
46
+ stream["itag"]
47
+ ) # stream format id (youtube nomenclature)
48
+
49
+ # set type and codec info
50
+
51
+ # 'video/webm; codecs="vp8, vorbis"' -> 'video/webm', ['vp8', 'vorbis']
52
+ self.mime_type, self.codecs = extract.mime_type_codec(stream["mimeType"])
53
+
54
+ # 'video/webm' -> 'video', 'webm'
55
+ self.type, self.subtype = self.mime_type.split("/")
56
+
57
+ # ['vp8', 'vorbis'] -> video_codec: vp8, audio_codec: vorbis. DASH
58
+ # streams return NoneType for audio/video depending.
59
+ self.video_codec, self.audio_codec = self.parse_codecs()
60
+
61
+ self.is_otf: bool = stream["is_otf"]
62
+ self.bitrate: Optional[int] = stream["bitrate"]
63
+
64
+ # filesize in bytes
65
+ self._filesize: Optional[int] = int(stream.get('contentLength', 0))
66
+
67
+ # filesize in kilobytes
68
+ self._filesize_kb: Optional[float] = float(ceil(float(stream.get('contentLength', 0)) / 1024 * 1000) / 1000)
69
+
70
+ # filesize in megabytes
71
+ self._filesize_mb: Optional[float] = float(ceil(float(stream.get('contentLength', 0)) / 1024 / 1024 * 1000) / 1000)
72
+
73
+ # filesize in gigabytes(fingers crossed we don't need terabytes going forward though)
74
+ self._filesize_gb: Optional[float] = float(ceil(float(stream.get('contentLength', 0)) / 1024 / 1024 / 1024 * 1000) / 1000)
75
+
76
+ # Additional information about the stream format, such as resolution,
77
+ # frame rate, and whether the stream is live (HLS) or 3D.
78
+ itag_profile = get_format_profile(self.itag)
79
+ self.is_dash = itag_profile["is_dash"]
80
+ self.abr = itag_profile["abr"] # average bitrate (audio streams only)
81
+ if 'fps' in stream:
82
+ self.fps = stream['fps'] # Video streams only
83
+ self.resolution = itag_profile[
84
+ "resolution"
85
+ ] # resolution (e.g.: "480p")
86
+ self.is_3d = itag_profile["is_3d"]
87
+ self.is_hdr = itag_profile["is_hdr"]
88
+ self.is_live = itag_profile["is_live"]
89
+
90
+ @property
91
+ def is_adaptive(self) -> bool:
92
+ """Whether the stream is DASH.
93
+
94
+ :rtype: bool
95
+ """
96
+ # if codecs has two elements (e.g.: ['vp8', 'vorbis']): 2 % 2 = 0
97
+ # if codecs has one element (e.g.: ['vp8']) 1 % 2 = 1
98
+ return bool(len(self.codecs) % 2)
99
+
100
+ @property
101
+ def is_progressive(self) -> bool:
102
+ """Whether the stream is progressive.
103
+
104
+ :rtype: bool
105
+ """
106
+ return not self.is_adaptive
107
+
108
+ @property
109
+ def includes_audio_track(self) -> bool:
110
+ """Whether the stream only contains audio.
111
+
112
+ :rtype: bool
113
+ """
114
+ return self.is_progressive or self.type == "audio"
115
+
116
+ @property
117
+ def includes_video_track(self) -> bool:
118
+ """Whether the stream only contains video.
119
+
120
+ :rtype: bool
121
+ """
122
+ return self.is_progressive or self.type == "video"
123
+
124
+ def parse_codecs(self) -> Tuple[Optional[str], Optional[str]]:
125
+ """Get the video/audio codecs from list of codecs.
126
+
127
+ Parse a variable length sized list of codecs and returns a
128
+ constant two element tuple, with the video codec as the first element
129
+ and audio as the second. Returns None if one is not available
130
+ (adaptive only).
131
+
132
+ :rtype: tuple
133
+ :returns:
134
+ A two element tuple with audio and video codecs.
135
+
136
+ """
137
+ video = None
138
+ audio = None
139
+ if not self.is_adaptive:
140
+ video, audio = self.codecs
141
+ elif self.includes_video_track:
142
+ video = self.codecs[0]
143
+ elif self.includes_audio_track:
144
+ audio = self.codecs[0]
145
+ return video, audio
146
+
147
+ @property
148
+ def filesize(self) -> int:
149
+ """File size of the media stream in bytes.
150
+
151
+ :rtype: int
152
+ :returns:
153
+ Filesize (in bytes) of the stream.
154
+ """
155
+ if self._filesize == 0:
156
+ try:
157
+ self._filesize = request.filesize(self.url)
158
+ except HTTPError as e:
159
+ if e.code != 404:
160
+ raise
161
+ self._filesize = request.seq_filesize(self.url)
162
+ return self._filesize
163
+
164
+ @property
165
+ def filesize_kb(self) -> float:
166
+ """File size of the media stream in kilobytes.
167
+
168
+ :rtype: float
169
+ :returns:
170
+ Rounded filesize (in kilobytes) of the stream.
171
+ """
172
+ if self._filesize_kb == 0:
173
+ try:
174
+ self._filesize_kb = float(ceil(request.filesize(self.url)/1024 * 1000) / 1000)
175
+ except HTTPError as e:
176
+ if e.code != 404:
177
+ raise
178
+ self._filesize_kb = float(ceil(request.seq_filesize(self.url)/1024 * 1000) / 1000)
179
+ return self._filesize_kb
180
+
181
+ @property
182
+ def filesize_mb(self) -> float:
183
+ """File size of the media stream in megabytes.
184
+
185
+ :rtype: float
186
+ :returns:
187
+ Rounded filesize (in megabytes) of the stream.
188
+ """
189
+ if self._filesize_mb == 0:
190
+ try:
191
+ self._filesize_mb = float(ceil(request.filesize(self.url)/1024/1024 * 1000) / 1000)
192
+ except HTTPError as e:
193
+ if e.code != 404:
194
+ raise
195
+ self._filesize_mb = float(ceil(request.seq_filesize(self.url)/1024/1024 * 1000) / 1000)
196
+ return self._filesize_mb
197
+
198
+ @property
199
+ def filesize_gb(self) -> float:
200
+ """File size of the media stream in gigabytes.
201
+
202
+ :rtype: float
203
+ :returns:
204
+ Rounded filesize (in gigabytes) of the stream.
205
+ """
206
+ if self._filesize_gb == 0:
207
+ try:
208
+ self._filesize_gb = float(ceil(request.filesize(self.url)/1024/1024/1024 * 1000) / 1000)
209
+ except HTTPError as e:
210
+ if e.code != 404:
211
+ raise
212
+ self._filesize_gb = float(ceil(request.seq_filesize(self.url)/1024/1024/1024 * 1000) / 1000)
213
+ return self._filesize_gb
214
+
215
+ @property
216
+ def title(self) -> str:
217
+ """Get title of video
218
+
219
+ :rtype: str
220
+ :returns:
221
+ Youtube video title
222
+ """
223
+ return self._monostate.title or "Unknown YouTube Video Title"
224
+
225
+ @property
226
+ def filesize_approx(self) -> int:
227
+ """Get approximate filesize of the video
228
+
229
+ Falls back to HTTP call if there is not sufficient information to approximate
230
+
231
+ :rtype: int
232
+ :returns: size of video in bytes
233
+ """
234
+ if self._monostate.duration and self.bitrate:
235
+ bits_in_byte = 8
236
+ return int(
237
+ (self._monostate.duration * self.bitrate) / bits_in_byte
238
+ )
239
+
240
+ return self.filesize
241
+
242
+ @property
243
+ def expiration(self) -> datetime:
244
+ expire = parse_qs(self.url.split("?")[1])["expire"][0]
245
+ return datetime.utcfromtimestamp(int(expire))
246
+
247
+ @property
248
+ def default_filename(self) -> str:
249
+ """Generate filename based on the video title.
250
+
251
+ :rtype: str
252
+ :returns:
253
+ An os file system compatible filename.
254
+ """
255
+ filename = safe_filename(self.title)
256
+ return f"{filename}.{self.subtype}"
257
+
258
+ def download(
259
+ self,
260
+ output_path: Optional[str] = None,
261
+ filename: Optional[str] = None,
262
+ filename_prefix: Optional[str] = None,
263
+ skip_existing: bool = True,
264
+ timeout: Optional[int] = None,
265
+ max_retries: Optional[int] = 0
266
+ ) -> str:
267
+ """Write the media stream to disk.
268
+
269
+ :param output_path:
270
+ (optional) Output path for writing media file. If one is not
271
+ specified, defaults to the current working directory.
272
+ :type output_path: str or None
273
+ :param filename:
274
+ (optional) Output filename (stem only) for writing media file.
275
+ If one is not specified, the default filename is used.
276
+ :type filename: str or None
277
+ :param filename_prefix:
278
+ (optional) A string that will be prepended to the filename.
279
+ For example a number in a playlist or the name of a series.
280
+ If one is not specified, nothing will be prepended
281
+ This is separate from filename so you can use the default
282
+ filename but still add a prefix.
283
+ :type filename_prefix: str or None
284
+ :param skip_existing:
285
+ (optional) Skip existing files, defaults to True
286
+ :type skip_existing: bool
287
+ :param timeout:
288
+ (optional) Request timeout length in seconds. Uses system default.
289
+ :type timeout: int
290
+ :param max_retries:
291
+ (optional) Number of retries to attempt after socket timeout. Defaults to 0.
292
+ :type max_retries: int
293
+ :returns:
294
+ Path to the saved video
295
+ :rtype: str
296
+
297
+ """
298
+ file_path = self.get_file_path(
299
+ filename=filename,
300
+ output_path=output_path,
301
+ filename_prefix=filename_prefix,
302
+ )
303
+
304
+ if skip_existing and self.exists_at_path(file_path):
305
+ logger.debug(f'file {file_path} already exists, skipping')
306
+ self.on_complete(file_path)
307
+ return file_path
308
+
309
+ bytes_remaining = self.filesize
310
+ logger.debug(f'downloading ({self.filesize} total bytes) file to {file_path}')
311
+
312
+ with open(file_path, "wb") as fh:
313
+ try:
314
+ for chunk in request.stream(
315
+ self.url,
316
+ timeout=timeout,
317
+ max_retries=max_retries
318
+ ):
319
+ # reduce the (bytes) remainder by the length of the chunk.
320
+ bytes_remaining -= len(chunk)
321
+ # send to the on_progress callback.
322
+ self.on_progress(chunk, fh, bytes_remaining)
323
+ except HTTPError as e:
324
+ if e.code != 404:
325
+ raise
326
+ # Some adaptive streams need to be requested with sequence numbers
327
+ for chunk in request.seq_stream(
328
+ self.url,
329
+ timeout=timeout,
330
+ max_retries=max_retries
331
+ ):
332
+ # reduce the (bytes) remainder by the length of the chunk.
333
+ bytes_remaining -= len(chunk)
334
+ # send to the on_progress callback.
335
+ self.on_progress(chunk, fh, bytes_remaining)
336
+ self.on_complete(file_path)
337
+ return file_path
338
+
339
+ def get_file_path(
340
+ self,
341
+ filename: Optional[str] = None,
342
+ output_path: Optional[str] = None,
343
+ filename_prefix: Optional[str] = None,
344
+ ) -> str:
345
+ if not filename:
346
+ filename = self.default_filename
347
+ if filename_prefix:
348
+ filename = f"{filename_prefix}{filename}"
349
+ return os.path.join(target_directory(output_path), filename)
350
+
351
+ def exists_at_path(self, file_path: str) -> bool:
352
+ return (
353
+ os.path.isfile(file_path)
354
+ and os.path.getsize(file_path) == self.filesize
355
+ )
356
+
357
+ def stream_to_buffer(self, buffer: BinaryIO) -> None:
358
+ """Write the media stream to buffer
359
+
360
+ :rtype: io.BytesIO buffer
361
+ """
362
+ bytes_remaining = self.filesize
363
+ logger.info(
364
+ "downloading (%s total bytes) file to buffer", self.filesize,
365
+ )
366
+
367
+ for chunk in request.stream(self.url):
368
+ # reduce the (bytes) remainder by the length of the chunk.
369
+ bytes_remaining -= len(chunk)
370
+ # send to the on_progress callback.
371
+ self.on_progress(chunk, buffer, bytes_remaining)
372
+ self.on_complete(None)
373
+
374
+ def on_progress(
375
+ self, chunk: bytes, file_handler: BinaryIO, bytes_remaining: int
376
+ ):
377
+ """On progress callback function.
378
+
379
+ This function writes the binary data to the file, then checks if an
380
+ additional callback is defined in the monostate. This is exposed to
381
+ allow things like displaying a progress bar.
382
+
383
+ :param bytes chunk:
384
+ Segment of media file binary data, not yet written to disk.
385
+ :param file_handler:
386
+ The file handle where the media is being written to.
387
+ :type file_handler:
388
+ :py:class:`io.BufferedWriter`
389
+ :param int bytes_remaining:
390
+ The delta between the total file size in bytes and amount already
391
+ downloaded.
392
+
393
+ :rtype: None
394
+
395
+ """
396
+ file_handler.write(chunk)
397
+ logger.debug("download remaining: %s", bytes_remaining)
398
+ if self._monostate.on_progress:
399
+ self._monostate.on_progress(self, chunk, bytes_remaining)
400
+
401
+ def on_complete(self, file_path: Optional[str]):
402
+ """On download complete handler function.
403
+
404
+ :param file_path:
405
+ The file handle where the media is being written to.
406
+ :type file_path: str
407
+
408
+ :rtype: None
409
+
410
+ """
411
+ logger.debug("download finished")
412
+ on_complete = self._monostate.on_complete
413
+ if on_complete:
414
+ logger.debug("calling on_complete callback %s", on_complete)
415
+ on_complete(self, file_path)
416
+
417
+ def __repr__(self) -> str:
418
+ """Printable object representation.
419
+
420
+ :rtype: str
421
+ :returns:
422
+ A string representation of a :class:`Stream <Stream>` object.
423
+ """
424
+ parts = ['itag="{s.itag}"', 'mime_type="{s.mime_type}"']
425
+ if self.includes_video_track:
426
+ parts.extend(['res="{s.resolution}"', 'fps="{s.fps}fps"'])
427
+ if not self.is_adaptive:
428
+ parts.extend(
429
+ ['vcodec="{s.video_codec}"', 'acodec="{s.audio_codec}"',]
430
+ )
431
+ else:
432
+ parts.extend(['vcodec="{s.video_codec}"'])
433
+ else:
434
+ parts.extend(['abr="{s.abr}"', 'acodec="{s.audio_codec}"'])
435
+ parts.extend(['progressive="{s.is_progressive}"', 'type="{s.type}"'])
436
+ return f"<Stream: {' '.join(parts).format(s=self)}>"
caesarpytube/version.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __version__ = "12.1.3"
2
+
3
+ if __name__ == "__main__":
4
+ print(__version__)
gitpytube ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit da3141f3d937459cd7cfd9180970b9ec1d14bb5e
main.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import numpy as np
4
+ from youtubesearchpython import VideosSearch,PlaylistsSearch
5
+ import uvicorn
6
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect,status
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from pydantic import BaseModel
9
+ from typing import Union,List
10
+ from fastapi.responses import FileResponse
11
+ from caesarmusic import CaesarMusic
12
+ from fastapi.responses import StreamingResponse
13
+ app = FastAPI()
14
+ class CaesarAIMusicModel(BaseModel):
15
+ artist :str
16
+ album: str
17
+ album_or_song: str
18
+ CURRENT_DIR = os.path.realpath(__file__).replace(f"/main.py","").replace(f"\main.py","")
19
+ download_dir = f"{CURRENT_DIR}/Songs"
20
+
21
+ caesarmusic = CaesarMusic(first_time_running=True)
22
+
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["*"], # can alter with time
26
+ allow_credentials=True,
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+ @app.get("/")
32
+ def caesaraihome():
33
+ return "Welcome to CaesarAIMusic"
34
+ @app.get("/caesarailogo")
35
+ def caesarailogo():
36
+ return FileResponse(f"{CURRENT_DIR}/CaesarAILogo.png")
37
+
38
+
39
+ @app.post("/caesarmusic")
40
+ def caesaraimusic(musicjson : CaesarAIMusicModel):
41
+ musicjson = dict(musicjson)
42
+ artist = musicjson["artist"]
43
+ album = musicjson["album"]
44
+ album_or_song = musicjson["album_or_song"]
45
+ query = f"{artist} {album}"
46
+ print("Hi")
47
+ caesarmusic = CaesarMusic()
48
+ try:
49
+ if album_or_song == "album":
50
+ videosSearch = PlaylistsSearch(query,limit=1)
51
+ playlisturl = [playlist["link"] for playlist in videosSearch.result()["result"] if "playlist" in playlist["link"]][0]
52
+ songs = caesarmusic.fetch_playslist_songs(artist,playlisturl)
53
+ songzipresponse = caesarmusic.caesarmusicextract(songs)
54
+ if songzipresponse == "No song detected":
55
+ return {"message":"No song detected"}
56
+ elif songzipresponse != "No song detected":
57
+ streamed_songs = caesarmusic.stream_song(songzipresponse)
58
+ response = StreamingResponse(
59
+ content=streamed_songs,
60
+ status_code=status.HTTP_200_OK,
61
+ media_type="audio/mpeg",
62
+ )
63
+ return response
64
+ elif album_or_song == "song":
65
+ songs = caesarmusic.caesarmusicfetch(artist,album)
66
+ songzipresponse = caesarmusic.caesarmusicextract(songs)
67
+ if songzipresponse == "No song detected":
68
+ return {"message":"No song detected"}
69
+ elif songzipresponse != "No song detected":
70
+ return songzipresponse
71
+ except Exception as ex:
72
+ return {"message":f"{type(ex)},{ex}"}
73
+ @app.websocket("/caesarmusicws")
74
+ async def caesarmusicws(websocket: WebSocket):
75
+ # listen for connections
76
+ await websocket.accept()
77
+
78
+ try:
79
+ #caesarmusic.clean_up_dir(download_dir,"mp3")
80
+ #caesarmusic.clean_up_dir(download_dir,"mp4")
81
+ while True:
82
+ musicjson = await websocket.receive_json()
83
+ artist = musicjson["artist"]
84
+ album = musicjson["album"]
85
+ album_or_song = musicjson["album_or_song"]
86
+
87
+ artist = artist.lower().strip()
88
+ if "youtube" not in album:
89
+ album = album.lower().strip()
90
+ query = f"{artist} {album}"
91
+ songs_downloaded = 0
92
+ if album_or_song == "album":
93
+ if "youtube" not in album:
94
+ videosSearch = PlaylistsSearch(query,limit=4)
95
+ resultnum,result = [],[]
96
+ for playlist in videosSearch.result()["result"]:
97
+ if "playlist" in playlist["link"]:
98
+ songs = caesarmusic.fetch_playslist_songs(artist,playlist["link"])
99
+ resultnum.append(len(songs))
100
+ result.append(songs)
101
+ biggest_number = max(resultnum)
102
+ #print(result)
103
+ songs = result[resultnum.index(biggest_number)]
104
+ elif "youtube" in album:
105
+ songs = caesarmusic.fetch_playslist_songs(artist,album)
106
+ #playlisturl = [playlist["link"] for playlist in videosSearch.result()["result"] if "playlist" in playlist["link"]][0]
107
+ #songs = caesarmusic.fetch_playslist_songs(artist,playlisturl)
108
+ #print(songs[0])
109
+ elif album_or_song == "song":
110
+ if "/watch?v=" not in album:
111
+ songs = caesarmusic.caesarmusicfetch(artist,album)
112
+ elif "/watch?v=" in album:
113
+ songs = [{"link":album}]
114
+ for song,songtitle in caesarmusic.caesarmusicextractgenerator(songs):
115
+ if song == "No song detected":
116
+ await websocket.send_json({"message":"No song detected"})
117
+ elif song != "No song detected":
118
+ #await websocket.send_bytes(song) # sends the buffer as bytes
119
+ #await websocket.send_json({"message":f"{songs_downloaded}/{len(songs)} - {round((songs_downloaded/len(songs))*100,2)}%"}) # sends the buffer as bytes
120
+
121
+ await websocket.send_json({"filename":songtitle,"filebase64":song,"message":f"{songs_downloaded}/{len(songs)} - {round((songs_downloaded/len(songs))*100,2)}%"})
122
+
123
+ songs_downloaded += 1
124
+ print({"message":"all songs are downloaded"})
125
+ caesarmusic.clean_up_dir(download_dir,"mp3")
126
+ caesarmusic.clean_up_dir(download_dir,"mp4")
127
+ #caesarmusic.first_time_running = False
128
+
129
+ await websocket.send_json({"message":"all songs are downloaded"})
130
+
131
+ except ValueError as vex:
132
+ response = {"message":"No song detected","error":f"{type(vex)}{vex}"}
133
+ await websocket.send_json(response)
134
+ except Exception as ex:
135
+ print("Client disconnected")
136
+ await websocket.send_json({"message":f"{type(ex)},{ex}"})
137
+ @app.get("/caesarmusicsongload/{songname}")
138
+ async def caesarmusicsongload(songname):
139
+ try:
140
+ songfile = FileResponse(f"{download_dir}/{songname}")
141
+ return songfile
142
+ except Exception as rrx:
143
+ return {"message":f"{type(rrx)},{rrx}"}
144
+
145
+ @app.get("/caesarcleanup")
146
+ async def caesarcleanup():
147
+ try:
148
+ caesarmusic.clean_up_dir(download_dir,"mp3")
149
+ caesarmusic.clean_up_dir(download_dir,"mp4")
150
+ return {"message":"directory is clean now"}
151
+ except Exception as rrx:
152
+ return {"message":"No need to remove"}
153
+
154
+
155
+ async def main():
156
+ config = uvicorn.Config("main:app", port=7860, log_level="info",host="0.0.0.0",reload=True)
157
+ server = uvicorn.Server(config)
158
+ await server.serve()
159
+
160
+ if __name__ == "__main__":
161
+ asyncio.run(main())
requirements.txt ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ anyio==3.6.2
2
+ certifi
3
+ charset-normalizer==3.1.0
4
+ click==8.1.3
5
+ colorama==0.4.6
6
+ decorator==4.4.2
7
+ fastapi==0.93.0
8
+ Flask==2.2.3
9
+ h11==0.14.0
10
+ h2==4.1.0
11
+ hpack==4.0.0
12
+ httpcore==0.16.3
13
+ httptools==0.5.0
14
+ httpx==0.23.3
15
+ hyperframe==6.0.1
16
+ idna==3.4
17
+ imageio==2.26.0
18
+ imageio-ffmpeg==0.4.8
19
+ install==1.3.5
20
+ itsdangerous==2.1.2
21
+ Jinja2==3.1.2
22
+ MarkupSafe==2.1.2
23
+ moviepy==1.0.3
24
+ numpy==1.24.2
25
+ Pillow==9.4.0
26
+ proglog==0.1.10
27
+ pyaes==1.6.1
28
+ pyasn1==0.4.8
29
+ pydantic==1.10.6
30
+ pyTelegramBotAPI==4.10.0
31
+ python-dotenv==1.0.0
32
+ python-telegram-bot==20.1
33
+ pytube==12.1.2
34
+ PyYAML==6.0
35
+ requests==2.28.2
36
+ rfc3986==1.5.0
37
+ rsa==4.9
38
+ sniffio==1.3.0
39
+ starlette==0.25.0
40
+ telebot==0.0.5
41
+ Telethon==1.27.0
42
+ tqdm==4.65.0
43
+ typing_extensions==4.5.0
44
+ urllib3==1.26.14
45
+ uvicorn==0.21.0
46
+ watchfiles==0.18.1
47
+ websockets==10.4
48
+ Werkzeug==2.2.3
49
+ wincertstore==0.2
50
+ wsproto==1.2.0
51
+ youtube-dl==2021.12.17
52
+ youtube-search-python==1.6.6
sendweb.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+
3
+ # WS client example
4
+
5
+ import asyncio
6
+ import websockets
7
+
8
+ async def hello():
9
+ uri = "wss://palondomus-caesarmusic.hf.space/caesarmusicws"
10
+ async with websockets.connect(uri) as websocket:
11
+
12
+
13
+ await websocket.send({"artist":"a boogie wit da hoodie","album":"hoodie szn","album_or_song":"album"})
14
+ while True:
15
+ greeting = await websocket.recv()
16
+ if "message" in greeting:
17
+ print(greeting)
18
+ if "finished"in greeting["message"]:
19
+ break
20
+
21
+
22
+ asyncio.get_event_loop().run_until_complete(hello())