diff --git a/.gitignore b/.gitignore index a9a364d..aaabb04 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,7 @@ vlc/ # yt-dlp executable (download from https://github.com/yt-dlp/yt-dlp/releases) yt-dlp.exe +# YouTube OAuth client (download from Google Cloud Console; never commit) +platforms/youtube/client_secret.json + todo.txt \ No newline at end of file diff --git a/GUI/account_options.py b/GUI/account_options.py index 70c86bd..77991c4 100644 --- a/GUI/account_options.py +++ b/GUI/account_options.py @@ -182,6 +182,11 @@ def __init__(self,account): self.notebook.AddPage(self.timelines_panel, "Timelines") self.alias_panel = AliasPanel(self.account, self.notebook) self.notebook.AddPage(self.alias_panel, "Aliases") + # Templates tab - currently YouTube-specific (post display template) + self.templates_panel = None + if getattr(self.account.prefs, 'platform_type', '') == 'youtube': + self.templates_panel = TemplatesPanel(self.account, self.notebook) + self.notebook.AddPage(self.templates_panel, "Templates") self.main_box.Add(self.notebook, 0, wx.ALL, 10) self.ok = wx.Button(self.panel, wx.ID_OK, "&OK") self.ok.SetDefault() @@ -203,6 +208,12 @@ def OnOK(self, event): # Save timeline order self.account.prefs.timeline_order = self.timelines_panel.get_order() + # Save YouTube post template (and refresh display so it takes effect now) + if self.templates_panel is not None: + self.account.prefs.youtube_template = self.templates_panel.get_template() + for tl in getattr(self.account, 'timelines', []): + tl.invalidate_display_cache() + # Handle mentions_in_notifications setting change if self.general.mentions_in_notifications is not None: old_value = self.account.prefs.mentions_in_notifications @@ -469,3 +480,52 @@ def on_remove(self, event): new_idx = min(idx, self.alias_list.GetCount() - 1) self.alias_list.SetSelection(new_idx) self._update_buttons() + + +# Default YouTube post template: author name + handle + title + date. +DEFAULT_YOUTUBE_TEMPLATE = "$account.display_name$ (@$account.acct$): $text$ $created_at$" + + +class TemplatesPanel(wx.Panel): + """Panel for customizing how YouTube posts (videos) are displayed.""" + + def __init__(self, account, parent): + super().__init__(parent) + self.account = account + self.main_box = wx.BoxSizer(wx.VERTICAL) + + info = wx.StaticText(self, -1, + "How YouTube videos are shown in timelines. Edit the template below.") + self.main_box.Add(info, 0, wx.ALL, 10) + + self.template_label = wx.StaticText(self, -1, "Post &template") + self.main_box.Add(self.template_label, 0, wx.LEFT | wx.TOP, 10) + current = getattr(account.prefs, 'youtube_template', '') or DEFAULT_YOUTUBE_TEMPLATE + self.template = wx.TextCtrl(self, -1, current, style=wx.TE_MULTILINE, name="Post template") + self.main_box.Add(self.template, 0, wx.EXPAND | wx.ALL, 10) + + # Placeholder reference + placeholders = ( + "Placeholders:\n" + "$text$ - video title\n" + "$account.display_name$ - channel name\n" + "$account.acct$ - channel @handle\n" + "$created_at$ - publish date\n" + "$description$ - full video description\n" + "$url$ - link to the video" + ) + self.placeholders_label = wx.StaticText(self, -1, placeholders) + self.main_box.Add(self.placeholders_label, 0, wx.ALL, 10) + + self.reset_btn = wx.Button(self, -1, "&Reset to Default") + self.reset_btn.Bind(wx.EVT_BUTTON, self.on_reset) + self.main_box.Add(self.reset_btn, 0, wx.ALL, 10) + + self.SetSizer(self.main_box) + + def on_reset(self, event): + self.template.SetValue(DEFAULT_YOUTUBE_TEMPLATE) + + def get_template(self): + value = self.template.GetValue().strip() + return value or DEFAULT_YOUTUBE_TEMPLATE diff --git a/GUI/accounts.py b/GUI/accounts.py index 752755a..0542fa9 100644 --- a/GUI/accounts.py +++ b/GUI/accounts.py @@ -81,6 +81,8 @@ def add_items(self): instance = parsed.netloc or parsed.path.strip('/') if instance: acct = f"{acct} on {instance}" + elif platform_type == 'youtube': + acct = f"{acct} on YouTube" self.list.Insert(acct, self.list.GetCount()) if i==app.currentAccount: self.list.SetSelection(index) @@ -92,7 +94,18 @@ def on_list_change(self,event): def New(self, event): app = get_app() num_accounts_before = len(app.accounts) - app.add_session() + # Use the next CONTIGUOUS folder index, not the lowest free one. Startup + # loads accounts via range(prefs.accounts), so an account placed at a + # non-contiguous index (caused by a leftover/orphan folder on disk) is + # never loaded -> the account seems to "not save" and must be re-added + # every boot. Clear any orphan squatting at that index (it isn't a + # loaded account, so it's stale data) before creating the new one. + new_index = app.prefs.accounts + orphan = os.path.join(app.confpath, f"account{new_index}") + loaded_here = any(getattr(a, 'folder_index', -1) == new_index for a in app.accounts) + if os.path.exists(orphan) and not loaded_here: + shutil.rmtree(orphan, ignore_errors=True) + app.add_session(new_index) # Check if a new account was actually added if len(app.accounts) <= num_accounts_before: # Account creation was cancelled or failed diff --git a/GUI/options.py b/GUI/options.py index 0779e4f..50822c5 100644 --- a/GUI/options.py +++ b/GUI/options.py @@ -356,8 +356,71 @@ def __init__(self, parent): vlc_sizer.Add(self.vlc_download, 0) self.main_box.Add(vlc_sizer, 0, wx.ALL, 10) + # YouTube account (Google OAuth) status + sign out + account_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.youtube_status = wx.StaticText(self, -1, self._youtube_status_text()) + account_sizer.Add(self.youtube_status, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 10) + self.youtube_signout = wx.Button(self, -1, "Sign out of YouTube") + self.youtube_signout.Bind(wx.EVT_BUTTON, self.on_youtube_signout) + self.youtube_signout.Enable(self._find_youtube_account() is not None) + account_sizer.Add(self.youtube_signout, 0) + self.main_box.Add(account_sizer, 0, wx.ALL, 10) + self.SetSizer(self.main_box) + def _find_youtube_account(self): + """Return a signed-in YouTube account (current first), or None.""" + app = get_app() + cur = getattr(app, 'currentAccount', None) + if cur is not None and getattr(cur.prefs, 'platform_type', '') == 'youtube': + return cur + for acc in getattr(app, 'accounts', []): + if getattr(acc.prefs, 'platform_type', '') == 'youtube': + return acc + return None + + def _youtube_status_text(self): + """Human-readable sign-in status for the YouTube account.""" + acc = self._find_youtube_account() + if acc is None: + return "YouTube: no account signed in." + me = getattr(getattr(acc, '_platform', None), 'me', None) + name = getattr(me, 'display_name', '') or getattr(me, 'username', '') + if name: + return f"YouTube: signed in as {name}." + return "YouTube: signed in." + + def on_youtube_signout(self, event): + """Clear the stored OAuth token so the next launch re-prompts login.""" + import speak + acc = self._find_youtube_account() + if acc is None: + speak.speak("No YouTube account is signed in.") + return + if wx.MessageBox( + "Sign out of YouTube? You'll need to log in again the next time you start FastSM.", + "Confirm Sign Out", wx.YES_NO | wx.ICON_QUESTION) != wx.YES: + return + # Best-effort: revoke the grant at Google so the token can't be reused. + try: + from platforms.youtube import oauth + token = acc.prefs.get("youtube_token", None) + if token: + oauth.revoke_token(dict(token) if not isinstance(token, dict) else token) + except Exception: + pass + # Per-account prefs autosave, so this persists immediately. + acc.prefs.youtube_token = {} + # Drop in-memory creds so nothing refreshes/re-saves the token this session. + try: + if getattr(acc, '_platform', None) is not None: + acc._platform.credentials = None + except Exception: + pass + self.youtube_status.SetLabel("YouTube: signed out. Restart FastSM to log in again.") + self.youtube_signout.Enable(False) + speak.speak("Signed out of YouTube. Restart FastSM to log in again.") + def on_ytdlp_browse(self, event): """Browse for yt-dlp executable.""" with wx.FileDialog(self, "Select yt-dlp executable", diff --git a/GUI/platform_dialog.py b/GUI/platform_dialog.py index 681a141..72a5f3c 100644 --- a/GUI/platform_dialog.py +++ b/GUI/platform_dialog.py @@ -21,10 +21,12 @@ def __init__(self, parent): # Radio buttons self.mastodon_radio = wx.RadioButton(panel, label="Mastodon", style=wx.RB_GROUP) self.bluesky_radio = wx.RadioButton(panel, label="Bluesky") + self.youtube_radio = wx.RadioButton(panel, label="YouTube") radio_sizer = wx.BoxSizer(wx.VERTICAL) radio_sizer.Add(self.mastodon_radio, 0, wx.ALL, 5) radio_sizer.Add(self.bluesky_radio, 0, wx.ALL, 5) + radio_sizer.Add(self.youtube_radio, 0, wx.ALL, 5) sizer.Add(radio_sizer, 0, wx.CENTER, 5) # Buttons @@ -46,6 +48,8 @@ def get_platform(self) -> str: """Return the selected platform name.""" if self.bluesky_radio.GetValue(): return "bluesky" + if self.youtube_radio.GetValue(): + return "youtube" return "mastodon" diff --git a/application.py b/application.py index 699ac57..29531ac 100644 --- a/application.py +++ b/application.py @@ -262,7 +262,10 @@ def load(self): parallelizable = [] sequential = [] for i in range(1, self.prefs.accounts): - if self._is_account_configured(i): + # YouTube accounts always load on the main thread: a dead/expired + # token triggers an interactive browser re-login, which must not + # run on a worker thread (it opens a browser + binds a local port). + if self._is_account_configured(i) and self._account_platform_type(i) != "youtube": parallelizable.append(i) else: sequential.append(i) @@ -314,6 +317,37 @@ def add_session(self, index=None): return # Otherwise just skip this account + @staticmethod + def _account_platform_type(index): + """Read an account's platform_type from disk without loading it.""" + import config + try: + if config.is_portable_mode(): + prefs = config.Config(name="account"+str(index), autosave=False, save_on_exit=False) + else: + prefs = config.Config(name="FastSM/account"+str(index), autosave=False, save_on_exit=False) + return prefs.get("platform_type", "") + except: + return "" + + @staticmethod + def _youtube_refresh_token(prefs): + """Return the stored YouTube refresh token string, or "". + + Coerces youtube_token (which may come back as a config.Config, a dict, + or something unexpected) to a dict so both account-state checks agree. + """ + import config + token = prefs.get("youtube_token", None) + if isinstance(token, config.Config): + try: + token = dict(token) + except Exception: + token = {} + elif not isinstance(token, dict): + token = {} + return token.get("refresh_token") or "" + def _is_account_configured(self, index): """Check if an account has credentials saved (no dialogs needed).""" import config @@ -329,6 +363,9 @@ def _is_account_configured(self, index): if platform_type == "bluesky": # Bluesky needs handle and password return bool(prefs.get("bluesky_handle", "")) and bool(prefs.get("bluesky_password", "")) + elif platform_type == "youtube": + # YouTube needs a stored OAuth refresh token + return bool(self._youtube_refresh_token(prefs)) else: # Mastodon needs instance URL and access token return bool(prefs.get("instance_url", "")) and bool(prefs.get("access_token", "")) @@ -358,6 +395,11 @@ def _is_account_partially_configured(self, index): return (True, "bluesky", handle or "incomplete") # Just platform_type set, nothing else return (True, "bluesky", "setup not started") + elif platform_type == "youtube": + if self._youtube_refresh_token(prefs): + return (False, None, None) # Fully configured + # Platform chosen but OAuth login never finished + return (True, "youtube", "login not completed") else: # Mastodon instance_url = prefs.get("instance_url", "") @@ -549,7 +591,21 @@ def process_status(self, s, return_only_text=False, template="", ignore_cw=False if is_scheduled: return self._process_scheduled_status(s) - if hasattr(s, 'content'): + # YouTube posts use a per-account template (Templates tab in account + # options). Default shows title + author; the description is opt-in via + # the $description$ placeholder. + if template == "" and getattr(s, '_platform', '') == 'youtube': + acct_obj = account or getattr(self, 'currentAccount', None) + if acct_obj is not None: + yt_tpl = getattr(acct_obj.prefs, 'youtube_template', '') or '' + if yt_tpl: + template = yt_tpl + + if getattr(s, '_platform', '') == 'youtube': + # The displayed post text is the video title (stored in .text); + # the full description lives in .content for $description$. + text = getattr(s, 'text', '') or (self.strip_html(s.content) if hasattr(s, 'content') else "") + elif hasattr(s, 'content'): text = self.strip_html(s.content) else: text = "" @@ -593,8 +649,10 @@ def process_status(self, s, return_only_text=False, template="", ignore_cw=False text = f"{filter_warning}. {text}" # 'ignore' mode: just use the text as-is - # Add media descriptions to text (only for non-reblogs to avoid duplication) - if self.prefs.include_media_descriptions and hasattr(s, 'media_attachments') and s.media_attachments: + # Add media descriptions to text (only for non-reblogs to avoid duplication). + # Skipped for YouTube: the video IS the post, so a "(Video) with no + # description" suffix is just noise. + if self.prefs.include_media_descriptions and getattr(s, '_platform', '') != 'youtube' and hasattr(s, 'media_attachments') and s.media_attachments: for media in s.media_attachments: # Handle both objects (from API) and dicts (from cache) if isinstance(media, dict): @@ -1021,6 +1079,12 @@ def template_to_string(self, s, template="", account=None): if template == "": template = self.prefs.postTemplate + # $description$ (YouTube): the full video description, kept in .content. + # Deferred like $text$ so $..$ inside it isn't treated as a template var. + description_content = None + if "$description$" in template: + description_content = self.strip_html(getattr(s, 'content', '') or '') + # Prepare text content now, but replace AFTER other substitutions # to prevent $..$ patterns in post text from being interpreted as template vars text_content = None @@ -1064,8 +1128,8 @@ def template_to_string(self, s, template="", account=None): if "$" in temp[i]: t = temp[i].split("$") r = t[1] - if r == "text": - continue # Already handled above + if r == "text" or r == "description": + continue # Handled (deferred) below if "." in r: q = r.split(".") # Support multi-level attribute access (e.g., reblog.account.display_name) @@ -1145,9 +1209,12 @@ def template_to_string(self, s, template="", account=None): template = template.replace("$" + t[1] + "$", str(getattr(s, t[1]))) except Exception as e: print(e) - # Replace $text$ last to prevent post content with $..$ from being interpreted as template vars + # Replace $text$ / $description$ last to prevent post content with $..$ + # from being interpreted as template vars if text_content is not None: template = template.replace("$text$", text_content) + if description_content is not None: + template = template.replace("$description$", description_content) return template def get_users_in_status(self, account, s): diff --git a/build.py b/build.py index 2bd5769..9a1b13c 100644 --- a/build.py +++ b/build.py @@ -97,6 +97,32 @@ def get_hidden_imports(): "platforms.mastodon.models", "platforms.bluesky", "platforms.bluesky.account", + # YouTube platform (imported lazily via the platform registry) + "platforms.youtube", + "platforms.youtube.account", + "platforms.youtube.models", + "platforms.youtube.oauth", + "platforms.youtube.innertube", + # Google OAuth + YouTube Data API (imported lazily inside functions, + # so PyInstaller's static analysis can't see them) + "google", + "google.auth", + "google.auth.transport.requests", + "google.oauth2", + "google.oauth2.credentials", + "google_auth_oauthlib", + "google_auth_oauthlib.flow", + "googleapiclient", + "googleapiclient.discovery", + "googleapiclient.http", + "googleapiclient.discovery_cache", + "google_auth_httplib2", + "httplib2", + "uritemplate", + # Vendored youtube-search-python (platforms/youtube/vendor) is imported + # as top-level `youtubesearchpython` via a runtime sys.path shim, so + # PyInstaller can't trace it or its httpx dependency statically. + "httpx", "GUI", "GUI.main", "GUI.tweet", @@ -273,6 +299,23 @@ def build_windows(script_dir: Path, output_dir: Path) -> tuple: cmd.extend(["--collect-submodules", "enchant"]) cmd.extend(["--collect-data", "enchant"]) + # YouTube Data API: collect googleapiclient's bundled discovery docs (build() + # uses static discovery) plus google-auth-oauthlib, all lazy-imported. + cmd.extend(["--collect-all", "googleapiclient"]) + cmd.extend(["--collect-all", "google_auth_oauthlib"]) + cmd.extend(["--collect-submodules", "google"]) + # Bundle the OAuth client so "Add account -> YouTube" works in the build. + client_secret = script_dir / "platforms" / "youtube" / "client_secret.json" + if client_secret.exists(): + cmd.extend(["--add-data", f"{client_secret}{os.pathsep}platforms/youtube"]) + # Bundle the vendored youtube-search-python tree so the runtime sys.path shim + # in platforms/youtube/__init__.py can import it in the frozen app. httpx + # ships data (CA bundle) so collect it fully. + yt_vendor = script_dir / "platforms" / "youtube" / "vendor" + if yt_vendor.exists(): + cmd.extend(["--add-data", f"{yt_vendor}{os.pathsep}platforms/youtube/vendor"]) + cmd.extend(["--collect-all", "httpx"]) + # Add runtime hook to redirect stderr to config directory early runtime_hook = script_dir / "runtime_hook.py" if runtime_hook.exists(): diff --git a/mastodon_api.py b/mastodon_api.py index 42f6034..26e97e8 100644 --- a/mastodon_api.py +++ b/mastodon_api.py @@ -109,6 +109,8 @@ def __init__(self, app, index): # Initialize based on platform type if self.prefs.platform_type == "bluesky": self._init_bluesky(index) + elif self.prefs.platform_type == "youtube": + self._init_youtube(index) else: self.prefs.platform_type = "mastodon" self._init_mastodon(index) @@ -502,6 +504,99 @@ def _init_bluesky(self, index): self._finish_timeline_init() + def _init_youtube(self, index): + """Initialize YouTube account via Google OAuth (installed-app flow).""" + from platforms.youtube import oauth + from platforms.youtube import YouTubeAccount + + # Optional user-supplied OAuth client (falls back to bundled FastSM client). + self.prefs.youtube_client_id = self.prefs.get("youtube_client_id", "") + self.prefs.youtube_client_secret = self.prefs.get("youtube_client_secret", "") + # Per-account post display template (editable in account options -> Templates). + # Default: author name + handle + title + date. Add $description$ to show + # the video description. + self.prefs.youtube_template = self.prefs.get( + "youtube_template", "$account.display_name$ (@$account.acct$): $text$ $created_at$") + # Stored token dict (refresh token + last access token). Empty = log in. + token = self.prefs.get("youtube_token", None) + if isinstance(token, config.Config): + token = dict(token) + + def save_token(token_dict): + """Persist refreshed/new tokens back to the account config.""" + self.prefs.youtube_token = token_dict + + try: + # Browser login if we have no stored refresh token yet. + if not token or not token.get("refresh_token"): + token = oauth.run_oauth_flow(self.prefs) + save_token(token) + + try: + creds = oauth.dict_to_credentials(token, self.prefs) + oauth.ensure_valid_credentials(creds, save_token) + except oauth.YouTubeReauthRequired: + # Stored token is dead (revoked, or expired after the 7-day + # Testing-mode limit). Re-run the browser login now instead of + # leaving the account broken. + speak.speak("Your YouTube session expired. Please sign in again.") + token = oauth.run_oauth_flow(self.prefs) + save_token(token) + creds = oauth.dict_to_credentials(token, self.prefs) + oauth.ensure_valid_credentials(creds, save_token) + service = oauth.build_youtube_service(creds) + + # Fetch the authenticated user's channel (identity). + me_resp = service.channels().list( + part="snippet,statistics,contentDetails", mine=True + ).execute() + items = me_resp.get("items", []) if me_resp else [] + if not items: + speak.speak("This Google account has no YouTube channel.") + _exit_app() + me_channel = items[0] + except oauth.YouTubeAuthError as e: + speak.speak("YouTube login error: " + str(e)) + # Clear a bad token so the next launch re-prompts for login. + self.prefs.youtube_token = {} + _exit_app() + except Exception as e: + if _logger: + _logger.exception("YouTube init failed: %s", e) + speak.speak("Error connecting to YouTube: " + str(e)) + _exit_app() + + # Platform properties + self.api = service + self.max_chars = 10000 # comment length ceiling + self.default_visibility = 'public' + + # Initialize platform backend + self._platform = YouTubeAccount( + self.app, index, service, me_channel, self.confpath, + credentials=creds, prefs=self.prefs, on_token_refresh=save_token, + ) + self.me = self._platform.me + + self._finish_init(index) + + # Built-in timelines (Home = recommendations, Liked, Your Videos) + self._create_builtin_timelines() + + # Restore saved searches (channel/user timelines can be re-added by the user) + for q in list(self.prefs.search_timelines): + try: + self.timelines.append(timeline.timeline(self, name=q + " Search", type="search", data=q, silent=True)) + except: + self.prefs.search_timelines.remove(q) + + # No streaming for YouTube + self.stream_listener = None + self.stream = None + self._stream_started = False + + self._finish_timeline_init() + def _finish_init(self, index): """Common initialization after platform-specific setup.""" import wx @@ -556,7 +651,16 @@ def _create_builtin_timelines(self): Respects the timeline_order preference if set, otherwise uses default order. """ # Define available built-in timelines for each platform - if self.prefs.platform_type == "bluesky": + if self.prefs.platform_type == "youtube": + # Home = recommendations (InnerTube), Liked = liked-videos playlist, + # Sent = your channel's uploads. No notifications/mentions/DMs on YouTube. + available = { + "home": ("Home", "home", None, None), + "favourites": ("Liked Videos", "favourites", None, None), + "sent": ("Your Videos", "user", self.me.acct, self.me), + } + default_order = ["home", "favourites", "sent"] + elif self.prefs.platform_type == "bluesky": available = { "home": ("Home", "home", None, None), "notifications": ("Notifications", "notifications", None, None), @@ -590,8 +694,8 @@ def _create_builtin_timelines(self): timeline.add(self, name, tl_type, data, user) def start_stream(self): - # Bluesky doesn't support streaming - if self.prefs.platform_type == "bluesky": + # Only Mastodon supports streaming + if self.prefs.platform_type in ("bluesky", "youtube"): return # Use lock to prevent race condition where multiple threads try to start stream @@ -613,8 +717,8 @@ def start_stream(self): self.stream_thread.start() def _run_stream(self): - # Bluesky doesn't support streaming - if self.prefs.platform_type == "bluesky": + # Only Mastodon supports streaming + if self.prefs.platform_type in ("bluesky", "youtube"): return import time diff --git a/platforms/youtube/__init__.py b/platforms/youtube/__init__.py new file mode 100644 index 0000000..fa2b70c --- /dev/null +++ b/platforms/youtube/__init__.py @@ -0,0 +1,28 @@ +"""YouTube platform implementation.""" + +# Make the vendored youtube-search-python copy importable as the top-level +# package `youtubesearchpython` (see vendor/README.md). Inserted at the front +# of sys.path so the maintained vendored copy wins over any stale PyPI install. +import os as _os +import sys as _sys +_vendor_dir = _os.path.join(_os.path.dirname(__file__), "vendor") +if _vendor_dir not in _sys.path: + _sys.path.insert(0, _vendor_dir) + +from .account import YouTubeAccount +from .models import ( + youtube_channel_to_universal, + youtube_video_to_universal, + youtube_subscription_to_universal, +) + +__all__ = [ + 'YouTubeAccount', + 'youtube_channel_to_universal', + 'youtube_video_to_universal', + 'youtube_subscription_to_universal', +] + +# Register this platform +from platforms import register_platform +register_platform('youtube', YouTubeAccount) diff --git a/platforms/youtube/account.py b/platforms/youtube/account.py new file mode 100644 index 0000000..b027617 --- /dev/null +++ b/platforms/youtube/account.py @@ -0,0 +1,737 @@ +"""YouTube platform account implementation. + +This is a *skeleton*: the OAuth/identity/subscriptions paths are real and use +the YouTube Data API v3; search uses the InnerTube scraper (the c:\\yt +youtube-search-python rebuild); and the parts the official API can't do — +the personalized recommendations feed and community ("posts" tab) posting — +are clearly marked stubs to be filled in with the cookie + InnerTube work. + +Design notes mapping FastSM concepts onto YouTube: + * A *status* is a video. The author account is the channel. + * The *home* timeline is recommendations (InnerTube, cookie-based). + * *following* a user means subscribing; *get_following(me)* is your + subscription list. + * *favourite* maps to a video "like" (videos.rate); *get_favourites* is + your liked-videos playlist. + * *replying* (post with reply_to_id) maps to posting a comment. + * Boost/block/mute and DMs have no YouTube equivalent and return falsy. +""" + +import threading +from typing import List, Optional, Any, Dict +from datetime import datetime, timezone + +from platforms.base import PlatformAccount +from models import UniversalStatus, UniversalUser, UniversalNotification, UserCache +from cache import TimelineCache + +from . import oauth +from .models import ( + youtube_channel_to_universal, + youtube_video_to_universal, + youtube_subscription_to_universal, + youtube_search_result_to_universal, + youtube_comment_to_universal, + innertube_video_to_universal, + innertube_channel_to_universal, + watch_url, + PLATFORM, +) + +try: + from logging_config import get_logger + _logger = get_logger('api') +except ImportError: + _logger = None + + +class YouTubeAccount(PlatformAccount): + """YouTube-specific account implementation (Data API + InnerTube).""" + + platform_name = "youtube" + + # Feature flags. YouTube's "social" surface is comments + subscriptions; + # it has no boosts, polls, CWs, DMs, or quote posts. + supports_visibility = False + supports_content_warning = False + supports_quote_posts = False + supports_polls = False + supports_lists = False + supports_direct_messages = False + supports_media_attachments = False + supports_scheduling = False + supports_editing = False + + def __init__(self, app, index: int, service, me_channel: dict, confpath: str, + credentials=None, prefs=None, on_token_refresh=None): + super().__init__(app, index) + self.service = service # googleapiclient youtube resource + self.credentials = credentials # google.oauth2.credentials.Credentials + self._on_token_refresh = on_token_refresh + self._prefs = prefs + self.confpath = confpath + self._me = youtube_channel_to_universal(me_channel) + self._max_chars = 10000 # comment length ceiling; community posts differ + + # Playlist IDs for "your" content, pulled from the channel resource. + related = (me_channel.get("contentDetails", {}) or {}).get("relatedPlaylists", {}) or {} + self._uploads_playlist = related.get("uploads", "") + self._likes_playlist = related.get("likes", "") + + self.user_cache = UserCache(confpath, PLATFORM, str(self._me.id) if self._me else "") + self.user_cache.load() + + if app.prefs.timeline_cache_enabled: + self.timeline_cache = TimelineCache(confpath, str(self._me.id) if self._me else "") + else: + self.timeline_cache = None + + # Page-token cursors for Data API pagination, keyed by timeline type. + self._page_tokens: Dict[str, str] = {} + + # google-api-python-client's service wraps a single httplib2.Http, which + # is NOT thread-safe. FastSM loads timelines concurrently, so without + # serialization the shared SSL connection corrupts (RECORD_LAYER_FAILURE). + # Guard every Data API call with this lock. + self._api_lock = threading.Lock() + + # channel_id -> @handle (without the @). Lets us show "@brandonbracey" + # instead of the channel title or raw UC... id. Seeded with our own + # channel (its handle came from channels.list customUrl at login). + self._handle_cache: Dict[str, str] = {} + if self._me and self._me.acct and self._me.acct != self._me.id: + self._handle_cache[self._me.id] = self._me.acct + + # Set once the stored token is found permanently dead (revoked / expired) + # so we stop hammering the API and re-speaking the same error all session. + self._auth_dead = False + + @property + def me(self) -> UniversalUser: + return self._me + + # ============ Internal helpers ============ + + def _ensure_creds(self): + """Refresh the access token if needed, persisting any new token.""" + if self.credentials is not None: + oauth.ensure_valid_credentials(self.credentials, self._on_token_refresh) + + def _on_auth_dead(self): + """Token is unusable: clear it (so next launch re-prompts) and tell the + user once. Subsequent API calls short-circuit via self._auth_dead.""" + if self._auth_dead: + return + self._auth_dead = True + try: + if self._on_token_refresh: + self._on_token_refresh({}) # wipe the stored token + except Exception: + pass + self.app.handle_error( + "Your YouTube session expired. Sign out and sign in again from the " + "YouTube options.", "YouTube login") + + def _execute(self, request, context: str = "YouTube API"): + """Run a Data API request with refresh + uniform error handling. + + Serialized via _api_lock because httplib2 (the transport under the + service) isn't thread-safe and FastSM fetches timelines concurrently. + """ + if self._auth_dead: + return None + try: + with self._api_lock: + self._ensure_creds() + return request.execute() + except oauth.YouTubeReauthRequired: + self._on_auth_dead() + return None + except Exception as e: + self.app.handle_error(e, context) + return None + + def _cookies_path(self) -> str: + """Path to the user's yt-dlp cookies file (reused for InnerTube). + + ytdlp_cookies is a GLOBAL setting (app.prefs / main config), same as + sound.py uses for playback — not a per-account pref. Read it from there, + falling back to the account pref only if a per-account override exists. + """ + path = getattr(self.app.prefs, "ytdlp_cookies", "") or "" + if not path and self._prefs is not None: + path = self._prefs.get("ytdlp_cookies", "") or "" + return path + + def _convert_playlist_items(self, items) -> List[UniversalStatus]: + """Turn playlistItems entries into video statuses (resolving full stats).""" + video_ids = [] + for it in items or []: + vid = (((it.get("snippet", {}) or {}).get("resourceId", {})) or {}).get("videoId") + if vid: + video_ids.append(vid) + return self._videos_by_ids(video_ids) + + def _videos_by_ids(self, video_ids: List[str]) -> List[UniversalStatus]: + """Fetch full video resources for a batch of IDs (<=50) and convert.""" + if not video_ids: + return [] + resp = self._execute( + self.service.videos().list( + part="snippet,statistics,contentDetails", + id=",".join(video_ids[:50]), + maxResults=50, + ), + "videos", + ) + if not resp: + return [] + # videos.list doesn't guarantee response order matches the request, so + # rebuild in the requested id order (preserves recommendation ranking). + by_id = {} + for item in resp.get("items", []): + status = youtube_video_to_universal(item) + if status: + by_id[status.id] = status + self.user_cache.add_users_from_status(status) + statuses = [by_id[v] for v in video_ids[:50] if v in by_id] + self._apply_handles([s.account for s in statuses]) + return statuses + + def _apply_handles(self, users) -> None: + """Resolve channel @handles (customUrl) and set each author's acct. + + Turns "@UC..." / channel-title into the real "@brandonbracey". Results + are cached per channel id, and channels.list costs only 1 quota unit + per batch of 50, so this is cheap across timeline refreshes. + """ + if not users: + return + need = [] + for u in users: + cid = getattr(u, "id", "") or "" + if cid.startswith("UC") and cid not in self._handle_cache: + need.append(cid) + need = list(dict.fromkeys(need)) # de-dupe, preserve order + for i in range(0, len(need), 50): + chunk = need[i:i + 50] + resp = self._execute( + self.service.channels().list(part="snippet", id=",".join(chunk), maxResults=50), + "channel handles", + ) + if not resp: + continue + for item in resp.get("items", []): + cid = item.get("id", "") + custom = ((item.get("snippet", {}) or {}).get("customUrl", "") or "") + if cid and custom: + self._handle_cache[cid] = custom.lstrip("@") + for u in users: + handle = self._handle_cache.get(getattr(u, "id", "")) + if handle: + u.acct = handle + u.username = handle + + # ============ Timeline Methods ============ + + def get_home_timeline(self, limit: int = 40, **kwargs) -> List[UniversalStatus]: + """Home feed. + + Prefers personalized recommendations (InnerTube, cookie-based), but + YouTube rotates/expires browser cookies and sometimes serves an empty + feed, so when recommendations come back empty we fall back to recent + uploads from your subscriptions via the Data API (always available). + """ + # Recent uploads from your subscriptions (durable, newest first). + subs = self.get_subscription_uploads(limit=limit) + # Recommendations when YouTube serves them (cookie-based; often empty). + recs = self._get_recommendations(limit=max(10, limit // 3)) + if not recs: + return subs + # Mix: a few recommendations up top, then the recent subscription feed, + # de-duplicated by video id. + seen = set() + combined = [] + for s in recs + subs: + if s.id and s.id not in seen: + seen.add(s.id) + combined.append(s) + return combined[:limit] + + def _get_recommendations(self, limit: int = 40) -> List[UniversalStatus]: + """Cookie-based recommendations feed via the InnerTube browse endpoint. + + Uses the user's yt-dlp cookies for personalization; falls back to a + generic feed when no cookies are configured. Never raises. + """ + from . import innertube + region = "US" + language = "en" + if self._prefs is not None: + region = self._prefs.get("youtube_region", "") or region + language = self._prefs.get("youtube_language", "") or language + items = innertube.get_recommendations( + cookies_path=self._cookies_path(), limit=limit, + region=region, language=language, + ) + # InnerTube only gives a fuzzy "3 days ago" time, so hydrate the IDs via + # the Data API for exact publish dates, real stats, and @handles. + video_ids = [it.get("id") for it in items if it.get("id")] + statuses = self._videos_by_ids(video_ids) + if statuses: + return statuses + # Fallback (e.g. Data API hiccup): use the InnerTube data as-is. + fallback = [] + for item in items: + status = innertube_video_to_universal(item) + if status: + fallback.append(status) + self.user_cache.add_users_from_status(status) + return fallback + + def get_subscription_uploads(self, limit: int = 40, max_channels: int = 30) -> List[UniversalStatus]: + """Recent uploads from channels you subscribe to, newest first (Data API). + + The official API has no subscription-feed endpoint, so we sample your + subscriptions (ordered by relevance), read each channel's latest uploads + from its computed uploads playlist (UU + channelId[2:], which avoids a + channels.list call per channel), then merge and sort by actual upload + date. One batched videos.list hydrates the final set. + """ + # Subscriptions ordered by relevance (more useful than alphabetical). + resp = self._execute( + self.service.subscriptions().list( + part="snippet", mine=True, maxResults=min(max_channels, 50), + order="relevance", + ), + "subscription feed", + ) + if not resp: + return [] + channel_ids = [] + for item in resp.get("items", []): + cid = (((item.get("snippet", {}) or {}).get("resourceId", {})) or {}).get("channelId", "") + if cid.startswith("UC"): + channel_ids.append(cid) + + # Collect (upload_date, video_id) across channels' latest uploads. + candidates = [] + for cid in channel_ids: + uploads = "UU" + cid[2:] + r = self._execute( + self.service.playlistItems().list( + part="contentDetails", playlistId=uploads, maxResults=2, + ), + "subscription uploads", + ) + if not r: + continue + for it in r.get("items", []): + cd = it.get("contentDetails", {}) or {} + vid = cd.get("videoId") + date = cd.get("videoPublishedAt", "") + if vid: + candidates.append((date or "", vid)) + + # Newest first, de-duped, then hydrate the top slice in that order. + candidates.sort(key=lambda c: c[0], reverse=True) + seen = set() + top_ids = [] + for _, vid in candidates: + if vid not in seen: + seen.add(vid) + top_ids.append(vid) + if len(top_ids) >= limit: + break + return self._videos_by_ids(top_ids) + + def get_mentions(self, limit: int = 40, **kwargs) -> List[UniversalStatus]: + """No direct "mentions" concept on YouTube.""" + return [] + + def get_notifications(self, limit: int = 40, **kwargs) -> List[UniversalNotification]: + """No notifications API on YouTube.""" + return [] + + def get_conversations(self, limit: int = 40, **kwargs) -> List[Any]: + """No direct messages on YouTube.""" + return [] + + def get_favourites(self, limit: int = 40, **kwargs) -> List[UniversalStatus]: + """Your liked videos (the 'LL' likes playlist).""" + if not self._likes_playlist: + return [] + resp = self._execute( + self.service.playlistItems().list( + part="snippet,contentDetails", + playlistId=self._likes_playlist, + maxResults=min(limit, 50), + ), + "liked videos", + ) + if not resp: + return [] + return self._convert_playlist_items(resp.get("items", [])) + + def get_user_statuses(self, user_id: str, limit: int = 40, **kwargs) -> List[UniversalStatus]: + """A channel's uploads (resolve channel -> uploads playlist -> items).""" + if not user_id: + return [] + # Our own uploads playlist is already known. + if self.me and user_id == self.me.id and self._uploads_playlist: + uploads = self._uploads_playlist + else: + ch = self._execute( + self.service.channels().list(part="contentDetails", id=user_id), + "channel uploads", + ) + items = ch.get("items", []) if ch else [] + if not items: + return [] + uploads = (((items[0].get("contentDetails", {}) or {}) + .get("relatedPlaylists", {})) or {}).get("uploads", "") + if not uploads: + return [] + resp = self._execute( + self.service.playlistItems().list( + part="snippet,contentDetails", + playlistId=uploads, + maxResults=min(limit, 50), + ), + "channel uploads", + ) + if not resp: + return [] + return self._convert_playlist_items(resp.get("items", [])) + + def get_list_timeline(self, list_id: str, limit: int = 40, **kwargs) -> List[UniversalStatus]: + """Playlists could map here later; not implemented yet.""" + return [] + + def search_statuses(self, query: str, limit: int = 40, **kwargs) -> List[UniversalStatus]: + """Search videos via InnerTube (no API quota), Data API as fallback.""" + try: + from youtubesearchpython import VideosSearch + results = VideosSearch(query, limit=min(limit, 20)).result() or {} + # Hydrate via Data API for exact dates/stats/handles (InnerTube only + # gives fuzzy "3 days ago" times). + video_ids = [it.get("id") for it in results.get("result", []) if it.get("id")] + statuses = self._videos_by_ids(video_ids) + if statuses: + return statuses + except ImportError: + if _logger: + _logger.info("youtubesearchpython not available; using Data API search.") + except Exception as e: + self.app.handle_error(e, "search") + # Fallback: Data API search (costs 100 quota units per call). + resp = self._execute( + self.service.search().list( + part="snippet", q=query, type="video", maxResults=min(limit, 25), + ), + "search", + ) + if not resp: + return [] + statuses = [] + for item in resp.get("items", []): + status = youtube_search_result_to_universal(item) + if isinstance(status, UniversalStatus): + statuses.append(status) + self._apply_handles([s.account for s in statuses]) + return statuses + + def get_status(self, status_id: str) -> Optional[UniversalStatus]: + """Fetch a single video by ID.""" + results = self._videos_by_ids([status_id]) + return results[0] if results else None + + def get_status_context(self, status_id: str) -> Dict[str, List[UniversalStatus]]: + """Comments for a video (descendants), or replies to a comment. + + Loaded via FastSM's "Load conversation" (Ctrl+G): on a video this shows + its comment threads + replies; on a comment it shows that comment's + replies. + """ + descendants: List[UniversalStatus] = [] + if self._is_comment_id(status_id): + descendants = self._get_comment_replies(status_id) + self._apply_handles([d.account for d in descendants]) + return {"ancestors": [], "descendants": descendants} + + # Handle this call directly so we can give a friendly message when a + # video has comments turned off (a normal state, not a real error). + resp = None + if self._auth_dead: + return {"ancestors": [], "descendants": []} + try: + with self._api_lock: + self._ensure_creds() + resp = self.service.commentThreads().list( + part="snippet,replies", videoId=status_id, + maxResults=50, order="relevance", textFormat="plainText", + ).execute() + except oauth.YouTubeReauthRequired: + self._on_auth_dead() + return {"ancestors": [], "descendants": []} + except Exception as e: + msg = str(e) + if "commentsDisabled" in msg or "disabled comments" in msg: + self.app.handle_error("Comments are turned off for this video.", "comments") + else: + self.app.handle_error(e, "comments") + return {"ancestors": [], "descendants": []} + if resp: + for item in resp.get("items", []): + tlc = ((item.get("snippet", {}) or {}).get("topLevelComment", {})) or {} + top = youtube_comment_to_universal(tlc, video_id=status_id) + if top: + descendants.append(top) + self.user_cache.add_user(top.account) + for rep in ((item.get("replies", {}) or {}).get("comments", []) or []): + rst = youtube_comment_to_universal(rep, video_id=status_id) + if rst: + descendants.append(rst) + self.user_cache.add_user(rst.account) + self._apply_handles([d.account for d in descendants]) + return {"ancestors": [], "descendants": descendants} + + def _get_comment_replies(self, parent_id: str) -> List[UniversalStatus]: + """Replies to a single top-level comment.""" + resp = self._execute( + self.service.comments().list( + part="snippet", parentId=parent_id, maxResults=50, textFormat="plainText", + ), + "comment replies", + ) + out = [] + if resp: + for c in resp.get("items", []): + st = youtube_comment_to_universal(c) + if st: + out.append(st) + self.user_cache.add_user(st.account) + return out + + @staticmethod + def _is_comment_id(value: str) -> bool: + """Heuristic: video IDs are 11 chars; comment IDs are much longer.""" + return bool(value) and len(value) > 15 + + # ============ Action Methods ============ + + def post(self, text: str, reply_to_id: Optional[str] = None, + visibility: Optional[str] = None, spoiler_text: Optional[str] = None, + **kwargs) -> Optional[UniversalStatus]: + """Reply to a video -> top-level comment; reply to a comment -> a reply. + + A top-level post (no reply target) would be a community update, which has + no official API, so it raises a clear message instead of failing quietly. + """ + if reply_to_id: + if self._is_comment_id(reply_to_id): + return self._reply_to_comment(reply_to_id, text) + return self._post_comment(reply_to_id, text) + raise NotImplementedError( + "Posting a YouTube community update isn't supported (no official API). " + "Reply to a video to comment, or reply to a comment to respond." + ) + + def _reply_to_comment(self, parent_id: str, text: str) -> Optional[UniversalStatus]: + """Reply to an existing comment (requires youtube.force-ssl).""" + body = {"snippet": {"parentId": parent_id, "textOriginal": text}} + resp = self._execute( + self.service.comments().insert(part="snippet", body=body), + "reply to comment", + ) + return youtube_comment_to_universal(resp) if resp else None + + def _post_comment(self, video_id: str, text: str) -> Optional[UniversalStatus]: + """Post a top-level comment on a video (requires youtube.force-ssl).""" + body = { + "snippet": { + "videoId": video_id, + "topLevelComment": {"snippet": {"textOriginal": text}}, + } + } + resp = self._execute( + self.service.commentThreads().insert(part="snippet", body=body), + "post comment", + ) + if not resp: + return None + # Represent the posted comment as a lightweight status. + return UniversalStatus( + id=resp.get("id", ""), + account=self.me, + content=text, + text=text, + created_at=datetime.now(timezone.utc), + in_reply_to_id=video_id, + url=watch_url(video_id), + _platform_data=resp, + _platform=PLATFORM, + ) + + def boost(self, status_id: str) -> bool: + return False # No "boost" on YouTube + + def unboost(self, status_id: str) -> bool: + return False + + def favourite(self, status_id: str) -> bool: + """Like a video (videos.rate). The API can't like comments.""" + if self._is_comment_id(status_id): + return False + self._execute( + self.service.videos().rate(id=status_id, rating="like"), + "like video", + ) + return True # rate() returns an empty body on success + + def unfavourite(self, status_id: str) -> bool: + """Remove a like (rating=none).""" + if self._is_comment_id(status_id): + return False + self._execute( + self.service.videos().rate(id=status_id, rating="none"), + "unlike video", + ) + return True + + def delete_status(self, status_id: str) -> bool: + """Delete one of your own comments (videos can't be deleted via this API).""" + if not self._is_comment_id(status_id): + return False + self._execute(self.service.comments().delete(id=status_id), "delete comment") + return True + + def edit(self, status_id: str, text: str, **kwargs) -> Optional[UniversalStatus]: + """Edit one of your own comments (comments.update).""" + if not self._is_comment_id(status_id): + return None + body = {"id": status_id, "snippet": {"textOriginal": text}} + resp = self._execute( + self.service.comments().update(part="snippet", body=body), + "edit comment", + ) + return youtube_comment_to_universal(resp) if resp else None + + # ============ User Methods ============ + + def get_user(self, user_id: str) -> Optional[UniversalUser]: + """Fetch a channel by ID.""" + resp = self._execute( + self.service.channels().list( + part="snippet,statistics,contentDetails", id=user_id, + ), + "channel", + ) + items = resp.get("items", []) if resp else [] + if not items: + return None + user = youtube_channel_to_universal(items[0]) + if user: + self.user_cache.add_user(user) + return user + + def search_users(self, query: str, limit: int = 10) -> List[UniversalUser]: + """Search channels via InnerTube, Data API as fallback.""" + try: + from youtubesearchpython import ChannelsSearch + results = ChannelsSearch(query, limit=min(limit, 20)).result() or {} + users = [] + for item in results.get("result", []): + user = innertube_channel_to_universal(item) + if user: + users.append(user) + self.user_cache.add_user(user) + if users: + return users + except ImportError: + pass + except Exception as e: + self.app.handle_error(e, "search users") + resp = self._execute( + self.service.search().list( + part="snippet", q=query, type="channel", maxResults=min(limit, 25), + ), + "search users", + ) + if not resp: + return [] + users = [] + for item in resp.get("items", []): + result = youtube_search_result_to_universal(item) + if isinstance(result, UniversalUser): + users.append(result) + return users + + def follow(self, user_id: str) -> bool: + """Subscribe to a channel.""" + body = {"snippet": {"resourceId": {"kind": "youtube#channel", "channelId": user_id}}} + return self._execute( + self.service.subscriptions().insert(part="snippet", body=body), + "subscribe", + ) is not None + + def unfollow(self, user_id: str) -> bool: + """Unsubscribe from a channel (look up the subscription id first).""" + resp = self._execute( + self.service.subscriptions().list( + part="snippet", mine=True, forChannelId=user_id, maxResults=1, + ), + "unsubscribe lookup", + ) + items = resp.get("items", []) if resp else [] + if not items: + return False + sub_id = items[0].get("id") + if not sub_id: + return False + self._execute(self.service.subscriptions().delete(id=sub_id), "unsubscribe") + return True + + def block(self, user_id: str) -> bool: + return False # No public block API + + def unblock(self, user_id: str) -> bool: + return False + + def mute(self, user_id: str) -> bool: + return False + + def unmute(self, user_id: str) -> bool: + return False + + def get_followers(self, user_id: str, limit: int = 80, **kwargs) -> List[UniversalUser]: + """Subscriber lists aren't exposed by the API.""" + return [] + + def get_following(self, user_id: str, limit: int = 80, **kwargs) -> List[UniversalUser]: + """Your subscriptions (only available for the authenticated user).""" + if not self.me or user_id != self.me.id: + return [] + resp = self._execute( + self.service.subscriptions().list( + part="snippet", mine=True, maxResults=min(limit, 50), + order="alphabetical", + ), + "subscriptions", + ) + if not resp: + return [] + users = [] + for item in resp.get("items", []): + user = youtube_subscription_to_universal(item) + if user: + users.append(user) + self.user_cache.add_user(user) + self._apply_handles(users) + return users + + # ============ Lifecycle ============ + + def close(self): + """Release resources on account removal / app shutdown.""" + pass diff --git a/platforms/youtube/innertube.py b/platforms/youtube/innertube.py new file mode 100644 index 0000000..fb3727a --- /dev/null +++ b/platforms/youtube/innertube.py @@ -0,0 +1,295 @@ +"""Cookie-based InnerTube access for the personalized recommendations feed. + +The official YouTube Data API has no recommendations endpoint, so the Home +feed is fetched the same way youtube.com's own web client does: a POST to +the private InnerTube `browse` endpoint with browseId 'FEwhat_to_watch'. + +To get *personalized* results (rather than generic trending) the request must +be authenticated. We reuse the user's yt-dlp cookies file (the existing +prefs.ytdlp_cookies, Netscape format) and build the SAPISIDHASH Authorization +header exactly like a browser does. Without cookies the call still works but +returns a generic feed. + +This is unofficial and can break whenever YouTube changes its internal +response shape — hence it lives apart from the Data API code, fails soft +(returns []), and parses defensively. Only `requests` is required (already a +FastSM dependency); google-api-python-client is not involved here. +""" + +import time +import hashlib +from http.cookiejar import MozillaCookieJar +from typing import List, Dict, Any, Optional + +import requests + +from .models import watch_url + +try: + from logging_config import get_logger + _logger = get_logger('api') +except ImportError: + _logger = None + +# Reuse the search library's client identity when it's importable so we stay in +# step with its maintained clientVersion; otherwise fall back to local copies. +try: + from youtubesearchpython.core.constants import ( + userAgent as _USER_AGENT, + searchKey as _API_KEY, + requestPayload as _BASE_PAYLOAD, + ) + import copy as _copy + _BASE_PAYLOAD = _copy.deepcopy(_BASE_PAYLOAD) +except Exception: + _USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36") + _API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" + _BASE_PAYLOAD = { + "context": { + "client": { + "clientName": "WEB", + "clientVersion": "2.20250514.01.00", + "newVisitorCookie": True, + }, + "user": {"lockedSafetyMode": False}, + } + } + +BROWSE_URL = "https://www.youtube.com/youtubei/v1/browse" +HOME_BROWSE_ID = "FEwhat_to_watch" +ORIGIN = "https://www.youtube.com" + + +# --------------------------------------------------------------------------- +# Cookies + auth +# --------------------------------------------------------------------------- + +def _load_cookies(cookies_path: str) -> Optional[MozillaCookieJar]: + """Load a Netscape-format cookies.txt into a jar, or None on failure.""" + if not cookies_path: + return None + try: + jar = MozillaCookieJar() + jar.load(cookies_path, ignore_discard=True, ignore_expires=True) + return jar + except Exception as e: + if _logger: + _logger.info("YouTube: could not load cookies file %r: %s", cookies_path, e) + return None + + +def _cookie_dict(jar: MozillaCookieJar) -> Dict[str, str]: + """Flatten a cookie jar to a name->value dict.""" + out = {} + for c in jar: + out[c.name] = c.value + return out + + +def _sapisid_hash_header(cookies: Dict[str, str]) -> Optional[str]: + """Build the SAPISIDHASH Authorization header from session cookies. + + Mirrors the browser: SHA1 of " ". YouTube accepts a + combined header carrying the same hash under the SAPISID / 1P / 3P labels. + """ + sapisid = (cookies.get("SAPISID") + or cookies.get("__Secure-3PAPISID") + or cookies.get("__Secure-1PAPISID")) + if not sapisid: + return None + ts = str(int(time.time())) + digest = hashlib.sha1(f"{ts} {sapisid} {ORIGIN}".encode("utf-8")).hexdigest() + token = f"{ts}_{digest}" + return f"SAPISIDHASH {token} SAPISID1PHASH {token} SAPISID3PHASH {token}" + + +# --------------------------------------------------------------------------- +# Response parsing (defensive) +# --------------------------------------------------------------------------- + +def _find_renderers(node: Any, key: str, out: list) -> None: + """Recursively collect every dict stored under `key` anywhere in `node`.""" + if isinstance(node, dict): + for k, v in node.items(): + if k == key and isinstance(v, dict): + out.append(v) + else: + _find_renderers(v, key, out) + elif isinstance(node, list): + for item in node: + _find_renderers(item, key, out) + + +def _text(node: Any) -> str: + """Pull display text out of the various text wrappers YouTube uses.""" + if not isinstance(node, dict): + return "" + if "simpleText" in node: + return node.get("simpleText", "") or "" + if "content" in node: # viewModel style + return node.get("content", "") or "" + runs = node.get("runs") + if isinstance(runs, list) and runs: + return "".join(r.get("text", "") for r in runs if isinstance(r, dict)) + return "" + + +def _last_thumb(thumbs: Any) -> Optional[str]: + """Highest-res URL from a thumbnail list (renderer or viewModel shape).""" + if isinstance(thumbs, dict): + thumbs = thumbs.get("thumbnails") or thumbs.get("sources") + if isinstance(thumbs, list) and thumbs: + last = thumbs[-1] + if isinstance(last, dict): + return last.get("url") + return None + + +def _parse_video_renderer(vr: dict) -> Optional[Dict[str, Any]]: + """videoRenderer -> the dict shape models.innertube_video_to_universal wants.""" + video_id = vr.get("videoId") + if not video_id: + return None + byline = vr.get("longBylineText") or vr.get("ownerText") or {} + channel_name = _text(byline) + channel_id = "" + runs = byline.get("runs") if isinstance(byline, dict) else None + if isinstance(runs, list) and runs: + channel_id = (((runs[0].get("navigationEndpoint", {}) or {}) + .get("browseEndpoint", {})) or {}).get("browseId", "") or "" + return { + "id": video_id, + "title": _text(vr.get("title")), + "channel": {"id": channel_id, "name": channel_name}, + "thumbnails": [{"url": _last_thumb(vr.get("thumbnail"))}] if _last_thumb(vr.get("thumbnail")) else [], + "link": watch_url(video_id), + } + + +def _parse_lockup(lvm: dict) -> Optional[Dict[str, Any]]: + """lockupViewModel (newer format) -> the same dict shape, best-effort.""" + if lvm.get("contentType") not in (None, "LOCKUP_CONTENT_TYPE_VIDEO"): + return None + video_id = lvm.get("contentId") + if not video_id: + return None + meta = (((lvm.get("metadata", {}) or {}).get("lockupMetadataViewModel", {})) or {}) + title = _text(meta.get("title")) + thumb = _last_thumb((((lvm.get("contentImage", {}) or {}) + .get("thumbnailViewModel", {})) or {}).get("image")) + channel_name, channel_id = _lockup_channel(meta) + return { + "id": video_id, + "title": title, + "channel": {"id": channel_id, "name": channel_name}, + "thumbnails": [{"url": thumb}] if thumb else [], + "link": watch_url(video_id), + } + + +def _lockup_channel(meta: dict): + """Best-effort channel (name, id) from a lockup's metadata rows.""" + rows = (((meta.get("metadata", {}) or {}) + .get("contentMetadataViewModel", {})) or {}).get("metadataRows") or [] + for row in rows: + for part in (row.get("metadataParts") or []): + text_node = part.get("text") or {} + name = text_node.get("content") or _text(text_node) + if not name: + continue + # The channel row carries a browse command; views/dates don't. + cid = "" + for run in (text_node.get("commandRuns") or []): + cid = (((run.get("onTap", {}) or {}).get("innertubeCommand", {}) + .get("browseEndpoint", {})) or {}).get("browseId", "") or "" + if cid: + break + if cid or not name[0].isdigit(): + return name, cid + return "", "" + + +def _parse_home(response: dict, limit: int) -> List[Dict[str, Any]]: + """Extract video dicts from a browse(FEwhat_to_watch) response.""" + videos: List[Dict[str, Any]] = [] + seen = set() + + renderers: list = [] + _find_renderers(response, "videoRenderer", renderers) + for vr in renderers: + parsed = _parse_video_renderer(vr) + if parsed and parsed["id"] not in seen: + seen.add(parsed["id"]) + videos.append(parsed) + + if len(videos) < limit: # newer responses use lockupViewModel + lockups: list = [] + _find_renderers(response, "lockupViewModel", lockups) + for lvm in lockups: + parsed = _parse_lockup(lvm) + if parsed and parsed["id"] not in seen: + seen.add(parsed["id"]) + videos.append(parsed) + + return videos[:limit] + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def get_recommendations(cookies_path: str = "", limit: int = 40, + region: str = "US", language: str = "en", + timeout: int = 10) -> List[Dict[str, Any]]: + """Fetch the Home / recommendations feed as a list of video dicts. + + Each dict matches models.innertube_video_to_universal's expected shape. + Returns [] (never raises) on any failure so the timeline degrades cleanly. + """ + import copy + payload: Dict[str, Any] = copy.deepcopy(_BASE_PAYLOAD) + payload["context"]["client"]["hl"] = language + payload["context"]["client"]["gl"] = region + payload["browseId"] = HOME_BROWSE_ID + + headers = { + "Content-Type": "application/json", + "User-Agent": _USER_AGENT, + "Origin": ORIGIN, + "X-Origin": ORIGIN, + "X-Goog-AuthUser": "0", + } + + jar = _load_cookies(cookies_path) + cookies = _cookie_dict(jar) if jar else {} + if cookies: + auth = _sapisid_hash_header(cookies) + if auth: + headers["Authorization"] = auth + else: + if _logger: + _logger.info("YouTube: cookies present but no SAPISID; feed will be generic.") + + try: + resp = requests.post( + BROWSE_URL, + params={"key": _API_KEY, "prettyPrint": "false"}, + json=payload, + headers=headers, + cookies=cookies or None, + timeout=timeout, + ) + resp.raise_for_status() + data = resp.json() + except Exception as e: + if _logger: + _logger.info("YouTube recommendations request failed: %s", e) + return [] + + try: + return _parse_home(data, limit) + except Exception as e: + if _logger: + _logger.info("YouTube recommendations parse failed: %s", e) + return [] diff --git a/platforms/youtube/models.py b/platforms/youtube/models.py new file mode 100644 index 0000000..57ad2d8 --- /dev/null +++ b/platforms/youtube/models.py @@ -0,0 +1,332 @@ +"""Conversion functions from YouTube Data API v3 resources to universal models. + +Two shapes of input show up here: + + * Official Data API resources (dicts from google-api-python-client), used + for identity, subscriptions, a channel's uploads, single videos, etc. + * InnerTube/scraped dicts from the youtube-search-python library, used for + search and the recommendations feed. Those are converted by the + innertube_* helpers, which tolerate the looser, differently-named fields. + +Everything funnels into UniversalStatus / UniversalUser so the rest of FastSM +(timelines, the audio player, the post renderer) treats YouTube like any +other platform. +""" + +from datetime import datetime, timezone +from typing import Optional, Any, Dict + +from models import UniversalStatus, UniversalUser, UniversalMedia + +PLATFORM = "youtube" + + +def watch_url(video_id: str) -> str: + return f"https://www.youtube.com/watch?v={video_id}" if video_id else "" + + +def channel_url(channel_id: str) -> str: + return f"https://www.youtube.com/channel/{channel_id}" if channel_id else "" + + +def _dig(obj: Any, *keys, default=None): + """Safely walk nested dict keys: _dig(item, 'snippet', 'title').""" + cur = obj + for key in keys: + if isinstance(cur, dict) and key in cur: + cur = cur[key] + else: + return default + return cur + + +def _best_thumbnail(thumbnails: Optional[dict]) -> Optional[str]: + """Pick the highest-resolution thumbnail URL available.""" + if not isinstance(thumbnails, dict): + return None + for size in ("maxres", "standard", "high", "medium", "default"): + url = _dig(thumbnails, size, "url") + if url: + return url + return None + + +def parse_datetime(value) -> Optional[datetime]: + """Parse an ISO-8601 timestamp (e.g. '2021-01-01T00:00:00Z').""" + if not value: + return None + if isinstance(value, datetime): + return value + try: + text = str(value).replace("Z", "+00:00") + dt = datetime.fromisoformat(text) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except (ValueError, TypeError): + return None + + +def _to_int(value) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +# --------------------------------------------------------------------------- +# Data API resources +# --------------------------------------------------------------------------- + +def youtube_channel_to_universal(channel: dict) -> Optional[UniversalUser]: + """Convert a channels.list item to a UniversalUser.""" + if not channel: + return None + channel_id = channel.get("id", "") + snippet = channel.get("snippet", {}) or {} + stats = channel.get("statistics", {}) or {} + custom_url = snippet.get("customUrl", "") # e.g. "@handle" + handle = custom_url.lstrip("@") if custom_url else (channel_id or "") + + return UniversalUser( + id=channel_id, + acct=handle or channel_id, + username=handle or channel_id, + display_name=snippet.get("title", "") or handle or channel_id, + note=snippet.get("description", "") or "", + avatar=_best_thumbnail(snippet.get("thumbnails")), + followers_count=_to_int(stats.get("subscriberCount")), + following_count=0, # YouTube doesn't expose who a channel subscribes to + statuses_count=_to_int(stats.get("videoCount")), + created_at=parse_datetime(snippet.get("publishedAt")), + url=channel_url(channel_id), + _platform_data=channel, + _platform=PLATFORM, + ) + + +def _channel_ref_to_universal(channel_id: str, title: str, + thumbnails: Optional[dict] = None) -> UniversalUser: + """Build a lightweight author UniversalUser from a video's snippet. + + Video snippets only carry channelId/channelTitle, not full channel stats, + so this fills the bare minimum needed to render a post author. + """ + # Use the channel name as the "acct" so the display reads "Name (@Name)" + # rather than the cryptic "@UC..." channel ID. The real ID stays in `id` + # for follow/subscribe actions. + return UniversalUser( + id=channel_id or "", + acct=title or channel_id or "", + username=title or channel_id or "", + display_name=title or "", + avatar=_best_thumbnail(thumbnails), + url=channel_url(channel_id), + _platform=PLATFORM, + ) + + +def youtube_video_to_universal(video: dict, channel_id: str = "", + channel_title: str = "") -> Optional[UniversalStatus]: + """Convert a videos.list item (or anything with snippet+id) to a status. + + A video becomes a "post": the channel is the author, the title+description + is the body, and the watch URL is attached as a video media attachment so + the existing yt-dlp audio player can play it. + """ + if not video: + return None + + video_id = video.get("id", "") + snippet = video.get("snippet", {}) or {} + stats = video.get("statistics", {}) or {} + + title = snippet.get("title", "") or "" + description = snippet.get("description", "") or "" + # Timeline line = title only (clean); full description lives in `content` + # for the detail view. Avoids dumping long descriptions into the timeline. + text = title + content = description or title + + author = _channel_ref_to_universal( + channel_id or snippet.get("channelId", ""), + channel_title or snippet.get("channelTitle", ""), + snippet.get("thumbnails"), + ) + + url = watch_url(video_id) + media = [] + if url: + # description=None so the media line doesn't repeat the title. + media.append(UniversalMedia( + id=video_id, + type="video", + url=url, + preview_url=_best_thumbnail(snippet.get("thumbnails")), + description=None, + _platform_data=video, + )) + + return UniversalStatus( + id=video_id, + account=author, + content=content, + text=text, + created_at=parse_datetime(snippet.get("publishedAt")) or datetime.now(timezone.utc), + favourites_count=_to_int(stats.get("likeCount")), + replies_count=_to_int(stats.get("commentCount")), + media_attachments=media, + url=url, + _platform_data=video, + _platform=PLATFORM, + ) + + +def youtube_subscription_to_universal(subscription: dict) -> Optional[UniversalUser]: + """Convert a subscriptions.list item to a UniversalUser (the subscribed channel).""" + if not subscription: + return None + snippet = subscription.get("snippet", {}) or {} + channel_id = _dig(snippet, "resourceId", "channelId", default="") or "" + title = snippet.get("title", "") or "" + + # acct falls back to the title (not the raw UC... id) for channels without + # a handle, e.g. auto-generated "- Topic" music channels. _apply_handles + # upgrades this to the real @handle when one exists. + return UniversalUser( + id=channel_id, + acct=title or channel_id, + username=title or channel_id, + display_name=title, + note=snippet.get("description", "") or "", + avatar=_best_thumbnail(snippet.get("thumbnails")), + url=channel_url(channel_id), + _platform_data=subscription, + _platform=PLATFORM, + ) + + +def youtube_comment_to_universal(comment: dict, video_id: str = "") -> Optional[UniversalStatus]: + """Convert a comment resource (top-level comment or reply) to a status. + + Accepts the shape used by commentThreads (topLevelComment), comments.list + replies, and comments.insert responses: a dict with 'id' and 'snippet'. + """ + if not comment: + return None + cid = comment.get("id", "") + sn = comment.get("snippet", {}) or {} + author = UniversalUser( + id=_dig(sn, "authorChannelId", "value", default="") or "", + acct=sn.get("authorDisplayName", "") or "", + username=sn.get("authorDisplayName", "") or "", + display_name=sn.get("authorDisplayName", "") or "", + avatar=sn.get("authorProfileImageUrl"), + url=sn.get("authorChannelUrl", "") or "", + _platform=PLATFORM, + ) + text = sn.get("textOriginal", "") or sn.get("textDisplay", "") or "" + return UniversalStatus( + id=cid, + account=author, + content=text, + text=text, + created_at=parse_datetime(sn.get("publishedAt")) or datetime.now(timezone.utc), + favourites_count=_to_int(sn.get("likeCount")), + in_reply_to_id=sn.get("parentId") or video_id or None, + url=watch_url(video_id) if video_id else None, + _platform_data=comment, + _platform=PLATFORM, + ) + + +def youtube_search_result_to_universal(item: dict): + """Convert a search.list item to a status (video) or user (channel). + + search.list returns a heterogeneous list; the id.kind field says which. + """ + if not item: + return None + kind = _dig(item, "id", "kind", default="") + if kind == "youtube#channel": + channel_id = _dig(item, "id", "channelId", default="") or "" + snippet = item.get("snippet", {}) or {} + return UniversalUser( + id=channel_id, + acct=channel_id, + username=snippet.get("channelTitle", "") or snippet.get("title", "") or channel_id, + display_name=snippet.get("title", "") or "", + note=snippet.get("description", "") or "", + avatar=_best_thumbnail(snippet.get("thumbnails")), + url=channel_url(channel_id), + _platform_data=item, + _platform=PLATFORM, + ) + # Default: treat as a video. + video_id = _dig(item, "id", "videoId", default="") or "" + video = {"id": video_id, "snippet": item.get("snippet", {})} + return youtube_video_to_universal(video) + + +# --------------------------------------------------------------------------- +# InnerTube / youtube-search-python scraped dicts (search + recommendations) +# --------------------------------------------------------------------------- + +def innertube_video_to_universal(item: Dict[str, Any]) -> Optional[UniversalStatus]: + """Convert a youtube-search-python video dict to a status. + + Shape (abbreviated): + {"id": "...", "title": "...", "channel": {"id": "...", "name": "..."}, + "publishedTime": "3 days ago", "thumbnails": [{"url": "..."}], ...} + publishedTime is a fuzzy human string, so created_at is left as "now". + """ + if not item: + return None + video_id = item.get("id", "") or "" + channel = item.get("channel", {}) or {} + thumbs = item.get("thumbnails") or [] + thumb_url = thumbs[-1].get("url") if thumbs else None + + author = _channel_ref_to_universal( + channel.get("id", ""), channel.get("name", ""), + ) + + title = item.get("title", "") or "" + url = item.get("link") or watch_url(video_id) + media = [] + if url: + media.append(UniversalMedia( + id=video_id, type="video", url=url, + preview_url=thumb_url, description=None, _platform_data=item, + )) + + return UniversalStatus( + id=video_id, + account=author, + content=title, + text=title, + created_at=datetime.now(timezone.utc), + media_attachments=media, + url=url, + _platform_data=item, + _platform=PLATFORM, + ) + + +def innertube_channel_to_universal(item: Dict[str, Any]) -> Optional[UniversalUser]: + """Convert a youtube-search-python channel dict to a UniversalUser.""" + if not item: + return None + channel_id = item.get("id", "") or "" + thumbs = item.get("thumbnails") or [] + return UniversalUser( + id=channel_id, + acct=channel_id, + username=item.get("title", "") or channel_id, + display_name=item.get("title", "") or "", + note=item.get("descriptionSnippet", "") or "", + avatar=(thumbs[-1].get("url") if thumbs else None), + url=item.get("link") or channel_url(channel_id), + _platform_data=item, + _platform=PLATFORM, + ) diff --git a/platforms/youtube/oauth.py b/platforms/youtube/oauth.py new file mode 100644 index 0000000..d9674bd --- /dev/null +++ b/platforms/youtube/oauth.py @@ -0,0 +1,376 @@ +"""OAuth 2.0 handling for the YouTube platform. + +YouTube uses Google's "installed app" OAuth flow (loopback redirect): +the user logs into their Google account in a browser, grants consent, and +Google redirects to a temporary local web server that FastSM spins up to +capture the authorization code. The code is exchanged for an access token +and a long-lived refresh token. Only the refresh token (plus the access +token and its expiry) is persisted in the per-account config; the access +token is silently refreshed from the refresh token on later launches, so +the browser is only needed once. + +What OAuth unlocks (YouTube Data API v3): + - Account identity (channels.list mine=true) + - Subscriptions (subscriptions.list mine=true) + - Likes / ratings, subscribe/unsubscribe, comments, uploads + +What OAuth does NOT cover (handled elsewhere, via cookies + InnerTube): + - The personalized recommendations / home feed + - Community ("posts" tab) reading and posting + +google-auth, google-auth-oauthlib and google-api-python-client are imported +lazily inside the functions that need them so importing this module never +fails on a machine that hasn't installed them yet (mirrors how the Bluesky +backend imports atproto lazily). +""" + +from typing import Optional, Callable, Dict, Any + +try: + from logging_config import get_logger + _logger = get_logger('api') +except ImportError: + _logger = None + + +# --------------------------------------------------------------------------- +# Client configuration +# --------------------------------------------------------------------------- +# +# These are the credentials for the single, Google-verified FastSM OAuth +# client (consent screen published to "In production"). A "Desktop app" +# client secret is not actually secret — Google treats installed-app clients +# as public, so shipping it in the binary is expected and supported. +# +# Fill these in after creating the OAuth client in Google Cloud Console and +# completing sensitive-scope verification. Until then, the login flow will +# raise a clear error telling the developer the client isn't configured. +# +# A user may also override these with their own client by setting +# prefs.youtube_client_id / prefs.youtube_client_secret (see get_client_config). + +BUNDLED_CLIENT_ID = "" # e.g. "1234567890-abc.apps.googleusercontent.com" +BUNDLED_CLIENT_SECRET = "" # e.g. "GOCSPX-..." + +# Scopes requested at consent time. Keep this list minimal — every sensitive +# scope added here lengthens Google's verification review. +SCOPES = [ + "https://www.googleapis.com/auth/youtube.readonly", # identity, subscriptions + "https://www.googleapis.com/auth/youtube.force-ssl", # like, subscribe, comment + # "https://www.googleapis.com/auth/youtube.upload", # enable if/when uploads land +] + +# Google's standard installed-app endpoints. +AUTH_URI = "https://accounts.google.com/o/oauth2/auth" +TOKEN_URI = "https://oauth2.googleapis.com/token" + + +class YouTubeAuthError(Exception): + """Raised when the OAuth flow fails or the client is not configured. + + Treated as a transient/ordinary error by callers — the stored token is NOT + discarded (e.g. a network blip during refresh shouldn't strand a valid login). + """ + pass + + +class YouTubeReauthRequired(YouTubeAuthError): + """The stored token is permanently unusable (revoked, or expired after the + 7-day Google "Testing" limit). Callers should clear the saved token and + prompt the user to sign in again.""" + pass + + +# Path to an optional, git-ignored credentials file sitting next to this module. +# Save the JSON Google Cloud Console gives you ("Download JSON" on the OAuth +# client) here and login works with no source edits. +import os as _os +LOCAL_CLIENT_FILE = _os.path.join(_os.path.dirname(__file__), "client_secret.json") + + +def _load_local_client_config() -> Optional[Dict[str, Any]]: + """Load client_secret.json if present, normalized to the 'installed' shape. + + Accepts Google's native download (top-level 'installed' or 'web' key). + Returns None when the file is absent or unusable. + """ + if not _os.path.isfile(LOCAL_CLIENT_FILE): + return None + try: + import json + with open(LOCAL_CLIENT_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + if _logger: + _logger.warning("Could not read %s: %s", LOCAL_CLIENT_FILE, e) + return None + + block = data.get("installed") or data.get("web") or data + client_id = (block.get("client_id") or "").strip() + client_secret = (block.get("client_secret") or "").strip() + if not client_id or not client_secret: + if _logger: + _logger.warning("%s is missing client_id/client_secret.", LOCAL_CLIENT_FILE) + return None + + return { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": block.get("auth_uri", AUTH_URI), + "token_uri": block.get("token_uri", TOKEN_URI), + "redirect_uris": block.get("redirect_uris", ["http://localhost"]), + } + } + + +def get_client_config(prefs=None) -> Dict[str, Any]: + """Build the client_config dict google-auth-oauthlib expects. + + Prefers a user-supplied client (prefs.youtube_client_id/secret) when + present, otherwise falls back to the bundled FastSM client. + """ + # 1. A local, git-ignored client_secret.json (the file Google lets you + # download with one click) wins if present — no source editing needed. + local = _load_local_client_config() + if local is not None: + return local + + # 2. Otherwise fall back to the bundled constants / prefs override. + client_id = BUNDLED_CLIENT_ID + client_secret = BUNDLED_CLIENT_SECRET + if prefs is not None: + client_id = (prefs.get("youtube_client_id", "") or "").strip() or client_id + client_secret = (prefs.get("youtube_client_secret", "") or "").strip() or client_secret + + if not client_id or not client_secret: + raise YouTubeAuthError( + "No YouTube OAuth client is configured. Save Google's downloaded " + "client_secret.json into platforms/youtube/, or set BUNDLED_CLIENT_ID/" + "BUNDLED_CLIENT_SECRET in platforms/youtube/oauth.py." + ) + + # "installed" is Google's key for desktop/loopback clients. + return { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": AUTH_URI, + "token_uri": TOKEN_URI, + "redirect_uris": ["http://localhost"], + } + } + + +def run_oauth_flow(prefs=None) -> Dict[str, Any]: + """Run the interactive browser login and return a token dict to persist. + + Spins up a temporary localhost web server, opens the user's browser to + Google's consent screen, and blocks until the user finishes (or the flow + errors / is abandoned). Must be called on a thread where blocking is fine + — for FastSM that's the account-setup path, same place the Mastodon code + enters its browser auth. + + Returns a JSON-serializable dict (see credentials_to_dict) suitable for + storing in prefs.youtube_token. + """ + try: + from google_auth_oauthlib.flow import InstalledAppFlow + except ImportError as e: + raise YouTubeAuthError( + "google-auth-oauthlib is not installed. Run " + "'pip install google-auth-oauthlib google-api-python-client' " + "and try again." + ) from e + + client_config = get_client_config(prefs) + flow = InstalledAppFlow.from_client_config(client_config, scopes=SCOPES) + + # port=0 lets the OS pick a free port for the loopback redirect. + # access_type=offline + prompt=consent guarantees we receive a refresh + # token (without prompt=consent Google omits it on repeat authorizations). + try: + flow.run_local_server( + port=0, + access_type="offline", + prompt="consent", + authorization_prompt_message=( + "Your browser has been opened to log in to your Google account. " + "After granting access you can return to FastSM." + ), + success_message=( + "FastSM is now connected to your YouTube account. " + "You can close this tab and return to the app." + ), + open_browser=True, + ) + except Exception as e: + if _logger: + _logger.exception("YouTube OAuth flow failed: %s", e) + raise YouTubeAuthError(f"YouTube login failed: {e}") from e + + creds = flow.credentials + if not creds or not creds.refresh_token: + raise YouTubeAuthError( + "Google did not return a refresh token. Remove FastSM from your " + "Google account permissions and try logging in again." + ) + if _logger: + _logger.info("YouTube OAuth flow completed; refresh token obtained.") + return credentials_to_dict(creds) + + +def credentials_to_dict(creds) -> Dict[str, Any]: + """Serialize a google.oauth2.credentials.Credentials to a plain dict.""" + return { + "token": creds.token, + "refresh_token": creds.refresh_token, + "token_uri": creds.token_uri, + "client_id": creds.client_id, + "client_secret": creds.client_secret, + "scopes": list(creds.scopes) if creds.scopes else SCOPES, + "expiry": creds.expiry.isoformat() if getattr(creds, "expiry", None) else None, + } + + +def dict_to_credentials(data: Dict[str, Any], prefs=None): + """Rebuild a Credentials object from a stored token dict. + + Falls back to the current bundled client_id/secret if the stored dict + predates a credential change, so an app update that rotates the client + doesn't strand existing logins. + """ + try: + from google.oauth2.credentials import Credentials + except ImportError as e: + raise YouTubeAuthError( + "google-auth is not installed. Run " + "'pip install google-auth google-api-python-client'." + ) from e + + if not data or not data.get("refresh_token"): + raise YouTubeAuthError("No stored YouTube refresh token; login required.") + + client_id = data.get("client_id") + client_secret = data.get("client_secret") + if not client_id or not client_secret: + cfg = get_client_config(prefs)["installed"] + client_id = client_id or cfg["client_id"] + client_secret = client_secret or cfg["client_secret"] + + creds = Credentials( + token=data.get("token"), + refresh_token=data.get("refresh_token"), + token_uri=data.get("token_uri", TOKEN_URI), + client_id=client_id, + client_secret=client_secret, + scopes=data.get("scopes", SCOPES), + ) + # Restore expiry so a still-valid cached access token is reused instead of + # forcing a refresh on every launch. Credentials.expiry must be naive UTC. + expiry = data.get("expiry") + if expiry: + try: + import datetime as _dt + dt = _dt.datetime.fromisoformat(expiry) + if dt.tzinfo is not None: + dt = dt.astimezone(_dt.timezone.utc).replace(tzinfo=None) + creds.expiry = dt + except Exception: + pass # bad value -> leave unset, which just forces a refresh + return creds + + +def ensure_valid_credentials(creds, on_token_refresh: Optional[Callable] = None): + """Refresh the access token if it's missing or expired. + + Calls on_token_refresh(token_dict) after a successful refresh so the + caller can persist the new access token/expiry back to prefs. + """ + try: + from google.auth.transport.requests import Request + except ImportError as e: + raise YouTubeAuthError("google-auth is not installed.") from e + + # If the stored scopes no longer cover what the app now needs (e.g. a new + # scope was added in an update), force a fresh consent rather than failing + # later with opaque 403s. + have = set(creds.scopes or []) + if have and not set(SCOPES).issubset(have): + raise YouTubeReauthRequired( + "YouTube permissions changed; please sign in again to grant them.") + + if creds.valid: + return creds + if creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + except Exception as e: + # invalid_grant => the refresh token is revoked or expired (the + # 7-day Google "Testing" limit). That needs a full re-login and the + # dead token should be cleared. Anything else (network/5xx) is + # transient and must NOT discard a possibly-valid token. + msg = str(e) + low = msg.lower() + if _logger: + _logger.warning("YouTube token refresh failed: %s", msg) + # Only treat the precise OAuth dead-token signals as fatal. Match + # Google's exact phrase, NOT a bare "expired" (which also appears in + # transient errors like "certificate has expired" and must not wipe + # a valid refresh token). + if ("invalid_grant" in low or "invalid_token" in low + or "token has been expired or revoked" in low): + raise YouTubeReauthRequired( + "Your YouTube session expired. Please sign in again.") from e + raise YouTubeAuthError( + f"Could not refresh YouTube access token (temporary): {e}") from e + if on_token_refresh: + try: + on_token_refresh(credentials_to_dict(creds)) + except Exception as save_err: + if _logger: + _logger.warning("Could not persist refreshed YouTube token: %s", save_err) + return creds + raise YouTubeReauthRequired( + "YouTube credentials are invalid and cannot be refreshed; please sign in again.") + + +def build_youtube_service(creds): + """Build a YouTube Data API v3 client from credentials.""" + try: + from googleapiclient.discovery import build + except ImportError as e: + raise YouTubeAuthError( + "google-api-python-client is not installed. Run " + "'pip install google-api-python-client'." + ) from e + # cache_discovery=False avoids the noisy file-cache warning in frozen builds. + return build("youtube", "v3", credentials=creds, cache_discovery=False) + + +REVOKE_URL = "https://oauth2.googleapis.com/revoke" + + +def revoke_token(data: Dict[str, Any]) -> bool: + """Best-effort revoke the grant at Google on sign-out. + + Revokes the refresh token (which also invalidates derived access tokens). + Returns True on success; never raises (network failures are non-fatal). + """ + if not data: + return False + tok = data.get("refresh_token") or data.get("token") + if not tok: + return False + try: + import requests + resp = requests.post( + REVOKE_URL, data={"token": tok}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + return resp.status_code == 200 + except Exception as e: + if _logger: + _logger.info("YouTube token revoke failed (non-fatal): %s", e) + return False diff --git a/platforms/youtube/vendor/README.md b/platforms/youtube/vendor/README.md new file mode 100644 index 0000000..e3b29b7 --- /dev/null +++ b/platforms/youtube/vendor/README.md @@ -0,0 +1,20 @@ +# Vendored third-party packages + +## youtubesearchpython + +A vendored, lightly modified copy of [youtube-search-python](https://pypi.org/project/youtube-search-python/) +(MIT licensed — see `youtubesearchpython/LICENSE`). + +FastSM's YouTube platform uses this library for search, suggestions, and +InnerTube-based metadata (the parts not covered by the official YouTube Data +API). It is vendored rather than installed from PyPI because the upstream +release is unmaintained and needed fixes to keep working against current +InnerTube responses (updated `clientVersion`, renderer paths, and request +payloads). + +It is made importable as the top-level package `youtubesearchpython` by a small +`sys.path` shim in `platforms/youtube/__init__.py`, so existing +`import youtubesearchpython` / `from youtubesearchpython.core...` imports work +unchanged whether or not the PyPI package is installed. + +Upstream: https://github.com/alexmercerind/youtube-search-python diff --git a/platforms/youtube/vendor/youtubesearchpython/LICENSE b/platforms/youtube/vendor/youtubesearchpython/LICENSE new file mode 100644 index 0000000..5e851ff --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Hitesh Kumar Saini and the youtube-search-python contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/platforms/youtube/vendor/youtubesearchpython/__future__/__init__.py b/platforms/youtube/vendor/youtubesearchpython/__future__/__init__.py new file mode 100644 index 0000000..7a4a119 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/__future__/__init__.py @@ -0,0 +1,11 @@ +from youtubesearchpython.__future__.search import Search, VideosSearch, ChannelsSearch, PlaylistsSearch, CustomSearch, ChannelSearch, ShortsSearch +from youtubesearchpython.__future__.extras import Video, Playlist, Suggestions, Hashtag, Comments, Transcript, Channel +from youtubesearchpython.__future__.streamurlfetcher import StreamURLFetcher +from youtubesearchpython.core.utils import * +from youtubesearchpython.core.constants import * + + +__title__ = 'youtube-search-python' +__version__ = '1.6.2' +__author__ = 'sujan rai' +__license__ = 'MIT' diff --git a/platforms/youtube/vendor/youtubesearchpython/__future__/extras.py b/platforms/youtube/vendor/youtubesearchpython/__future__/extras.py new file mode 100644 index 0000000..1fddd7c --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/__future__/extras.py @@ -0,0 +1,1898 @@ +import copy +from typing import Union + +from youtubesearchpython.core import VideoCore +from youtubesearchpython.core.comments import CommentsCore +from youtubesearchpython.core.constants import ResultMode, ChannelRequestType +from youtubesearchpython.core.hashtag import HashtagCore +from youtubesearchpython.core.playlist import PlaylistCore +from youtubesearchpython.core.suggestions import SuggestionsCore +from youtubesearchpython.core.transcript import TranscriptCore +from youtubesearchpython.core.channel import ChannelCore + + +class Video: + @staticmethod + async def get(videoLink: str, resultMode: int = ResultMode.dict, timeout: int = 2, get_upload_date: bool = False) -> \ + Union[dict, None]: + '''Fetches information and formats for the given video link or ID. + Returns None if video is unavailable. + + Args: + videoLink (str): link or ID of the video on YouTube. + + Examples: + + >>> video = await Video.get("E07s5ZYygMg") + >>> print(video) + { + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "viewCount": { + "text": "170389228" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCT6nkbmYf-zbqAFgzF0D9PUhtsOQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA-JdoctyNp4aaj9dVtR0c6l5RDVw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBquHs9OWY5Dy1nE_syglwKP6-pMw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDSjHwdHxt9aU8NTojucGLp4PurTA", + "width": 336, + "height": 188 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/maxresdefault.jpg?v=5ebedc0c", + "width": 1920, + "height": 1080 + } + ], + "description": "This video is dedicated to touching. Listen to Harry Styles\u2019 new album \u2018Fine Line\u2019 now: https://HStyles.lnk.to/FineLineAY Follow Harry Styles: Facebook: https://HarryStyles.lnk.to/followFI Instagram: https://HarryStyles.lnk.to/followII Twitter: https://HarryStyles.lnk.to/followTI Website: https://HarryStyles.lnk.to/followWI Spotify: https://HarryStyles.lnk.to/followSI YouTube: https://HarryStyles.lnk.to/subscribeYD Lyrics: Tastes like strawberries On a summer evening And it sounds just like a song I want more berries And that summer feeling It\u2019s so wonderful and warm Breathe me in Breathe me out I don\u2019t know if I could ever go without I\u2019m just thinking out loud I don\u2019t know if I could ever go without Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar Strawberries On a summer evening Baby, you\u2019re the end of June I want your belly And that summer feeling Getting washed away in you Breathe me in Breathe me out I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Tastes like strawberries On a summer evening And it sounds just like a song I want your belly And that summer feeling I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Watermelon Sugar #HarryStyles #WatermelonSugar #FineLine", + "channel": { + "name": "HarryStylesVEVO", + "id": "UCbOCbp5gXL8jigIBZLqMPrw", + "link": "https://www.youtube.com/channel/UCbOCbp5gXL8jigIBZLqMPrw" + }, + "averageRating": 4.9043722, + "keywords": [ + "Fine Line", + "Harry Styles Fine Line", + "New Harry Styles", + "Harry Styles Album", + "HS2", + "One Direction", + "Eroda", + "HStyles", + "HarryStyles", + "New HS", + "Watermelon", + "Sugar", + "Watermlon Sugar", + "Harry Styles Watermelon Sugar", + "Fine Line Watermelon Sugar", + "Watermelon Sugar Fine Line", + "Harry Styles Watermelon Sguar Official Audio", + "Harry Styles Watermelon Sugar Song", + "HS Watermelon Sugar", + "Harry Styles Watermelon Sugar Video", + "Harry Styles Watermelon Sugar Official Video", + "Harry" + ], + "publishDate": "2020-05-18", + "uploadDate": "2020-05-18", + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "streamingData": { + "expiresInSeconds": "21540", + "formats": [ + { + "itag": 18, + "mimeType": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", + "bitrate": 635291, + "width": 640, + "height": 360, + "lastModified": "1594495537943093", + "contentLength": "14993923", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 635096, + "audioQuality": "AUDIO_QUALITY_LOW", + "approxDurationMs": "188871", + "audioSampleRate": "44100", + "audioChannels": 2, + "signatureCipher": "s=%3D%3D%3D%3DQodOF5O8RrqTn2rAkcM8v_YNimZ3DfiiO8ZPw9KyyeSBiASFkFP5N0jiMesLzywq2YSWUDXD5Z6lrU9gubyH9Go_MAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3DVfvQKqQ_NZHn6Dor7spmHhEF%26gir%3Dyes%26clen%3D14993923%26ratebypass%3Dyes%26dur%3D188.871%26lmt%3D1594495537943093%26mt%3D1610773720%26fvip%3D7%26beids%3D23886208%26c%3DWEB%26txp%3D5531432%26n%3DWJb1Ck1hxc089s%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgMhVOt4fvPig34e70PugZ4fF9_eaMIxFjkxoViFq_o7QCIHOuwB1qTokkaSC_SRacI2M1ThRYliS_9grHyI5qyZMS" + } + ], + "adaptiveFormats": [ + { + "itag": 396, + "mimeType": "video/mp4; codecs=\"av01.0.01M.08\"", + "bitrate": 382566, + "width": 640, + "height": 360, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609487253789141", + "contentLength": "6728270", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 285076, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dk%3DkdR4btYwFQpMSXtob0p2mPKAXiWMK-RwiWxxIt5LWu2AEiAL2zMdQnSgnygDbjo9yzBHDJxs-xM8C6T3kjsh6awJQOAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D396%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D6728270%26dur%3D188.813%26lmt%3D1609487253789141%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhANojIwQjR-uyA0up-8AVzVVmluYtBAUv7-xfcVLx78VWAiEA70Nv2CCMerX-aagqEvaYtfvWlJnxfIrXbrNh6-9Jlqw%253D" + }, + { + "itag": 133, + "mimeType": "video/mp4; codecs=\"avc1.4d4015\"", + "bitrate": 305779, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "738" + }, + "indexRange": { + "start": "739", + "end": "1226" + }, + "lastModified": "1601811623766061", + "contentLength": "4866855", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 206208, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3Dws707EGUqrkpRbQj1iDJx96vnuQ3Pdpyw_htdH4w4QvBiAn-2tm8pntaCUkaYr9xiHrb4lmcGfYyhtAebKdghPsGIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D133%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4866855%26dur%3D188.813%26lmt%3D1601811623766061%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgb0GIgg73jyyDh_JXbJEHYHpNgLpeGa92-oy7wr-WoLcCIQDjOTwDMClu68TTAo5e09v-6mGnnk-g3XypBrPbPHmiLA%253D%253D" + }, + { + "itag": 242, + "mimeType": "video/webm; codecs=\"vp9\"", + "bitrate": 227782, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "218" + }, + "indexRange": { + "start": "219", + "end": "837" + }, + "lastModified": "1594499983390711", + "contentLength": "4309129", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 182577, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dg%3Dgwb9JzjrAAoXMNBLNQ5qD2iPHnJ4YzENIdt3mbm44OyAEiAzwBK8Zyqox-LOCWIQYKqubPgZxkUzUWZplemU0D6-QPAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D242%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fwebm%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4309129%26dur%3D188.813%26lmt%3D1594499983390711%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhAKi5zlxKb1XGoMRCFAqs3dS9YUzyLPOHyWxDvZD0tHNRAiEAhE-TFI9B_K_oL0ButpdTjJyx01WaC4axvEGcnAL8SaI%253D" + }, + { + "itag": 395, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 182316, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609496650516481", + "contentLength": "3387984", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 143548, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DQ7tMYTeWztx_qFnE9iEOGgHx3wUENk2RY17qdu8FlWVDQICEBt2q4X76zIMwAwS3rCLv4Tvh7rzvEmhokR3o1siuLBgIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D395%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D3387984%26dur%3D188.813%26lmt%3D1609496650516481%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgflPTDLTISSYw4YCfRfUC9QIXUHap_ozlFzKQY2Bw_C0CIQC5ew2MDaI5VmSLOKWu3DgwLLMOXEvVrDWML-NeSicHuw%253D%253D" + } + { + "itag": 394, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 82683, + "width": 256, + "height": 144, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609493305821258", + "contentLength": "1659821", + "quality": "tiny", + "fps": 24, + "qualityLabel": "144p", + "projectionType": "RECTANGULAR", + "averageBitrate": 70326, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DACyTUDBvASFI8Ffxayro2mbZkq635OC5aYXXRc2kRfoAiAfc3-OE7SvRgrsjDiCcCriEaeEsaS1NMDNr5M2b_8PHIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D394%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D1659821%26dur%3D188.813%26lmt%3D1609493305821258%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhALudpHTWdYjzD8IWi9i3ksOL4Ks41vD4P3fQJy42gvU_AiEA5einGPLuqholaqIuBiyFH292aMcRL6bZTeqxEed6So8%253D" + } + ] + } + ''' + video = VideoCore(videoLink, None, resultMode, timeout, get_upload_date) + if get_upload_date: + await video.async_html_create() + await video.async_create() + return video.result + + @staticmethod + async def getInfo(videoLink: str, resultMode: int = ResultMode.dict, timeout: int = 2) -> Union[dict, None]: + '''Fetches only information for the given video link or ID. + Returns None if video is unavailable. + + Args: + videoLink (str): link or ID of the video on YouTube. + + Examples: + + >>> video = await Video.getInfo("E07s5ZYygMg") + >>> print(video) + { + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "viewCount": { + "text": "170389228" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCT6nkbmYf-zbqAFgzF0D9PUhtsOQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA-JdoctyNp4aaj9dVtR0c6l5RDVw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBquHs9OWY5Dy1nE_syglwKP6-pMw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDSjHwdHxt9aU8NTojucGLp4PurTA", + "width": 336, + "height": 188 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/maxresdefault.jpg?v=5ebedc0c", + "width": 1920, + "height": 1080 + } + ], + "description": "This video is dedicated to touching. Listen to Harry Styles\u2019 new album \u2018Fine Line\u2019 now: https://HStyles.lnk.to/FineLineAY Follow Harry Styles: Facebook: https://HarryStyles.lnk.to/followFI Instagram: https://HarryStyles.lnk.to/followII Twitter: https://HarryStyles.lnk.to/followTI Website: https://HarryStyles.lnk.to/followWI Spotify: https://HarryStyles.lnk.to/followSI YouTube: https://HarryStyles.lnk.to/subscribeYD Lyrics: Tastes like strawberries On a summer evening And it sounds just like a song I want more berries And that summer feeling It\u2019s so wonderful and warm Breathe me in Breathe me out I don\u2019t know if I could ever go without I\u2019m just thinking out loud I don\u2019t know if I could ever go without Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar Strawberries On a summer evening Baby, you\u2019re the end of June I want your belly And that summer feeling Getting washed away in you Breathe me in Breathe me out I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Tastes like strawberries On a summer evening And it sounds just like a song I want your belly And that summer feeling I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Watermelon Sugar #HarryStyles #WatermelonSugar #FineLine", + "channel": { + "name": "HarryStylesVEVO", + "id": "UCbOCbp5gXL8jigIBZLqMPrw", + "link": "https://www.youtube.com/channel/UCbOCbp5gXL8jigIBZLqMPrw" + }, + "averageRating": 4.9043722, + "keywords": [ + "Fine Line", + "Harry Styles Fine Line", + "New Harry Styles", + "Harry Styles Album", + "HS2", + "One Direction", + "Eroda", + "HStyles", + "HarryStyles", + "New HS", + "Watermelon", + "Sugar", + "Watermlon Sugar", + "Harry Styles Watermelon Sugar", + "Fine Line Watermelon Sugar", + "Watermelon Sugar Fine Line", + "Harry Styles Watermelon Sguar Official Audio", + "Harry Styles Watermelon Sugar Song", + "HS Watermelon Sugar", + "Harry Styles Watermelon Sugar Video", + "Harry Styles Watermelon Sugar Official Video", + "Harry" + ], + "publishDate": "2020-05-18", + "uploadDate": "2020-05-18", + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + } + ''' + video = VideoCore(videoLink, "getInfo", resultMode, timeout, True) + await video.async_html_create() + video.post_request_only_html_processing() + return video.result + + @staticmethod + async def getFormats(videoLink: str, resultMode: int = ResultMode.dict, timeout: int = 2) -> Union[dict, None]: + '''Fetches formats for the given video link or ID. + Returns None if video is unavailable. + + Args: + videoLink (str): link or ID of the video on YouTube. + + Examples: + + >>> video = await Video.getFormats("E07s5ZYygMg") + >>> print(video) + { + "streamingData": { + "expiresInSeconds": "21540", + "formats": [ + { + "itag": 18, + "mimeType": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", + "bitrate": 635291, + "width": 640, + "height": 360, + "lastModified": "1594495537943093", + "contentLength": "14993923", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 635096, + "audioQuality": "AUDIO_QUALITY_LOW", + "approxDurationMs": "188871", + "audioSampleRate": "44100", + "audioChannels": 2, + "signatureCipher": "s=%3D%3D%3D%3DQodOF5O8RrqTn2rAkcM8v_YNimZ3DfiiO8ZPw9KyyeSBiASFkFP5N0jiMesLzywq2YSWUDXD5Z6lrU9gubyH9Go_MAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3DVfvQKqQ_NZHn6Dor7spmHhEF%26gir%3Dyes%26clen%3D14993923%26ratebypass%3Dyes%26dur%3D188.871%26lmt%3D1594495537943093%26mt%3D1610773720%26fvip%3D7%26beids%3D23886208%26c%3DWEB%26txp%3D5531432%26n%3DWJb1Ck1hxc089s%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgMhVOt4fvPig34e70PugZ4fF9_eaMIxFjkxoViFq_o7QCIHOuwB1qTokkaSC_SRacI2M1ThRYliS_9grHyI5qyZMS" + } + ], + "adaptiveFormats": [ + { + "itag": 396, + "mimeType": "video/mp4; codecs=\"av01.0.01M.08\"", + "bitrate": 382566, + "width": 640, + "height": 360, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609487253789141", + "contentLength": "6728270", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 285076, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dk%3DkdR4btYwFQpMSXtob0p2mPKAXiWMK-RwiWxxIt5LWu2AEiAL2zMdQnSgnygDbjo9yzBHDJxs-xM8C6T3kjsh6awJQOAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D396%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D6728270%26dur%3D188.813%26lmt%3D1609487253789141%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhANojIwQjR-uyA0up-8AVzVVmluYtBAUv7-xfcVLx78VWAiEA70Nv2CCMerX-aagqEvaYtfvWlJnxfIrXbrNh6-9Jlqw%253D" + }, + { + "itag": 133, + "mimeType": "video/mp4; codecs=\"avc1.4d4015\"", + "bitrate": 305779, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "738" + }, + "indexRange": { + "start": "739", + "end": "1226" + }, + "lastModified": "1601811623766061", + "contentLength": "4866855", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 206208, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3Dws707EGUqrkpRbQj1iDJx96vnuQ3Pdpyw_htdH4w4QvBiAn-2tm8pntaCUkaYr9xiHrb4lmcGfYyhtAebKdghPsGIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D133%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4866855%26dur%3D188.813%26lmt%3D1601811623766061%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgb0GIgg73jyyDh_JXbJEHYHpNgLpeGa92-oy7wr-WoLcCIQDjOTwDMClu68TTAo5e09v-6mGnnk-g3XypBrPbPHmiLA%253D%253D" + }, + { + "itag": 242, + "mimeType": "video/webm; codecs=\"vp9\"", + "bitrate": 227782, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "218" + }, + "indexRange": { + "start": "219", + "end": "837" + }, + "lastModified": "1594499983390711", + "contentLength": "4309129", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 182577, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dg%3Dgwb9JzjrAAoXMNBLNQ5qD2iPHnJ4YzENIdt3mbm44OyAEiAzwBK8Zyqox-LOCWIQYKqubPgZxkUzUWZplemU0D6-QPAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D242%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fwebm%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4309129%26dur%3D188.813%26lmt%3D1594499983390711%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhAKi5zlxKb1XGoMRCFAqs3dS9YUzyLPOHyWxDvZD0tHNRAiEAhE-TFI9B_K_oL0ButpdTjJyx01WaC4axvEGcnAL8SaI%253D" + }, + { + "itag": 395, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 182316, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609496650516481", + "contentLength": "3387984", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 143548, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DQ7tMYTeWztx_qFnE9iEOGgHx3wUENk2RY17qdu8FlWVDQICEBt2q4X76zIMwAwS3rCLv4Tvh7rzvEmhokR3o1siuLBgIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D395%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D3387984%26dur%3D188.813%26lmt%3D1609496650516481%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgflPTDLTISSYw4YCfRfUC9QIXUHap_ozlFzKQY2Bw_C0CIQC5ew2MDaI5VmSLOKWu3DgwLLMOXEvVrDWML-NeSicHuw%253D%253D" + } + { + "itag": 394, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 82683, + "width": 256, + "height": 144, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609493305821258", + "contentLength": "1659821", + "quality": "tiny", + "fps": 24, + "qualityLabel": "144p", + "projectionType": "RECTANGULAR", + "averageBitrate": 70326, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DACyTUDBvASFI8Ffxayro2mbZkq635OC5aYXXRc2kRfoAiAfc3-OE7SvRgrsjDiCcCriEaeEsaS1NMDNr5M2b_8PHIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D394%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D1659821%26dur%3D188.813%26lmt%3D1609493305821258%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhALudpHTWdYjzD8IWi9i3ksOL4Ks41vD4P3fQJy42gvU_AiEA5einGPLuqholaqIuBiyFH292aMcRL6bZTeqxEed6So8%253D" + } + ] + } + } + ''' + video = VideoCore(videoLink, "getFormats", resultMode, timeout, False) + await video.async_create() + return video.result + + +class Suggestions: + '''Gets search suggestions for the given query. + + Args: + language (str, optional): Sets the suggestion language. Defaults to 'en'. + region (str, optional): Sets the suggestion region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> suggestions = await Suggestions.get('Harry Styles', language = 'en', region = 'US') + >>> print(suggestions) + { + 'result': [ + 'harry styles', + 'harry styles treat people with kindness', + 'harry styles golden music video', + 'harry styles interview', + 'harry styles adore you', + 'harry styles watermelon sugar', + 'harry styles snl', + 'harry styles falling', + 'harry styles tpwk', + 'harry styles sign of the times', + 'harry styles jingle ball 2020', + 'harry styles christmas', + 'harry styles live', + 'harry styles juice' + ] + } + ''' + + @staticmethod + async def get(query: str, language: str = 'en', region: str = 'US', mode: int = ResultMode.dict): + '''Fetches & returns the search suggestions for the given query. + + Args: + language (str, optional): Sets the language of the result. Defaults to 'en'. + region (str, optional): Sets the region of the result. Defaults to 'US'. + + Returns: + Union[str, dict]: Returns JSON or dictionary. + ''' + suggestionsInternal = SuggestionsCore(language=language, region=region) + suggestions = await suggestionsInternal._getAsync(query, mode) + return suggestions + + +class Playlist: + '''Fetches information and videos for the given playlist link. + Returns None if playlist is unavailable. + + The information of the playlist can be accessed in the `info` field of the class. + And the retrieved videos of the playlist are present inside the `videos` field of the class, as a list. + + Due to limit of being able to retrieve only 100 videos at a time, call `getNextVideos` method to get more videos of the playlist, + which will be appended to the `videos` list. + + `hasMoreVideos` stores boolean to indicate whether more videos are present in the playlist. + If this field is True, then you can call `getNextVideos` method again to get more videos of the playlist. + + Args: + playlistLink (str): link of the playlist on YouTube. + ''' + playlistLink = None + videos = [] + info = None + hasMoreVideos = True + __playlist = None + + def __init__(self, playlistLink: str): + self.playlistLink = playlistLink + + '''Fetches more susequent videos of the playlist, and appends to the `videos` list. + `hasMoreVideos` bool indicates whether more videos can be fetched or not. + ''' + + async def getNextVideos(self) -> None: + if not self.info: + self.__playlist = PlaylistCore(self.playlistLink, None, ResultMode.dict, 2) + await self.__playlist._async_next() + self.info = copy.deepcopy(self.__playlist.playlistComponent) + self.videos = self.__playlist.playlistComponent['videos'] + self.hasMoreVideos = self.__playlist.continuationKey != None + self.info.pop('videos') + else: + await self.__playlist._async_next() + self.videos = self.__playlist.playlistComponent['videos'] + self.hasMoreVideos = self.__playlist.continuationKey != None + + @staticmethod + async def get(playlistLink: str) -> Union[dict, str, None]: + '''Fetches information and videos for the given playlist link. + Returns None if playlist is unavailable. + + Args: + playlistLink (str): link of the playlist on YouTube. + + Examples: + + >>> playlist = await Playlist.get("https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK") + >>> print(playlist) + { + "id": "PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "title": "🔥 NCS: House", + "videoCount": "209", + "viewCount": "155,772,054 views", + "thumbnails": { + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLDHZYoB-WNHmvT3CZy6SpdqygsO4A", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCMQBEG5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLACCxCIRvCn65_OS1z_4tLAq5Jb8Q", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCPYBEIoBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBt00cYTIVBdrnHsSNLinhq7meCpQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBFaqqO6kCAuqya1SIJo5Cf45Ndxg", + "width": 336, + "height": 188 + } + ] + }, + "link": "https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s48-c-k-c0x00ffffff-no-rj", + "width": 48, + "height": 48 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s176-c-k-c0x00ffffff-no-rj", + "width": 176, + "height": 176 + } + ], + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "videos": [ + { + "id": "0oq2Ej36nlY", + "title": "Axol - Mars [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAYA8xOuVJyq4ZdmdZEy3128mkHSg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBVen9Zle-8QDR10u73EEHbHc_MAQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBFv2xNC53WtsAQahBV1kRW2knJ2w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBMkco75LBzq-XCblRqQZkcFbDf4w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:56", + "accessibility": { + "title": "Axol - Mars [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 56 seconds", + "duration": "2 minutes, 56 seconds" + }, + "link": "https://www.youtube.com/watch?v=0oq2Ej36nlY" + }, + { + "id": "iv7ZJecuu_o", + "title": "NIVIRO - The Floor Is Lava [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCkw6PB0tE3tROCegrF7uPK0tHM4w", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDxeEJ7qhh1Du1V2GiStjP0XGTniQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB_0R_xsvuIqYr30BgvOdcHsSCoUQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCcr_u0591ANz4Mes7MCECuvRikUA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:16", + "accessibility": { + "title": "NIVIRO - The Floor Is Lava [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 16 seconds", + "duration": "3 minutes, 16 seconds" + }, + "link": "https://www.youtube.com/watch?v=iv7ZJecuu_o" + }, + { + "id": "cmVdgWL5548", + "title": "Raven & Kreyn - So Happy [NCS Official Video]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBa3HKnW5uNkAP25X5668d5Yxx_GQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBghyhFRtdIWD4AT3BZBuOhlzB4JA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDgYP3wdhqlhDEMPuAW6vMt415fIQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD94rVwtv3iglKBdtQ_oKtxZT1iJA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:41", + "accessibility": { + "title": "Raven & Kreyn - So Happy [NCS Official Video] by NoCopyrightSounds 3 years ago 2 minutes, 41 seconds", + "duration": "2 minutes, 41 seconds" + }, + "link": "https://www.youtube.com/watch?v=cmVdgWL5548" + }, + { + "id": "ldDCHrBeOlg", + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDFrVolfV84PcVgXzpjZNaxJqqTyw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDEOg15NmmhCRL9_lQQmK-6axAqyw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDt3q3px1x8SQ8flQYJebkg9fef5g", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBB0B2f0D7RuCc420npQdZpYGb7QQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:39", + "accessibility": { + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 39 seconds", + "duration": "4 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=ldDCHrBeOlg" + }, + { + "id": "PhzDIABahyc", + "title": "Jensation - Delicious [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBwntSl_7Buk4Udzrvko_zJ4nQf8Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCUyQIjjZ0eA5ZgHfBXZOYdDtfHGQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBQKS12wSDYhcIBYFeBjiT1VQLSxQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAE72b5ac2xa9x1ccrKiXsFQwsACA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:49", + "accessibility": { + "title": "Jensation - Delicious [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 49 seconds", + "duration": "2 minutes, 49 seconds" + }, + "link": "https://www.youtube.com/watch?v=PhzDIABahyc" + }, + { + "id": "Y5TnYaZ31b0", + "title": "Waysons - Running [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAEY6qDwWgh6QjKsRN_hB92IiZlMw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLATd9F2LOxmWU7cirUbLqwTfq75xg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBDUIuqcX7vg17NY21ykR8JNyd3A", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBXImJda2jfUE9_L10N5KJLsQTuA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:08", + "accessibility": { + "title": "Waysons - Running [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 8 seconds", + "duration": "3 minutes, 8 seconds" + }, + "link": "https://www.youtube.com/watch?v=Y5TnYaZ31b0" + }, + { + "id": "2Nv5juZKhKo", + "title": "NIVIRO - You [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCBLGyqDfAqaZ3nTk15H4k7EhAaxg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCoyqiY8380ua84NIqVNaDDn6zecg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCxW4Qnr1k3EE5MWbuJlThIm02oYg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBDOvAvcUtCAVB519ww32RtplBkNw", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "NIVIRO - You [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=2Nv5juZKhKo" + }, + { + "id": "odThebFOFVg", + "title": "Elektronomia - The Other Side [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDhut1THu5o6SRzgEfCmEURV3ob7Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBkgcLev1knPC0x_aWkEjsKj8HMpA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDhHu2Y4U_b05FEskx70NHqnReNFw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD1FR4CWnJbkrWD_QsVWEpjq_CzjQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:11", + "accessibility": { + "title": "Elektronomia - The Other Side [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 11 seconds", + "duration": "4 minutes, 11 seconds" + }, + "link": "https://www.youtube.com/watch?v=odThebFOFVg" + }, + { + "id": "9phWj3Iygq8", + "title": "Raven & Kreyn - Get This Party [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBxD8ouCe61I6X4oiHQhPjmu7G8rw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDRVh4TEJG0WTAWz-LnFPjQQxhQaw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLARKdRufUYSduQ3IPGO831vvoQ_8w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAuGbI8xMrYBZ46shlinaj7Na9chg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:39", + "accessibility": { + "title": "Raven & Kreyn - Get This Party [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 39 seconds", + "duration": "2 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=9phWj3Iygq8" + }, + { + "id": "dM2hrLwdaoU", + "title": "Distrion & Alex Skrindo - Lightning [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDuprc64g80t_DXa9UE5SrzLEkAdw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA3bgMR8b2UKtbCpbYzSmsLhgTK7g", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB3LNf1rjgiGHtMa7UH9cQ9B29-yQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBXvevJvz3sTF4ZjpunveJF8Z-gSg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:27", + "accessibility": { + "title": "Distrion & Alex Skrindo - Lightning [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 27 seconds", + "duration": "3 minutes, 27 seconds" + }, + "link": "https://www.youtube.com/watch?v=dM2hrLwdaoU" + }, + { + "id": "vKAHowm3Ry0", + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC4BvoPiuOIA_mTbacI2BobXfm8gA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDOmyUcbQL2EffQm7T19yI9FIe89w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBsI7UIpQCI3Ty6CJxL1R4wRF2EqQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBJivP3UVcYXjkKjdTYLKJO7L329g", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:02", + "accessibility": { + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release by NoCopyrightSounds 3 years ago 3 minutes, 2 seconds", + "duration": "3 minutes, 2 seconds" + }, + "link": "https://www.youtube.com/watch?v=vKAHowm3Ry0" + }, + { + "id": "FseAiTb8Se0", + "title": "Kovan & Electro-Light - Skyline [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBQ5gJjpS6VprS0z0SxgZxEVxGaJA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC5oJlZLpCbxAxQHUceUuVIvUKNSw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDyWw_4fzlujqrtOT90Ya6_cpLeFg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBytsYOYycFUOdBrF47tyEUjnC_-A", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "Kovan & Electro-Light - Skyline [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=FseAiTb8Se0" + }, + { + "id": "BoI6g46zuU4", + "title": "RetroVision & Domastic - SICC [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC-hfMNYvUViQKf8jD1d1XwQDtfNA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDlXueYSIv8fVNN0k4s7CLlJBUw8w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD2PqxAsUQcuqNP_uxtZDeMaWd5sA", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCNtEtObJdzEJykxkMqkcR6qYin0w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:46", + "accessibility": { + "title": "RetroVision & Domastic - SICC [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 46 seconds", + "duration": "2 minutes, 46 seconds" + }, + "link": "https://www.youtube.com/watch?v=BoI6g46zuU4" + } + ] + } + ''' + playlist = PlaylistCore(playlistLink, None, ResultMode.dict, 2) + await playlist.async_create() + return playlist.playlistComponent + + @staticmethod + async def getInfo(playlistLink: str) -> Union[dict, str, None]: + '''Fetches only information for the given playlist link. + Returns None if playlist is unavailable. + + Args: + playlistLink (str): link of the playlist on YouTube. + + Examples: + + >>> playlist = await Playlist.getInfo("https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK") + >>> print(playlist) + { + "id": "PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "title": "🔥 NCS: House", + "videoCount": "209", + "viewCount": "155,772,054 views", + "thumbnails": { + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLDHZYoB-WNHmvT3CZy6SpdqygsO4A", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCMQBEG5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLACCxCIRvCn65_OS1z_4tLAq5Jb8Q", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCPYBEIoBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBt00cYTIVBdrnHsSNLinhq7meCpQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBFaqqO6kCAuqya1SIJo5Cf45Ndxg", + "width": 336, + "height": 188 + } + ] + }, + "link": "https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s48-c-k-c0x00ffffff-no-rj", + "width": 48, + "height": 48 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s176-c-k-c0x00ffffff-no-rj", + "width": 176, + "height": 176 + } + ], + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + } + } + ''' + playlist = PlaylistCore(playlistLink, 'getInfo', ResultMode.dict, 2) + await playlist.async_create() + return playlist.playlistComponent + + @staticmethod + async def getVideos(playlistLink: str) -> Union[dict, str, None]: + '''Fetches only videos in the given playlist from link. + Returns None if playlist is unavailable. + + Args: + playlistLink (str): link of the playlist on YouTube. + + Examples: + + >>> playlist = Playlist.getInfo("https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK") + >>> print(playlist) + { + "videos": [ + { + "id": "0oq2Ej36nlY", + "title": "Axol - Mars [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAYA8xOuVJyq4ZdmdZEy3128mkHSg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBVen9Zle-8QDR10u73EEHbHc_MAQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBFv2xNC53WtsAQahBV1kRW2knJ2w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBMkco75LBzq-XCblRqQZkcFbDf4w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:56", + "accessibility": { + "title": "Axol - Mars [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 56 seconds", + "duration": "2 minutes, 56 seconds" + }, + "link": "https://www.youtube.com/watch?v=0oq2Ej36nlY" + }, + { + "id": "iv7ZJecuu_o", + "title": "NIVIRO - The Floor Is Lava [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCkw6PB0tE3tROCegrF7uPK0tHM4w", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDxeEJ7qhh1Du1V2GiStjP0XGTniQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB_0R_xsvuIqYr30BgvOdcHsSCoUQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCcr_u0591ANz4Mes7MCECuvRikUA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:16", + "accessibility": { + "title": "NIVIRO - The Floor Is Lava [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 16 seconds", + "duration": "3 minutes, 16 seconds" + }, + "link": "https://www.youtube.com/watch?v=iv7ZJecuu_o" + }, + { + "id": "cmVdgWL5548", + "title": "Raven & Kreyn - So Happy [NCS Official Video]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBa3HKnW5uNkAP25X5668d5Yxx_GQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBghyhFRtdIWD4AT3BZBuOhlzB4JA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDgYP3wdhqlhDEMPuAW6vMt415fIQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD94rVwtv3iglKBdtQ_oKtxZT1iJA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:41", + "accessibility": { + "title": "Raven & Kreyn - So Happy [NCS Official Video] by NoCopyrightSounds 3 years ago 2 minutes, 41 seconds", + "duration": "2 minutes, 41 seconds" + }, + "link": "https://www.youtube.com/watch?v=cmVdgWL5548" + }, + { + "id": "ldDCHrBeOlg", + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDFrVolfV84PcVgXzpjZNaxJqqTyw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDEOg15NmmhCRL9_lQQmK-6axAqyw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDt3q3px1x8SQ8flQYJebkg9fef5g", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBB0B2f0D7RuCc420npQdZpYGb7QQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:39", + "accessibility": { + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 39 seconds", + "duration": "4 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=ldDCHrBeOlg" + }, + { + "id": "PhzDIABahyc", + "title": "Jensation - Delicious [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBwntSl_7Buk4Udzrvko_zJ4nQf8Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCUyQIjjZ0eA5ZgHfBXZOYdDtfHGQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBQKS12wSDYhcIBYFeBjiT1VQLSxQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAE72b5ac2xa9x1ccrKiXsFQwsACA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:49", + "accessibility": { + "title": "Jensation - Delicious [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 49 seconds", + "duration": "2 minutes, 49 seconds" + }, + "link": "https://www.youtube.com/watch?v=PhzDIABahyc" + }, + { + "id": "Y5TnYaZ31b0", + "title": "Waysons - Running [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAEY6qDwWgh6QjKsRN_hB92IiZlMw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLATd9F2LOxmWU7cirUbLqwTfq75xg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBDUIuqcX7vg17NY21ykR8JNyd3A", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBXImJda2jfUE9_L10N5KJLsQTuA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:08", + "accessibility": { + "title": "Waysons - Running [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 8 seconds", + "duration": "3 minutes, 8 seconds" + }, + "link": "https://www.youtube.com/watch?v=Y5TnYaZ31b0" + }, + { + "id": "2Nv5juZKhKo", + "title": "NIVIRO - You [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCBLGyqDfAqaZ3nTk15H4k7EhAaxg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCoyqiY8380ua84NIqVNaDDn6zecg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCxW4Qnr1k3EE5MWbuJlThIm02oYg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBDOvAvcUtCAVB519ww32RtplBkNw", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "NIVIRO - You [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=2Nv5juZKhKo" + }, + { + "id": "odThebFOFVg", + "title": "Elektronomia - The Other Side [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDhut1THu5o6SRzgEfCmEURV3ob7Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBkgcLev1knPC0x_aWkEjsKj8HMpA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDhHu2Y4U_b05FEskx70NHqnReNFw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD1FR4CWnJbkrWD_QsVWEpjq_CzjQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:11", + "accessibility": { + "title": "Elektronomia - The Other Side [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 11 seconds", + "duration": "4 minutes, 11 seconds" + }, + "link": "https://www.youtube.com/watch?v=odThebFOFVg" + }, + { + "id": "9phWj3Iygq8", + "title": "Raven & Kreyn - Get This Party [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBxD8ouCe61I6X4oiHQhPjmu7G8rw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDRVh4TEJG0WTAWz-LnFPjQQxhQaw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLARKdRufUYSduQ3IPGO831vvoQ_8w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAuGbI8xMrYBZ46shlinaj7Na9chg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:39", + "accessibility": { + "title": "Raven & Kreyn - Get This Party [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 39 seconds", + "duration": "2 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=9phWj3Iygq8" + }, + { + "id": "dM2hrLwdaoU", + "title": "Distrion & Alex Skrindo - Lightning [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDuprc64g80t_DXa9UE5SrzLEkAdw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA3bgMR8b2UKtbCpbYzSmsLhgTK7g", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB3LNf1rjgiGHtMa7UH9cQ9B29-yQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBXvevJvz3sTF4ZjpunveJF8Z-gSg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:27", + "accessibility": { + "title": "Distrion & Alex Skrindo - Lightning [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 27 seconds", + "duration": "3 minutes, 27 seconds" + }, + "link": "https://www.youtube.com/watch?v=dM2hrLwdaoU" + }, + { + "id": "vKAHowm3Ry0", + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC4BvoPiuOIA_mTbacI2BobXfm8gA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDOmyUcbQL2EffQm7T19yI9FIe89w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBsI7UIpQCI3Ty6CJxL1R4wRF2EqQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBJivP3UVcYXjkKjdTYLKJO7L329g", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:02", + "accessibility": { + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release by NoCopyrightSounds 3 years ago 3 minutes, 2 seconds", + "duration": "3 minutes, 2 seconds" + }, + "link": "https://www.youtube.com/watch?v=vKAHowm3Ry0" + }, + { + "id": "FseAiTb8Se0", + "title": "Kovan & Electro-Light - Skyline [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBQ5gJjpS6VprS0z0SxgZxEVxGaJA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC5oJlZLpCbxAxQHUceUuVIvUKNSw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDyWw_4fzlujqrtOT90Ya6_cpLeFg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBytsYOYycFUOdBrF47tyEUjnC_-A", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "Kovan & Electro-Light - Skyline [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=FseAiTb8Se0" + }, + { + "id": "BoI6g46zuU4", + "title": "RetroVision & Domastic - SICC [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC-hfMNYvUViQKf8jD1d1XwQDtfNA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDlXueYSIv8fVNN0k4s7CLlJBUw8w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD2PqxAsUQcuqNP_uxtZDeMaWd5sA", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCNtEtObJdzEJykxkMqkcR6qYin0w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:46", + "accessibility": { + "title": "RetroVision & Domastic - SICC [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 46 seconds", + "duration": "2 minutes, 46 seconds" + }, + "link": "https://www.youtube.com/watch?v=BoI6g46zuU4" + } + ] + } + ''' + playlist = PlaylistCore(playlistLink, 'getVideos', ResultMode.dict, 2) + await playlist.async_create() + return playlist.playlistComponent + + +class Hashtag(HashtagCore): + '''Fetches videos for the given hashtag. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + >>> hashtag = Hashtags('ncs', limit = 1) + >>> result = await hashtag.next() + >>> print(result) + { + "result": [ + { + "type": "video", + "id": "c9FF4Tfj2w8", + "title": "Ascence - About You [NCS 1 HOUR]", + "publishedTime": "1 year ago", + "duration": "1:00:00", + "viewCount": { + "text": "226,354 views", + "short": "226K views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA8V3x_PigkymVQxQcptr8Wfz20-A", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLABh5Ylb5wbuulOAWLcSYtfYQKiAQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAykmTivOgjlW6a4tKWnLJpL9yqKw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC8qRkotPyH9kGGHe29QuyOh-F9KA", + "width": 336, + "height": 188 + } + ], + "richThumbnail": { + "url": "https://i.ytimg.com/an_webp/c9FF4Tfj2w8/mqdefault_6s.webp?du=3000&sqp=CPGE-YgG&rs=AOn4CLAJAC5zmDOtySflLFMQpAoaPUqHjA", + "width": 320, + "height": 180 + }, + "descriptionSnippet": null, + "channel": { + "name": "Good Vibes Music", + "id": "UChCPI0uvKwrkYhTEx8UVrnQ", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AKedOLSFYY0mvwL0DbRzddMAQdbgFshM42R5byhI9FiEBQ=s68-c-k-c0x00ffffff-no-rj", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UChCPI0uvKwrkYhTEx8UVrnQ" + }, + "accessibility": { + "title": "Ascence - About You [NCS 1 HOUR] by Good Vibes Music 1 year ago 1 hour 226,354 views", + "duration": "1 hour" + }, + "link": "https://www.youtube.com/watch?v=c9FF4Tfj2w8", + "shelfTitle": null + } + ] + } + ''' + + def __init__(self, hashtag: str, limit: int = 60, language: str = 'en', region: str = 'US', timeout: int = None): + super().__init__(hashtag, limit, language, region, timeout) + + async def next(self) -> dict: + '''Gets the videos from the next page. + Returns: + dict: Returns dictionary containing the search result. + ''' + self.response = None + self.resultComponents = [] + if self.params is None: + await self._asyncGetParams() + await self._asyncMakeRequest() + self._getComponents() + return { + 'result': self.resultComponents, + } + + +class Comments: + comments = [] + hasMoreComments = True + __comments = None + + def __init__(self, playlistLink: str, timeout: int = None): + self.timeout = timeout + self.playlistLink = playlistLink + + async def getNextComments(self) -> None: + if self.__comments is None: + self.__comments = CommentsCore(self.playlistLink) + await self.__comments.async_create() + else: + await self.__comments.async_create_next() + self.comments = self.__comments.commentsComponent + self.hasMoreComments = self.__comments.continuationKey is not None + + @staticmethod + async def get(playlistLink: str) -> Union[dict, str, None]: + pc = CommentsCore(playlistLink) + await pc.async_create() + return pc.commentsComponent + + +class Transcript: + @staticmethod + async def get(videoLink: str, params: str = None): + transcript_core = TranscriptCore(videoLink, params) + await transcript_core.async_create() + return transcript_core.result + + +class Channel(ChannelCore): + def __init__(self, channel_id: str, request_type: str = ChannelRequestType.playlists): + super().__init__(channel_id, request_type) + + async def init(self): + await self.async_create() + + async def next(self): + await self.async_next() + + @staticmethod + async def get(channel_id: str, request_type: str = ChannelRequestType.playlists): + channel_core = ChannelCore(channel_id, request_type) + await channel_core.async_create() + return channel_core.result diff --git a/platforms/youtube/vendor/youtubesearchpython/__future__/search.py b/platforms/youtube/vendor/youtubesearchpython/__future__/search.py new file mode 100644 index 0000000..1caad78 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/__future__/search.py @@ -0,0 +1,432 @@ +from typing import Any, Dict, Optional + +from youtubesearchpython.core.channelsearch import ChannelSearchCore +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.search import SearchCore + + +class Search(SearchCore): + '''Searches for videos, channels & playlists in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = Search('Watermelon Sugar', limit = 1) + >>> result = await search.next() + >>> print(result) + { + "result": [ + { + "type": "video", + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "publishedTime": "6 months ago", + "duration": "3:09", + "viewCount": { + "text": "162,235,006 views", + "short": "162M views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAOWBTE1SDrtrDQ1aWNzpDZ7YiMIw", + "width": 360, + "height": 202 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD7U54pGZLPKTuMP-J3kpm4LIDPVg", + "width": 720, + "height": 404 + } + ], + "descriptionSnippet": [ + { + "text": "This video is dedicated to touching. Listen to Harry Styles' new album 'Fine Line' now: https://HStyles.lnk.to/FineLineAY Follow\u00a0..." + } + ], + "channel": { + "name": "Harry Styles", + "id": "UCZFWPqqPkFlNwIxcpsLOwew", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/a-/AOh14GgNUvHxwlnz4RpHamcGnZF1px13VHj01TPksw=s68-c-k-c0x00ffffff-no-rj-mo", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UCZFWPqqPkFlNwIxcpsLOwew" + }, + "accessibility": { + "title": "Harry Styles - Watermelon Sugar (Official Video) by Harry Styles 6 months ago 3 minutes, 9 seconds 162,235,006 views", + "duration": "3 minutes, 9 seconds" + }, + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "shelfTitle": null + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: Optional[int] = None): + self.searchMode = (True, True, True) + super().__init__(query, limit, language, region, None, timeout) # type: ignore + + async def next(self) -> Dict[str, Any]: + return await self._nextAsync() # type: ignore + + +class VideosSearch(SearchCore): + '''Searches for videos in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = VideosSearch('Watermelon Sugar', limit = 1) + >>> result = await search.next() + >>> print(result) + { + "result": [ + { + "type": "video", + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "publishedTime": "6 months ago", + "duration": "3:09", + "viewCount": { + "text": "162,235,006 views", + "short": "162M views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAOWBTE1SDrtrDQ1aWNzpDZ7YiMIw", + "width": 360, + "height": 202 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD7U54pGZLPKTuMP-J3kpm4LIDPVg", + "width": 720, + "height": 404 + } + ], + "descriptionSnippet": [ + { + "text": "This video is dedicated to touching. Listen to Harry Styles' new album 'Fine Line' now: https://HStyles.lnk.to/FineLineAY Follow\u00a0..." + } + ], + "channel": { + "name": "Harry Styles", + "id": "UCZFWPqqPkFlNwIxcpsLOwew", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/a-/AOh14GgNUvHxwlnz4RpHamcGnZF1px13VHj01TPksw=s68-c-k-c0x00ffffff-no-rj-mo", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UCZFWPqqPkFlNwIxcpsLOwew" + }, + "accessibility": { + "title": "Harry Styles - Watermelon Sugar (Official Video) by Harry Styles 6 months ago 3 minutes, 9 seconds 162,235,006 views", + "duration": "3 minutes, 9 seconds" + }, + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "shelfTitle": null + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: Optional[int] = None): + self.searchMode = (True, False, False) + super().__init__(query, limit, language, region, SearchMode.videos, timeout) # type: ignore + + async def next(self) -> Dict[str, Any]: + return await self._nextAsync() # type: ignore + + +class ChannelsSearch(SearchCore): + '''Searches for channels in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = ChannelsSearch('Harry Styles', limit = 1) + >>> result = await search.next() + >>> print(result) + { + "result": [ + { + "type": "channel", + "id": "UCZFWPqqPkFlNwIxcpsLOwew", + "title": "Harry Styles", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhR81ocC_KalYEk5ItnJcfMBqaiIpuM1B0lJyg4Rw=s88-c-k-c0x00ffffff-no-rj-mo", + "width": 88, + "height": 88 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhR81ocC_KalYEk5ItnJcfMBqaiIpuM1B0lJyg4Rw=s176-c-k-c0x00ffffff-no-rj-mo", + "width": 176, + "height": 176 + } + ], + "videoCount": "7", + "descriptionSnippet": null, + "subscribers": "9.25M subscribers", + "link": "https://www.youtube.com/channel/UCZFWPqqPkFlNwIxcpsLOwew" + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: Optional[int] = None): + self.searchMode = (False, True, False) + super().__init__(query, limit, language, region, SearchMode.channels, timeout) # type: ignore + + async def next(self) -> Dict[str, Any]: + return await self._nextAsync() # type: ignore + + +class PlaylistsSearch(SearchCore): + '''Searches for playlists in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = PlaylistsSearch('Harry Styles', limit = 1) + >>> result = await search.next() + >>> print(result) + { + "result": [ + { + "type": "playlist", + "id": "PL-Rt4gIwHnyvxpEl-9Le0ePztR7WxGDGV", + "title": "fine line harry styles full album lyrics", + "videoCount": "12", + "channel": { + "name": "ourmemoriestonight", + "id": "UCZCmb5a8LE9LMxW9I3-BFjA", + "link": "https://www.youtube.com/channel/UCZCmb5a8LE9LMxW9I3-BFjA" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLCdCfOQYMrPImHMObdrMcNimKi1PA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEWCMQBEG5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLDsKmyGH8bkmt9MzZqIoXI4UaduBw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEXCPYBEIoBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD9v7S0KeHLBLr0bF-LrRjYVycUFA", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAIzQIVxZsC0PfvLOt-v9UWJ-109Q", + "width": 336, + "height": 188 + } + ], + "link": "https://www.youtube.com/playlist?list=PL-Rt4gIwHnyvxpEl-9Le0ePztR7WxGDGV" + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: Optional[int] = None): + self.searchMode = (False, False, True) + super().__init__(query, limit, language, region, SearchMode.playlists, timeout) # type: ignore + + async def next(self) -> Dict[str, Any]: + return await self._nextAsync() # type: ignore + +class CustomSearch(SearchCore): + '''Performs custom search in YouTube with search filters or sorting orders. + Few of the predefined filters and sorting orders are: + + 1 - SearchMode.videos + 2 - VideoUploadDateFilter.lastHour + 3 - VideoDurationFilter.long + 4 - VideoSortOrder.viewCount + + There are many other to use. + The value of `sp` parameter in the YouTube search query can be used as a search filter e.g. + `EgQIBRAB` from https://www.youtube.com/results?search_query=NoCopyrightSounds&sp=EgQIBRAB can be passed as `searchPreferences`, to get videos, which are uploaded this year. + + Args: + query (str): Sets the search query. + searchPreferences (str): Sets the `sp` query parameter in the YouTube search request. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = CustomSearch('Harry Styles', VideoSortOrder.viewCount, limit = 1) + >>> result = await search.next() + >>> print(result) + { + "result": [ + { + "type": "video", + "id": "QJO3ROT-A4E", + "title": "One Direction - What Makes You Beautiful (Official Video)", + "publishedTime": "9 years ago", + "duration": "3:27", + "viewCount": { + "text": "1,212,146,802 views", + "short": "1.2B views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/QJO3ROT-A4E/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDeFKrH99gmpnvKyG4czdd__YRDkw", + "width": 360, + "height": 202 + }, + { + "url": "https://i.ytimg.com/vi/QJO3ROT-A4E/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBJ_wUjsRFXGsbvRpwYpSLlsGmbkw", + "width": 720, + "height": 404 + } + ], + "descriptionSnippet": [ + { + "text": "One Direction \u2013 What Makes You Beautiful (Official Video) Follow on Spotify - https://1D.lnk.to/Spotify Listen on Apple Music\u00a0..." + } + ], + "channel": { + "name": "One Direction", + "id": "UCb2HGwORFBo94DmRx4oLzow", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/a-/AOh14Gj3SMvtIAvVNUrHWFTJFubPN7qozzPl5gFkoA=s68-c-k-c0x00ffffff-no-rj-mo", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UCb2HGwORFBo94DmRx4oLzow" + }, + "accessibility": { + "title": "One Direction - What Makes You Beautiful (Official Video) by One Direction 9 years ago 3 minutes, 27 seconds 1,212,146,802 views", + "duration": "3 minutes, 27 seconds" + }, + "link": "https://www.youtube.com/watch?v=QJO3ROT-A4E", + "shelfTitle": null + } + ] + } + ''' + def __init__(self, query: str, searchPreferences: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: Optional[int] = None): + self.searchMode = (True, False, False) if searchPreferences == SearchMode.shorts else (True, True, True) + super().__init__(query, limit, language, region, searchPreferences, timeout) # type: ignore + self.forceShorts = searchPreferences == SearchMode.shorts + + async def next(self) -> Dict[str, Any]: + return await self._nextAsync() # type: ignore + + +class ShortsSearch(CustomSearch): + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: Optional[int] = None): + self.searchMode = (True, False, False) + super().__init__(query, SearchMode.shorts, limit, language, region, timeout) + self.forceShorts = True + +class ChannelSearch(ChannelSearchCore): + '''Searches for videos in specific channel in YouTube. + + Args: + query (str): Sets the search query. + browseId (str): Channel ID + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = ChannelSearch('Watermelon Sugar', "UCZFWPqqPkFlNwIxcpsLOwew") + >>> result = await search.next() + >>> print(result) + { + "result": [ + { + "id": "WMcIfZuRuU8", + "thumbnails": { + "normal": [ + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClFg6C1r5NfTQy7TYUq6X5qHUmPA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAoOyftwY0jLV4geWb5hejULYp3Zw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCdqkhn7JDwLvRtTNx3jq-olz7k-Q", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAhYedsqBFKI0Ra2qzIv9cVoZhfKQ", + "width": 336, + "height": 188 + } + ], + "rich": null + }, + "title": "Harry Styles \u2013 Watermelon Sugar (Lost Tour Visual)", + "descriptionSnippet": "This video is dedicated to touching.\nListen to Harry Styles\u2019 new album \u2018Fine Line\u2019 now: https://HStyles.lnk.to/FineLineAY \n\nFollow Harry Styles:\nFacebook: https://HarryStyles.lnk.to/followFI...", + "uri": "/watch?v=WMcIfZuRuU8", + "views": { + "precise": "3,888,287 views", + "simple": "3.8M views", + "approximate": "3.8 million views" + }, + "duration": { + "simpleText": "2:55", + "text": "2 minutes, 55 seconds" + }, + "published": "10 months ago", + "channel": { + "name": "Harry Styles", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhR81ocC_KalYEk5ItnJcfMBqaiIpuM1B0lJyg4Rw=s88-c-k-c0x00ffffff-no-rj", + "width": 68, + "height": 68 + } + ] + }, + "type": "video" + }, + ] + } + ''' + + def __init__(self, query: str, browseId: str, language: str = 'en', region: str = 'US', searchPreferences: str = "EgZzZWFyY2g%3D", timeout: Optional[int] = None): + super().__init__(query, language, region, searchPreferences, browseId, timeout) # type: ignore diff --git a/platforms/youtube/vendor/youtubesearchpython/__future__/streamurlfetcher.py b/platforms/youtube/vendor/youtubesearchpython/__future__/streamurlfetcher.py new file mode 100644 index 0000000..0492598 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/__future__/streamurlfetcher.py @@ -0,0 +1,140 @@ +from typing import Union +from youtubesearchpython.core.streamurlfetcher import StreamURLFetcherCore + + +class StreamURLFetcher(StreamURLFetcherCore): + '''Gets direct stream URLs for a YouTube video fetched using `Video.get` or `Video.getFormats`. + + This class can fetch direct video URLs without any additional network requests (that's really fast). + + Call `get` or `getAll` method of this class & pass response returned by `Video.get` or `Video.getFormats` as parameter to fetch direct URLs. + Getting URLs or downloading streams using youtube-dl or PyTube is can be a slow, because of the fact that they make requests to fetch the same content, which one might have already recieved at the time of showing it to the user etc. + This class makes use of PyTube (if installed) & makes some slight improvements to functioning of PyTube. + + Call `self.getJavaScript` method before any other method from this class. + Do not call this method more than once & avoid reinstaciating the class. + + Raises: + Exception: "ERROR: PyTube is not installed. To use this functionality of youtube-search-python, PyTube must be installed." + + Examples: + Returns direct stream URL. + + >>> from youtubesearchpython import * + >>> fetcher = StreamURLFetcher() + >>> fetcher.getJavaScript() + >>> video = Video.get("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + >>> url = fetcher.get(video, 251) + >>> print(url) + "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=251&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=10210834&dur=634.601&lmt=1544629945422176&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgKKIEiwQTgXsdKPEyOckgVPs_LMH6KJoeaYmZic_lelECIHXHs1ZnSP5mgtpffNlIMJM3DhxcvDbA-4udFFE6AmVP&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D" + ''' + def __init__(self): + super().__init__() + + async def get(self, videoFormats: dict, itag: int) -> Union[str, None]: + '''Gets direct stream URL for a YouTube video fetched using `Video.get` or `Video.getFormats`. + + Args: + videoFormats (dict): Dictionary returned by `Video.get` or `Video.getFormats`. + itag (int): Itag of the required stream. + + Returns: + Union[str, None]: Returns stream URL as string. None, if no stream is present for that itag. + + Examples: + Returns direct stream URL. + + >>> from youtubesearchpython import * + >>> fetcher = StreamURLFetcher() + >>> await fetcher.getJavaScript() + >>> video = await Video.get("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + >>> url = await fetcher.get(video, 251) + >>> print(url) + "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=251&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=10210834&dur=634.601&lmt=1544629945422176&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgKKIEiwQTgXsdKPEyOckgVPs_LMH6KJoeaYmZic_lelECIHXHs1ZnSP5mgtpffNlIMJM3DhxcvDbA-4udFFE6AmVP&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D" + ''' + self._getDecipheredURLs(videoFormats, itag) + if len(self._streams) == 1: + return self._streams[0]["url"] + return None + + async def getAll(self, videoFormats: dict) -> dict: + '''Gets all stream URLs for a YouTube video fetched using `Video.get` or `Video.getFormats`. + + Args: + videoFormats (dict): Dictionary returned by `Video.get` or `Video.getFormats`. + + Returns: + Union[dict, None]: Returns stream URLs in a dictionary. + + Examples: + Returns direct stream URLs in a dictionary. + + >>> from youtubesearchpython import * + >>> fetcher = StreamURLFetcher() + >>> await fetcher.getJavaScript() + >>> video = await Video.get("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + >>> allURL = await fetcher.getAll(video) + >>> print(allURL) + { + "streams": [ + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=18&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fmp4&ns=AAHB1CvhVqlATtzQj67WHI8F&gir=yes&clen=47526444&ratebypass=yes&dur=634.624&lmt=1544610273905877&mt=1610776131&fvip=6&c=WEB&txp=5531432&n=Laycu1cJ2fCN_K&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIgdjTwmtEc3MpmRxH27ZvTgktL-d2by5HXXGFwo3EGR4MCIQDi0oiI8mshGssiOFu1XzQCqljZuNLhA6z19S8Ig0CRTQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", + "quality": "medium", + "itag": 18, + "bitrate": 599167, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=22&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fmp4&ns=AAHB1CvhVqlATtzQj67WHI8F&ratebypass=yes&dur=634.624&lmt=1544610886483826&mt=1610776131&fvip=6&c=WEB&txp=5532432&n=Laycu1cJ2fCN_K&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALaSHkcx0m9rfqJKoiJT1dY7spIKf-zDfq12SOdN7Ej5AiBCgvcUvLUGqGoMBnc0NIQtDeNM8ETJD2lTt9Bi7T186g%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/mp4; codecs=\"avc1.64001F, mp4a.40.2\"", + "quality": "hd720", + "itag": 22, + "bitrate": 1340380, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=315&aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C308%2C315%2C394%2C395%2C396%2C397%2C398%2C399&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=1648069666&dur=634.566&lmt=1544611995945231&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5532432&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgGaJmx70EkBCsfAYOI1lI695hXnFSEn-ZAfRiqWrnt9ACIQClBT5YZlou5ttgFzKnLZkUKxjZznxMJGPTNvtXCAlebw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/webm; codecs=\"vp9\"", + "quality": "hd2160", + "itag": 315, + "bitrate": 26416339, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=308&aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C308%2C315%2C394%2C395%2C396%2C397%2C398%2C399&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=627075264&dur=634.566&lmt=1544611159960793&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5532432&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALl1_ksmnpBhD49Hgjdg-z-Y4H2AL8hBx63ephvsvhbCAiAFrqyy65MimA4mCXYQBopP67G9dtwH9xyjHS_0hZ-rJA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/webm; codecs=\"vp9\"", + "quality": "hd1440", + "itag": 308, + "bitrate": 13381315, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=134&aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C308%2C315%2C394%2C395%2C396%2C397%2C398%2C399&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fmp4&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=26072934&dur=634.566&lmt=1544609325917976&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5532432&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKT9N5EmUz3OQOc9IA8P1CuYgzPStz4ulJvCkA8Y1Cf4AiEAwwC2mCjOFWD5jFhAu8g0O6EF5fYJ7HmwskN1sjqTHlA%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/mp4; codecs=\"avc1.4d401e\"", + "quality": "medium", + "itag": 134, + "bitrate": 723888, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=249&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=3936299&dur=634.601&lmt=1544629945028066&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAJ_UffgeslE26GFwlMZHBsW-zYLcnanMqrvESdjWoupYAiAH7KlvQlYsokTVCCcD7jflD21Fjiim28qNzhOKZ88D3Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "audio/webm; codecs=\"opus\"", + "quality": "tiny", + "itag": 249, + "bitrate": 57976, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=258&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fmp4&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=30769612&dur=634.666&lmt=1544629837561969&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAP6XrnFm3AHxyk8xjU6mJLdVN-uWLl1ItHk5_ONUiRuPAiEAlEYQBsOoEraFemkJIL7OMyHL9aszxW4CbDlxro-AY3Q%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "audio/mp4; codecs=\"mp4a.40.2\"", + "quality": "tiny", + "itag": 258, + "bitrate": 390017, + "is_otf": false + } + ] + } + ''' + self._getDecipheredURLs(videoFormats) + return {"streams": self._streams} diff --git a/platforms/youtube/vendor/youtubesearchpython/__init__.py b/platforms/youtube/vendor/youtubesearchpython/__init__.py new file mode 100644 index 0000000..55d12e6 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/__init__.py @@ -0,0 +1,24 @@ +from youtubesearchpython.search import ( + Search, + VideosSearch, + ChannelsSearch, + PlaylistsSearch, + CustomSearch, + ChannelSearch, + ShortsSearch +) +from youtubesearchpython.extras import Video, Playlist, Suggestions, Hashtag, Comments, Transcript, Channel +from youtubesearchpython.streamurlfetcher import StreamURLFetcher +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.utils import * + + +__title__ = 'youtube-search-python' +__version__ = '1.6.2' +__author__ = 'sujan rai' +__license__ = 'MIT' + + +''' Deprecated. Present for legacy support. ''' +from youtubesearchpython.legacy import SearchVideos, SearchPlaylists +from youtubesearchpython.legacy import SearchVideos as searchYoutube diff --git a/platforms/youtube/vendor/youtubesearchpython/core/__init__.py b/platforms/youtube/vendor/youtubesearchpython/core/__init__.py new file mode 100644 index 0000000..0a181de --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/__init__.py @@ -0,0 +1,2 @@ +from .video import VideoCore +from .constants import * diff --git a/platforms/youtube/vendor/youtubesearchpython/core/channel.py b/platforms/youtube/vendor/youtubesearchpython/core/channel.py new file mode 100644 index 0000000..fc079bf --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/channel.py @@ -0,0 +1,151 @@ +import copy +import json +from typing import Union, List +from urllib.parse import urlencode + +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.requests import RequestCore +from youtubesearchpython.core.componenthandler import getValue, getVideoId + + +class ChannelCore(RequestCore): + def __init__(self, channel_id: str, request_params: str): + super().__init__() + self.browseId = channel_id + self.params = request_params + self.result = {} + self.continuation = None + + def prepare_request(self): + self.url = 'https://www.youtube.com/youtubei/v1/browse' + "?" + urlencode({ + 'key': searchKey, + "prettyPrint": "false" + }) + self.data = copy.deepcopy(requestPayload) + if not self.continuation: + self.data["params"] = self.params + self.data["browseId"] = self.browseId + else: + self.data["continuation"] = self.continuation + + def playlist_parse(self, i) -> dict: + return { + "id": getValue(i, ["playlistId"]), + "thumbnails": getValue(i, ["thumbnail", "thumbnails"]), + "title": getValue(i, ["title", "runs", 0, "text"]), + "videoCount": getValue(i, ["videoCountShortText", "simpleText"]), + "lastEdited": getValue(i, ["publishedTimeText", "simpleText"]), + } + + def parse_response(self): + response = self.data.json() + + thumbnails = [] + try: + thumbnails.extend(getValue(response, ["header", "c4TabbedHeaderRenderer", "avatar", "thumbnails"])) + except: + pass + try: + thumbnails.extend(getValue(response, ["metadata", "channelMetadataRenderer", "avatar", "thumbnails"])) + except: + pass + try: + thumbnails.extend(getValue(response, ["microformat", "microformatDataRenderer", "thumbnail", "thumbnails"])) + except: + pass + + tabData: dict = {} + playlists: list = [] + + for tab in getValue(response, ["contents", "twoColumnBrowseResultsRenderer", "tabs"]): + tab: dict + title = getValue(tab, ["tabRenderer", "title"]) + if title == "Playlists": + playlist = getValue(tab, + ["tabRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", + "contents", 0, "gridRenderer", "items"]) + if playlist is not None and getValue(playlist, [0, "gridPlaylistRenderer"]): + for i in playlist: + if getValue(i, ["continuationItemRenderer"]): + self.continuation = getValue(i, ["continuationItemRenderer", "continuationEndpoint", + "continuationCommand", "token"]) + break + i: dict = i["gridPlaylistRenderer"] + playlists.append(self.playlist_parse(i)) + elif title == "About": + tabData = tab["tabRenderer"] + + metadata = getValue(tabData, + ["content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, + "channelAboutFullMetadataRenderer"]) + + self.result = { + "id": getValue(response, ["metadata", "channelMetadataRenderer", "externalId"]), + "url": getValue(response, ["metadata", "channelMetadataRenderer", "channelUrl"]), + "description": getValue(response, ["metadata", "channelMetadataRenderer", "description"]), + "title": getValue(response, ["metadata", "channelMetadataRenderer", "title"]), + "banners": getValue(response, ["header", "c4TabbedHeaderRenderer", "banner", "thumbnails"]), + "subscribersCount": { + "text": getValue(response, ["header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText"]), + "short": getValue(response, ["header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText"]), + "label": getValue(response, ["header", "c4TabbedHeaderRenderer", "subscriberCountText", "accessibility", + "accessibilityData", "label"]) + }, + "thumbnails": thumbnails, + "availableCountryCodes": getValue(response, + ["metadata", "channelMetadataRenderer", "availableCountryCodes"]), + "isFamilySafe": getValue(response, ["metadata", "channelMetadataRenderer", "isFamilySafe"]), + "keywords": getValue(response, ["metadata", "channelMetadataRenderer", "keywords"]), + "tags": getValue(response, ["microformat", "microformatDataRenderer", "tags"]), + "views": getValue(metadata, ["viewCountText", "simpleText"]) if metadata else None, + "joinedDate": getValue(metadata, ["joinedDateText", "runs", -1, "text"]) if metadata else None, + "country": getValue(metadata, ["country", "simpleText"]) if metadata else None, + "playlists": playlists, + } + if not self.result["subscribersCount"]["text"]: + self.result["subscribersCount"]["text"] = "No subscribers" + if not self.result["subscribersCount"]["short"]: + self.result["subscribersCount"]["short"] = self.result["subscribersCount"]["text"] + if not self.result["subscribersCount"]["label"]: + self.result["subscribersCount"]["label"] = self.result["subscribersCount"]["text"] + + def parse_next_response(self): + response = self.data.json() + + self.continuation = None + + response = getValue(response, ["onResponseReceivedActions", 0, "appendContinuationItemsAction", "continuationItems"]) + for i in response: + if getValue(i, ["continuationItemRenderer"]): + self.continuation = getValue(i, ["continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"]) + break + elif getValue(i, ['gridPlaylistRenderer']): + self.result["playlists"].append(self.playlist_parse(getValue(i, ['gridPlaylistRenderer']))) + # TODO: Handle other types like gridShowRenderer + + async def async_next(self): + if not self.continuation: + return + self.prepare_request() + self.data = await self.asyncPostRequest() + self.parse_next_response() + + def sync_next(self): + if not self.continuation: + return + self.prepare_request() + self.data = self.syncPostRequest() + self.parse_next_response() + + def has_more_playlists(self): + return self.continuation is not None + + async def async_create(self): + self.prepare_request() + self.data = await self.asyncPostRequest() + self.parse_response() + + def sync_create(self): + self.prepare_request() + self.data = self.syncPostRequest() + self.parse_response() diff --git a/platforms/youtube/vendor/youtubesearchpython/core/channelsearch.py b/platforms/youtube/vendor/youtubesearchpython/core/channelsearch.py new file mode 100644 index 0000000..aaa8397 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/channelsearch.py @@ -0,0 +1,97 @@ +import copy +from typing import Union +import json +from urllib.parse import urlencode + +from youtubesearchpython.core.requests import RequestCore +from youtubesearchpython.handlers.componenthandler import ComponentHandler +from youtubesearchpython.core.constants import * + + +class ChannelSearchCore(RequestCore, ComponentHandler): + response = None + responseSource = None + resultComponents = [] + + def __init__(self, query: str, language: str, region: str, searchPreferences: str, browseId: str, timeout: int): + super().__init__() + self.query = query + self.language = language + self.region = region + self.browseId = browseId + self.searchPreferences = searchPreferences + self.continuationKey = None + self.timeout = timeout + + def sync_create(self): + self._syncRequest() + self._parseChannelSearchSource() + self.response = self._getChannelSearchComponent(self.response) + + async def next(self): + await self._asyncRequest() + self._parseChannelSearchSource() + self.response = self._getChannelSearchComponent(self.response) + return self.response + + def _parseChannelSearchSource(self) -> None: + try: + last_tab = self.response["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][-1] + if 'expandableTabRenderer' in last_tab: + self.response = last_tab["expandableTabRenderer"]["content"]["sectionListRenderer"]["contents"] + else: + tab_renderer = last_tab["tabRenderer"] + if 'content' in tab_renderer: + self.response = tab_renderer["content"]["sectionListRenderer"]["contents"] + else: + self.response = [] + except: + raise Exception('ERROR: Could not parse YouTube response.') + + def _getRequestBody(self): + ''' Fixes #47 ''' + requestBody = copy.deepcopy(requestPayload) + requestBody['query'] = self.query + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + requestBody['params'] = self.searchPreferences + requestBody['browseId'] = self.browseId + self.url = 'https://www.youtube.com/youtubei/v1/browse' + '?' + urlencode({ + 'key': searchKey, + }) + self.data = requestBody + + def _syncRequest(self) -> None: + ''' Fixes #47 ''' + self._getRequestBody() + + request = self.syncPostRequest() + try: + self.response = request.json() + except: + raise Exception('ERROR: Could not make request.') + + async def _asyncRequest(self) -> None: + ''' Fixes #47 ''' + self._getRequestBody() + + request = await self.asyncPostRequest() + try: + self.response = request.json() + except: + raise Exception('ERROR: Could not make request.') + + def result(self, mode: int = ResultMode.dict) -> Union[str, dict]: + '''Returns the search result. + Args: + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + Returns: + Union[str, dict]: Returns JSON or dictionary. + ''' + if mode == ResultMode.json: + return json.dumps({'result': self.response}, indent=4) + elif mode == ResultMode.dict: + return {'result': self.response} + diff --git a/platforms/youtube/vendor/youtubesearchpython/core/comments.py b/platforms/youtube/vendor/youtubesearchpython/core/comments.py new file mode 100644 index 0000000..af2f503 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/comments.py @@ -0,0 +1,204 @@ +import collections +import copy +import itertools +import json +from typing import Iterable, Mapping, Tuple, TypeVar, Union, List +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from youtubesearchpython.core.componenthandler import getVideoId, getValue +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.requests import RequestCore + +K = TypeVar("K") +T = TypeVar("T") + + +class CommentsCore(RequestCore): + result = None + continuationKey = None + isNextRequest = False + response = None + + def __init__(self, videoLink: str): + super().__init__() + self.commentsComponent = {"result": []} + self.responseSource = None + self.videoLink = videoLink + + def prepare_continuation_request(self): + self.data = { + "context": {"client": {"clientName": "WEB", "clientVersion": "2.20210820.01.00"}}, + "videoId": getVideoId(self.videoLink) + } + self.url = f"https://www.youtube.com/youtubei/v1/next?key={searchKey}" + + def prepare_comments_request(self): + self.data = { + "context": {"client": {"clientName": "WEB", "clientVersion": "2.20210820.01.00"}}, + "continuation": self.continuationKey + } + + def parse_source(self): + self.responseSource = getValue(self.response.json(), [ + "onResponseReceivedEndpoints", + 0 if self.isNextRequest else 1, + "appendContinuationItemsAction" if self.isNextRequest else "reloadContinuationItemsCommand", + "continuationItems", + ]) + + def parse_continuation_source(self): + self.continuationKey = getValue( + self.response.json(), + [ + "contents", + "twoColumnWatchNextResults", + "results", + "results", + "contents", + -1, + "itemSectionRenderer", + "contents", + 0, + "continuationItemRenderer", + "continuationEndpoint", + "continuationCommand", + "token", + ] + ) + + def sync_make_comment_request(self): + self.prepare_comments_request() + self.response = self.syncPostRequest() + if self.response.status_code == 200: + self.parse_source() + + def sync_make_continuation_request(self): + self.prepare_continuation_request() + self.response = self.syncPostRequest() + if self.response.status_code == 200: + self.parse_continuation_source() + if not self.continuationKey: + raise Exception("Could not retrieve continuation token") + else: + raise Exception("Status code is not 200") + + async def async_make_comment_request(self): + self.prepare_comments_request() + self.response = await self.asyncPostRequest() + if self.response.status_code == 200: + self.parse_source() + + async def async_make_continuation_request(self): + self.prepare_continuation_request() + self.response = await self.asyncPostRequest() + if self.response.status_code == 200: + self.parse_continuation_source() + if not self.continuationKey: + raise Exception("Could not retrieve continuation token") + else: + raise Exception("Status code is not 200") + + def sync_create(self): + self.sync_make_continuation_request() + self.sync_make_comment_request() + self.__getComponents() + + def sync_create_next(self): + self.isNextRequest = True + self.sync_make_comment_request() + self.__getComponents() + + async def async_create(self): + await self.async_make_continuation_request() + await self.async_make_comment_request() + self.__getComponents() + + async def async_create_next(self): + self.isNextRequest = True + await self.async_make_comment_request() + self.__getComponents() + + def __getComponents(self) -> None: + comments = [] + for comment in self.responseSource: + comment = getValue(comment, ["commentThreadRenderer", "comment", "commentRenderer"]) + #print(json.dumps(comment, indent=4)) + try: + j = { + "id": self.__getValue(comment, ["commentId"]), + "author": { + "id": self.__getValue(comment, ["authorEndpoint", "browseEndpoint", "browseId"]), + "name": self.__getValue(comment, ["authorText", "simpleText"]), + "thumbnails": self.__getValue(comment, ["authorThumbnail", "thumbnails"]) + }, + "content": self.__getValue(comment, ["contentText", "runs", 0, "text"]), + "published": self.__getValue(comment, ["publishedTimeText", "runs", 0, "text"]), + "isLiked": self.__getValue(comment, ["isLiked"]), + "authorIsChannelOwner": self.__getValue(comment, ["authorIsChannelOwner"]), + "voteStatus": self.__getValue(comment, ["voteStatus"]), + "votes": { + "simpleText": self.__getValue(comment, ["voteCount", "simpleText"]), + "label": self.__getValue(comment, ["voteCount", "accessibility", "accessibilityData", "label"]) + }, + "replyCount": self.__getValue(comment, ["replyCount"]), + } + comments.append(j) + except: + pass + + self.commentsComponent["result"].extend(comments) + self.continuationKey = self.__getValue(self.responseSource, [-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"]) + + def __result(self, mode: int) -> Union[dict, str]: + if mode == ResultMode.dict: + return self.commentsComponent + elif mode == ResultMode.json: + return json.dumps(self.commentsComponent, indent=4) + + def __getValue(self, source: dict, path: Iterable[str]) -> Union[str, int, dict, None]: + value = source + for key in path: + if type(key) is str: + if key in value.keys(): + value = value[key] + else: + value = None + break + elif type(key) is int: + if len(value) != 0: + value = value[key] + else: + value = None + break + return value + + def __getAllWithKey(self, source: Iterable[Mapping[K, T]], key: K) -> Iterable[T]: + for item in source: + if key in item: + yield item[key] + + def __getValueEx(self, source: dict, path: List[str]) -> Iterable[Union[str, int, dict, None]]: + if len(path) <= 0: + yield source + return + key = path[0] + upcoming = path[1:] + if key is None: + following_key = upcoming[0] + upcoming = upcoming[1:] + if following_key is None: + raise Exception("Cannot search for a key twice consecutive or at the end with no key given") + values = self.__getAllWithKey(source, following_key) + for val in values: + yield from self.__getValueEx(val, path=upcoming) + else: + val = self.__getValue(source, path=[key]) + yield from self.__getValueEx(val, path=upcoming) + + def __getFirstValue(self, source: dict, path: Iterable[str]) -> Union[str, int, dict, None]: + values = self.__getValueEx(source, list(path)) + for val in values: + if val is not None: + return val + return None diff --git a/platforms/youtube/vendor/youtubesearchpython/core/componenthandler.py b/platforms/youtube/vendor/youtubesearchpython/core/componenthandler.py new file mode 100644 index 0000000..d5c883a --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/componenthandler.py @@ -0,0 +1,33 @@ +from typing import Union, List + + +def getValue(source: dict, path: List[str]) -> Union[str, int, dict, None]: + value = source + for key in path: + if type(key) is str: + if key in value.keys(): + value = value[key] + else: + value = None + break + elif type(key) is int: + if len(value) != 0: + value = value[key] + else: + value = None + break + return value + + +def getVideoId(videoLink: str) -> str: + if 'youtu.be' in videoLink: + if videoLink[-1] == '/': + return videoLink.split('/')[-2] + return videoLink.split('/')[-1] + elif 'youtube.com' in videoLink: + if '&' not in videoLink: + return videoLink[videoLink.index('v=') + 2:] + return videoLink[videoLink.index('v=') + 2: videoLink.index('&')] + else: + return videoLink + diff --git a/platforms/youtube/vendor/youtubesearchpython/core/constants.py b/platforms/youtube/vendor/youtubesearchpython/core/constants.py new file mode 100644 index 0000000..fbb083d --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/constants.py @@ -0,0 +1,93 @@ +requestPayload = { + "context": { + "client": { + "clientName": "WEB", + "clientVersion": "2.20250514.01.00", + "newVisitorCookie": True, + }, + "user": { + "lockedSafetyMode": False, + } + } +} +userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' + + +videoElementKey = 'videoRenderer' +channelElementKey = 'channelRenderer' +playlistElementKey = 'playlistRenderer' +shelfElementKey = 'shelfRenderer' +itemSectionKey = 'itemSectionRenderer' +continuationItemKey = 'continuationItemRenderer' +playerResponseKey = 'playerResponse' +richItemKey = 'richItemRenderer' +hashtagElementKey = 'hashtagTileRenderer' +hashtagBrowseKey = 'FEhashtag' +hashtagVideosPath = ['contents', 'twoColumnBrowseResultsRenderer', 'tabs', 0, 'tabRenderer', 'content', 'richGridRenderer', 'contents'] +hashtagContinuationVideosPath = ['onResponseReceivedActions', 0, 'appendContinuationItemsAction', 'continuationItems'] +searchKey = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' +contentPath = ['contents', 'twoColumnSearchResultsRenderer', 'primaryContents', 'sectionListRenderer', 'contents'] +fallbackContentPath = ['contents', 'twoColumnSearchResultsRenderer', 'primaryContents', 'richGridRenderer', 'contents'] +continuationContentPath = ['onResponseReceivedCommands', 0, 'appendContinuationItemsAction', 'continuationItems'] +continuationKeyPath = ['continuationItemRenderer', 'continuationEndpoint', 'continuationCommand', 'token'] +playlistInfoPath = ['response', 'sidebar', 'playlistSidebarRenderer', 'items'] +playlistVideosPath = ['response', 'contents', 'twoColumnBrowseResultsRenderer', 'tabs', 0, 'tabRenderer', 'content', 'sectionListRenderer', 'contents', 0, 'itemSectionRenderer', 'contents', 0, 'playlistVideoListRenderer', 'contents'] +playlistPrimaryInfoKey = 'playlistSidebarPrimaryInfoRenderer' +playlistSecondaryInfoKey = 'playlistSidebarSecondaryInfoRenderer' +playlistVideoKey = 'playlistVideoRenderer' + + +class ResultMode: + json = 0 + dict = 1 + + +class SearchMode: + all = None + videos = 'EgIQAQ%3D%3D' + shorts = 'EgIQCQ%3D%3D' + channels = 'EgIQAg%3D%3D' + playlists = 'EgIQAw%3D%3D' + movies = 'EgIQBA%3D%3D' + livestreams = 'EgJAAQ%3D%3D' + + +class VideoUploadDateFilter: + today = 'EgIIAg%3D%3D' + thisWeek = 'EgIIAw%3D%3D' + thisMonth = 'EgIIBA%3D%3D' + thisYear = 'EgIIBQ%3D%3D' + + +class VideoDurationFilter: + under4Minutes = 'EgIYBA%3D%3D' + between4And20Minutes = 'EgIYBQ%3D%3D' + over20Minutes = 'EgIYAg%3D%3D' + short = under4Minutes + medium = between4And20Minutes + long = over20Minutes + + +class VideoSortOrder: + relevance = '' + popularity = 'CAM%3D' + viewCount = popularity + + +class VideoFeature: + live = 'EgJAAQ%3D%3D' + fourK = 'EgJwAQ%3D%3D' + hd = 'EgIgAQ%3D%3D' + subtitles = 'EgIoAQ%3D%3D' + creativeCommons = 'EgIwAQ%3D%3D' + spherical360 = 'EgJ4AQ%3D%3D' + vr180 = 'EgPQAQE%3D' + threeD = 'EgI4AQ%3D%3D' + hdr = 'EgPIAQE%3D' + location = 'EgO4AQE%3D' + purchased = 'EgJIAQ%3D%3D' + + +class ChannelRequestType: + info = "EgVhYm91dA%3D%3D" + playlists = "EglwbGF5bGlzdHMYAyABcAA%3D" diff --git a/platforms/youtube/vendor/youtubesearchpython/core/hashtag.py b/platforms/youtube/vendor/youtubesearchpython/core/hashtag.py new file mode 100644 index 0000000..8c79f8a --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/hashtag.py @@ -0,0 +1,193 @@ +import copy +import json +from typing import Union +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +import httpx + +from youtubesearchpython.core.constants import * +from youtubesearchpython.handlers.componenthandler import ComponentHandler + + +class HashtagCore(ComponentHandler): + response = None + resultComponents = [] + + def __init__(self, hashtag: str, limit: int, language: str, region: str, timeout: int): + self.hashtag = hashtag + self.limit = limit + self.language = language + self.region = region + self.timeout = timeout + self.continuationKey = None + self.params = None + + def sync_create(self): + self._getParams() + self._makeRequest() + self._getComponents() + + def result(self, mode: int = ResultMode.dict) -> Union[str, dict]: + '''Returns the hashtag videos. + Args: + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + Returns: + Union[str, dict]: Returns JSON or dictionary. + ''' + if mode == ResultMode.json: + return json.dumps({'result': self.resultComponents}, indent = 4) + elif mode == ResultMode.dict: + return {'result': self.resultComponents} + + def next(self) -> bool: + '''Gets the videos from the next page. Call result + Returns: + bool: Returns True if getting more results was successful. + ''' + self.response = None + self.resultComponents = [] + if self.continuationKey: + self._makeRequest() + self._getComponents() + if self.resultComponents: + return True + return False + + def _getParams(self) -> None: + requestBody = copy.deepcopy(requestPayload) + requestBody['query'] = "#" + self.hashtag + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + requestBodyBytes = json.dumps(requestBody).encode('utf_8') + request = Request( + 'https://www.youtube.com/youtubei/v1/search' + '?' + urlencode({ + 'key': searchKey, + }), + data = requestBodyBytes, + headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': len(requestBodyBytes), + 'User-Agent': userAgent, + } + ) + try: + response = urlopen(request, timeout=self.timeout).read().decode('utf_8') + except: + raise Exception('ERROR: Could not make request.') + content = self._getValue(json.loads(response), contentPath) + for item in self._getValue(content, [0, 'itemSectionRenderer', 'contents']): + if hashtagElementKey in item.keys(): + self.params = self._getValue(item[hashtagElementKey], ['onTapCommand', 'browseEndpoint', 'params']) + return + + async def _asyncGetParams(self) -> None: + requestBody = copy.deepcopy(requestPayload) + requestBody['query'] = "#" + self.hashtag + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + try: + async with httpx.AsyncClient() as client: + response = await client.post( + 'https://www.youtube.com/youtubei/v1/search', + params = { + 'key': searchKey, + }, + headers = { + 'User-Agent': userAgent, + }, + json = requestBody, + timeout = self.timeout + ) + response = response.json() + except: + raise Exception('ERROR: Could not make request.') + content = self._getValue(response, contentPath) + for item in self._getValue(content, [0, 'itemSectionRenderer', 'contents']): + if hashtagElementKey in item.keys(): + self.params = self._getValue(item[hashtagElementKey], ['onTapCommand', 'browseEndpoint', 'params']) + return + + def _makeRequest(self) -> None: + if self.params == None: + return + requestBody = copy.deepcopy(requestPayload) + requestBody['browseId'] = hashtagBrowseKey + requestBody['params'] = self.params + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + if self.continuationKey: + requestBody['continuation'] = self.continuationKey + requestBodyBytes = json.dumps(requestBody).encode('utf_8') + request = Request( + 'https://www.youtube.com/youtubei/v1/browse' + '?' + urlencode({ + 'key': searchKey, + }), + data = requestBodyBytes, + headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': len(requestBodyBytes), + 'User-Agent': userAgent, + } + ) + try: + self.response = urlopen(request, timeout=self.timeout).read().decode('utf_8') + except: + raise Exception('ERROR: Could not make request.') + + async def _asyncMakeRequest(self) -> None: + if self.params == None: + return + requestBody = copy.deepcopy(requestPayload) + requestBody['browseId'] = hashtagBrowseKey + requestBody['params'] = self.params + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + if self.continuationKey: + requestBody['continuation'] = self.continuationKey + try: + async with httpx.AsyncClient() as client: + response = await client.post( + 'https://www.youtube.com/youtubei/v1/browse', + params = { + 'key': searchKey, + }, + headers = { + 'User-Agent': userAgent, + }, + json = requestBody, + timeout = self.timeout + ) + self.response = response.content + except: + raise Exception('ERROR: Could not make request.') + + def _getComponents(self) -> None: + if self.response == None: + return + self.resultComponents = [] + try: + if not self.continuationKey: + responseSource = self._getValue(json.loads(self.response), hashtagVideosPath) + else: + responseSource = self._getValue(json.loads(self.response), hashtagContinuationVideosPath) + if responseSource: + for element in responseSource: + if richItemKey in element.keys(): + richItemElement = self._getValue(element, [richItemKey, 'content']) + if videoElementKey in richItemElement.keys(): + videoComponent = self._getVideoComponent(richItemElement) + self.resultComponents.append(videoComponent) + if len(self.resultComponents) >= self.limit: + break + self.continuationKey = self._getValue(responseSource[-1], continuationKeyPath) + except: + raise Exception('ERROR: Could not parse YouTube response.') \ No newline at end of file diff --git a/platforms/youtube/vendor/youtubesearchpython/core/playlist.py b/platforms/youtube/vendor/youtubesearchpython/core/playlist.py new file mode 100644 index 0000000..f18649f --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/playlist.py @@ -0,0 +1,443 @@ +import collections +import copy +import itertools +import json +import re +from typing import Iterable, Mapping, Tuple, TypeVar, Union, List +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.requests import RequestCore + + +K = TypeVar("K") +T = TypeVar("T") + + +class PlaylistCore(RequestCore): + playlistComponent = None + result = None + continuationKey = None + + def __init__(self, playlistLink: str, componentMode: str, resultMode: int, timeout: int): + super().__init__() + self.componentMode = componentMode + self.resultMode = resultMode + self.timeout = timeout + self.url = playlistLink + + def post_processing(self): + self.__parseSource() + self.__getComponents() + if self.resultMode == ResultMode.json: + self.result = json.dumps(self.playlistComponent, indent=4) + else: + self.result = self.playlistComponent + + def sync_create(self): + statusCode = self.__makeRequest() + if statusCode == 200: + self.post_processing() + else: + raise Exception('ERROR: Invalid status code.') + + async def async_create(self): + # Why do I use sync request in a async function, you might ask + # Well, there were some problems with httpx. + # Until I solve those problems, it is going to stay this way. + statusCode = await self.__makeAsyncRequest() + if statusCode == 200: + self.post_processing() + else: + raise Exception('ERROR: Invalid status code.') + + def next_post_processing(self): + self.__parseSource() + self.__getNextComponents() + if self.resultMode == ResultMode.json: + self.result = json.dumps(self.playlistComponent, indent=4) + else: + self.result = self.playlistComponent + + def _next(self): + self.prepare_next_request() + if self.continuationKey: + statusCode = self.syncPostRequest() + self.response = statusCode.text + if statusCode.status_code == 200: + self.next_post_processing() + else: + raise Exception('ERROR: Invalid status code.') + + async def _async_next(self): + if self.continuationKey: + self.prepare_next_request() + statusCode = await self.asyncPostRequest() + self.response = statusCode.text + if statusCode.status_code == 200: + self.next_post_processing() + else: + raise Exception('ERROR: Invalid status code.') + else: + await self.async_create() + + def prepare_first_request(self): + self.url.strip('/') + + id = re.search(r"(?<=list=)([a-zA-Z0-9+/=_-]+)", self.url).group() + browseId = "VL" + id if not id.startswith("VL") else id + + self.url = 'https://www.youtube.com/youtubei/v1/browse' + '?' + urlencode({ + 'key': searchKey, + }) + self.data = { + "browseId": browseId, + } + self.data.update(copy.deepcopy(requestPayload)) + + def __makeRequest(self) -> int: + self.prepare_first_request() + request = self.syncPostRequest() + self.response = request.text + return request.status_code + + async def __makeAsyncRequest(self) -> int: + self.prepare_first_request() + request = await self.asyncPostRequest() + self.response = request.text + return request.status_code + + def prepare_next_request(self): + requestBody = copy.deepcopy(requestPayload) + requestBody['continuation'] = self.continuationKey + self.data = requestBody + self.url = 'https://www.youtube.com/youtubei/v1/browse' + '?' + urlencode({ + 'key': searchKey, + }) + + def __makeNextRequest(self) -> int: + response = self.syncPostRequest() + try: + self.response = response.text + return response.status_code + except: + raise Exception('ERROR: Could not make request.') + + def __parseSource(self) -> None: + try: + self.responseSource = json.loads(self.response) + except: + raise Exception('ERROR: Could not parse YouTube response.') + + def __getComponents(self) -> None: + sidebar = self.responseSource["sidebar"]["playlistSidebarRenderer"]["items"] + inforenderer = sidebar[0]["playlistSidebarPrimaryInfoRenderer"] + channel_details_available = len(sidebar) != 1 + channelrenderer = sidebar[1]["playlistSidebarSecondaryInfoRenderer"]["videoOwner"]["videoOwnerRenderer"] if channel_details_available else None + videorenderer: list = self.__getFirstValue(self.responseSource, ["contents", "twoColumnBrowseResultsRenderer", "tabs", None, "tabRenderer", "content", "sectionListRenderer", "contents", None, "itemSectionRenderer", "contents", None, "playlistVideoListRenderer", "contents"]) + videos = [] + for video in videorenderer: + try: + video = video["playlistVideoRenderer"] + j = { + "id": self.__getValue(video, ["videoId"]), + "thumbnails": self.__getValue(video, ["thumbnail", "thumbnails"]), + "title": self.__getText(self.__getValue(video, ["title"])), + "channel": { + "name": self.__getText(self.__getValue(video, ["shortBylineText"])), + "id": self.__getValue(video, ["shortBylineText", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId"]), + "link": self.__getCanonicalUrl(self.__getValue(video, ["shortBylineText", "runs", 0, "navigationEndpoint"])), + }, + "duration": self.__getText(self.__getValue(video, ["lengthText"])), + "accessibility": { + "title": self.__getValue(video, ["title", "accessibility", "accessibilityData", "label"]), + "duration": self.__getValue(video, ["lengthText", "accessibility", "accessibilityData", "label"]), + }, + "link": self.__getCanonicalUrl(self.__getValue(video, ["navigationEndpoint"])), + "isPlayable": self.__getValue(video, ["isPlayable"]), + } + videos.append(self.__sanitizeVideo(j)) + except: + pass + + view_count_text = self.__getText(self.__getValue(inforenderer, ["stats", 1])) + video_count_text = self.__getText(self.__getValue(inforenderer, ["stats", 0])) + description = self.__getText(self.__getValue(inforenderer, ["description"])) + playlistElement = { + 'info': { + "id": self.__getValue(inforenderer, ["title", "runs", 0, "navigationEndpoint", "watchEndpoint", "playlistId"]), + "thumbnails": self.__getValue(inforenderer, ["thumbnailRenderer", "playlistVideoThumbnailRenderer", "thumbnail", "thumbnails"]), + "title": self.__getText(self.__getValue(inforenderer, ["title"])), + "description": description, + "videoCount": video_count_text, + "viewCount": { + "text": view_count_text, + "short": view_count_text, + }, + "link": self.__getValue(self.responseSource, ["microformat", "microformatDataRenderer", "urlCanonical"]), + "channel": { + "id": self.__getValue(channelrenderer, ["title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId"]) if channel_details_available else None, + "name": self.__getText(self.__getValue(channelrenderer, ["title"])) if channel_details_available else None, + "detailsAvailable": channel_details_available, + "link": self.__getCanonicalUrl(self.__getValue(channelrenderer, ["title", "runs", 0, "navigationEndpoint"])) if channel_details_available else None, + "thumbnails": self.__getValue(channelrenderer, ["thumbnail", "thumbnails"]) if channel_details_available else None, + } + }, + 'videos': videos, + } + playlistElement['info'] = self.__sanitizeInfo(playlistElement['info']) + if self.componentMode == "getInfo": + self.playlistComponent = playlistElement["info"] + elif self.componentMode == "getVideos": + self.playlistComponent = {"videos": videos} + else: + self.playlistComponent = playlistElement + self.continuationKey = self.__getValue(videorenderer, [-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"]) + + def __getNextComponents(self) -> None: + self.continuationKey = None + playlistComponent = { + 'videos': [], + } + continuationElements = self.__getValue(self.responseSource, + ['onResponseReceivedActions', 0, 'appendContinuationItemsAction', + 'continuationItems']) + if continuationElements is None: + # YouTube Backend issue - See https://github.com/alexmercerind/youtube-search-python/issues/157 + return + for videoElement in continuationElements: + if playlistVideoKey in videoElement.keys(): + videoComponent = { + 'id': self.__getValue(videoElement, [playlistVideoKey, 'videoId']), + 'title': self.__getText(self.__getValue(videoElement, [playlistVideoKey, 'title'])), + 'thumbnails': self.__getValue(videoElement, [playlistVideoKey, 'thumbnail', 'thumbnails']), + 'link': self.__getCanonicalUrl(self.__getValue(videoElement, [playlistVideoKey, "navigationEndpoint"])), + 'channel': { + 'name': self.__getText(self.__getValue(videoElement, [playlistVideoKey, 'shortBylineText'])), + 'id': self.__getValue(videoElement, + [playlistVideoKey, 'shortBylineText', 'runs', 0, 'navigationEndpoint', + 'browseEndpoint', 'browseId']), + "link": self.__getCanonicalUrl(self.__getValue(videoElement, [playlistVideoKey, "shortBylineText", "runs", 0, "navigationEndpoint"])) + }, + 'duration': self.__getText(self.__getValue(videoElement, [playlistVideoKey, 'lengthText'])), + 'accessibility': { + 'title': self.__getValue(videoElement, + [playlistVideoKey, 'title', 'accessibility', 'accessibilityData', + 'label']), + 'duration': self.__getValue(videoElement, [playlistVideoKey, 'lengthText', 'accessibility', + 'accessibilityData', 'label']), + }, + } + playlistComponent['videos'].append( + self.__sanitizeVideo(videoComponent) + ) + self.continuationKey = self.__getValue(videoElement, continuationKeyPath) + self.playlistComponent["videos"].extend(playlistComponent['videos']) + + def __getPlaylistComponent(self, element: dict, mode: str) -> dict: + playlistComponent = {} + if mode in ['getInfo', None]: + for infoElement in element['info']: + if playlistPrimaryInfoKey in infoElement.keys(): + component = { + 'id': self.__getValue(infoElement, + [playlistPrimaryInfoKey, 'title', 'runs', 0, 'navigationEndpoint', + 'watchEndpoint', 'playlistId']), + 'title': self.__getValue(infoElement, [playlistPrimaryInfoKey, 'title', 'runs', 0, 'text']), + 'videoCount': self.__getValue(infoElement, + [playlistPrimaryInfoKey, 'stats', 0, 'runs', 0, 'text']), + 'viewCount': self.__getValue(infoElement, [playlistPrimaryInfoKey, 'stats', 1, 'simpleText']), + 'thumbnails': self.__getValue(infoElement, [playlistPrimaryInfoKey, 'thumbnailRenderer', + 'playlistVideoThumbnailRenderer', 'thumbnail']), + } + if not component['thumbnails']: + component['thumbnails'] = self.__getValue(infoElement, + [playlistPrimaryInfoKey, 'thumbnailRenderer', + 'playlistCustomThumbnailRenderer', 'thumbnail', + 'thumbnails']), + component['link'] = 'https://www.youtube.com/playlist?list=' + component['id'] + playlistComponent.update(component) + if playlistSecondaryInfoKey in infoElement.keys(): + component = { + 'channel': { + 'name': self.__getValue(infoElement, + [playlistSecondaryInfoKey, 'videoOwner', 'videoOwnerRenderer', + 'title', 'runs', 0, 'text']), + 'id': self.__getValue(infoElement, + [playlistSecondaryInfoKey, 'videoOwner', 'videoOwnerRenderer', + 'title', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', + 'browseId']), + 'thumbnails': self.__getValue(infoElement, + [playlistSecondaryInfoKey, 'videoOwner', 'videoOwnerRenderer', + 'thumbnail', 'thumbnails']), + }, + } + component['channel']['link'] = 'https://www.youtube.com/channel/' + component['channel']['id'] + playlistComponent.update(component) + if mode in ['getVideos', None]: + self.continuationKey = None + playlistComponent['videos'] = [] + for videoElement in element['videos']: + if playlistVideoKey in videoElement.keys(): + videoComponent = { + 'id': self.__getValue(videoElement, [playlistVideoKey, 'videoId']), + 'title': self.__getValue(videoElement, [playlistVideoKey, 'title', 'runs', 0, 'text']), + 'thumbnails': self.__getValue(videoElement, [playlistVideoKey, 'thumbnail', 'thumbnails']), + 'channel': { + 'name': self.__getValue(videoElement, + [playlistVideoKey, 'shortBylineText', 'runs', 0, 'text']), + 'id': self.__getValue(videoElement, + [playlistVideoKey, 'shortBylineText', 'runs', 0, 'navigationEndpoint', + 'browseEndpoint', 'browseId']), + }, + 'duration': self.__getValue(videoElement, [playlistVideoKey, 'lengthText', 'simpleText']), + 'accessibility': { + 'title': self.__getValue(videoElement, + [playlistVideoKey, 'title', 'accessibility', 'accessibilityData', + 'label']), + 'duration': self.__getValue(videoElement, [playlistVideoKey, 'lengthText', 'accessibility', + 'accessibilityData', 'label']), + }, + } + videoComponent['link'] = 'https://www.youtube.com/watch?v=' + videoComponent['id'] + videoComponent['channel']['link'] = 'https://www.youtube.com/channel/' + videoComponent['channel'][ + 'id'] + playlistComponent['videos'].append( + videoComponent + ) + if continuationItemKey in videoElement.keys(): + self.continuationKey = self.__getValue(videoElement, continuationKeyPath) + return playlistComponent + + def __result(self, mode: int) -> Union[dict, str]: + if mode == ResultMode.dict: + return self.playlistComponent + elif mode == ResultMode.json: + return json.dumps(self.playlistComponent, indent=4) + + def __getText(self, source): + if source is None: + return None + if isinstance(source, str): + return source + if isinstance(source, dict): + if isinstance(source.get('simpleText'), str): + return source['simpleText'] + if isinstance(source.get('content'), str): + return source['content'] + runs = source.get('runs') + if isinstance(runs, list): + parts = [] + for run in runs: + text = self.__getText(run) + if text: + parts.append(text) + if parts: + return ''.join(parts) + text = source.get('text') + if text: + return self.__getText(text) + return None + + def __getCanonicalUrl(self, endpoint: dict) -> Union[str, None]: + if endpoint is None: + return None + url = self.__getValue(endpoint, ['commandMetadata', 'webCommandMetadata', 'url']) + if isinstance(url, str): + return self.__normalizeUrl(url) + url = self.__getValue(endpoint, ['browseEndpoint', 'canonicalBaseUrl']) + if isinstance(url, str): + return self.__normalizeUrl(url) + return None + + def __normalizeUrl(self, url): + if not isinstance(url, str) or not url: + return None + if url.startswith('//'): + return 'https:' + url + if url.startswith('/'): + return 'https://www.youtube.com' + url + if url.startswith('http://www.youtube.com'): + return 'https://' + url[len('http://'):] + return url + + def __defaultText(self, value, fallback): + if isinstance(value, str) and value.strip(): + return value + return fallback + + def __sanitizeChannel(self, channel): + channel['id'] = self.__defaultText(channel.get('id'), 'No channel id') + channel['name'] = self.__defaultText(channel.get('name'), 'No channel') + channel['link'] = self.__defaultText(self.__normalizeUrl(channel.get('link')), 'No channel link') + channel['thumbnails'] = channel.get('thumbnails') or [] + return channel + + def __sanitizeVideo(self, video): + video['id'] = self.__defaultText(video.get('id'), 'No id') + video['title'] = self.__defaultText(video.get('title'), 'No title') + video['thumbnails'] = video.get('thumbnails') or [] + video['link'] = self.__defaultText(self.__normalizeUrl(video.get('link')), 'No link') + video['duration'] = self.__defaultText(video.get('duration'), 'No duration') + video['channel'] = self.__sanitizeChannel(video.get('channel') or {}) + return video + + def __sanitizeInfo(self, info): + info['id'] = self.__defaultText(info.get('id'), 'No id') + info['title'] = self.__defaultText(info.get('title'), 'No title') + info['description'] = self.__defaultText(info.get('description'), 'No description') + info['videoCount'] = self.__defaultText(info.get('videoCount'), 'No videos') + view_text = self.__defaultText((info.get('viewCount') or {}).get('text') if isinstance(info.get('viewCount'), dict) else None, 'No views') + info['viewCount'] = {'text': view_text, 'short': view_text} + info['thumbnails'] = info.get('thumbnails') or [] + info['link'] = self.__defaultText(self.__normalizeUrl(info.get('link')), 'No link') + info['channel'] = self.__sanitizeChannel(info.get('channel') or {}) + return info + + def __getValue(self, source: dict, path: Iterable[str]) -> Union[str, int, dict, None]: + value = source + for key in path: + if type(key) is str: + if key in value.keys(): + value = value[key] + else: + value = None + break + elif type(key) is int: + if len(value) != 0: + value = value[key] + else: + value = None + break + return value + + def __getAllWithKey(self, source: Iterable[Mapping[K, T]], key: K) -> Iterable[T]: + for item in source: + if key in item: + yield item[key] + + def __getValueEx(self, source: dict, path: List[str]) -> Iterable[Union[str, int, dict, None]]: + if len(path) <= 0: + yield source + return + key = path[0] + upcoming = path[1:] + if key is None: + following_key = upcoming[0] + upcoming = upcoming[1:] + if following_key is None: + raise Exception("Cannot search for a key twice consecutive or at the end with no key given") + values = self.__getAllWithKey(source, following_key) + for val in values: + yield from self.__getValueEx(val, path=upcoming) + else: + val = self.__getValue(source, path=[key]) + yield from self.__getValueEx(val, path=upcoming) + + def __getFirstValue(self, source: dict, path: Iterable[str]) -> Union[str, int, dict, list, None]: + values = self.__getValueEx(source, list(path)) + for val in values: + if val is not None: + return val + return None diff --git a/platforms/youtube/vendor/youtubesearchpython/core/requests.py b/platforms/youtube/vendor/youtubesearchpython/core/requests.py new file mode 100644 index 0000000..3b3ad1d --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/requests.py @@ -0,0 +1,50 @@ +import httpx +import os + +from youtubesearchpython.core.constants import userAgent + +class RequestCore: + def __init__(self): + self.url = None + self.data = None + self.timeout = 2 + self.proxy = None + http_proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") + https_proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") + + proxy_url = https_proxy or http_proxy + if proxy_url: + self.proxy = proxy_url + + def _get_client_kwargs(self): + kwargs = { + "headers": {"User-Agent": userAgent}, + "timeout": self.timeout, + } + if self.proxy: + kwargs["proxy"] = self.proxy + return kwargs + + def syncPostRequest(self) -> httpx.Response: + client_kwargs = self._get_client_kwargs() + return httpx.post( + self.url, + json=self.data, + **client_kwargs + ) + + async def asyncPostRequest(self) -> httpx.Response: + client_kwargs = self._get_client_kwargs() + async with httpx.AsyncClient(**client_kwargs) as client: + r = await client.post(self.url, json=self.data) + return r + + def syncGetRequest(self) -> httpx.Response: + client_kwargs = self._get_client_kwargs() + return httpx.get(self.url, **client_kwargs) + + async def asyncGetRequest(self) -> httpx.Response: + client_kwargs = self._get_client_kwargs() + async with httpx.AsyncClient(**client_kwargs) as client: + r = await client.get(self.url) + return r diff --git a/platforms/youtube/vendor/youtubesearchpython/core/search.py b/platforms/youtube/vendor/youtubesearchpython/core/search.py new file mode 100644 index 0000000..a97d926 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/search.py @@ -0,0 +1,383 @@ +import copy +from typing import Union +from urllib.parse import urlencode +from concurrent.futures import ThreadPoolExecutor + +from youtubesearchpython.core.requests import RequestCore +from youtubesearchpython.handlers.componenthandler import ComponentHandler +from youtubesearchpython.handlers.requesthandler import RequestHandler +from youtubesearchpython.core.constants import * + +import json + + +class SearchCore(RequestCore, RequestHandler, ComponentHandler): + def __init__(self, query: str, limit: int, language: str, region: str, searchPreferences: str, timeout: int): + super().__init__() + self.query = query + self.limit = limit + self.language = language + self.region = region + self.searchPreferences = searchPreferences + self.timeout = timeout + self.response = None + self.responseSource = None + self.resultComponents = [] + self.continuationKey = None + self._channel_cache = {} + self._playlist_cache = {} + self._short_cache = {} + self.forceShorts = False + self.max_workers = 1 + + def sync_create(self): + self._makeRequest() + self._parseSource() + + def _getRequestBody(self): + ''' Fixes #47 ''' + requestBody = copy.deepcopy(requestPayload) + requestBody['query'] = self.query + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + if self.searchPreferences: + requestBody['params'] = self.searchPreferences + if self.continuationKey: + requestBody['continuation'] = self.continuationKey + self.url = 'https://www.youtube.com/youtubei/v1/search' + '?' + urlencode({ + 'key': searchKey, + }) + self.data = requestBody + + def _makeRequest(self) -> None: + self._getRequestBody() + request = self.syncPostRequest() + try: + self.response = request.text + except Exception: + raise Exception('ERROR: Could not make request.') + + async def _makeAsyncRequest(self) -> None: + self._getRequestBody() + request = await self.asyncPostRequest() + try: + self.response = request.text + except Exception: + raise Exception('ERROR: Could not make request.') + + def result(self, mode: int = ResultMode.dict) -> Union[str, dict]: + '''Returns the search result. + + Args: + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Returns: + Union[str, dict]: Returns JSON or dictionary. + ''' + if mode == ResultMode.json: + return json.dumps({'result': self.resultComponents}, indent=4) + elif mode == ResultMode.dict: + return {'result': self.resultComponents} + + def _next(self) -> bool: + '''Gets the subsequent search result. Call result + + Args: + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Returns: + Union[str, dict]: Returns True if getting more results was successful. + ''' + if self.continuationKey: + self.response = None + self.responseSource = None + self.resultComponents = [] + self._makeRequest() + self._parseSource() + # Guard against None responseSource after parsing + if self.responseSource is not None: + self._getComponents(*self.searchMode) + return True + return False + else: + return False + + async def _nextAsync(self) -> dict: + self.response = None + self.responseSource = None + self.resultComponents = [] + await self._makeAsyncRequest() + self._parseSource() + if self.responseSource is not None: + self._getComponents(*self.searchMode) + return { + 'result': self.resultComponents, + } + + def _sync_browse(self, browse_id: str) -> dict: + previous_url = self.url + previous_data = self.data + try: + self.url = 'https://www.youtube.com/youtubei/v1/browse' + '?' + urlencode({ + 'key': searchKey, + }) + request_body = copy.deepcopy(requestPayload) + request_body['browseId'] = browse_id + self.data = request_body + response = self.syncPostRequest() + return json.loads(response.text) + finally: + self.url = previous_url + self.data = previous_data + + def _sync_player(self, video_id: str) -> dict: + previous_url = self.url + previous_data = self.data + try: + self.url = 'https://www.youtube.com/youtubei/v1/player' + '?' + urlencode({ + 'key': searchKey, + }) + request_body = copy.deepcopy(requestPayload) + request_body['videoId'] = video_id + self.data = request_body + response = self.syncPostRequest() + return json.loads(response.text) + finally: + self.url = previous_url + self.data = previous_data + + def _extract_video_count_from_channel_browse(self, response: dict) -> str: + legacy_header_count = self._getText(self._getValue(response, ['header', 'c4TabbedHeaderRenderer', 'videosCountText'])) + if legacy_header_count and 'video' in legacy_header_count.lower(): + return legacy_header_count + rows = self._getValue(response, ['header', 'pageHeaderRenderer', 'content', 'pageHeaderViewModel', 'metadata', 'contentMetadataViewModel', 'metadataRows']) or [] + for row in rows: + parts = self._getValue(row, ['metadataParts']) or [] + for part in parts: + text = self._getText(self._getValue(part, ['text'])) + if text and 'video' in text.lower(): + return text + return 'No videos' + + def _enrich_channel_component(self, component: dict) -> dict: + channel_id = component.get('id') + if not channel_id or channel_id == 'No id': + return component + cached = self._channel_cache.get(channel_id) + if cached is None: + try: + response = self._sync_browse(channel_id) + cached = { + 'videoCount': self._extract_video_count_from_channel_browse(response), + } + except Exception: + cached = { + 'videoCount': component.get('videoCount') or 'No videos', + } + self._channel_cache[channel_id] = cached + component['videoCount'] = cached.get('videoCount') or component.get('videoCount') or 'No videos' + return component + + def _enrich_playlist_component(self, component: dict) -> dict: + playlist_id = component.get('id') + if not playlist_id or playlist_id == 'No id': + return component + cached = self._playlist_cache.get(playlist_id) + if cached is None: + try: + response = self._sync_browse('VL' + playlist_id if not str(playlist_id).startswith('VL') else playlist_id) + primary = self._getValue(response, ['sidebar', 'playlistSidebarRenderer', 'items', 0, 'playlistSidebarPrimaryInfoRenderer']) or {} + secondary = self._getValue(response, ['sidebar', 'playlistSidebarRenderer', 'items', 1, 'playlistSidebarSecondaryInfoRenderer', 'videoOwner', 'videoOwnerRenderer']) or {} + description = self._getText(self._getValue(primary, ['description'])) + view_count = self._getText(self._getValue(primary, ['stats', 1])) + video_count = self._getText(self._getValue(primary, ['stats', 0])) + channel_link = self._getCanonicalUrl(self._getValue(secondary, ['title', 'runs', 0, 'navigationEndpoint'])) + cached = { + 'videoCount': video_count or component.get('videoCount') or 'No videos', + 'viewCount': { + 'text': view_count or 'No views', + 'short': view_count or 'No views', + }, + 'descriptionSnippet': [{'text': description}] if description else component.get('descriptionSnippet') or [], + 'channel': { + 'id': self._getValue(secondary, ['title', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']) or self._getValue(component, ['channel', 'id']), + 'name': self._getText(self._getValue(secondary, ['title'])) or self._getValue(component, ['channel', 'name']), + 'link': channel_link or self._getValue(component, ['channel', 'link']), + 'thumbnails': self._getThumbnailSources(self._getValue(secondary, ['thumbnail'])) or self._getValue(component, ['channel', 'thumbnails']) or [], + } + } + except Exception: + cached = { + 'videoCount': component.get('videoCount') or 'No videos', + 'viewCount': component.get('viewCount') or {'text': 'No views', 'short': 'No views'}, + 'descriptionSnippet': component.get('descriptionSnippet') or [], + 'channel': component.get('channel') or {}, + } + self._playlist_cache[playlist_id] = cached + component['videoCount'] = cached.get('videoCount') or component.get('videoCount') or 'No videos' + component['viewCount'] = cached.get('viewCount') or component.get('viewCount') or {'text': 'No views', 'short': 'No views'} + component['descriptionSnippet'] = cached.get('descriptionSnippet') or component.get('descriptionSnippet') or [] + component['channel'] = cached.get('channel') or component.get('channel') or {} + return self._finalizeComponent(component) + + def _enrich_short_component(self, component: dict) -> dict: + video_id = component.get('id') + if not video_id or video_id == 'No id': + return component + + cached = self._short_cache.get(video_id) + if cached is None: + try: + response = self._sync_player(video_id) + video_details = self._getValue(response, ['videoDetails']) or {} + microformat = self._getValue(response, ['microformat', 'playerMicroformatRenderer']) or {} + channel_id = video_details.get('channelId') + channel_link = self._normalizeUrl(self._getValue(microformat, ['ownerProfileUrl'])) or ('https://www.youtube.com/channel/' + channel_id if channel_id else None) + channel_thumbnails = [] + if channel_id: + try: + channel_response = self._sync_browse(channel_id) + channel_link = ( + self._normalizeUrl(self._getValue(channel_response, ['metadata', 'channelMetadataRenderer', 'vanityChannelUrl'])) + or self._normalizeUrl(self._getValue(channel_response, ['metadata', 'channelMetadataRenderer', 'channelUrl'])) + or channel_link + ) + channel_thumbnails = ( + self._getThumbnailSources(self._getValue(channel_response, ['header', 'c4TabbedHeaderRenderer', 'avatar'])) + or self._getThumbnailSources(self._getValue(channel_response, ['header', 'pageHeaderRenderer', 'content', 'pageHeaderViewModel', 'image', 'decoratedAvatarViewModel', 'avatar', 'avatarViewModel', 'image'])) + or [] + ) + except Exception: + channel_thumbnails = [] + cached = { + 'title': video_details.get('title'), + 'duration': self._seconds_to_timestamp(video_details.get('lengthSeconds')), + 'accessibilityDuration': self._seconds_to_accessibility_duration(video_details.get('lengthSeconds')), + 'viewCount': self._build_view_count(video_details.get('viewCount')), + 'descriptionSnippet': self._description_from_text(video_details.get('shortDescription')), + 'publishedTime': self._iso_to_relative_time(microformat.get('publishDate') or microformat.get('uploadDate')), + 'thumbnails': self._getThumbnailSources(self._getValue(video_details, ['thumbnail'])), + 'channel': { + 'name': video_details.get('author'), + 'id': channel_id, + 'link': channel_link, + 'thumbnails': channel_thumbnails, + }, + 'link': 'https://www.youtube.com/shorts/' + video_id, + } + except Exception: + cached = { + 'title': component.get('title'), + 'duration': component.get('duration'), + 'accessibilityDuration': self._getValue(component, ['accessibility', 'duration']), + 'viewCount': component.get('viewCount'), + 'descriptionSnippet': component.get('descriptionSnippet'), + 'publishedTime': component.get('publishedTime'), + 'thumbnails': component.get('thumbnails'), + 'channel': component.get('channel'), + 'link': component.get('link'), + } + self._short_cache[video_id] = cached + + for field in ('title', 'duration', 'publishedTime', 'link'): + if cached.get(field): + component[field] = cached[field] + + if cached.get('viewCount'): + component['viewCount'] = cached['viewCount'] + if cached.get('descriptionSnippet'): + component['descriptionSnippet'] = cached['descriptionSnippet'] + if cached.get('thumbnails'): + component['thumbnails'] = cached['thumbnails'] + + channel = component.get('channel') or {} + cached_channel = cached.get('channel') or {} + merged_channel = { + 'name': cached_channel.get('name') if self._isMissingValue(channel.get('name')) else channel.get('name'), + 'id': cached_channel.get('id') if self._isMissingValue(channel.get('id')) else channel.get('id'), + 'link': cached_channel.get('link') if self._isMissingValue(channel.get('link')) else channel.get('link'), + 'thumbnails': cached_channel.get('thumbnails') if self._isMissingValue(channel.get('thumbnails')) else channel.get('thumbnails'), + } + component['channel'] = merged_channel + component.setdefault('accessibility', {}) + if self._isMissingValue(self._getValue(component, ['accessibility', 'duration'])) and cached.get('accessibilityDuration'): + component['accessibility']['duration'] = cached['accessibilityDuration'] + component['type'] = 'shorts' + return self._finalizeComponent(component) + + def _getComponents(self, findVideos: bool, findChannels: bool, findPlaylists: bool) -> None: + self.resultComponents = [] + # Safety: ensure responseSource is iterable + if not self.responseSource: + return + + raw_components = [] + + def add_component(component): + if component is None: + return False + raw_components.append(component) + return len(raw_components) >= self.limit + + for element in self.responseSource: + if not isinstance(element, dict): + continue + + if videoElementKey in element.keys() and findVideos: + if add_component(self._getVideoComponent(element)): + break + if channelElementKey in element.keys() and findChannels: + if add_component(self._getChannelComponent(element)): + break + if playlistElementKey in element.keys() and findPlaylists: + if add_component(self._getPlaylistComponent(element)): + break + if 'lockupViewModel' in element.keys() and findPlaylists: + if self._getValue(element, ['lockupViewModel', 'contentType']) == 'LOCKUP_CONTENT_TYPE_PLAYLIST': + if add_component(self._getPlaylistComponent(element)): + break + + if shelfElementKey in element.keys() and findVideos: + shelfComponent = self._getShelfComponent(element) + if shelfComponent and isinstance(shelfComponent, dict): + elements = shelfComponent.get('elements') + if elements and hasattr(elements, '__iter__'): + for shelfElement in elements: + if shelfElement and isinstance(shelfElement, dict): + if add_component(self._getVideoComponent(shelfElement, shelfTitle=shelfComponent.get('title'))): + break + if len(self.resultComponents) >= self.limit: + break + + if richItemKey in element.keys() and findVideos: + richItemElement = self._getValue(element, [richItemKey, 'content']) + if richItemElement and isinstance(richItemElement, dict): + if videoElementKey in richItemElement.keys(): + if add_component(self._getVideoComponent(richItemElement)): + break + + if 'gridShelfViewModel' in element.keys() and findVideos: + for short_component in self._getGridShelfComponents(element): + if add_component(short_component): + break + + if len(raw_components) >= self.limit: + break + + def enrich_component(component): + if component.get('type') == 'channel': + return self._enrich_channel_component(component) + if component.get('type') == 'playlist': + return self._enrich_playlist_component(component) + if component.get('type') == 'shorts': + return self._enrich_short_component(component) + return component + + max_workers = max(1, min(getattr(self, 'max_workers', 1), len(raw_components) or 1)) + if max_workers > 1 and len(raw_components) > 1: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + self.resultComponents = list(executor.map(enrich_component, raw_components)) + else: + self.resultComponents = [enrich_component(component) for component in raw_components] diff --git a/platforms/youtube/vendor/youtubesearchpython/core/streamurlfetcher.py b/platforms/youtube/vendor/youtubesearchpython/core/streamurlfetcher.py new file mode 100644 index 0000000..aa38607 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/streamurlfetcher.py @@ -0,0 +1,147 @@ +import copy +import urllib.request +import urllib.parse + +import re + +from youtubesearchpython.core.constants import ResultMode +from youtubesearchpython.core.video import VideoCore +from youtubesearchpython.core.componenthandler import getValue +from youtubesearchpython.core.requests import RequestCore + +isYtDLPinstalled = False + +try: + from yt_dlp.extractor.youtube import YoutubeBaseInfoExtractor, YoutubeIE + from yt_dlp import YoutubeDL + from yt_dlp.utils import url_or_none, try_get, update_url_query, ExtractorError + + isYtDLPinstalled = True +except: + pass + + +class StreamURLFetcherCore(RequestCore): + ''' + Overrided parent's constructor. + ''' + def __init__(self): + if isYtDLPinstalled: + super().__init__() + self._js_url = None + self._js = None + #self.ytdlp = YoutubeBaseInfoExtractor() + self.ytie = YoutubeIE() + self.ytie.set_downloader(YoutubeDL()) + self._streams = [] + else: + raise Exception('ERROR: yt-dlp is not installed. To use this functionality of youtube-search-python, yt-dlp must be installed.') + + ''' + Saving videoFormats inside a dictionary with key "player_response" for apply_descrambler & apply_signature methods. + ''' + def _getDecipheredURLs(self, videoFormats: dict, formatId: int = None) -> None: + # We reset our stream list + # See https://github.com/alexmercerind/youtube-search-python/pull/155#discussion_r790165920 + # If we don't reset it, then it's going to cache older URLs and as we are using length comparison in upper class + # it would return None, because length is not 1 + self._streams = [] + + self.video_id = videoFormats["id"] + if not videoFormats["streamingData"]: + # Video is age-restricted. Try to retrieve it using ANDROID_EMBED client and override old response. + # This works most time. + vc = VideoCore(self.video_id, None, ResultMode.dict, None, False, overridedClient="TV_EMBED") + vc.sync_create() + videoFormats = vc.result + if not videoFormats["streamingData"]: + # Video is: + # 1. Either age-restricted on so called level 3 + # 2. Needs payment (is only for users that use so called "Join feature") + raise Exception("streamingData is not present in Video.get. This is most likely a age-restricted video") + # We deepcopy a list, otherwise it would duplicate + # See https://github.com/alexmercerind/youtube-search-python/pull/155#discussion_r790165920 + self._player_response = copy.deepcopy(videoFormats["streamingData"]["formats"]) + self._player_response.extend(videoFormats["streamingData"]["adaptiveFormats"]) + self.format_id = formatId + self._decipher() + + def extract_js_url(self, res: str): + if res: + # My modified RegEx derived from yt-dlp, that retrieves JavaScript version + # Source: https://github.com/yt-dlp/yt-dlp/blob/e600a5c90817f4caac221679f6639211bba1f3a2/yt_dlp/extractor/youtube.py#L2258 + player_version = re.search( + r'([0-9a-fA-F]{8})\\?', res) + player_version = player_version.group().replace("\\", "") + self._js_url = f'https://www.youtube.com/s/player/{player_version}/player_ias.vflset/en_US/base.js' + else: + raise Exception("Failed to retrieve JavaScript for this video") + + def _getJS(self) -> None: + # Here we get a JavaScript that links to specific Player JavaScript + self.url = 'https://www.youtube.com/iframe_api' + res = self.syncGetRequest() + self.extract_js_url(res.text) + + async def getJavaScript(self): + # Same as in _getJS(), except it's asynchronous + self.url = 'https://www.youtube.com/iframe_api' + res = await self.asyncGetRequest() + self.extract_js_url(res.text) + + def _decipher(self, retry: bool = False): + if not self._js_url or retry: + self._js_url = None + self._js = None + self._getJS() + try: + # We need to decipher one URL at time. + for yt_format in self._player_response: + # If format_id is specified, then it means that we requested only for one URL (ITAG), thus we can skip + # all other ITAGs, which would take up our precious system resources and our valuable time + if self.format_id == yt_format["itag"] or self.format_id is None: + # If "url" is specified in JSON, it is definitely an unciphered URL. + # Thus we can skip deciphering completely. + if getValue(yt_format, ["url"]): + # This is a non-ciphered URL + yt_format["throttled"] = False + self._streams.append(yt_format) + continue + else: + cipher = yt_format["signatureCipher"] + # Some deciphering magic from yt-dlp + # Source: https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L2972-L2981 + sc = urllib.parse.parse_qs(cipher) + fmt_url = url_or_none(try_get(sc, lambda x: x['url'][0])) + encrypted_sig = try_get(sc, lambda x: x['s'][0]) + if not (sc and fmt_url and encrypted_sig): + # It's not ciphered + yt_format["throttled"] = False + self._streams.append(yt_format) + continue + if not cipher: + continue + signature = self.ytie._decrypt_signature(sc['s'][0], self.video_id, self._js_url) + sp = try_get(sc, lambda x: x['sp'][0]) or 'signature' + fmt_url += '&' + sp + '=' + signature + + # Some magic to unthrottle streams + # Source: https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L2983-L2993 + query = urllib.parse.parse_qs(fmt_url) + throttled = False + if query.get('n'): + try: + fmt_url = update_url_query(fmt_url, { + 'n': self.ytie._decrypt_nsig(query['n'][0], self.video_id, self._js_url)}) + except ExtractorError as e: + throttled = True + yt_format["url"] = fmt_url + yt_format["throttled"] = throttled + self._streams.append(yt_format) + except Exception as e: + if retry: + raise e + ''' + Fetch updated player JavaScript to get new cipher algorithm. + ''' + self._decipher(retry=True) diff --git a/platforms/youtube/vendor/youtubesearchpython/core/suggestions.py b/platforms/youtube/vendor/youtubesearchpython/core/suggestions.py new file mode 100644 index 0000000..a95837f --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/suggestions.py @@ -0,0 +1,102 @@ +import json +from typing import Union +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +import httpx + +from youtubesearchpython.core.constants import ResultMode, userAgent +from youtubesearchpython.core.requests import RequestCore + + +class SuggestionsCore(RequestCore): + '''Gets search suggestions for the given query. + + Args: + language (str, optional): Sets the suggestion language. Defaults to 'en'. + region (str, optional): Sets the suggestion region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> suggestions = Suggestions(language = 'en', region = 'US').get('Harry Styles', mode = ResultMode.json) + >>> print(suggestions) + { + 'result': [ + 'harry styles', + 'harry styles treat people with kindness', + 'harry styles golden music video', + 'harry styles interview', + 'harry styles adore you', + 'harry styles watermelon sugar', + 'harry styles snl', + 'harry styles falling', + 'harry styles tpwk', + 'harry styles sign of the times', + 'harry styles jingle ball 2020', + 'harry styles christmas', + 'harry styles live', + 'harry styles juice' + ] + } + ''' + + def __init__(self, language: str = 'en', region: str = 'US', timeout: int = None): + super().__init__() + self.language = language + self.region = region + self.timeout = timeout + + def _post_request_processing(self, mode): + searchSuggestions = [] + + self.__parseSource() + for element in self.responseSource: + if type(element) is list: + for searchSuggestionElement in element: + searchSuggestions.append(searchSuggestionElement[0]) + break + if mode == ResultMode.dict: + return {'result': searchSuggestions} + elif mode == ResultMode.json: + return json.dumps({'result': searchSuggestions}, indent=4) + + def _get(self, query: str, mode: int = ResultMode.dict) -> Union[dict, str]: + self.url = 'https://clients1.google.com/complete/search' + '?' + urlencode({ + 'hl': self.language, + 'gl': self.region, + 'q': query, + 'client': 'youtube', + 'gs_ri': 'youtube', + 'ds': 'yt', + }) + + self.__makeRequest() + return self._post_request_processing(mode) + + async def _getAsync(self, query: str, mode: int = ResultMode.dict) -> Union[dict, str]: + self.url = 'https://clients1.google.com/complete/search' + '?' + urlencode({ + 'hl': self.language, + 'gl': self.region, + 'q': query, + 'client': 'youtube', + 'gs_ri': 'youtube', + 'ds': 'yt', + }) + + await self.__makeAsyncRequest() + return self._post_request_processing(mode) + + def __parseSource(self) -> None: + try: + self.responseSource = json.loads(self.response[self.response.index('(') + 1: self.response.index(')')]) + except: + raise Exception('ERROR: Could not parse YouTube response.') + + def __makeRequest(self) -> None: + request = self.syncGetRequest() + self.response = request.text + + async def __makeAsyncRequest(self) -> None: + request = await self.asyncGetRequest() + self.response = request.text diff --git a/platforms/youtube/vendor/youtubesearchpython/core/transcript.py b/platforms/youtube/vendor/youtubesearchpython/core/transcript.py new file mode 100644 index 0000000..3b6b186 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/transcript.py @@ -0,0 +1,112 @@ +import copy +import json +from typing import Union, List +from urllib.parse import urlencode + +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.requests import RequestCore +from youtubesearchpython.core.componenthandler import getValue, getVideoId + + + +class TranscriptCore(RequestCore): + def __init__(self, videoLink: str, key: str): + super().__init__() + self.videoLink = videoLink + self.key = key + + def prepare_params_request(self): + self.url = 'https://www.youtube.com/youtubei/v1/next' + "?" + urlencode({ + 'key': searchKey, + "prettyPrint": "false" + }) + self.data = copy.deepcopy(requestPayload) + self.data["videoId"] = getVideoId(self.videoLink) + + def extract_continuation_key(self, r): + j = r.json() + panels = getValue(j, ["engagementPanels"]) + if not panels: + raise Exception("Failed to create first request - No engagementPanels is present.") + key = "" + for panel in panels: + panel = panel["engagementPanelSectionListRenderer"] + if getValue(panel, ["targetId"]) == "engagement-panel-searchable-transcript": + key = getValue(panel, ["content", "continuationItemRenderer", "continuationEndpoint", "getTranscriptEndpoint", "params"]) + if key == "" or not key: + self.result = {"segments": [], "languages": []} + return True + self.key = key + return False + + def prepare_transcript_request(self): + self.url = 'https://www.youtube.com/youtubei/v1/get_transcript' + "?" + urlencode({ + 'key': searchKey, + "prettyPrint": "false" + }) + # clientVersion must be newer than in requestPayload + self.data = { + "context": { + "client": { + "clientName": "WEB", + "clientVersion": "2.20220318.00.00", + "newVisitorCookie": True, + }, + "user": { + "lockedSafetyMode": False, + } + }, + "params": self.key + } + + def extract_transcript(self): + response = self.data.json() + transcripts = getValue(response, ["actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer", "initialSegments"]) + segments = [] + languages = [] + for segment in transcripts: + segment = getValue(segment, ["transcriptSegmentRenderer"]) + j = { + "startMs": getValue(segment, ["startMs"]), + "endMs": getValue(segment, ["endMs"]), + "text": getValue(segment, ["snippet", "runs", 0, "text"]), + "startTime": getValue(segment, ["startTimeText", "simpleText"]) + } + segments.append(j) + langs = getValue(response, ["actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", "content", "transcriptSearchPanelRenderer", "footer", "transcriptFooterRenderer", "languageMenu", "sortFilterSubMenuRenderer", "subMenuItems"]) + if langs: + for language in langs: + j = { + "params": getValue(language, ["continuation", "reloadContinuationData", "continuation"]), + "selected": getValue(language, ["selected"]), + "title": getValue(language, ["title"]) + } + languages.append(j) + self.result = { + "segments": segments, + "languages": languages + } + + async def async_create(self): + if not self.key: + self.prepare_params_request() + r = await self.asyncPostRequest() + end = self.extract_continuation_key(r) + if end: + return + self.prepare_transcript_request() + self.data = await self.asyncPostRequest() + self.extract_transcript() + + def sync_create(self): + if not self.key: + self.prepare_params_request() + r = self.syncPostRequest() + end = self.extract_continuation_key(r) + if end: + return + self.prepare_transcript_request() + self.data = self.syncPostRequest() + self.extract_transcript() + + diff --git a/platforms/youtube/vendor/youtubesearchpython/core/utils.py b/platforms/youtube/vendor/youtubesearchpython/core/utils.py new file mode 100644 index 0000000..cb1ab01 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/utils.py @@ -0,0 +1,3 @@ +def playlist_from_channel_id(channel_id: str) -> str: + list_id = "UU" + channel_id[2:] + return f"https://www.youtube.com/playlist?list={list_id}" diff --git a/platforms/youtube/vendor/youtubesearchpython/core/video.py b/platforms/youtube/vendor/youtubesearchpython/core/video.py new file mode 100644 index 0000000..8ad4b86 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/core/video.py @@ -0,0 +1,260 @@ +import copy +import json +from typing import Union, List +from urllib.parse import urlencode + +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.requests import RequestCore +from youtubesearchpython.core.componenthandler import getValue, getVideoId + + +CLIENTS = { + "WEB": { + 'context': { + 'client': { + 'clientName': 'WEB', + 'clientVersion': '2.20240818.01.00' + } + }, + 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + }, + "MWEB": { + 'context': { + 'client': { + 'clientName': 'MWEB', + 'clientVersion': '2.20240818.01.00' + } + }, + 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + }, + "ANDROID": { + 'context': { + 'client': { + 'clientName': 'ANDROID', + 'clientVersion': '19.29.37' + } + }, + 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + }, + "ANDROID_EMBED": { + 'context': { + 'client': { + 'clientName': 'ANDROID', + 'clientVersion': '19.29.37', + 'clientScreen': 'EMBED' + } + }, + 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + }, + "TV_EMBED": { + "context": { + "client": { + "clientName": "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + "clientVersion": "2.0" + }, + "thirdParty": { + "embedUrl": "https://www.youtube.com/", + } + }, + 'api_key': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8' + } +} + + +class VideoCore(RequestCore): + def __init__(self, videoLink: str, componentMode: str, resultMode: int, timeout: int, enableHTML: bool, overridedClient: str = "WEB"): + super().__init__() + self.timeout = timeout + self.resultMode = resultMode + self.componentMode = componentMode + self.videoLink = videoLink + self.enableHTML = enableHTML + self.overridedClient = overridedClient + + # We call this when we use only HTML + def post_request_only_html_processing(self): + self.__getVideoComponent(self.componentMode) + self.result = self.__videoComponent + + def post_request_processing(self): + self.__parseSource() + self.__getVideoComponent(self.componentMode) + self.result = self.__videoComponent + + def prepare_innertube_request(self): + self.url = 'https://www.youtube.com/youtubei/v1/player' + "?" + urlencode({ + 'key': searchKey, + 'contentCheckOk': True, + 'racyCheckOk': True, + "videoId": getVideoId(self.videoLink) + }) + self.data = copy.deepcopy(CLIENTS[self.overridedClient]) + self.data["videoId"] = getVideoId(self.videoLink) + + async def async_create(self): + clients_to_try = [self.overridedClient] + for fallback in ["WEB", "MWEB", "ANDROID"]: + if fallback not in clients_to_try: + clients_to_try.append(fallback) + + last_exception = None + for client in clients_to_try: + try: + self.overridedClient = client + self.prepare_innertube_request() + response = await self.asyncPostRequest() + self.response = response.text + if response.status_code == 200: + self.post_request_processing() + return + else: + last_exception = Exception(f'ERROR: Invalid status code {response.status_code} for client {client}.') + except Exception as e: + last_exception = e + + if last_exception: + raise last_exception + else: + raise Exception('ERROR: Invalid status code.') + + def sync_create(self): + clients_to_try = [self.overridedClient] + for fallback in ["WEB", "MWEB", "ANDROID"]: + if fallback not in clients_to_try: + clients_to_try.append(fallback) + + last_exception = None + for client in clients_to_try: + try: + self.overridedClient = client + self.prepare_innertube_request() + response = self.syncPostRequest() + self.response = response.text + if response.status_code == 200: + self.post_request_processing() + return + else: + last_exception = Exception(f'ERROR: Invalid status code {response.status_code} for client {client}.') + except Exception as e: + last_exception = e + + if last_exception: + raise last_exception + else: + raise Exception('ERROR: Invalid status code.') + + def prepare_html_request(self): + self.url = 'https://www.youtube.com/youtubei/v1/player' + "?" + urlencode({ + 'key': searchKey, + 'contentCheckOk': True, + 'racyCheckOk': True, + "videoId": getVideoId(self.videoLink) + }) + self.data = copy.deepcopy(CLIENTS["MWEB"]) + self.data["videoId"] = getVideoId(self.videoLink) + + def sync_html_create(self): + last_exception = None + for client in ["MWEB", "WEB"]: + try: + self.url = 'https://www.youtube.com/youtubei/v1/player' + "?" + urlencode({ + 'key': searchKey, + 'contentCheckOk': True, + 'racyCheckOk': True, + "videoId": getVideoId(self.videoLink) + }) + self.data = copy.deepcopy(CLIENTS[client]) + self.data["videoId"] = getVideoId(self.videoLink) + response = self.syncPostRequest() + if response.status_code == 200: + self.HTMLresponseSource = response.json() + return + else: + last_exception = Exception(f'ERROR: Invalid status code {response.status_code} for client {client}.') + except Exception as e: + last_exception = e + if last_exception: + raise last_exception + + async def async_html_create(self): + last_exception = None + for client in ["MWEB", "WEB"]: + try: + self.url = 'https://www.youtube.com/youtubei/v1/player' + "?" + urlencode({ + 'key': searchKey, + 'contentCheckOk': True, + 'racyCheckOk': True, + "videoId": getVideoId(self.videoLink) + }) + self.data = copy.deepcopy(CLIENTS[client]) + self.data["videoId"] = getVideoId(self.videoLink) + response = await self.asyncPostRequest() + if response.status_code == 200: + self.HTMLresponseSource = response.json() + return + else: + last_exception = Exception(f'ERROR: Invalid status code {response.status_code} for client {client}.') + except Exception as e: + last_exception = e + if last_exception: + raise last_exception + + def __parseSource(self) -> None: + try: + self.responseSource = json.loads(self.response) + except Exception as e: + raise Exception('ERROR: Could not parse YouTube response.') + + def __result(self, mode: int) -> Union[dict, str]: + if mode == ResultMode.dict: + return self.__videoComponent + elif mode == ResultMode.json: + return json.dumps(self.__videoComponent, indent=4) + + def __getVideoComponent(self, mode: str) -> None: + videoComponent = {} + if mode in ['getInfo', None]: + try: + responseSource = self.responseSource + except: + responseSource = None + if self.enableHTML: + responseSource = self.HTMLresponseSource + component = { + 'id': getValue(responseSource, ['videoDetails', 'videoId']), + 'title': getValue(responseSource, ['videoDetails', 'title']), + 'duration': { + 'secondsText': getValue(responseSource, ['videoDetails', 'lengthSeconds']), + }, + 'viewCount': { + 'text': getValue(responseSource, ['videoDetails', 'viewCount']) + }, + 'thumbnails': getValue(responseSource, ['videoDetails', 'thumbnail', 'thumbnails']), + 'description': getValue(responseSource, ['videoDetails', 'shortDescription']), + 'channel': { + 'name': getValue(responseSource, ['videoDetails', 'author']), + 'id': getValue(responseSource, ['videoDetails', 'channelId']), + }, + 'allowRatings': getValue(responseSource, ['videoDetails', 'allowRatings']), + 'averageRating': getValue(responseSource, ['videoDetails', 'averageRating']), + 'keywords': getValue(responseSource, ['videoDetails', 'keywords']), + 'isLiveContent': getValue(responseSource, ['videoDetails', 'isLiveContent']), + 'publishDate': getValue(responseSource, ['microformat', 'playerMicroformatRenderer', 'publishDate']), + 'uploadDate': getValue(responseSource, ['microformat', 'playerMicroformatRenderer', 'uploadDate']), + 'isFamilySafe': getValue(responseSource, ['microformat', 'playerMicroformatRenderer', 'isFamilySafe']), + 'category': getValue(responseSource, ['microformat', 'playerMicroformatRenderer', 'category']), + } + component['isLiveNow'] = component['isLiveContent'] and component['duration']['secondsText'] == "0" + component['link'] = 'https://www.youtube.com/watch?v=' + component['id'] + component['channel']['link'] = 'https://www.youtube.com/channel/' + component['channel']['id'] + videoComponent.update(component) + if mode in ['getFormats', None]: + videoComponent.update( + { + "streamingData": getValue(self.responseSource, ["streamingData"]) + } + ) + if self.enableHTML: + videoComponent["publishDate"] = getValue(self.HTMLresponseSource, ['microformat', 'playerMicroformatRenderer', 'publishDate']) + videoComponent["uploadDate"] = getValue(self.HTMLresponseSource, ['microformat', 'playerMicroformatRenderer', 'uploadDate']) + self.__videoComponent = videoComponent diff --git a/platforms/youtube/vendor/youtubesearchpython/extras.py b/platforms/youtube/vendor/youtubesearchpython/extras.py new file mode 100644 index 0000000..d442ac2 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/extras.py @@ -0,0 +1,1844 @@ +import copy +from typing import Union + +from youtubesearchpython.core import VideoCore +from youtubesearchpython.core.comments import CommentsCore +from youtubesearchpython.core.hashtag import HashtagCore +from youtubesearchpython.core.playlist import PlaylistCore +from youtubesearchpython.core.suggestions import SuggestionsCore +from youtubesearchpython.core.transcript import TranscriptCore +from youtubesearchpython.core.channel import ChannelCore +from youtubesearchpython.core.constants import * + + +class Video: + @staticmethod + def get(videoLink: str, mode: int = ResultMode.dict, timeout: int = None, get_upload_date: bool = False) -> Union[ + dict, str, None]: + '''Fetches information and formats for the given video link or ID. + Returns None if video is unavailable. + + Args: + videoLink (str): link or ID of the video on YouTube. + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Examples: + + >>> video = Video.get("E07s5ZYygMg", mode = ResultMode.dict) + >>> print(video) + { + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "viewCount": { + "text": "170389228" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCT6nkbmYf-zbqAFgzF0D9PUhtsOQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA-JdoctyNp4aaj9dVtR0c6l5RDVw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBquHs9OWY5Dy1nE_syglwKP6-pMw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDSjHwdHxt9aU8NTojucGLp4PurTA", + "width": 336, + "height": 188 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/maxresdefault.jpg?v=5ebedc0c", + "width": 1920, + "height": 1080 + } + ], + "description": "This video is dedicated to touching. Listen to Harry Styles\u2019 new album \u2018Fine Line\u2019 now: https://HStyles.lnk.to/FineLineAY Follow Harry Styles: Facebook: https://HarryStyles.lnk.to/followFI Instagram: https://HarryStyles.lnk.to/followII Twitter: https://HarryStyles.lnk.to/followTI Website: https://HarryStyles.lnk.to/followWI Spotify: https://HarryStyles.lnk.to/followSI YouTube: https://HarryStyles.lnk.to/subscribeYD Lyrics: Tastes like strawberries On a summer evening And it sounds just like a song I want more berries And that summer feeling It\u2019s so wonderful and warm Breathe me in Breathe me out I don\u2019t know if I could ever go without I\u2019m just thinking out loud I don\u2019t know if I could ever go without Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar Strawberries On a summer evening Baby, you\u2019re the end of June I want your belly And that summer feeling Getting washed away in you Breathe me in Breathe me out I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Tastes like strawberries On a summer evening And it sounds just like a song I want your belly And that summer feeling I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Watermelon Sugar #HarryStyles #WatermelonSugar #FineLine", + "channel": { + "name": "HarryStylesVEVO", + "id": "UCbOCbp5gXL8jigIBZLqMPrw", + "link": "https://www.youtube.com/channel/UCbOCbp5gXL8jigIBZLqMPrw" + }, + "averageRating": 4.9043722, + "keywords": [ + "Fine Line", + "Harry Styles Fine Line", + "New Harry Styles", + "Harry Styles Album", + "HS2", + "One Direction", + "Eroda", + "HStyles", + "HarryStyles", + "New HS", + "Watermelon", + "Sugar", + "Watermlon Sugar", + "Harry Styles Watermelon Sugar", + "Fine Line Watermelon Sugar", + "Watermelon Sugar Fine Line", + "Harry Styles Watermelon Sguar Official Audio", + "Harry Styles Watermelon Sugar Song", + "HS Watermelon Sugar", + "Harry Styles Watermelon Sugar Video", + "Harry Styles Watermelon Sugar Official Video", + "Harry" + ], + "publishDate": "2020-05-18", + "uploadDate": "2020-05-18", + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "streamingData": { + "expiresInSeconds": "21540", + "formats": [ + { + "itag": 18, + "mimeType": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", + "bitrate": 635291, + "width": 640, + "height": 360, + "lastModified": "1594495537943093", + "contentLength": "14993923", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 635096, + "audioQuality": "AUDIO_QUALITY_LOW", + "approxDurationMs": "188871", + "audioSampleRate": "44100", + "audioChannels": 2, + "signatureCipher": "s=%3D%3D%3D%3DQodOF5O8RrqTn2rAkcM8v_YNimZ3DfiiO8ZPw9KyyeSBiASFkFP5N0jiMesLzywq2YSWUDXD5Z6lrU9gubyH9Go_MAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3DVfvQKqQ_NZHn6Dor7spmHhEF%26gir%3Dyes%26clen%3D14993923%26ratebypass%3Dyes%26dur%3D188.871%26lmt%3D1594495537943093%26mt%3D1610773720%26fvip%3D7%26beids%3D23886208%26c%3DWEB%26txp%3D5531432%26n%3DWJb1Ck1hxc089s%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgMhVOt4fvPig34e70PugZ4fF9_eaMIxFjkxoViFq_o7QCIHOuwB1qTokkaSC_SRacI2M1ThRYliS_9grHyI5qyZMS" + } + ], + "adaptiveFormats": [ + { + "itag": 396, + "mimeType": "video/mp4; codecs=\"av01.0.01M.08\"", + "bitrate": 382566, + "width": 640, + "height": 360, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609487253789141", + "contentLength": "6728270", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 285076, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dk%3DkdR4btYwFQpMSXtob0p2mPKAXiWMK-RwiWxxIt5LWu2AEiAL2zMdQnSgnygDbjo9yzBHDJxs-xM8C6T3kjsh6awJQOAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D396%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D6728270%26dur%3D188.813%26lmt%3D1609487253789141%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhANojIwQjR-uyA0up-8AVzVVmluYtBAUv7-xfcVLx78VWAiEA70Nv2CCMerX-aagqEvaYtfvWlJnxfIrXbrNh6-9Jlqw%253D" + }, + { + "itag": 133, + "mimeType": "video/mp4; codecs=\"avc1.4d4015\"", + "bitrate": 305779, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "738" + }, + "indexRange": { + "start": "739", + "end": "1226" + }, + "lastModified": "1601811623766061", + "contentLength": "4866855", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 206208, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3Dws707EGUqrkpRbQj1iDJx96vnuQ3Pdpyw_htdH4w4QvBiAn-2tm8pntaCUkaYr9xiHrb4lmcGfYyhtAebKdghPsGIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D133%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4866855%26dur%3D188.813%26lmt%3D1601811623766061%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgb0GIgg73jyyDh_JXbJEHYHpNgLpeGa92-oy7wr-WoLcCIQDjOTwDMClu68TTAo5e09v-6mGnnk-g3XypBrPbPHmiLA%253D%253D" + }, + { + "itag": 242, + "mimeType": "video/webm; codecs=\"vp9\"", + "bitrate": 227782, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "218" + }, + "indexRange": { + "start": "219", + "end": "837" + }, + "lastModified": "1594499983390711", + "contentLength": "4309129", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 182577, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dg%3Dgwb9JzjrAAoXMNBLNQ5qD2iPHnJ4YzENIdt3mbm44OyAEiAzwBK8Zyqox-LOCWIQYKqubPgZxkUzUWZplemU0D6-QPAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D242%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fwebm%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4309129%26dur%3D188.813%26lmt%3D1594499983390711%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhAKi5zlxKb1XGoMRCFAqs3dS9YUzyLPOHyWxDvZD0tHNRAiEAhE-TFI9B_K_oL0ButpdTjJyx01WaC4axvEGcnAL8SaI%253D" + }, + { + "itag": 395, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 182316, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609496650516481", + "contentLength": "3387984", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 143548, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DQ7tMYTeWztx_qFnE9iEOGgHx3wUENk2RY17qdu8FlWVDQICEBt2q4X76zIMwAwS3rCLv4Tvh7rzvEmhokR3o1siuLBgIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D395%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D3387984%26dur%3D188.813%26lmt%3D1609496650516481%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgflPTDLTISSYw4YCfRfUC9QIXUHap_ozlFzKQY2Bw_C0CIQC5ew2MDaI5VmSLOKWu3DgwLLMOXEvVrDWML-NeSicHuw%253D%253D" + } + { + "itag": 394, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 82683, + "width": 256, + "height": 144, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609493305821258", + "contentLength": "1659821", + "quality": "tiny", + "fps": 24, + "qualityLabel": "144p", + "projectionType": "RECTANGULAR", + "averageBitrate": 70326, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DACyTUDBvASFI8Ffxayro2mbZkq635OC5aYXXRc2kRfoAiAfc3-OE7SvRgrsjDiCcCriEaeEsaS1NMDNr5M2b_8PHIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D394%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D1659821%26dur%3D188.813%26lmt%3D1609493305821258%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhALudpHTWdYjzD8IWi9i3ksOL4Ks41vD4P3fQJy42gvU_AiEA5einGPLuqholaqIuBiyFH292aMcRL6bZTeqxEed6So8%253D" + } + ] + } + ''' + vc = VideoCore(videoLink, None, mode, timeout, get_upload_date) + if get_upload_date: + vc.sync_html_create() + vc.sync_create() + return vc.result + + @staticmethod + def getInfo(videoLink: str, mode: int = ResultMode.dict, timeout: int = None) -> Union[dict, str, None]: + '''Fetches only information for the given video link or ID. + Returns None if video is unavailable. + + Args: + videoLink (str): link or ID of the video on YouTube. + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Examples: + + >>> video = Video.getInfo("E07s5ZYygMg") + >>> print(video) + { + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "viewCount": { + "text": "170389228" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCT6nkbmYf-zbqAFgzF0D9PUhtsOQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA-JdoctyNp4aaj9dVtR0c6l5RDVw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBquHs9OWY5Dy1nE_syglwKP6-pMw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDSjHwdHxt9aU8NTojucGLp4PurTA", + "width": 336, + "height": 188 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/maxresdefault.jpg?v=5ebedc0c", + "width": 1920, + "height": 1080 + } + ], + "description": "This video is dedicated to touching. Listen to Harry Styles\u2019 new album \u2018Fine Line\u2019 now: https://HStyles.lnk.to/FineLineAY Follow Harry Styles: Facebook: https://HarryStyles.lnk.to/followFI Instagram: https://HarryStyles.lnk.to/followII Twitter: https://HarryStyles.lnk.to/followTI Website: https://HarryStyles.lnk.to/followWI Spotify: https://HarryStyles.lnk.to/followSI YouTube: https://HarryStyles.lnk.to/subscribeYD Lyrics: Tastes like strawberries On a summer evening And it sounds just like a song I want more berries And that summer feeling It\u2019s so wonderful and warm Breathe me in Breathe me out I don\u2019t know if I could ever go without I\u2019m just thinking out loud I don\u2019t know if I could ever go without Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar high Watermelon sugar Strawberries On a summer evening Baby, you\u2019re the end of June I want your belly And that summer feeling Getting washed away in you Breathe me in Breathe me out I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Tastes like strawberries On a summer evening And it sounds just like a song I want your belly And that summer feeling I don\u2019t know if I could ever go without Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high I just wanna taste it I just wanna taste it Watermelon sugar high Watermelon Sugar #HarryStyles #WatermelonSugar #FineLine", + "channel": { + "name": "HarryStylesVEVO", + "id": "UCbOCbp5gXL8jigIBZLqMPrw", + "link": "https://www.youtube.com/channel/UCbOCbp5gXL8jigIBZLqMPrw" + }, + "averageRating": 4.9043722, + "keywords": [ + "Fine Line", + "Harry Styles Fine Line", + "New Harry Styles", + "Harry Styles Album", + "HS2", + "One Direction", + "Eroda", + "HStyles", + "HarryStyles", + "New HS", + "Watermelon", + "Sugar", + "Watermlon Sugar", + "Harry Styles Watermelon Sugar", + "Fine Line Watermelon Sugar", + "Watermelon Sugar Fine Line", + "Harry Styles Watermelon Sguar Official Audio", + "Harry Styles Watermelon Sugar Song", + "HS Watermelon Sugar", + "Harry Styles Watermelon Sugar Video", + "Harry Styles Watermelon Sugar Official Video", + "Harry" + ], + "publishDate": "2020-05-18", + "uploadDate": "2020-05-18", + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + } + ''' + vc = VideoCore(videoLink, "getInfo", mode, timeout, True) + vc.sync_html_create() + vc.post_request_only_html_processing() + return vc.result + + @staticmethod + def getFormats(videoLink: str, mode: int = ResultMode.dict, timeout: int = None) -> Union[dict, str, None]: + '''Fetches formats for the given video link or ID. + Returns None if video is unavailable. + + Args: + videoLink (str): link or ID of the video on YouTube. + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Examples: + + >>> video = Video.getFormats("E07s5ZYygMg", mode = ResultMode.dict) + >>> print(video) + { + "streamingData": { + "expiresInSeconds": "21540", + "formats": [ + { + "itag": 18, + "mimeType": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", + "bitrate": 635291, + "width": 640, + "height": 360, + "lastModified": "1594495537943093", + "contentLength": "14993923", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 635096, + "audioQuality": "AUDIO_QUALITY_LOW", + "approxDurationMs": "188871", + "audioSampleRate": "44100", + "audioChannels": 2, + "signatureCipher": "s=%3D%3D%3D%3DQodOF5O8RrqTn2rAkcM8v_YNimZ3DfiiO8ZPw9KyyeSBiASFkFP5N0jiMesLzywq2YSWUDXD5Z6lrU9gubyH9Go_MAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D18%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3DVfvQKqQ_NZHn6Dor7spmHhEF%26gir%3Dyes%26clen%3D14993923%26ratebypass%3Dyes%26dur%3D188.871%26lmt%3D1594495537943093%26mt%3D1610773720%26fvip%3D7%26beids%3D23886208%26c%3DWEB%26txp%3D5531432%26n%3DWJb1Ck1hxc089s%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cratebypass%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRAIgMhVOt4fvPig34e70PugZ4fF9_eaMIxFjkxoViFq_o7QCIHOuwB1qTokkaSC_SRacI2M1ThRYliS_9grHyI5qyZMS" + } + ], + "adaptiveFormats": [ + { + "itag": 396, + "mimeType": "video/mp4; codecs=\"av01.0.01M.08\"", + "bitrate": 382566, + "width": 640, + "height": 360, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609487253789141", + "contentLength": "6728270", + "quality": "medium", + "fps": 24, + "qualityLabel": "360p", + "projectionType": "RECTANGULAR", + "averageBitrate": 285076, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dk%3DkdR4btYwFQpMSXtob0p2mPKAXiWMK-RwiWxxIt5LWu2AEiAL2zMdQnSgnygDbjo9yzBHDJxs-xM8C6T3kjsh6awJQOAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D396%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D6728270%26dur%3D188.813%26lmt%3D1609487253789141%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhANojIwQjR-uyA0up-8AVzVVmluYtBAUv7-xfcVLx78VWAiEA70Nv2CCMerX-aagqEvaYtfvWlJnxfIrXbrNh6-9Jlqw%253D" + }, + { + "itag": 133, + "mimeType": "video/mp4; codecs=\"avc1.4d4015\"", + "bitrate": 305779, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "738" + }, + "indexRange": { + "start": "739", + "end": "1226" + }, + "lastModified": "1601811623766061", + "contentLength": "4866855", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 206208, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3Dws707EGUqrkpRbQj1iDJx96vnuQ3Pdpyw_htdH4w4QvBiAn-2tm8pntaCUkaYr9xiHrb4lmcGfYyhtAebKdghPsGIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D133%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4866855%26dur%3D188.813%26lmt%3D1601811623766061%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgb0GIgg73jyyDh_JXbJEHYHpNgLpeGa92-oy7wr-WoLcCIQDjOTwDMClu68TTAo5e09v-6mGnnk-g3XypBrPbPHmiLA%253D%253D" + }, + { + "itag": 242, + "mimeType": "video/webm; codecs=\"vp9\"", + "bitrate": 227782, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "218" + }, + "indexRange": { + "start": "219", + "end": "837" + }, + "lastModified": "1594499983390711", + "contentLength": "4309129", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 182577, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3Dg%3Dgwb9JzjrAAoXMNBLNQ5qD2iPHnJ4YzENIdt3mbm44OyAEiAzwBK8Zyqox-LOCWIQYKqubPgZxkUzUWZplemU0D6-QPAhIgAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D242%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fwebm%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D4309129%26dur%3D188.813%26lmt%3D1594499983390711%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5535432%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhAKi5zlxKb1XGoMRCFAqs3dS9YUzyLPOHyWxDvZD0tHNRAiEAhE-TFI9B_K_oL0ButpdTjJyx01WaC4axvEGcnAL8SaI%253D" + }, + { + "itag": 395, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 182316, + "width": 426, + "height": 240, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609496650516481", + "contentLength": "3387984", + "quality": "small", + "fps": 24, + "qualityLabel": "240p", + "projectionType": "RECTANGULAR", + "averageBitrate": 143548, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DQ7tMYTeWztx_qFnE9iEOGgHx3wUENk2RY17qdu8FlWVDQICEBt2q4X76zIMwAwS3rCLv4Tvh7rzvEmhokR3o1siuLBgIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D395%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D3387984%26dur%3D188.813%26lmt%3D1609496650516481%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRQIgflPTDLTISSYw4YCfRfUC9QIXUHap_ozlFzKQY2Bw_C0CIQC5ew2MDaI5VmSLOKWu3DgwLLMOXEvVrDWML-NeSicHuw%253D%253D" + } + { + "itag": 394, + "mimeType": "video/mp4; codecs=\"av01.0.00M.08\"", + "bitrate": 82683, + "width": 256, + "height": 144, + "initRange": { + "start": "0", + "end": "699" + }, + "indexRange": { + "start": "700", + "end": "1187" + }, + "lastModified": "1609493305821258", + "contentLength": "1659821", + "quality": "tiny", + "fps": 24, + "qualityLabel": "144p", + "projectionType": "RECTANGULAR", + "averageBitrate": 70326, + "colorInfo": { + "primaries": "COLOR_PRIMARIES_BT709", + "transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_BT709", + "matrixCoefficients": "COLOR_MATRIX_COEFFICIENTS_BT709" + }, + "approxDurationMs": "188813", + "signatureCipher": "s=%3D%3D%3D%3DACyTUDBvASFI8Ffxayro2mbZkq635OC5aYXXRc2kRfoAiAfc3-OE7SvRgrsjDiCcCriEaeEsaS1NMDNr5M2b_8PHIAhIQAw8JQ0qRORO&sp=sig&url=https://r7---sn-gwpa-5bge.googlevideo.com/videoplayback%3Fexpire%3D1610795853%26ei%3D7XYCYPjqL86L3LUPsuqOwAw%26ip%3D2409%253A4053%253A803%253A2b22%253Adc68%253Adfb9%253Aa676%253A26a3%26id%3Do-AABrI6NBWfT4rkPYNA8z0KQ_le3lQiAHSFem5FtT8eBq%26itag%3D394%26aitags%3D133%252C134%252C135%252C136%252C137%252C160%252C242%252C243%252C244%252C247%252C248%252C278%252C394%252C395%252C396%252C397%252C398%252C399%26source%3Dyoutube%26requiressl%3Dyes%26mh%3DCl%26mm%3D31%252C29%26mn%3Dsn-gwpa-5bge%252Csn-gwpa-qxay%26ms%3Dau%252Crdu%26mv%3Dm%26mvi%3D7%26pl%3D36%26gcr%3Din%26initcwndbps%3D151250%26vprv%3D1%26mime%3Dvideo%252Fmp4%26ns%3Dm85HcnnYCRMEVrPeHkFE5QgF%26gir%3Dyes%26clen%3D1659821%26dur%3D188.813%26lmt%3D1609493305821258%26mt%3D1610773720%26fvip%3D7%26keepalive%3Dyes%26beids%3D23886208%26c%3DWEB%26txp%3D5532434%26n%3Dfg3i3LCZK719E3%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Caitags%252Csource%252Crequiressl%252Cgcr%252Cvprv%252Cmime%252Cns%252Cgir%252Cclen%252Cdur%252Clmt%26lsparams%3Dmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Cinitcwndbps%26lsig%3DAG3C_xAwRgIhALudpHTWdYjzD8IWi9i3ksOL4Ks41vD4P3fQJy42gvU_AiEA5einGPLuqholaqIuBiyFH292aMcRL6bZTeqxEed6So8%253D" + } + ] + } + } + ''' + vc = VideoCore(videoLink, "getFormats", mode, timeout, False) + vc.sync_create() + return vc.result + + +class Playlist: + '''Fetches information and videos for the given playlist link. + Returns None if playlist is unavailable. + + The information of the playlist can be accessed in the `info` field of the class. + And the retrieved videos of the playlist are present inside the `videos` field of the class, as a list. + + Due to limit of being able to retrieve only 100 videos at a time, call `getNextVideos` method to get more videos of the playlist, + which will be appended to the `videos` list. + + `hasMoreVideos` stores boolean to indicate whether more videos are present in the playlist. + If this field is True, then you can call `getNextVideos` method again to get more videos of the playlist. + + Args: + playlistLink (str): link of the playlist on YouTube. + ''' + __playlist = None + videos = [] + info = None + hasMoreVideos = False + + def __init__(self, playlistLink: str, timeout: int = None): + self.timeout = timeout + self.__playlist = PlaylistCore(playlistLink, None, ResultMode.dict, self.timeout) + self.__playlist.sync_create() + self.info = copy.deepcopy(self.__playlist.result) + self.videos = self.__playlist.result['videos'] + self.hasMoreVideos = self.__playlist.continuationKey != None + self.info.pop('videos') + + '''Fetches more susequent videos of the playlist, and appends to the `videos` list. + `hasMoreVideos` bool indicates whether more videos can be fetched or not. + ''' + + def getNextVideos(self) -> None: + self.__playlist._next() + self.videos = self.__playlist.result['videos'] + self.hasMoreVideos = self.__playlist.continuationKey != None + + @staticmethod + def get(playlistLink: str, mode: int = ResultMode.dict, timeout: int = None) -> Union[dict, str, None]: + '''Fetches information and videos for the given playlist link. + Returns None if playlist is unavailable. + + Args: + playlistLink (str): link of the playlist on YouTube. + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Examples: + + >>> playlist = Playlist.get("https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", mode = ResultMode.dict) + >>> print(playlist) + { + "id": "PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "title": "🔥 NCS: House", + "videoCount": "209", + "viewCount": "155,772,054 views", + "thumbnails": { + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLDHZYoB-WNHmvT3CZy6SpdqygsO4A", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCMQBEG5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLACCxCIRvCn65_OS1z_4tLAq5Jb8Q", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCPYBEIoBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBt00cYTIVBdrnHsSNLinhq7meCpQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBFaqqO6kCAuqya1SIJo5Cf45Ndxg", + "width": 336, + "height": 188 + } + ] + }, + "link": "https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s48-c-k-c0x00ffffff-no-rj", + "width": 48, + "height": 48 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s176-c-k-c0x00ffffff-no-rj", + "width": 176, + "height": 176 + } + ], + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "videos": [ + { + "id": "0oq2Ej36nlY", + "title": "Axol - Mars [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAYA8xOuVJyq4ZdmdZEy3128mkHSg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBVen9Zle-8QDR10u73EEHbHc_MAQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBFv2xNC53WtsAQahBV1kRW2knJ2w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBMkco75LBzq-XCblRqQZkcFbDf4w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:56", + "accessibility": { + "title": "Axol - Mars [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 56 seconds", + "duration": "2 minutes, 56 seconds" + }, + "link": "https://www.youtube.com/watch?v=0oq2Ej36nlY" + }, + { + "id": "iv7ZJecuu_o", + "title": "NIVIRO - The Floor Is Lava [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCkw6PB0tE3tROCegrF7uPK0tHM4w", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDxeEJ7qhh1Du1V2GiStjP0XGTniQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB_0R_xsvuIqYr30BgvOdcHsSCoUQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCcr_u0591ANz4Mes7MCECuvRikUA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:16", + "accessibility": { + "title": "NIVIRO - The Floor Is Lava [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 16 seconds", + "duration": "3 minutes, 16 seconds" + }, + "link": "https://www.youtube.com/watch?v=iv7ZJecuu_o" + }, + { + "id": "cmVdgWL5548", + "title": "Raven & Kreyn - So Happy [NCS Official Video]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBa3HKnW5uNkAP25X5668d5Yxx_GQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBghyhFRtdIWD4AT3BZBuOhlzB4JA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDgYP3wdhqlhDEMPuAW6vMt415fIQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD94rVwtv3iglKBdtQ_oKtxZT1iJA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:41", + "accessibility": { + "title": "Raven & Kreyn - So Happy [NCS Official Video] by NoCopyrightSounds 3 years ago 2 minutes, 41 seconds", + "duration": "2 minutes, 41 seconds" + }, + "link": "https://www.youtube.com/watch?v=cmVdgWL5548" + }, + { + "id": "ldDCHrBeOlg", + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDFrVolfV84PcVgXzpjZNaxJqqTyw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDEOg15NmmhCRL9_lQQmK-6axAqyw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDt3q3px1x8SQ8flQYJebkg9fef5g", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBB0B2f0D7RuCc420npQdZpYGb7QQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:39", + "accessibility": { + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 39 seconds", + "duration": "4 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=ldDCHrBeOlg" + }, + { + "id": "PhzDIABahyc", + "title": "Jensation - Delicious [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBwntSl_7Buk4Udzrvko_zJ4nQf8Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCUyQIjjZ0eA5ZgHfBXZOYdDtfHGQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBQKS12wSDYhcIBYFeBjiT1VQLSxQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAE72b5ac2xa9x1ccrKiXsFQwsACA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:49", + "accessibility": { + "title": "Jensation - Delicious [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 49 seconds", + "duration": "2 minutes, 49 seconds" + }, + "link": "https://www.youtube.com/watch?v=PhzDIABahyc" + }, + { + "id": "Y5TnYaZ31b0", + "title": "Waysons - Running [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAEY6qDwWgh6QjKsRN_hB92IiZlMw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLATd9F2LOxmWU7cirUbLqwTfq75xg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBDUIuqcX7vg17NY21ykR8JNyd3A", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBXImJda2jfUE9_L10N5KJLsQTuA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:08", + "accessibility": { + "title": "Waysons - Running [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 8 seconds", + "duration": "3 minutes, 8 seconds" + }, + "link": "https://www.youtube.com/watch?v=Y5TnYaZ31b0" + }, + { + "id": "2Nv5juZKhKo", + "title": "NIVIRO - You [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCBLGyqDfAqaZ3nTk15H4k7EhAaxg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCoyqiY8380ua84NIqVNaDDn6zecg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCxW4Qnr1k3EE5MWbuJlThIm02oYg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBDOvAvcUtCAVB519ww32RtplBkNw", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "NIVIRO - You [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=2Nv5juZKhKo" + }, + { + "id": "odThebFOFVg", + "title": "Elektronomia - The Other Side [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDhut1THu5o6SRzgEfCmEURV3ob7Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBkgcLev1knPC0x_aWkEjsKj8HMpA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDhHu2Y4U_b05FEskx70NHqnReNFw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD1FR4CWnJbkrWD_QsVWEpjq_CzjQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:11", + "accessibility": { + "title": "Elektronomia - The Other Side [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 11 seconds", + "duration": "4 minutes, 11 seconds" + }, + "link": "https://www.youtube.com/watch?v=odThebFOFVg" + }, + { + "id": "9phWj3Iygq8", + "title": "Raven & Kreyn - Get This Party [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBxD8ouCe61I6X4oiHQhPjmu7G8rw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDRVh4TEJG0WTAWz-LnFPjQQxhQaw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLARKdRufUYSduQ3IPGO831vvoQ_8w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAuGbI8xMrYBZ46shlinaj7Na9chg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:39", + "accessibility": { + "title": "Raven & Kreyn - Get This Party [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 39 seconds", + "duration": "2 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=9phWj3Iygq8" + }, + { + "id": "dM2hrLwdaoU", + "title": "Distrion & Alex Skrindo - Lightning [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDuprc64g80t_DXa9UE5SrzLEkAdw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA3bgMR8b2UKtbCpbYzSmsLhgTK7g", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB3LNf1rjgiGHtMa7UH9cQ9B29-yQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBXvevJvz3sTF4ZjpunveJF8Z-gSg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:27", + "accessibility": { + "title": "Distrion & Alex Skrindo - Lightning [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 27 seconds", + "duration": "3 minutes, 27 seconds" + }, + "link": "https://www.youtube.com/watch?v=dM2hrLwdaoU" + }, + { + "id": "vKAHowm3Ry0", + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC4BvoPiuOIA_mTbacI2BobXfm8gA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDOmyUcbQL2EffQm7T19yI9FIe89w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBsI7UIpQCI3Ty6CJxL1R4wRF2EqQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBJivP3UVcYXjkKjdTYLKJO7L329g", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:02", + "accessibility": { + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release by NoCopyrightSounds 3 years ago 3 minutes, 2 seconds", + "duration": "3 minutes, 2 seconds" + }, + "link": "https://www.youtube.com/watch?v=vKAHowm3Ry0" + }, + { + "id": "FseAiTb8Se0", + "title": "Kovan & Electro-Light - Skyline [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBQ5gJjpS6VprS0z0SxgZxEVxGaJA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC5oJlZLpCbxAxQHUceUuVIvUKNSw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDyWw_4fzlujqrtOT90Ya6_cpLeFg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBytsYOYycFUOdBrF47tyEUjnC_-A", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "Kovan & Electro-Light - Skyline [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=FseAiTb8Se0" + }, + { + "id": "BoI6g46zuU4", + "title": "RetroVision & Domastic - SICC [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC-hfMNYvUViQKf8jD1d1XwQDtfNA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDlXueYSIv8fVNN0k4s7CLlJBUw8w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD2PqxAsUQcuqNP_uxtZDeMaWd5sA", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCNtEtObJdzEJykxkMqkcR6qYin0w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:46", + "accessibility": { + "title": "RetroVision & Domastic - SICC [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 46 seconds", + "duration": "2 minutes, 46 seconds" + }, + "link": "https://www.youtube.com/watch?v=BoI6g46zuU4" + } + ] + } + ''' + pc = PlaylistCore(playlistLink, None, mode, timeout) + pc.sync_create() + return pc.result + + @staticmethod + def getInfo(playlistLink: str, mode: int = ResultMode.dict, timeout: int = None) -> Union[dict, str, None]: + '''Fetches only information for the given playlist link. + Returns None if playlist is unavailable. + + Args: + playlistLink (str): link of the playlist on YouTube. + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Examples: + + >>> playlist = Playlist.getInfo("https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", mode = ResultMode.dict) + >>> print(playlist) + { + "id": "PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "title": "🔥 NCS: House", + "videoCount": "209", + "viewCount": "155,772,054 views", + "thumbnails": { + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLDHZYoB-WNHmvT3CZy6SpdqygsO4A", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEWCMQBEG5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLACCxCIRvCn65_OS1z_4tLAq5Jb8Q", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCPYBEIoBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBt00cYTIVBdrnHsSNLinhq7meCpQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/LIvSF0fQPJc/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBFaqqO6kCAuqya1SIJo5Cf45Ndxg", + "width": 336, + "height": 188 + } + ] + }, + "link": "https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s48-c-k-c0x00ffffff-no-rj", + "width": 48, + "height": 48 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhwQpPaPL_w-2bQM3TXQN0bdsQQSeEW74TDNXDfHQ=s176-c-k-c0x00ffffff-no-rj", + "width": 176, + "height": 176 + } + ], + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + } + } + ''' + ps = PlaylistCore(playlistLink, 'getInfo', mode, timeout) + ps.sync_create() + return ps.result + + @staticmethod + def getVideos(playlistLink: str, mode: int = ResultMode.dict, timeout: int = None) -> Union[dict, str, None]: + '''Fetches only videos in the given playlist from link. + Returns None if playlist is unavailable. + + Args: + playlistLink (str): link of the playlist on YouTube. + mode (int, optional): Sets the type of result. Defaults to ResultMode.dict. + + Examples: + + >>> playlist = Playlist.getInfo("https://www.youtube.com/playlist?list=PLRBp0Fe2GpgmsW46rJyudVFlY6IYjFBIK", mode = ResultMode.dict) + >>> print(playlist) + { + "videos": [ + { + "id": "0oq2Ej36nlY", + "title": "Axol - Mars [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAYA8xOuVJyq4ZdmdZEy3128mkHSg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBVen9Zle-8QDR10u73EEHbHc_MAQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBFv2xNC53WtsAQahBV1kRW2knJ2w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/0oq2Ej36nlY/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBMkco75LBzq-XCblRqQZkcFbDf4w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:56", + "accessibility": { + "title": "Axol - Mars [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 56 seconds", + "duration": "2 minutes, 56 seconds" + }, + "link": "https://www.youtube.com/watch?v=0oq2Ej36nlY" + }, + { + "id": "iv7ZJecuu_o", + "title": "NIVIRO - The Floor Is Lava [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCkw6PB0tE3tROCegrF7uPK0tHM4w", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDxeEJ7qhh1Du1V2GiStjP0XGTniQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB_0R_xsvuIqYr30BgvOdcHsSCoUQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/iv7ZJecuu_o/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCcr_u0591ANz4Mes7MCECuvRikUA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:16", + "accessibility": { + "title": "NIVIRO - The Floor Is Lava [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 16 seconds", + "duration": "3 minutes, 16 seconds" + }, + "link": "https://www.youtube.com/watch?v=iv7ZJecuu_o" + }, + { + "id": "cmVdgWL5548", + "title": "Raven & Kreyn - So Happy [NCS Official Video]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBa3HKnW5uNkAP25X5668d5Yxx_GQ", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBghyhFRtdIWD4AT3BZBuOhlzB4JA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDgYP3wdhqlhDEMPuAW6vMt415fIQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/cmVdgWL5548/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD94rVwtv3iglKBdtQ_oKtxZT1iJA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:41", + "accessibility": { + "title": "Raven & Kreyn - So Happy [NCS Official Video] by NoCopyrightSounds 3 years ago 2 minutes, 41 seconds", + "duration": "2 minutes, 41 seconds" + }, + "link": "https://www.youtube.com/watch?v=cmVdgWL5548" + }, + { + "id": "ldDCHrBeOlg", + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDFrVolfV84PcVgXzpjZNaxJqqTyw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDEOg15NmmhCRL9_lQQmK-6axAqyw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDt3q3px1x8SQ8flQYJebkg9fef5g", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/ldDCHrBeOlg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBB0B2f0D7RuCc420npQdZpYGb7QQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:39", + "accessibility": { + "title": "Phantom Sage - Kingdom (feat. Miss Lina) [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 39 seconds", + "duration": "4 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=ldDCHrBeOlg" + }, + { + "id": "PhzDIABahyc", + "title": "Jensation - Delicious [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBwntSl_7Buk4Udzrvko_zJ4nQf8Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCUyQIjjZ0eA5ZgHfBXZOYdDtfHGQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBQKS12wSDYhcIBYFeBjiT1VQLSxQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/PhzDIABahyc/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAE72b5ac2xa9x1ccrKiXsFQwsACA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:49", + "accessibility": { + "title": "Jensation - Delicious [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 49 seconds", + "duration": "2 minutes, 49 seconds" + }, + "link": "https://www.youtube.com/watch?v=PhzDIABahyc" + }, + { + "id": "Y5TnYaZ31b0", + "title": "Waysons - Running [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLAEY6qDwWgh6QjKsRN_hB92IiZlMw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLATd9F2LOxmWU7cirUbLqwTfq75xg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBDUIuqcX7vg17NY21ykR8JNyd3A", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/Y5TnYaZ31b0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCBXImJda2jfUE9_L10N5KJLsQTuA", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:08", + "accessibility": { + "title": "Waysons - Running [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 8 seconds", + "duration": "3 minutes, 8 seconds" + }, + "link": "https://www.youtube.com/watch?v=Y5TnYaZ31b0" + }, + { + "id": "2Nv5juZKhKo", + "title": "NIVIRO - You [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCBLGyqDfAqaZ3nTk15H4k7EhAaxg", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLCoyqiY8380ua84NIqVNaDDn6zecg", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCxW4Qnr1k3EE5MWbuJlThIm02oYg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/2Nv5juZKhKo/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBDOvAvcUtCAVB519ww32RtplBkNw", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "NIVIRO - You [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=2Nv5juZKhKo" + }, + { + "id": "odThebFOFVg", + "title": "Elektronomia - The Other Side [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDhut1THu5o6SRzgEfCmEURV3ob7Q", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBkgcLev1knPC0x_aWkEjsKj8HMpA", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDhHu2Y4U_b05FEskx70NHqnReNFw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/odThebFOFVg/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD1FR4CWnJbkrWD_QsVWEpjq_CzjQ", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "4:11", + "accessibility": { + "title": "Elektronomia - The Other Side [NCS Release] by NoCopyrightSounds 3 years ago 4 minutes, 11 seconds", + "duration": "4 minutes, 11 seconds" + }, + "link": "https://www.youtube.com/watch?v=odThebFOFVg" + }, + { + "id": "9phWj3Iygq8", + "title": "Raven & Kreyn - Get This Party [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBxD8ouCe61I6X4oiHQhPjmu7G8rw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDRVh4TEJG0WTAWz-LnFPjQQxhQaw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLARKdRufUYSduQ3IPGO831vvoQ_8w", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/9phWj3Iygq8/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAuGbI8xMrYBZ46shlinaj7Na9chg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:39", + "accessibility": { + "title": "Raven & Kreyn - Get This Party [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 39 seconds", + "duration": "2 minutes, 39 seconds" + }, + "link": "https://www.youtube.com/watch?v=9phWj3Iygq8" + }, + { + "id": "dM2hrLwdaoU", + "title": "Distrion & Alex Skrindo - Lightning [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDuprc64g80t_DXa9UE5SrzLEkAdw", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLA3bgMR8b2UKtbCpbYzSmsLhgTK7g", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLB3LNf1rjgiGHtMa7UH9cQ9B29-yQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/dM2hrLwdaoU/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBXvevJvz3sTF4ZjpunveJF8Z-gSg", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:27", + "accessibility": { + "title": "Distrion & Alex Skrindo - Lightning [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 27 seconds", + "duration": "3 minutes, 27 seconds" + }, + "link": "https://www.youtube.com/watch?v=dM2hrLwdaoU" + }, + { + "id": "vKAHowm3Ry0", + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC4BvoPiuOIA_mTbacI2BobXfm8gA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDOmyUcbQL2EffQm7T19yI9FIe89w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBsI7UIpQCI3Ty6CJxL1R4wRF2EqQ", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/vKAHowm3Ry0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBJivP3UVcYXjkKjdTYLKJO7L329g", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:02", + "accessibility": { + "title": "Kontinuum - Lost (feat. Savoi) [Sunroof Remix] | NCS Release by NoCopyrightSounds 3 years ago 3 minutes, 2 seconds", + "duration": "3 minutes, 2 seconds" + }, + "link": "https://www.youtube.com/watch?v=vKAHowm3Ry0" + }, + { + "id": "FseAiTb8Se0", + "title": "Kovan & Electro-Light - Skyline [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLBQ5gJjpS6VprS0z0SxgZxEVxGaJA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC5oJlZLpCbxAxQHUceUuVIvUKNSw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDyWw_4fzlujqrtOT90Ya6_cpLeFg", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/FseAiTb8Se0/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLBytsYOYycFUOdBrF47tyEUjnC_-A", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "3:50", + "accessibility": { + "title": "Kovan & Electro-Light - Skyline [NCS Release] by NoCopyrightSounds 3 years ago 3 minutes, 50 seconds", + "duration": "3 minutes, 50 seconds" + }, + "link": "https://www.youtube.com/watch?v=FseAiTb8Se0" + }, + { + "id": "BoI6g46zuU4", + "title": "RetroVision & Domastic - SICC [NCS Release]", + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCKgBEF5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLC-hfMNYvUViQKf8jD1d1XwQDtfNA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEiCMQBEG5IWvKriqkDFQgBFQAAAAAYASUAAMhCPQCAokN4AQ==&rs=AOn4CLDlXueYSIv8fVNN0k4s7CLlJBUw8w", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCPYBEIoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLD2PqxAsUQcuqNP_uxtZDeMaWd5sA", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/BoI6g46zuU4/hqdefault.jpg?sqp=-oaymwEjCNACELwBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLCNtEtObJdzEJykxkMqkcR6qYin0w", + "width": 336, + "height": 188 + } + ], + "channel": { + "name": "NoCopyrightSounds", + "id": "UC_aEa8K-EOJ3D6gOs7HcyNg", + "link": "https://www.youtube.com/channel/UC_aEa8K-EOJ3D6gOs7HcyNg" + }, + "duration": "2:46", + "accessibility": { + "title": "RetroVision & Domastic - SICC [NCS Release] by NoCopyrightSounds 3 years ago 2 minutes, 46 seconds", + "duration": "2 minutes, 46 seconds" + }, + "link": "https://www.youtube.com/watch?v=BoI6g46zuU4" + } + ] + } + ''' + ps = PlaylistCore(playlistLink, 'getVideos', mode, timeout) + ps.sync_create() + return ps.result + + +class Hashtag(HashtagCore): + '''Fetches videos for the given hashtag. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> hashtag = Hashtag('ncs', limit = 1) + >>> print(hashtag.result()) + { + "result": [ + { + "type": "video", + "id": "c9FF4Tfj2w8", + "title": "Ascence - About You [NCS 1 HOUR]", + "publishedTime": "1 year ago", + "duration": "1:00:00", + "viewCount": { + "text": "226,354 views", + "short": "226K views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLA8V3x_PigkymVQxQcptr8Wfz20-A", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLABh5Ylb5wbuulOAWLcSYtfYQKiAQ", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAykmTivOgjlW6a4tKWnLJpL9yqKw", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/c9FF4Tfj2w8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLC8qRkotPyH9kGGHe29QuyOh-F9KA", + "width": 336, + "height": 188 + } + ], + "richThumbnail": { + "url": "https://i.ytimg.com/an_webp/c9FF4Tfj2w8/mqdefault_6s.webp?du=3000&sqp=CPGE-YgG&rs=AOn4CLAJAC5zmDOtySflLFMQpAoaPUqHjA", + "width": 320, + "height": 180 + }, + "descriptionSnippet": null, + "channel": { + "name": "Good Vibes Music", + "id": "UChCPI0uvKwrkYhTEx8UVrnQ", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AKedOLSFYY0mvwL0DbRzddMAQdbgFshM42R5byhI9FiEBQ=s68-c-k-c0x00ffffff-no-rj", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UChCPI0uvKwrkYhTEx8UVrnQ" + }, + "accessibility": { + "title": "Ascence - About You [NCS 1 HOUR] by Good Vibes Music 1 year ago 1 hour 226,354 views", + "duration": "1 hour" + }, + "link": "https://www.youtube.com/watch?v=c9FF4Tfj2w8", + "shelfTitle": null + } + ] + } + ''' + + def __init__(self, hashtag: str, limit: int = 60, language: str = 'en', region: str = 'US', timeout: int = None): + super().__init__(hashtag, limit, language, region, timeout) + self.sync_create() + + +class Suggestions(SuggestionsCore): + def __init__(self, language: str = "en", region: str = "US", timeout: int = None): + super().__init__(language, region, timeout) + + def get(self, query: str, mode: int = ResultMode.dict): + return self._get(query, mode) + + +class Comments: + comments = [] + hasMoreComments = False + + def __init__(self, playlistLink: str, timeout: int = None): + self.timeout = timeout + self.__comments = CommentsCore(playlistLink) + self.__comments.sync_create() + self.comments = self.__comments.commentsComponent + self.hasMoreComments = self.__comments.continuationKey is not None + + def getNextComments(self) -> None: + self.__comments.sync_create_next() + self.comments = self.__comments.commentsComponent + self.hasMoreComments = self.__comments.continuationKey is not None + + @staticmethod + def get(playlistLink: str) -> Union[dict, str, None]: + pc = CommentsCore(playlistLink) + pc.sync_create() + return pc.commentsComponent + + +class Transcript: + @staticmethod + def get(videoLink: str, params: str = None): + transcript_core = TranscriptCore(videoLink, params) + transcript_core.sync_create() + return transcript_core.result + + +class Channel(ChannelCore): + def __init__(self, channel_id: str, request_type: str = ChannelRequestType.playlists): + super().__init__(channel_id, request_type) + self.sync_create() + + def next(self): + self.sync_next() + + @staticmethod + def get(channel_id: str, request_type: str = ChannelRequestType.info): + channel_core = ChannelCore(channel_id, request_type) + channel_core.sync_create() + return channel_core.result diff --git a/platforms/youtube/vendor/youtubesearchpython/handlers/componenthandler.py b/platforms/youtube/vendor/youtubesearchpython/handlers/componenthandler.py new file mode 100644 index 0000000..e7df0c8 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/handlers/componenthandler.py @@ -0,0 +1,569 @@ +from typing import Iterable, List, Union +from datetime import datetime, timezone + +from youtubesearchpython.core.constants import * + + +class ComponentHandler: + def _normalizeUrl(self, url: Union[str, None]) -> Union[str, None]: + if not isinstance(url, str) or not url: + return None + if url.startswith('http://www.youtube.com/') or url.startswith('http://youtube.com/'): + return 'https://' + url[len('http://'):] + if url.startswith('//'): + return 'https:' + url + if url.startswith('/'): + return 'https://www.youtube.com' + url + return url + + def _getText(self, source: dict) -> Union[str, None]: + if source is None: + return None + if isinstance(source, str): + return source + if isinstance(source, dict): + if isinstance(source.get('simpleText'), str): + return source['simpleText'] + if isinstance(source.get('content'), str): + return source['content'] + if isinstance(source.get('text'), str): + return source['text'] + if isinstance(source.get('text'), dict): + return self._getText(source.get('text')) + runs = source.get('runs') + if isinstance(runs, list): + texts = [] + for run in runs: + text = self._getText(run) + if text: + texts.append(text) + if texts: + return ''.join(texts) + if isinstance(source, list): + texts = [] + for item in source: + text = self._getText(item) + if text: + texts.append(text) + if texts: + return ''.join(texts) + return None + + def _getThumbnailSources(self, source: dict) -> Union[list, None]: + if source is None: + return None + if isinstance(source, list): + return source or None + + candidates = [ + ['thumbnails'], + ['thumbnail', 'thumbnails'], + ['image', 'sources'], + ['sources'], + ['thumbnailViewModel', 'image', 'sources'], + ['primaryThumbnail', 'thumbnailViewModel', 'image', 'sources'], + ] + for path in candidates: + value = self._getValue(source, path) + if isinstance(value, list) and value: + normalized = [] + for item in value: + if isinstance(item, dict) and item.get('url'): + item = dict(item) + item['url'] = self._normalizeUrl(item.get('url')) + normalized.append(item) + return normalized + return None + + def _getCanonicalUrl(self, endpoint: dict) -> Union[str, None]: + if endpoint is None: + return None + url = self._getValue(endpoint, ['commandMetadata', 'webCommandMetadata', 'url']) + if isinstance(url, str): + return self._normalizeUrl(url) + url = self._getValue(endpoint, ['browseEndpoint', 'canonicalBaseUrl']) + if isinstance(url, str): + return self._normalizeUrl(url) + return None + + def _defaultText(self, value: Union[str, None], fallback: str) -> str: + if isinstance(value, str) and value.strip(): + return value + return fallback + + def _isMissingValue(self, value) -> bool: + if value is None: + return True + if isinstance(value, str): + text = value.strip() + return not text or text.lower().startswith('no ') + if isinstance(value, list) or isinstance(value, dict): + return len(value) == 0 + return False + + def _isShortsUrl(self, url: Union[str, None]) -> bool: + return isinstance(url, str) and '/shorts/' in url + + def _seconds_to_timestamp(self, seconds: Union[str, int, None]) -> Union[str, None]: + try: + total_seconds = int(seconds) + except (TypeError, ValueError): + return None + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f'{hours}:{minutes:02d}:{secs:02d}' + return f'{minutes}:{secs:02d}' + + def _seconds_to_accessibility_duration(self, seconds: Union[str, int, None]) -> Union[str, None]: + try: + total_seconds = int(seconds) + except (TypeError, ValueError): + return None + hours, remainder = divmod(total_seconds, 3600) + minutes, secs = divmod(remainder, 60) + parts = [] + if hours: + parts.append(f'{hours} hour' + ('s' if hours != 1 else '')) + if minutes: + parts.append(f'{minutes} minute' + ('s' if minutes != 1 else '')) + if secs or not parts: + parts.append(f'{secs} second' + ('s' if secs != 1 else '')) + return ', '.join(parts) + + def _compact_number(self, value: Union[str, int, None]) -> Union[str, None]: + try: + number = int(value) + except (TypeError, ValueError): + return None + thresholds = ( + (1_000_000_000, 'B'), + (1_000_000, 'M'), + (1_000, 'K'), + ) + for threshold, suffix in thresholds: + if number >= threshold: + compact = number / threshold + if compact >= 10: + return f'{compact:.0f}{suffix} views' + return f'{compact:.1f}{suffix} views' + return f'{number} views' + + def _build_view_count(self, value: Union[str, int, None]) -> dict: + count_text = None + count_short = None + if isinstance(value, int): + count_text = f'{value:,} views' + count_short = self._compact_number(value) + elif isinstance(value, str) and value.strip(): + normalized = value.strip() + if normalized.isdigit(): + number = int(normalized) + count_text = f'{number:,} views' + count_short = self._compact_number(number) + else: + count_text = normalized if 'view' in normalized.lower() else f'{normalized} views' + count_short = count_text + return { + 'text': count_text, + 'short': count_short or count_text, + } + + def _description_from_text(self, value: Union[str, None]) -> Union[list, None]: + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + return [{'text': text}] + + def _iso_to_relative_time(self, value: Union[str, None]) -> Union[str, None]: + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.strip().replace('Z', '+00:00') + try: + published = datetime.fromisoformat(normalized) + except ValueError: + return None + if published.tzinfo is None: + published = published.replace(tzinfo=timezone.utc) + now = datetime.now(timezone.utc) + delta = now - published.astimezone(timezone.utc) + seconds = max(int(delta.total_seconds()), 0) + + units = ( + ('year', 365 * 24 * 60 * 60), + ('month', 30 * 24 * 60 * 60), + ('week', 7 * 24 * 60 * 60), + ('day', 24 * 60 * 60), + ('hour', 60 * 60), + ('minute', 60), + ) + for label, size in units: + if seconds >= size: + amount = seconds // size + suffix = '' if amount == 1 else 's' + return f'{amount} {label}{suffix} ago' + return 'just now' + + def _withDefaultCounts(self, counts: dict, fallback: str) -> dict: + text = self._defaultText(counts.get('text'), fallback) + short = self._defaultText(counts.get('short'), text) + result = dict(counts) + result['text'] = text + result['short'] = short + if 'label' in result: + result['label'] = self._defaultText(result.get('label'), text) + return result + + def _finalizeComponent(self, component: dict) -> dict: + accessibility = component.get('accessibility') + if isinstance(accessibility, dict) and 'duration' in component: + accessible_duration = accessibility.get('duration') + if isinstance(accessible_duration, str) and accessible_duration.strip(): + component['duration'] = accessible_duration + component['id'] = self._defaultText(component.get('id'), 'No id') + component['title'] = self._defaultText(component.get('title'), 'No title') + component['thumbnails'] = component.get('thumbnails') or [] + if 'richThumbnail' in component and component['richThumbnail'] is None: + component['richThumbnail'] = {} + if 'descriptionSnippet' in component and component['descriptionSnippet'] is None: + component['descriptionSnippet'] = [] + if 'publishedTime' in component: + component['publishedTime'] = self._defaultText(component.get('publishedTime'), 'No published time') + if 'duration' in component: + component['duration'] = self._defaultText(component.get('duration'), 'No duration') + if 'viewCount' in component and isinstance(component['viewCount'], dict): + component['viewCount'] = self._withDefaultCounts(component['viewCount'], 'No views') + if 'link' in component: + component['link'] = self._defaultText(self._normalizeUrl(component.get('link')), 'No link') + if 'shelfTitle' in component: + component['shelfTitle'] = self._defaultText(component.get('shelfTitle'), 'No shelf title') + channel = component.get('channel') + if isinstance(channel, dict): + channel['name'] = self._defaultText(channel.get('name'), 'No channel') + channel['id'] = self._defaultText(channel.get('id'), 'No channel id') + channel['link'] = self._defaultText(self._normalizeUrl(channel.get('link')), 'No channel link') + channel['thumbnails'] = channel.get('thumbnails') or [] + component['channel'] = channel + return component + + def _getTextFromMetadataRows(self, rows: list) -> list: + parsed_rows = [] + for row in rows or []: + parts = self._getValue(row, ['metadataParts']) or [] + texts = [] + for part in parts: + text = self._getText(self._getValue(part, ['text'])) + if text: + texts.append(text) + if texts: + parsed_rows.append(texts) + return parsed_rows + + def _getVideoComponent(self, element: dict, shelfTitle: str = None) -> dict: + video = element[videoElementKey] + title = self._getText(self._getValue(video, ['title'])) or self._getText(self._getValue(video, ['headline'])) + view_text = self._getText(self._getValue(video, ['viewCountText'])) + view_short = self._getText(self._getValue(video, ['shortViewCountText'])) + channel_name = self._getText(self._getValue(video, ['ownerText'])) or self._getText(self._getValue(video, ['longBylineText'])) or self._getText(self._getValue(video, ['shortBylineText'])) + channel_id = self._getValue(video, ['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']) or self._getValue(video, ['longBylineText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']) or self._getValue(video, ['shortBylineText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']) + channel_navigation = self._getValue(video, ['ownerText', 'runs', 0, 'navigationEndpoint']) or self._getValue(video, ['longBylineText', 'runs', 0, 'navigationEndpoint']) or self._getValue(video, ['shortBylineText', 'runs', 0, 'navigationEndpoint']) + link = self._getCanonicalUrl(self._getValue(video, ['navigationEndpoint'])) + component_type = 'shorts' if getattr(self, 'forceShorts', False) or self._isShortsUrl(link) else 'video' + component = { + 'type': component_type, + 'id': self._getValue(video, ['videoId']), + 'title': title, + 'publishedTime': self._getText(self._getValue(video, ['publishedTimeText'])), + 'duration': self._getText(self._getValue(video, ['lengthText'])), + 'viewCount': { + 'text': view_text, + 'short': view_short or view_text, + }, + 'thumbnails': self._getThumbnailSources(self._getValue(video, ['thumbnail'])), + 'richThumbnail': self._getValue(video, ['richThumbnail', 'movingThumbnailRenderer', 'movingThumbnailDetails', 'thumbnails', 0]), + 'descriptionSnippet': self._getValue(video, ['detailedMetadataSnippets', 0, 'snippetText', 'runs']) or self._getValue(video, ['descriptionSnippet', 'runs']), + 'channel': { + 'name': channel_name, + 'id': channel_id, + 'thumbnails': self._getThumbnailSources(self._getValue(video, ['channelThumbnailSupportedRenderers', 'channelThumbnailWithLinkRenderer', 'thumbnail'])), + }, + 'accessibility': { + 'title': self._getValue(video, ['title', 'accessibility', 'accessibilityData', 'label']), + 'duration': self._getValue(video, ['lengthText', 'accessibility', 'accessibilityData', 'label']), + }, + } + if link: + component['link'] = link + elif component_type == 'shorts' and component['id']: + component['link'] = 'https://www.youtube.com/shorts/' + component['id'] + else: + component['link'] = 'https://www.youtube.com/watch?v=' + component['id'] if component['id'] else '' + channel_id = component['channel']['id'] + component['channel']['link'] = self._getCanonicalUrl(channel_navigation) or ('https://www.youtube.com/channel/' + channel_id if channel_id else '') + component['shelfTitle'] = shelfTitle + return self._finalizeComponent(component) + + def _getChannelComponent(self, element: dict) -> dict: + channel = element[channelElementKey] + subscriber_text = self._getText(self._getValue(channel, ['videoCountText'])) + subscriber_label = self._getValue(channel, ['videoCountText', 'accessibility', 'accessibilityData', 'label']) + video_count = None + video_count_candidate = self._getText(self._getValue(channel, ['subscriberCountText'])) + if video_count_candidate and 'subscriber' not in video_count_candidate.lower() and not video_count_candidate.startswith('@'): + video_count = video_count_candidate + component = { + 'type': 'channel', + 'id': self._getValue(channel, ['channelId']), + 'title': self._getText(self._getValue(channel, ['title'])), + 'thumbnails': self._getThumbnailSources(self._getValue(channel, ['thumbnail'])), + 'videoCount': video_count, + 'descriptionSnippet': self._getValue(channel, ['descriptionSnippet', 'runs']), + 'subscribersCount': { + 'text': subscriber_text, + 'short': subscriber_text, + 'label': subscriber_label or subscriber_text, + }, + 'handle': self._getText(self._getValue(channel, ['subscriberCountText'])), + } + component['link'] = self._getCanonicalUrl(self._getValue(channel, ['navigationEndpoint'])) or ('https://www.youtube.com/channel/' + component['id'] if component['id'] else '') + component['videoCount'] = self._defaultText(component.get('videoCount'), 'No videos') + component['subscribersCount'] = self._withDefaultCounts(component['subscribersCount'], 'No subscribers') + component['handle'] = self._defaultText(component.get('handle'), 'No handle') + return self._finalizeComponent(component) + + def _getPlaylistComponent(self, element: dict) -> dict: + if playlistElementKey in element: + playlist = element[playlistElementKey] + component = { + 'type': 'playlist', + 'id': self._getValue(playlist, ['playlistId']), + 'title': self._getText(self._getValue(playlist, ['title'])), + 'videoCount': self._getText(self._getValue(playlist, ['videoCountText'])) or self._getValue(playlist, ['videoCount']), + 'viewCount': { + 'text': None, + 'short': None, + }, + 'channel': { + 'name': self._getText(self._getValue(playlist, ['shortBylineText'])), + 'id': self._getValue(playlist, ['shortBylineText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']), + }, + 'thumbnails': self._getThumbnailSources(self._getValue(playlist, ['thumbnailRenderer', 'playlistVideoThumbnailRenderer', 'thumbnail'])), + 'descriptionSnippet': self._getValue(playlist, ['descriptionSnippet', 'runs']), + } + component['link'] = self._getCanonicalUrl(self._getValue(playlist, ['navigationEndpoint'])) or ('https://www.youtube.com/playlist?list=' + component['id'] if component['id'] else '') + channel_id = component['channel']['id'] + component['channel']['link'] = self._getCanonicalUrl(self._getValue(playlist, ['shortBylineText', 'runs', 0, 'navigationEndpoint'])) or ('https://www.youtube.com/channel/' + channel_id if channel_id else '') + component['videoCount'] = self._defaultText(component.get('videoCount'), 'No videos') + component['viewCount'] = self._withDefaultCounts(component['viewCount'], 'No views') + return self._finalizeComponent(component) + + playlist = element.get('lockupViewModel', {}) + metadata = self._getValue(playlist, ['metadata', 'lockupMetadataViewModel']) or {} + metadata_rows = self._getTextFromMetadataRows(self._getValue(metadata, ['metadata', 'contentMetadataViewModel', 'metadataRows']) or []) + thumbnail_badges = self._getValue(playlist, ['contentImage', 'collectionThumbnailViewModel', 'primaryThumbnail', 'thumbnailViewModel', 'overlays', 0, 'thumbnailOverlayBadgeViewModel', 'thumbnailBadges']) or [] + video_count = None + for badge in thumbnail_badges: + text = self._getText(self._getValue(badge, ['thumbnailBadgeViewModel', 'text'])) + if text and any(char.isdigit() for char in text): + video_count = text + break + row0_part0 = self._getValue(playlist, ['metadata', 'lockupMetadataViewModel', 'metadata', 'contentMetadataViewModel', 'metadataRows', 0, 'metadataParts', 0, 'text']) + command = self._getValue(row0_part0, ['commandRuns', 0, 'onTap', 'innertubeCommand']) or {} + channel_name = metadata_rows[0][0] if metadata_rows else None + channel_id = self._getValue(command, ['browseEndpoint', 'browseId']) + channel_link = self._getCanonicalUrl(command) + component = { + 'type': 'playlist', + 'id': self._getValue(playlist, ['contentId']), + 'title': self._getText(self._getValue(metadata, ['title'])), + 'videoCount': video_count, + 'viewCount': { + 'text': None, + 'short': None, + }, + 'channel': { + 'name': channel_name, + 'id': channel_id, + 'link': channel_link, + }, + 'thumbnails': self._getThumbnailSources(self._getValue(playlist, ['contentImage', 'collectionThumbnailViewModel'])), + 'descriptionSnippet': None, + } + component['link'] = 'https://www.youtube.com/playlist?list=' + component['id'] if component['id'] else self._getCanonicalUrl(self._getValue(playlist, ['rendererContext', 'commandContext', 'onTap', 'innertubeCommand'])) + component['videoCount'] = self._defaultText(component.get('videoCount'), 'No videos') + component['viewCount'] = self._withDefaultCounts(component['viewCount'], 'No views') + return self._finalizeComponent(component) + + def _getShortComponent(self, short: dict, shelfTitle: str = None) -> dict: + title = self._getText(self._getValue(short, ['overlayMetadata', 'primaryText'])) + short_view_text = self._getText(self._getValue(short, ['overlayMetadata', 'secondaryText'])) + video_id = self._getValue(short, ['onTap', 'innertubeCommand', 'reelWatchEndpoint', 'videoId']) + if not video_id: + entity_id = self._getValue(short, ['entityId']) + if isinstance(entity_id, str) and entity_id.startswith('shorts-shelf-item-'): + video_id = entity_id.rsplit('-', 1)[-1] + component = { + 'type': 'shorts', + 'id': video_id, + 'title': title, + 'publishedTime': None, + 'duration': None, + 'viewCount': { + 'text': short_view_text, + 'short': short_view_text, + }, + 'thumbnails': self._getThumbnailSources(self._getValue(short, ['thumbnailViewModel'])) or self._getThumbnailSources(self._getValue(short, ['onTap', 'innertubeCommand', 'reelWatchEndpoint', 'thumbnail'])), + 'richThumbnail': None, + 'descriptionSnippet': None, + 'channel': { + 'name': None, + 'id': None, + 'thumbnails': None, + 'link': None, + }, + 'accessibility': { + 'title': self._getValue(short, ['accessibilityText']), + 'duration': None, + }, + 'link': self._getCanonicalUrl(self._getValue(short, ['onTap', 'innertubeCommand'])) or ('https://www.youtube.com/shorts/' + video_id if video_id else ''), + 'shelfTitle': shelfTitle, + } + return self._finalizeComponent(component) + + def _getGridShelfComponents(self, element: dict) -> list: + shelf = element.get('gridShelfViewModel', {}) + shelf_title = self._getText(self._getValue(shelf, ['header', 'sectionHeaderViewModel', 'headline'])) + components = [] + for item in self._getValue(shelf, ['contents']) or []: + shorts_lockup = self._getValue(item, ['shortsLockupViewModel']) + if shorts_lockup: + components.append(self._getShortComponent(shorts_lockup, shelfTitle=shelf_title)) + return components + + def _getVideoFromChannelSearch(self, elements: list) -> list: + channelsearch = [] + for element in elements: + element = self._getValue(element, ["childVideoRenderer"]) + if element is None: + continue + json_data = { + "id": self._getValue(element, ["videoId"]), + "title": self._getText(self._getValue(element, ["title"])), + "uri": self._getValue(element, ["navigationEndpoint", "commandMetadata", "webCommandMetadata", "url"]), + "duration": { + "simpleText": self._getText(self._getValue(element, ["lengthText"])), + "text": self._getValue(element, ["lengthText", "accessibility", "accessibilityData", "label"]) + } + } + channelsearch.append(json_data) + return channelsearch + + def _getChannelSearchComponent(self, elements: list) -> list: + channelsearch = [] + for element in elements: + responsetype = None + + if 'gridPlaylistRenderer' in element: + element = element['gridPlaylistRenderer'] + responsetype = 'gridplaylist' + elif 'itemSectionRenderer' in element: + contents = element["itemSectionRenderer"].get("contents", []) + if not contents: + continue + first_content = contents[0] + if 'videoRenderer' in first_content: + element = first_content['videoRenderer'] + responsetype = "video" + elif 'playlistRenderer' in first_content: + element = first_content["playlistRenderer"] + responsetype = "playlist" + else: + continue + elif 'continuationItemRenderer' in element: + continue + else: + continue + + if responsetype == "video": + json_data = { + "id": self._getValue(element, ["videoId"]), + "thumbnails": { + "normal": self._getThumbnailSources(self._getValue(element, ["thumbnail"])), + "rich": self._getValue(element, ["richThumbnail", "movingThumbnailRenderer", "movingThumbnailDetails", "thumbnails"]) + }, + "title": self._getText(self._getValue(element, ["title"])), + "descriptionSnippet": self._getText(self._getValue(element, ["descriptionSnippet"])) if self._getValue(element, ["descriptionSnippet"]) else None, + "uri": self._getValue(element, ["navigationEndpoint", "commandMetadata", "webCommandMetadata", "url"]), + "views": { + "precise": self._getText(self._getValue(element, ["viewCountText"])), + "simple": self._getText(self._getValue(element, ["shortViewCountText"])), + "approximate": self._getValue(element, ["shortViewCountText", "accessibility", "accessibilityData", "label"]) + }, + "duration": { + "simpleText": self._getText(self._getValue(element, ["lengthText"])), + "text": self._getValue(element, ["lengthText", "accessibility", "accessibilityData", "label"]) + }, + "published": self._getText(self._getValue(element, ["publishedTimeText"])), + "channel": { + "name": self._getText(self._getValue(element, ["ownerText"])), + "thumbnails": self._getThumbnailSources(self._getValue(element, ["channelThumbnailSupportedRenderers", "channelThumbnailWithLinkRenderer", "thumbnail"])) + }, + "type": responsetype + } + elif responsetype == 'playlist': + json_data = { + "id": self._getValue(element, ["playlistId"]), + "videos": self._getVideoFromChannelSearch(self._getValue(element, ["videos"]) or []), + "thumbnails": { + "normal": self._getThumbnailSources(self._getValue(element, ["thumbnails"])), + }, + "title": self._getText(self._getValue(element, ["title"])), + "uri": self._getValue(element, ["navigationEndpoint", "commandMetadata", "webCommandMetadata", "url"]), + "channel": { + "name": self._getText(self._getValue(element, ["longBylineText"])), + }, + "type": responsetype + } + else: + json_data = { + "id": self._getValue(element, ["playlistId"]), + "thumbnails": { + "normal": self._getValue(element, ["thumbnail", "thumbnails", 0]) if self._getValue(element, ["thumbnail", "thumbnails"]) else None, + }, + "title": self._getText(self._getValue(element, ["title"])), + "uri": self._getValue(element, ["navigationEndpoint", "commandMetadata", "webCommandMetadata", "url"]), + "type": 'playlist' + } + channelsearch.append(json_data) + return channelsearch + + def _getShelfComponent(self, element: dict) -> dict: + shelf = element[shelfElementKey] + return { + 'title': self._getText(self._getValue(shelf, ['title'])), + 'elements': self._getValue(shelf, ['content', 'verticalListRenderer', 'items']), + } + + def _getValue(self, source: dict, path: List[Union[str, int]]) -> Union[str, int, dict, list, None]: + if source is None: + return None + value = source + for key in path: + if value is None: + return None + if type(key) is str: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return None + elif type(key) is int: + if isinstance(value, list) and value: + try: + value = value[key] + except IndexError: + return None + else: + return None + return value diff --git a/platforms/youtube/vendor/youtubesearchpython/handlers/requesthandler.py b/platforms/youtube/vendor/youtubesearchpython/handlers/requesthandler.py new file mode 100644 index 0000000..0470f49 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/handlers/requesthandler.py @@ -0,0 +1,78 @@ +from urllib.request import Request, urlopen +from urllib.parse import urlencode +import json +import copy +from youtubesearchpython.handlers.componenthandler import ComponentHandler +from youtubesearchpython.core.constants import * + + +class RequestHandler(ComponentHandler): + def _collectSearchItems(self, elements): + items = [] + for element in elements or []: + if not isinstance(element, dict): + continue + if itemSectionKey in element: + items.extend(self._collectSearchItems(self._getValue(element, [itemSectionKey, 'contents']) or [])) + continue + if continuationItemKey in element: + token = self._getValue(element, continuationKeyPath) + if token: + self.continuationKey = token + continue + if ( + videoElementKey in element + or channelElementKey in element + or playlistElementKey in element + or richItemKey in element + or shelfElementKey in element + or 'lockupViewModel' in element + or 'gridShelfViewModel' in element + ): + items.append(element) + return items + + def _makeRequest(self) -> None: + ''' Fixes #47 ''' + requestBody = copy.deepcopy(requestPayload) + requestBody['query'] = self.query + requestBody['client'] = { + 'hl': self.language, + 'gl': self.region, + } + if self.searchPreferences: + requestBody['params'] = self.searchPreferences + if self.continuationKey: + requestBody['continuation'] = self.continuationKey + requestBodyBytes = json.dumps(requestBody).encode('utf_8') + request = Request( + 'https://www.youtube.com/youtubei/v1/search' + '?' + urlencode({ + 'key': searchKey, + }), + data = requestBodyBytes, + headers = { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': len(requestBodyBytes), + 'User-Agent': userAgent, + } + ) + try: + self.response = urlopen(request, timeout=self.timeout).read().decode('utf_8') + except: + raise Exception('ERROR: Could not make request.') + + def _parseSource(self) -> None: + try: + if not self.continuationKey: + response = json.loads(self.response) + responseContent = self._getValue(response, contentPath) + else: + response = json.loads(self.response) + responseContent = self._getValue(response, continuationContentPath) + if responseContent: + self.responseSource = self._collectSearchItems(responseContent) + else: + fallback = self._getValue(response, fallbackContentPath) or [] + self.responseSource = self._collectSearchItems(fallback) + except: + raise Exception('ERROR: Could not parse YouTube response.') diff --git a/platforms/youtube/vendor/youtubesearchpython/legacy/__init__.py b/platforms/youtube/vendor/youtubesearchpython/legacy/__init__.py new file mode 100644 index 0000000..da610d2 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/legacy/__init__.py @@ -0,0 +1,245 @@ +from typing import List, Union +import json +from youtubesearchpython.handlers.componenthandler import ComponentHandler +from youtubesearchpython.handlers.requesthandler import RequestHandler +from youtubesearchpython.core.constants import * + + +def overrides(interface_class): + def overrider(method): + assert(method.__name__ in dir(interface_class)) + return method + return overrider + + +class LegacyComponentHandler(RequestHandler, ComponentHandler): + index = 0 + + @overrides(ComponentHandler) + def _getVideoComponent(self, element: dict, shelfTitle: str = None) -> dict: + video = element[videoElementKey] + videoId = self.__getValue(video, ['videoId']) + viewCount = 0 + thumbnails = [] + for character in self.__getValue(video, ['viewCountText', 'simpleText']): + if character.isnumeric(): + viewCount = viewCount * 10 + int(character) + modes = ['default', 'hqdefault', 'mqdefault', 'sddefault', 'maxresdefault'] + for mode in modes: + thumbnails.append('https://img.youtube.com/vi/' + videoId + '/' + mode + '.jpg') + component = { + 'index': self.index, + 'id': videoId, + 'link': 'https://www.youtube.com/watch?v=' + videoId, + 'title': self.__getValue(video, ['title', 'runs', 0, 'text']), + 'channel': self.__getValue(video, ['ownerText', 'runs', 0, 'text']), + 'duration': self.__getValue(video, ['lengthText', 'simpleText']), + 'views': viewCount, + 'thumbnails': thumbnails, + 'channeId': self.__getValue(video, ['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']), + 'publishTime': self.__getValue(video, ['publishedTimeText', 'simpleText']), + } + self.index += 1 + return component + + @overrides(ComponentHandler) + def _getPlaylistComponent(self, element: dict) -> dict: + playlist = element[playlistElementKey] + playlistId = self.__getValue(playlist, ['playlistId']) + thumbnailVideoId = self.__getValue(playlist, ['navigationEndpoint', 'watchEndpoint', 'videoId']) + thumbnails = [] + modes = ['default', 'hqdefault', 'mqdefault', 'sddefault', 'maxresdefault'] + for mode in modes: + thumbnails.append('https://img.youtube.com/vi/' + thumbnailVideoId + '/' + mode + '.jpg') + component = { + 'index': self.index, + 'id': playlistId, + 'link': 'https://www.youtube.com/playlist?list=' + playlistId, + 'title': self.__getValue(playlist, ['title', 'simpleText']), + 'thumbnails': thumbnails, + 'count': self.__getValue(playlist, ['videoCount']), + 'channel': self.__getValue(playlist, ['shortBylineText', 'runs', 0, 'text']), + } + self.index += 1 + return component + + @overrides(ComponentHandler) + def _getShelfComponent(self, element: dict) -> dict: + shelf = element[shelfElementKey] + return { + 'title': self.__getValue(shelf, ['title', 'simpleText']), + 'elements': self.__getValue(shelf, ['content', 'verticalListRenderer', 'items']), + } + + def __getValue(self, component: dict, path: List[str]) -> Union[str, int, dict]: + value = component + for key in path: + if type(key) is str: + if key in value.keys(): + value = value[key] + else: + value = 'LIVE' + break + elif type(key) is int: + if len(value) != 0: + value = value[key] + else: + value = 'LIVE' + break + return value + +class LegacySearchInternal(LegacyComponentHandler): + exception = False + resultComponents = [] + responseSource = [] + + def __init__(self, keyword, offset, mode, max_results, language, region): + self.page = offset + self.query = keyword + self.mode = mode + self.limit = max_results + self.language = language + self.region = region + self.continuationKey = None + self.timeout = None + + def result(self) -> Union[str, dict, list, None]: + '''Returns the search result. + + Returns: + Union[str, dict, list, None]: Returns JSON, list or dictionary & None in case of any exception. + ''' + if self.exception or len(self.resultComponents) == 0: + return None + else: + if self.mode == 'dict': + return {'search_result': self.resultComponents} + elif self.mode == 'json': + return json.dumps({'search_result': self.resultComponents}, indent = 4) + elif self.mode == 'list': + result = [] + for component in self.resultComponents: + listComponent = [] + for key in component.keys(): + listComponent.append(component[key]) + result.append(listComponent) + return result + + +class SearchVideos(LegacySearchInternal): + ''' + DEPRECATED + ---------- + Use `VideosSearch` instead. + + Searches for playlists in YouTube. + + Args: + keyword (str): Sets the search query. + offset (int, optional): Sets the search result page number. Defaults to 1. + mode (str, optional): Sets the result type, can be 'json', 'dict' or 'list'. Defaults to 'json'. + max_results (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en-US'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = SearchPlaylists('Harry Styles', max_results = 1) + >>> print(search.result()) + { + "search_result": [ + { + "index": 0, + "id": "PLj-vAPBrjcxoBfEk3q2Jp-naXRFpekySW", + "link": "https://www.youtube.com/playlist?list=PLj-vAPBrjcxoBfEk3q2Jp-naXRFpekySW", + "title": "Harry Styles - Harry Styles Full Album videos with lyrics", + "thumbnails": [ + "https://img.youtube.com/vi/Y9yOG_dJwFg/default.jpg", + "https://img.youtube.com/vi/Y9yOG_dJwFg/hqdefault.jpg", + "https://img.youtube.com/vi/Y9yOG_dJwFg/mqdefault.jpg", + "https://img.youtube.com/vi/Y9yOG_dJwFg/sddefault.jpg", + "https://img.youtube.com/vi/Y9yOG_dJwFg/maxresdefault.jpg" + ], + "count": "10", + "channel": "Jana Hol\u00fabkov\u00e1" + } + ] + } + ''' + def __init__(self, keyword, offset = 1, mode = 'json', max_results = 20, language = 'en', region = 'US'): + super().__init__(keyword, offset, mode, max_results, language, region) + self.searchPreferences = 'EgIQAQ%3D%3D' + self._makeRequest() + self._parseSource() + self.__makeComponents() + + def __makeComponents(self) -> None: + self.resultComponents = [] + for element in self.responseSource: + if videoElementKey in element.keys(): + self.resultComponents.append(self._getVideoComponent(element)) + if shelfElementKey in element.keys(): + for shelfElement in self._getShelfComponent(element)['elements']: + self.resultComponents.append(self._getVideoComponent(shelfElement)) + if len(self.resultComponents) >= self.limit: + break + +class SearchPlaylists(LegacySearchInternal): + ''' + DEPRECATED + ---------- + Use `PlaylistsSearch` instead. + + Searches for playlists in YouTube. + + Args: + keyword (str): Sets the search query. + offset (int, optional): Sets the search result page number. Defaults to 1. + mode (str, optional): Sets the result type, can be 'json', 'dict' or 'list'. Defaults to 'json'. + max_results (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en-US'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = SearchVideos('Watermelon Sugar', max_results = 1) + >>> print(search.result()) + { + "search_result": [ + { + "index": 0, + "id": "E07s5ZYygMg", + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "channel": "Harry Styles", + "duration": "3:09", + "views": 162235006, + "thumbnails": [ + "https://img.youtube.com/vi/E07s5ZYygMg/default.jpg", + "https://img.youtube.com/vi/E07s5ZYygMg/hqdefault.jpg", + "https://img.youtube.com/vi/E07s5ZYygMg/mqdefault.jpg", + "https://img.youtube.com/vi/E07s5ZYygMg/sddefault.jpg", + "https://img.youtube.com/vi/E07s5ZYygMg/maxresdefault.jpg" + ], + "channeId": "UCZFWPqqPkFlNwIxcpsLOwew", + "publishTime": "6 months ago" + } + ] + } + ''' + def __init__(self, keyword, offset = 1, mode = 'json', max_results = 20, language = 'en', region = 'US'): + super().__init__(keyword, offset, mode, max_results, language, region) + self.searchPreferences = 'EgIQAw%3D%3D' + self._makeRequest() + self._parseSource() + self.__makeComponents() + + def __makeComponents(self) -> None: + self.resultComponents = [] + for element in self.responseSource: + if playlistElementKey in element.keys(): + self.resultComponents.append(self._getPlaylistComponent(element)) + if len(self.resultComponents) >= self.limit: + break diff --git a/platforms/youtube/vendor/youtubesearchpython/search.py b/platforms/youtube/vendor/youtubesearchpython/search.py new file mode 100644 index 0000000..b3a46a3 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/search.py @@ -0,0 +1,457 @@ +from youtubesearchpython.core.constants import * +from youtubesearchpython.core.search import SearchCore +from youtubesearchpython.core.channelsearch import ChannelSearchCore + + +class Search(SearchCore): + '''Searches for videos, channels & playlists in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = Search('Watermelon Sugar', limit = 1) + >>> print(search.result()) + { + "result": [ + { + "type": "video", + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "publishedTime": "6 months ago", + "duration": "3:09", + "viewCount": { + "text": "162,235,006 views", + "short": "162M views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAOWBTE1SDrtrDQ1aWNzpDZ7YiMIw", + "width": 360, + "height": 202 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD7U54pGZLPKTuMP-J3kpm4LIDPVg", + "width": 720, + "height": 404 + } + ], + "descriptionSnippet": [ + { + "text": "This video is dedicated to touching. Listen to Harry Styles' new album 'Fine Line' now: https://HStyles.lnk.to/FineLineAY Follow\u00a0..." + } + ], + "channel": { + "name": "Harry Styles", + "id": "UCZFWPqqPkFlNwIxcpsLOwew", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/a-/AOh14GgNUvHxwlnz4RpHamcGnZF1px13VHj01TPksw=s68-c-k-c0x00ffffff-no-rj-mo", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UCZFWPqqPkFlNwIxcpsLOwew" + }, + "accessibility": { + "title": "Harry Styles - Watermelon Sugar (Official Video) by Harry Styles 6 months ago 3 minutes, 9 seconds 162,235,006 views", + "duration": "3 minutes, 9 seconds" + }, + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "shelfTitle": null + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: int = None): + self.searchMode = (True, True, True) + super().__init__(query, limit, language, region, None, timeout) + self.max_workers = 50 + self.sync_create() + self._getComponents(*self.searchMode) + + def next(self) -> bool: + return self._next() + +class VideosSearch(SearchCore): + '''Searches for videos in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = VideosSearch('Watermelon Sugar', limit = 1) + >>> print(search.result()) + { + "result": [ + { + "type": "video", + "id": "E07s5ZYygMg", + "title": "Harry Styles - Watermelon Sugar (Official Video)", + "publishedTime": "6 months ago", + "duration": "3:09", + "viewCount": { + "text": "162,235,006 views", + "short": "162M views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLAOWBTE1SDrtrDQ1aWNzpDZ7YiMIw", + "width": 360, + "height": 202 + }, + { + "url": "https://i.ytimg.com/vi/E07s5ZYygMg/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD7U54pGZLPKTuMP-J3kpm4LIDPVg", + "width": 720, + "height": 404 + } + ], + "descriptionSnippet": [ + { + "text": "This video is dedicated to touching. Listen to Harry Styles' new album 'Fine Line' now: https://HStyles.lnk.to/FineLineAY Follow\u00a0..." + } + ], + "channel": { + "name": "Harry Styles", + "id": "UCZFWPqqPkFlNwIxcpsLOwew", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/a-/AOh14GgNUvHxwlnz4RpHamcGnZF1px13VHj01TPksw=s68-c-k-c0x00ffffff-no-rj-mo", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UCZFWPqqPkFlNwIxcpsLOwew" + }, + "accessibility": { + "title": "Harry Styles - Watermelon Sugar (Official Video) by Harry Styles 6 months ago 3 minutes, 9 seconds 162,235,006 views", + "duration": "3 minutes, 9 seconds" + }, + "link": "https://www.youtube.com/watch?v=E07s5ZYygMg", + "shelfTitle": null + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: int = None): + self.searchMode = (True, False, False) + super().__init__(query, limit, language, region, SearchMode.videos, timeout) + self.max_workers = 50 + self.sync_create() + self._getComponents(*self.searchMode) + + def next(self) -> bool: + return self._next() + + +class ChannelsSearch(SearchCore): + '''Searches for channels in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = ChannelsSearch('Harry Styles', limit = 1) + >>> print(search.result()) + { + "result": [ + { + "type": "channel", + "id": "UCZFWPqqPkFlNwIxcpsLOwew", + "title": "Harry Styles", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhR81ocC_KalYEk5ItnJcfMBqaiIpuM1B0lJyg4Rw=s88-c-k-c0x00ffffff-no-rj-mo", + "width": 88, + "height": 88 + }, + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhR81ocC_KalYEk5ItnJcfMBqaiIpuM1B0lJyg4Rw=s176-c-k-c0x00ffffff-no-rj-mo", + "width": 176, + "height": 176 + } + ], + "videoCount": "7", + "descriptionSnippet": null, + "subscribers": "9.25M subscribers", + "link": "https://www.youtube.com/channel/UCZFWPqqPkFlNwIxcpsLOwew" + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: int = None): + self.searchMode = (False, True, False) + super().__init__(query, limit, language, region, SearchMode.channels, timeout) + self.max_workers = 50 + self.sync_create() + self._getComponents(*self.searchMode) + + def next(self) -> bool: + return self._next() + + +class PlaylistsSearch(SearchCore): + '''Searches for playlists in YouTube. + + Args: + query (str): Sets the search query. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = PlaylistsSearch('Harry Styles', limit = 1) + >>> print(search.result()) + { + "result": [ + { + "type": "playlist", + "id": "PL-Rt4gIwHnyvxpEl-9Le0ePztR7WxGDGV", + "title": "fine line harry styles full album lyrics", + "videoCount": "12", + "channel": { + "name": "ourmemoriestonight", + "id": "UCZCmb5a8LE9LMxW9I3-BFjA", + "link": "https://www.youtube.com/channel/UCZCmb5a8LE9LMxW9I3-BFjA" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEWCKgBEF5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLCdCfOQYMrPImHMObdrMcNimKi1PA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEWCMQBEG5IWvKriqkDCQgBFQAAiEIYAQ==&rs=AOn4CLDsKmyGH8bkmt9MzZqIoXI4UaduBw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEXCPYBEIoBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLD9v7S0KeHLBLr0bF-LrRjYVycUFA", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/raTh8Mu5oyM/hqdefault.jpg?sqp=-oaymwEXCNACELwBSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLAIzQIVxZsC0PfvLOt-v9UWJ-109Q", + "width": 336, + "height": 188 + } + ], + "link": "https://www.youtube.com/playlist?list=PL-Rt4gIwHnyvxpEl-9Le0ePztR7WxGDGV" + } + ] + } + ''' + def __init__(self, query: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: int = None): + self.searchMode = (False, False, True) + super().__init__(query, limit, language, region, SearchMode.playlists, timeout) + self.max_workers = 50 + self.sync_create() + self._getComponents(*self.searchMode) + + def next(self) -> bool: + return self._next() + + +class ChannelSearch(ChannelSearchCore): + '''Searches for videos in specific channel in YouTube. + + Args: + query (str): Sets the search query. + browseId (str): Channel ID + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = ChannelSearch('Watermelon Sugar', "UCZFWPqqPkFlNwIxcpsLOwew") + >>> print(search.result()) + { + "result": [ + { + "id": "WMcIfZuRuU8", + "thumbnails": { + "normal": [ + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEbCKgBEF5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLClFg6C1r5NfTQy7TYUq6X5qHUmPA", + "width": 168, + "height": 94 + }, + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEbCMQBEG5IVfKriqkDDggBFQAAiEIYAXABwAEG&rs=AOn4CLAoOyftwY0jLV4geWb5hejULYp3Zw", + "width": 196, + "height": 110 + }, + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEcCPYBEIoBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLCdqkhn7JDwLvRtTNx3jq-olz7k-Q", + "width": 246, + "height": 138 + }, + { + "url": "https://i.ytimg.com/vi/WMcIfZuRuU8/hqdefault.jpg?sqp=-oaymwEcCNACELwBSFXyq4qpAw4IARUAAIhCGAFwAcABBg==&rs=AOn4CLAhYedsqBFKI0Ra2qzIv9cVoZhfKQ", + "width": 336, + "height": 188 + } + ], + "rich": null + }, + "title": "Harry Styles \u2013 Watermelon Sugar (Lost Tour Visual)", + "descriptionSnippet": "This video is dedicated to touching.\nListen to Harry Styles\u2019 new album \u2018Fine Line\u2019 now: https://HStyles.lnk.to/FineLineAY \n\nFollow Harry Styles:\nFacebook: https://HarryStyles.lnk.to/followFI...", + "uri": "/watch?v=WMcIfZuRuU8", + "views": { + "precise": "3,888,287 views", + "simple": "3.8M views", + "approximate": "3.8 million views" + }, + "duration": { + "simpleText": "2:55", + "text": "2 minutes, 55 seconds" + }, + "published": "10 months ago", + "channel": { + "name": "Harry Styles", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/ytc/AAUvwnhR81ocC_KalYEk5ItnJcfMBqaiIpuM1B0lJyg4Rw=s88-c-k-c0x00ffffff-no-rj", + "width": 68, + "height": 68 + } + ] + }, + "type": "video" + }, + ] + } + ''' + + def __init__(self, query: str, browseId: str, language: str = 'en', region: str = 'US', searchPreferences: str = "EgZzZWFyY2g%3D", timeout: int = None): + super().__init__(query, language, region, searchPreferences, browseId, timeout) + self.sync_create() + + +class CustomSearch(SearchCore): + '''Performs custom search in YouTube with search filters or sorting orders. + Few of the predefined filters and sorting orders are: + + 1 - SearchMode.videos + 2 - VideoUploadDateFilter.lastHour + 3 - VideoDurationFilter.long + 4 - VideoSortOrder.viewCount + + There are many other to use. + The value of `sp` parameter in the YouTube search query can be used as a search filter e.g. + `EgQIBRAB` from https://www.youtube.com/results?search_query=NoCopyrightSounds&sp=EgQIBRAB can be passed as `searchPreferences`, to get videos, which are uploaded this year. + + Args: + query (str): Sets the search query. + searchPreferences (str): Sets the `sp` query parameter in the YouTube search request. + limit (int, optional): Sets limit to the number of results. Defaults to 20. + language (str, optional): Sets the result language. Defaults to 'en'. + region (str, optional): Sets the result region. Defaults to 'US'. + + Examples: + Calling `result` method gives the search result. + + >>> search = CustomSearch('Harry Styles', VideoSortOrder.viewCount, limit = 1) + >>> print(search.result()) + { + "result": [ + { + "type": "video", + "id": "QJO3ROT-A4E", + "title": "One Direction - What Makes You Beautiful (Official Video)", + "publishedTime": "9 years ago", + "duration": "3:27", + "viewCount": { + "text": "1,212,146,802 views", + "short": "1.2B views" + }, + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/QJO3ROT-A4E/hq720.jpg?sqp=-oaymwEjCOgCEMoBSFryq4qpAxUIARUAAAAAGAElAADIQj0AgKJDeAE=&rs=AOn4CLDeFKrH99gmpnvKyG4czdd__YRDkw", + "width": 360, + "height": 202 + }, + { + "url": "https://i.ytimg.com/vi/QJO3ROT-A4E/hq720.jpg?sqp=-oaymwEXCNAFEJQDSFryq4qpAwkIARUAAIhCGAE=&rs=AOn4CLBJ_wUjsRFXGsbvRpwYpSLlsGmbkw", + "width": 720, + "height": 404 + } + ], + "descriptionSnippet": [ + { + "text": "One Direction \u2013 What Makes You Beautiful (Official Video) Follow on Spotify - https://1D.lnk.to/Spotify Listen on Apple Music\u00a0..." + } + ], + "channel": { + "name": "One Direction", + "id": "UCb2HGwORFBo94DmRx4oLzow", + "thumbnails": [ + { + "url": "https://yt3.ggpht.com/a-/AOh14Gj3SMvtIAvVNUrHWFTJFubPN7qozzPl5gFkoA=s68-c-k-c0x00ffffff-no-rj-mo", + "width": 68, + "height": 68 + } + ], + "link": "https://www.youtube.com/channel/UCb2HGwORFBo94DmRx4oLzow" + }, + "accessibility": { + "title": "One Direction - What Makes You Beautiful (Official Video) by One Direction 9 years ago 3 minutes, 27 seconds 1,212,146,802 views", + "duration": "3 minutes, 27 seconds" + }, + "link": "https://www.youtube.com/watch?v=QJO3ROT-A4E", + "shelfTitle": null + } + ] + } + ''' + def __init__(self, query: str, searchPreferences: str, limit: int = 20, language: str = 'en', region: str = 'US', timeout: int = None): + self.searchMode = (True, False, False) if searchPreferences == SearchMode.shorts else (True, True, True) + super().__init__(query, limit, language, region, searchPreferences, timeout) + self.max_workers = 50 + self.forceShorts = searchPreferences == SearchMode.shorts + self.sync_create() + self._getComponents(*self.searchMode) + + def next(self): + return self._next() + +class ShortsSearch(CustomSearch): + def __init__( + self, + query: str, + limit: int = 20, + language: str = 'en', + region: str = 'US', + timeout: int = None + ): + self.searchMode = (True, False, False) + + super().__init__( + query=query, + searchPreferences=SearchMode.shorts, + limit=limit, + language=language, + region=region, + timeout=timeout + ) + self.forceShorts = True + self.resultComponents = [] + self._getComponents(*self.searchMode) diff --git a/platforms/youtube/vendor/youtubesearchpython/streamurlfetcher.py b/platforms/youtube/vendor/youtubesearchpython/streamurlfetcher.py new file mode 100644 index 0000000..1bbe7a0 --- /dev/null +++ b/platforms/youtube/vendor/youtubesearchpython/streamurlfetcher.py @@ -0,0 +1,136 @@ +from typing import Union +from youtubesearchpython.core.streamurlfetcher import StreamURLFetcherCore + + +class StreamURLFetcher(StreamURLFetcherCore): + '''Gets direct stream URLs for a YouTube video fetched using `Video.get` or `Video.getFormats`. + + This class can fetch direct video URLs without any additional network requests (that's really fast). + + Call `get` or `getAll` method of this class & pass response returned by `Video.get` or `Video.getFormats` as parameter to fetch direct URLs. + Getting URLs or downloading streams using youtube-dl or PyTube is can be a slow, because of the fact that they make requests to fetch the same content, which one might have already recieved at the time of showing it to the user etc. + This class makes use of PyTube (if installed) & makes some slight improvements to functioning of PyTube. + Avoid instantiating this class more than once, it will be slow (making global object of the class will be a recommended solution). + + Raises: + Exception: "ERROR: PyTube is not installed. To use this functionality of youtube-search-python, PyTube must be installed." + + Examples: + Returns direct stream URL. + + >>> from youtubesearchpython import * + >>> fetcher = StreamURLFetcher() + >>> video = Video.get("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + >>> url = fetcher.get(video, 251) + >>> print(url) + "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=251&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=10210834&dur=634.601&lmt=1544629945422176&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgKKIEiwQTgXsdKPEyOckgVPs_LMH6KJoeaYmZic_lelECIHXHs1ZnSP5mgtpffNlIMJM3DhxcvDbA-4udFFE6AmVP&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D" + ''' + def __init__(self): + super().__init__() + #self._getJS() + + def get(self, videoFormats: dict, itag: int) -> Union[str, None]: + '''Gets direct stream URL for a YouTube video fetched using `Video.get` or `Video.getFormats`. + + Args: + videoFormats (dict): Dictionary returned by `Video.get` or `Video.getFormats`. + itag (int): Itag of the required stream. + + Returns: + Union[str, None]: Returns stream URL as string. None, if no stream is present for that itag. + + Examples: + Returns direct stream URL. + + >>> from youtubesearchpython import * + >>> fetcher = StreamURLFetcher() + >>> video = Video.get("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + >>> url = fetcher.get(video, 251) + >>> print(url) + "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=251&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=10210834&dur=634.601&lmt=1544629945422176&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRAIgKKIEiwQTgXsdKPEyOckgVPs_LMH6KJoeaYmZic_lelECIHXHs1ZnSP5mgtpffNlIMJM3DhxcvDbA-4udFFE6AmVP&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D" + ''' + self._getDecipheredURLs(videoFormats, itag) + if len(self._streams) == 1: + return self._streams[0]["url"] + return None + + def getAll(self, videoFormats: dict) -> Union[dict, None]: + '''Gets all stream URLs for a YouTube video fetched using `Video.get` or `Video.getFormats`. + + Args: + videoFormats (dict): Dictionary returned by `Video.get` or `Video.getFormats`. + + Returns: + Union[dict, None]: Returns stream URLs in a dictionary. + + Examples: + Returns direct stream URLs in a dictionary. + + >>> from youtubesearchpython import * + >>> fetcher = StreamURLFetcher() + >>> video = Video.get("https://www.youtube.com/watch?v=aqz-KE-bpKQ") + >>> allUrls = fetcher.getAll(video) + >>> print(allUrls) + { + "streams": [ + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=18&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fmp4&ns=AAHB1CvhVqlATtzQj67WHI8F&gir=yes&clen=47526444&ratebypass=yes&dur=634.624&lmt=1544610273905877&mt=1610776131&fvip=6&c=WEB&txp=5531432&n=Laycu1cJ2fCN_K&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIgdjTwmtEc3MpmRxH27ZvTgktL-d2by5HXXGFwo3EGR4MCIQDi0oiI8mshGssiOFu1XzQCqljZuNLhA6z19S8Ig0CRTQ%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", + "quality": "medium", + "itag": 18, + "bitrate": 599167, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=22&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fmp4&ns=AAHB1CvhVqlATtzQj67WHI8F&ratebypass=yes&dur=634.624&lmt=1544610886483826&mt=1610776131&fvip=6&c=WEB&txp=5532432&n=Laycu1cJ2fCN_K&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALaSHkcx0m9rfqJKoiJT1dY7spIKf-zDfq12SOdN7Ej5AiBCgvcUvLUGqGoMBnc0NIQtDeNM8ETJD2lTt9Bi7T186g%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/mp4; codecs=\"avc1.64001F, mp4a.40.2\"", + "quality": "hd720", + "itag": 22, + "bitrate": 1340380, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=315&aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C308%2C315%2C394%2C395%2C396%2C397%2C398%2C399&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=1648069666&dur=634.566&lmt=1544611995945231&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5532432&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgGaJmx70EkBCsfAYOI1lI695hXnFSEn-ZAfRiqWrnt9ACIQClBT5YZlou5ttgFzKnLZkUKxjZznxMJGPTNvtXCAlebw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/webm; codecs=\"vp9\"", + "quality": "hd2160", + "itag": 315, + "bitrate": 26416339, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=308&aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C308%2C315%2C394%2C395%2C396%2C397%2C398%2C399&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=627075264&dur=634.566&lmt=1544611159960793&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5532432&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALl1_ksmnpBhD49Hgjdg-z-Y4H2AL8hBx63ephvsvhbCAiAFrqyy65MimA4mCXYQBopP67G9dtwH9xyjHS_0hZ-rJA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/webm; codecs=\"vp9\"", + "quality": "hd1440", + "itag": 308, + "bitrate": 13381315, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=134&aitags=133%2C134%2C135%2C136%2C160%2C242%2C243%2C244%2C247%2C278%2C298%2C299%2C302%2C303%2C308%2C315%2C394%2C395%2C396%2C397%2C398%2C399&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=video%2Fmp4&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=26072934&dur=634.566&lmt=1544609325917976&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5532432&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Caitags%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAKT9N5EmUz3OQOc9IA8P1CuYgzPStz4ulJvCkA8Y1Cf4AiEAwwC2mCjOFWD5jFhAu8g0O6EF5fYJ7HmwskN1sjqTHlA%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "video/mp4; codecs=\"avc1.4d401e\"", + "quality": "medium", + "itag": 134, + "bitrate": 723888, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=249&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fwebm&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=3936299&dur=634.601&lmt=1544629945028066&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAJ_UffgeslE26GFwlMZHBsW-zYLcnanMqrvESdjWoupYAiAH7KlvQlYsokTVCCcD7jflD21Fjiim28qNzhOKZ88D3Q%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "audio/webm; codecs=\"opus\"", + "quality": "tiny", + "itag": 249, + "bitrate": 57976, + "is_otf": false + }, + { + "url": "https://r6---sn-gwpa-5bgk.googlevideo.com/videoplayback?expire=1610798125&ei=zX8CYITXEIGKz7sP9MWL0AE&ip=2409%3A4053%3A803%3A2b22%3Adc68%3Adfb9%3Aa676%3A26a3&id=o-APBakKSE2_eMDMegtCmeWXfuhhUfAzJTmOCWj4lkEjAM&itag=258&source=youtube&requiressl=yes&mh=aP&mm=31%2C29&mn=sn-gwpa-5bgk%2Csn-gwpa-qxad&ms=au%2Crdu&mv=m&mvi=6&pl=36&initcwndbps=146250&vprv=1&mime=audio%2Fmp4&ns=ULL4mkMO31KDtEhOjkOrmpkF&gir=yes&clen=30769612&dur=634.666&lmt=1544629837561969&mt=1610776131&fvip=6&keepalive=yes&c=WEB&txp=5511222&n=uEjSqtzBZaJyVn&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Cmime%2Cns%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAP6XrnFm3AHxyk8xjU6mJLdVN-uWLl1ItHk5_ONUiRuPAiEAlEYQBsOoEraFemkJIL7OMyHL9aszxW4CbDlxro-AY3Q%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAPmhL745RYeL_ffgUJk_xJLC-8riXKMylLTLA_pITYWWAiB2qUIXur8ThW7cLfQ73mIVK61mMZc2ncK6FZWjUHGcUw%3D%3D", + "type": "audio/mp4; codecs=\"mp4a.40.2\"", + "quality": "tiny", + "itag": 258, + "bitrate": 390017, + "is_otf": false + } + ] + } + ''' + self._getDecipheredURLs(videoFormats) + return {"streams": self._streams} diff --git a/requirements.txt b/requirements.txt index d9ee452..1e98239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,14 @@ Mastodon.py>=2.0.0 atproto +# YouTube platform: Google OAuth + Data API v3 +google-auth +google-auth-oauthlib +google-api-python-client +# YouTube search/recommendations via InnerTube scraping. +# The maintained fork is vendored at platforms/youtube/vendor/youtubesearchpython +# (made importable by a sys.path shim in platforms/youtube/__init__.py), so the +# library itself is not a PyPI dependency. It does need httpx at runtime: +httpx pyinstaller pyperclip requests