aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.build.yml12
-rw-r--r--.drone.yml10
-rw-r--r--.editorconfig16
-rw-r--r--.gitea/workflows/ci.yaml23
-rw-r--r--.gitea/workflows/git-sync.yaml40
-rw-r--r--.gitignore4
-rw-r--r--README.md148
-rw-r--r--docs/HACKING.md66
-rw-r--r--docs/basic-script-openrc/README.md76
-rw-r--r--generate_release.py135
-rw-r--r--pytest.ini4
-rw-r--r--requirements-dev.txt5
-rw-r--r--requirements.txt2
-rw-r--r--run.bat14
-rw-r--r--server.py210
-rw-r--r--settings.py448
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/conftest.py14
-rw-r--r--tests/test_responses/429.html28
-rw-r--r--tests/test_util.py76
-rw-r--r--youtube/__init__.py143
-rw-r--r--youtube/accounts.py336
-rw-r--r--youtube/channel.py607
-rw-r--r--youtube/comments.py344
-rw-r--r--youtube/get_app_version/__init__.py1
-rw-r--r--youtube/get_app_version/get_app_version.py47
-rw-r--r--youtube/local_playlist.py98
-rw-r--r--youtube/opensearch.xml8
-rw-r--r--youtube/playlist.py136
-rw-r--r--youtube/post_comment.py181
-rw-r--r--youtube/proto.py132
-rw-r--r--youtube/proto_debug.py611
-rw-r--r--youtube/search.py61
-rw-r--r--youtube/static/channel.css572
-rw-r--r--youtube/static/comments.css368
-rw-r--r--youtube/static/dark_theme.css58
-rw-r--r--youtube/static/gray_theme.css37
-rw-r--r--youtube/static/home.css218
-rw-r--r--youtube/static/js/av-merge.js1013
-rw-r--r--youtube/static/js/comments.js20
-rw-r--r--youtube/static/js/common.js116
-rw-r--r--youtube/static/js/hotkeys.js61
-rw-r--r--youtube/static/js/playlistadd.js86
-rw-r--r--youtube/static/js/plyr-start.js136
-rw-r--r--youtube/static/js/sponsorblock.js40
-rw-r--r--youtube/static/js/transcript-table.js151
-rw-r--r--youtube/static/js/watch.js200
-rw-r--r--youtube/static/license.css293
-rw-r--r--youtube/static/light_theme.css38
-rw-r--r--youtube/static/local_playlist.css538
-rw-r--r--youtube/static/message_box.css12
-rw-r--r--youtube/static/modules/plyr/blank.webmbin0 -> 594 bytes
-rw-r--r--youtube/static/modules/plyr/build-instructions.md23
-rw-r--r--youtube/static/modules/plyr/custom_plyr.css77
-rw-r--r--youtube/static/modules/plyr/plyr.css1
-rw-r--r--youtube/static/modules/plyr/plyr.js9320
-rw-r--r--youtube/static/modules/plyr/plyr.min.js4
-rw-r--r--youtube/static/modules/plyr/plyr.min.js.map1
-rw-r--r--youtube/static/modules/plyr/plyr.svg1
-rw-r--r--youtube/static/normalize.css349
-rw-r--r--youtube/static/playlist.css548
-rw-r--r--youtube/static/search.css522
-rw-r--r--youtube/static/settings.css262
-rw-r--r--youtube/static/shared.css300
-rw-r--r--youtube/static/subscription.css542
-rw-r--r--youtube/static/subscription_manager.css366
-rw-r--r--youtube/static/unsubscribe.css269
-rw-r--r--youtube/static/watch.css700
-rw-r--r--youtube/subscriptions.py524
-rw-r--r--youtube/templates/base.html258
-rw-r--r--youtube/templates/channel.html207
-rw-r--r--youtube/templates/comments.html77
-rw-r--r--youtube/templates/comments_page.html29
-rw-r--r--youtube/templates/common_elements.html136
-rw-r--r--youtube/templates/delete_comment.html21
-rw-r--r--youtube/templates/embed.html74
-rw-r--r--youtube/templates/error.html37
-rw-r--r--youtube/templates/home.html11
-rw-r--r--youtube/templates/licenses.html64
-rw-r--r--youtube/templates/local_playlist.html63
-rw-r--r--youtube/templates/local_playlists_list.html10
-rw-r--r--youtube/templates/login.html57
-rw-r--r--youtube/templates/playlist.html84
-rw-r--r--youtube/templates/post_comment.html20
-rw-r--r--youtube/templates/search.html62
-rw-r--r--youtube/templates/settings.html86
-rw-r--r--youtube/templates/shared.css5
-rw-r--r--youtube/templates/status.html1
-rw-r--r--youtube/templates/subscription_manager.html99
-rw-r--r--youtube/templates/subscriptions.html82
-rw-r--r--youtube/templates/subscriptions.xml9
-rw-r--r--youtube/templates/unsubscribe_verify.html8
-rw-r--r--youtube/templates/watch.html492
-rw-r--r--youtube/util.py693
-rw-r--r--youtube/version.py (renamed from youtube_dl/version.py)2
-rw-r--r--youtube/watch.py941
-rw-r--r--youtube/yt_data_extract.py451
-rw-r--r--youtube/yt_data_extract/__init__.py13
-rw-r--r--youtube/yt_data_extract/common.py610
-rw-r--r--youtube/yt_data_extract/everything_else.py372
-rw-r--r--youtube/yt_data_extract/watch_extraction.py948
-rw-r--r--youtube_dl/YoutubeDL.py2392
-rw-r--r--youtube_dl/__init__.py481
-rw-r--r--youtube_dl/__main__.py19
-rw-r--r--youtube_dl/aes.py361
-rw-r--r--youtube_dl/cache.py96
-rw-r--r--youtube_dl/compat.py3016
-rw-r--r--youtube_dl/downloader/__init__.py61
-rw-r--r--youtube_dl/downloader/common.py389
-rw-r--r--youtube_dl/downloader/dash.py80
-rw-r--r--youtube_dl/downloader/external.py354
-rw-r--r--youtube_dl/downloader/f4m.py438
-rw-r--r--youtube_dl/downloader/fragment.py268
-rw-r--r--youtube_dl/downloader/hls.py204
-rw-r--r--youtube_dl/downloader/http.py354
-rw-r--r--youtube_dl/downloader/ism.py259
-rw-r--r--youtube_dl/downloader/rtmp.py214
-rw-r--r--youtube_dl/downloader/rtsp.py47
-rw-r--r--youtube_dl/extractor/__init__.py46
-rw-r--r--youtube_dl/extractor/adobepass.py1567
-rw-r--r--youtube_dl/extractor/common.py2862
-rw-r--r--youtube_dl/extractor/commonmistakes.py50
-rw-r--r--youtube_dl/extractor/commonprotocols.py60
-rw-r--r--youtube_dl/extractor/extractors.py31
-rw-r--r--youtube_dl/extractor/generic.py3335
-rw-r--r--youtube_dl/extractor/openload.py379
-rw-r--r--youtube_dl/extractor/youtube.py3264
-rw-r--r--youtube_dl/extractor/youtube_unmodified_reference.py3192
-rw-r--r--youtube_dl/jsinterp.py262
-rw-r--r--youtube_dl/options.py916
-rw-r--r--youtube_dl/postprocessor/__init__.py40
-rw-r--r--youtube_dl/postprocessor/common.py69
-rw-r--r--youtube_dl/postprocessor/embedthumbnail.py93
-rw-r--r--youtube_dl/postprocessor/execafterdownload.py31
-rw-r--r--youtube_dl/postprocessor/ffmpeg.py613
-rw-r--r--youtube_dl/postprocessor/metadatafromtitle.py48
-rw-r--r--youtube_dl/postprocessor/xattrpp.py79
-rw-r--r--youtube_dl/socks.py273
-rw-r--r--youtube_dl/swfinterp.py834
-rw-r--r--youtube_dl/update.py187
-rw-r--r--youtube_dl/utils.py3990
141 files changed, 24360 insertions, 34718 deletions
diff --git a/.build.yml b/.build.yml
new file mode 100644
index 0000000..47de52e
--- /dev/null
+++ b/.build.yml
@@ -0,0 +1,12 @@
+image: debian/buster
+packages:
+ - python3-pip
+ - virtualenv
+tasks:
+ - test: |
+ cd yt-local
+ virtualenv -p python3 venv
+ source venv/bin/activate
+ python --version
+ pip install -r requirements-dev.txt
+ pytest
diff --git a/.drone.yml b/.drone.yml
new file mode 100644
index 0000000..188bf7c
--- /dev/null
+++ b/.drone.yml
@@ -0,0 +1,10 @@
+kind: pipeline
+name: default
+
+steps:
+- name: test
+ image: python:3.7.3
+ commands:
+ - pip install --upgrade pip
+ - pip install -r requirements-dev.txt
+ - pytest
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..5b34ee1
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,16 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.js]
+indent_style = space
+indent_size = 2
+
+[*.py]
+indent_style = space
+indent_size = 4
diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
new file mode 100644
index 0000000..d27f068
--- /dev/null
+++ b/.gitea/workflows/ci.yaml
@@ -0,0 +1,23 @@
+name: CI
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+
+ - name: Install dependencies
+ run: |
+ pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Run tests
+ run: pytest
diff --git a/.gitea/workflows/git-sync.yaml b/.gitea/workflows/git-sync.yaml
new file mode 100644
index 0000000..706df7e
--- /dev/null
+++ b/.gitea/workflows/git-sync.yaml
@@ -0,0 +1,40 @@
+name: git-sync-with-mirror
+
+on:
+ push:
+ branches: [ master ]
+ workflow_dispatch:
+
+jobs:
+ git-sync:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: git-sync
+ env:
+ git_sync_source_repo: git@git.fridu.us:heckyel/yt-local.git
+ git_sync_destination_repo: ssh://git@c.fridu.us/software/yt-local.git
+ if: env.git_sync_source_repo && env.git_sync_destination_repo
+ uses: astounds/git-sync@v1
+ with:
+ source_repo: git@git.fridu.us:heckyel/yt-local.git
+ source_branch: "master"
+ destination_repo: ssh://git@c.fridu.us/software/yt-local.git
+ destination_branch: "master"
+ source_ssh_private_key: ${{ secrets.GIT_SYNC_SOURCE_SSH_PRIVATE_KEY }}
+ destination_ssh_private_key: ${{ secrets.GIT_SYNC_DESTINATION_SSH_PRIVATE_KEY }}
+
+ - name: git-sync-sourcehut
+ env:
+ git_sync_source_repo: git@git.fridu.us:heckyel/yt-local.git
+ git_sync_destination_repo: git@git.sr.ht:~heckyel/yt-local
+ if: env.git_sync_source_repo && env.git_sync_destination_repo
+ uses: astounds/git-sync@v1
+ with:
+ source_repo: git@git.fridu.us:heckyel/yt-local.git
+ source_branch: "master"
+ destination_repo: git@git.sr.ht:~heckyel/yt-local
+ destination_branch: "master"
+ source_ssh_private_key: ${{ secrets.GIT_SYNC_SOURCE_SSH_PRIVATE_KEY }}
+ destination_ssh_private_key: ${{ secrets.GIT_SYNC_DESTINATION_SSH_PRIVATE_KEY }}
+ continue-on-error: true
diff --git a/.gitignore b/.gitignore
index c190df3..a76d5cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,10 +4,12 @@ debug/
data/
python/
release/
-youtube-local/
+yt-local/
banned_addresses.txt
settings.txt
get-pip.py
latest-dist.zip
+*.7z
*.zip
*venv*
+flycheck_*
diff --git a/README.md b/README.md
index 6683cea..be1a473 100644
--- a/README.md
+++ b/README.md
@@ -1,32 +1,34 @@
-# youtube-local
+# yt-local
-![screenshot](https://user-images.githubusercontent.com/28744867/64483429-8a890780-d1b6-11e9-8423-6956ff7c588d.png)
-youtube-local is a browser-based client written in Python for watching Youtube anonymously and without the lag of the slow page used by Youtube. One of the primary features is that all requests are routed through Tor, except for the video file at googlevideo.com. This is analogous to what HookTube (defunct) and invidious do, except that you do not have to trust a third-party to respect your privacy. The assumption here is that Google won't put the effort in to incorporate the video file requests into their tracking, as it's not worth pursuing the incredibly small number of users who care about privacy. Tor has high latency, so this will not be as fast as regular Youtube. However, using Tor is optional; when not routing through Tor, video pages may load faster than they do with Youtube's laggy page depending on your browser.
+Fork of [youtube-local](https://github.com/user234683/youtube-local)
-The Youtube API is not used, so no keys or anything are needed. It uses the same requests as the Youtube webpage.
+yt-local is a browser-based client written in Python for watching YouTube anonymously and without the lag of the slow page used by YouTube. One of the primary features is that all requests are routed through Tor, except for the video file at googlevideo.com. This is analogous to what HookTube (defunct) and Invidious do, except that you do not have to trust a third-party to respect your privacy. The assumption here is that Google won't put the effort in to incorporate the video file requests into their tracking, as it's not worth pursuing the incredibly small number of users who care about privacy (Tor video routing is also provided as an option). Tor has high latency, so this will not be as fast network-wise as regular YouTube. However, using Tor is optional; when not routing through Tor, video pages may load faster than they do with YouTube's page depending on your browser.
+
+The YouTube API is not used, so no keys or anything are needed. It uses the same requests as the YouTube webpage.
## Screenshots
-[Gray theme](https://user-images.githubusercontent.com/28744867/64483431-8e1c8e80-d1b6-11e9-999c-14d36ddd582f.png)
-[Dark theme](https://user-images.githubusercontent.com/28744867/64483432-8fe65200-d1b6-11e9-90bd-32869542e32e.png)
+[Light theme](https://pic.infini.fr/l7WINjzS/0Ru6MrhA.png)
-[Non-Theater mode](https://user-images.githubusercontent.com/28744867/64483433-92e14280-d1b6-11e9-9b56-2ef5d64c372f.png)
+[Gray theme](https://pic.infini.fr/znnQXWNc/hL78CRzo.png)
-[Channel](https://user-images.githubusercontent.com/28744867/64483436-95dc3300-d1b6-11e9-8efc-b19b1f1f3bcf.png)
+[Dark theme](https://pic.infini.fr/iXwFtTWv/mt2kS5bv.png)
-[Downloads](https://user-images.githubusercontent.com/28744867/64483437-a2608b80-d1b6-11e9-9e5a-4114391b7304.png)
+[Channel](https://pic.infini.fr/JsenWVYe/SbdIQlS6.png)
## Features
-* Standard pages of Youtube: search, channels, playlists
+* Standard pages of YouTube: search, channels, playlists
* Anonymity from Google's tracking by routing requests through Tor
-* Local playlists: These solve the two problems with creating playlists on Youtube: (1) they're datamined and (2) videos frequently get deleted by Youtube and lost from the playlist, making it very difficult to find a reupload as the title of the deleted video is not displayed.
+* Local playlists: These solve the two problems with creating playlists on YouTube: (1) they're datamined and (2) videos frequently get deleted by YouTube and lost from the playlist, making it very difficult to find a reupload as the title of the deleted video is not displayed.
* Themes: Light, Gray, and Dark
* Subtitles
-* Easily download videos or their audio
-* View or post comments
-* Works without Javascript
+* Easily download videos or their audio. (Disabled by default)
+* No ads
+* View comments
+* JavaScript not required
* Theater and non-theater mode
-* Subscriptions that are independent from Youtube
+* Subscriptions that are independent from YouTube
+ * Can import subscriptions from YouTube
* Works by checking channels individually
* Can be set to automatically check channels.
* For efficiency of requests, frequency of checking is based on how quickly channel posts videos
@@ -39,27 +41,30 @@ The Youtube API is not used, so no keys or anything are needed. It uses the same
1. Shown by default, with click to hide
2. Hidden by default, with click to show
3. Never shown
+* Optionally skip sponsored segments using [SponsorBlock](https://github.com/ajayyy/SponsorBlock)'s API
+* Custom video speeds
+* Video transcript
+* Supports all available video qualities: 144p through 2160p
## Planned features
- [ ] Putting videos from subscriptions or local playlists into the related videos
-- [ ] Information about video (geographic regions, region of Tor exit node, etc)
+- [x] Information about video (geographic regions, region of Tor exit node, etc)
- [ ] Ability to delete playlists
- [ ] Auto-saving of local playlist videos
- [ ] Import youtube playlist into a local playlist
- [ ] Rearrange items of local playlist
-- [ ] Muxing of 1080p
-- [ ] Corrected .m4a downloads
-- [ ] Replying to comment replies
-- [ ] Editing comments
-- [ ] Indicate if comments are disabled
-- [ ] Indicate how many comments a video has
+- [x] Video qualities other than 360p and 720p by muxing video and audio
+- [x] Indicate if comments are disabled
+- [x] Indicate how many comments a video has
- [ ] Featured channels page
- [ ] Channel comments
-- [ ] Video transcript
-- [ ] Tor RC
-- [ ] Support &t parameter
+- [x] Video transcript
+- [x] Automatic Tor circuit change when blocked
+- [x] Support &t parameter
- [ ] Subscriptions: Option to mark what has been watched
- [ ] Subscriptions: Option to filter videos based on keywords in title or description
+- [ ] Subscriptions: Delete old entries and thumbnails
+- [ ] Support for more sites, such as Vimeo, Dailymotion, LBRY, etc.
## Installing
@@ -67,44 +72,104 @@ The Youtube API is not used, so no keys or anything are needed. It uses the same
Download the zip file under the Releases page. Unzip it anywhere you choose.
-### Linux/MacOS
+### GNU+Linux/MacOS
Download the tarball under the Releases page and extract it. `cd` into the directory and run
-```
-pip3 install -r requirements.txt
-```
-**Note**: If pip isn't installed, install it according to [this answer](https://unix.stackexchange.com/a/182467), but make sure you run `python3 get-pip.py` instead of `python get-pip.py`
+1. `cd yt-local`
+2. `virtualenv -p python3 venv`
+3. `source venv/bin/activate`
+4. `pip install -r requirements.txt`
+5. `python server.py`
+
+
+**Note**: If pip isn't installed, first try installing it from your package manager. Make sure you install pip for python 3. For example, the package you need on debian is python3-pip rather than python-pip. If your package manager doesn't provide it, try to install it according to [this answer](https://unix.stackexchange.com/a/182467), but make sure you run `python3 get-pip.py` instead of `python get-pip.py`
## Usage
-Firstly, if you wish to run this in portable mode, create the empty file "settings.txt" in the program's main directory. If the file is there, settings and data will be stored in the same directory as the program. Otherwise, settings and data will be stored in `C:\Users\[your username]\.youtube-local` on Windows and `~/.youtube-local` on Linux/MacOS.
+Firstly, if you wish to run this in portable mode, create the empty file "settings.txt" in the program's main directory. If the file is there, settings and data will be stored in the same directory as the program. Otherwise, settings and data will be stored in `C:\Users\[your username]\.yt-local` on Windows and `~/.yt-local` on GNU+Linux/MacOS.
-To run the program on windows, open `run.bat`. On Linux/MacOS, run `python3 server.py`.
+To run the program on windows, open `run.bat`. On GNU+Linux/MacOS, run `python3 server.py`.
+Access youtube URLs by prefixing them with `http://localhost:9010/`.
+For instance, `http://localhost:9010/https://www.youtube.com/watch?v=vBgulDeV2RU`
+You can use an addon such as Redirector ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/redirector/)|[Chrome](https://chrome.google.com/webstore/detail/redirector/ocgpenflpmgnfapjedencafcfakcekcd)) to automatically redirect YouTube URLs to yt-local. I use the include pattern `^(https?://(?:[a-zA-Z0-9_-]*\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com)/.*)` and redirect pattern `http://localhost:9010/$1` (Make sure you're using regular expression mode).
-Access youtube URLs by prefixing them with `localhost:8080/`, For instance, `http://localhost:8080/https://www.youtube.com/watch?v=vBgulDeV2RU`
-You can use an addon such as [Redirector](https://addons.mozilla.org/en-US/firefox/addon/redirector/) to automatically redirect Youtube URLs to youtube-local. I use the include pattern `^(https?://(?:[a-zA-Z0-9_-]*\.)?(?:youtube\.com|youtu\.be)/.*)` and the redirect pattern `http://localhost:8080/$1`.
+If you want embeds on web to also redirect to yt-local, make sure "Iframes" is checked under advanced options in your redirector rule. Check test `http://localhost:9010/youtube.com/embed/vBgulDeV2RU`
-youtube-local can be added as a search engine in firefox to make searching more convenient. See [here](https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox) for information on firefox search plugins.
+yt-local can be added as a search engine in firefox to make searching more convenient. See [here](https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox) for information on firefox search plugins.
### Using Tor
-In the settings page, enable "Route Tor". Be sure to save the settings.
+In the settings page, set "Route Tor" to "On, except video" (the second option). Be sure to save the settings.
+
+Ensure Tor is listening for Socks5 connections on port 9150. A simple way to accomplish this is by opening the Tor Browser Bundle and leaving it open. However, you will not be accessing the program (at https://localhost:8080) through the Tor Browser. You will use your regular browser for that. Rather, this is just a quick way to give the program access to Tor routing.
+
+### Standalone Tor
+
+If you don't want to waste system resources leaving the Tor Browser open in addition to your regular browser, you can configure standalone Tor to run instead using the following instructions.
+
+For Windows, to make standalone Tor run at startup, press Windows Key + R and type `shell:startup` to open the Startup folder. Create a new shortcut there. For the command of the shortcut, enter `"C:\[path-to-Tor-Browser-directory]\Tor\tor.exe" SOCKSPort 9150 ControlPort 9151`. You can then launch this shortcut to start it. Alternatively, if something isn't working, to see what's wrong, open `cmd.exe` and go to the directory `C:\[path-to-Tor-Browser-directory]\Tor`. Then run `tor SOCKSPort 9150 ControlPort 9151 | more`. The `more` part at the end is just to make sure any errors are displayed, to fix a bug in Windows cmd where tor doesn't display any output. You can stop tor in the task manager.
+
+For Debian/Ubuntu, you can `sudo apt install tor` to install the command line version of Tor, and then run `sudo systemctl start tor` to run it as a background service that will get started during boot as well. However, Tor on the command line uses the port `9050` by default (rather than the 9150 used by the Tor Browser). So you will need to change `Tor port` to 9050 and `Tor control port` to `9051` in yt-local settings page. Additionally, you will need to enable the Tor control port by uncommenting the line `ControlPort 9051`, and setting `CookieAuthentication` to 0 in `/etc/tor/torrc`. If no Tor package is available for your distro, you can configure the `tor` binary located at `./Browser/TorBrowser/Tor/tor` inside the Tor Browser installation location to run at start time, or create a service to do it.
+
+### Tor video routing
+
+If you wish to route the video through Tor, set "Route Tor" to "On, including video". Because this is bandwidth-intensive, you are strongly encouraged to donate to the [consortium of Tor node operators](https://torservers.net/donate.html). For instance, donations to [NoiseTor](https://noisetor.net/) go straight towards funding nodes. Using their numbers for bandwidth costs, together with an average of 485 kbit/sec for a diverse sample of videos, and assuming n hours of video watched per day, gives $0.03n/month. A $1/month donation will be a very generous amount to not only offset losses, but help keep the network healthy.
+
+In general, Tor video routing will be slower (for instance, moving around in the video is quite slow). I've never seen any signs that watch history in yt-local affects on-site Youtube recommendations. It's likely that requests to googlevideo are logged for some period of time, but are not integrated into Youtube's larger advertisement/recommendation systems, since those presumably depend more heavily on in-page tracking through Javascript rather than CDN requests to googlevideo.
-Ensure Tor is listening for Socks5 connections on port 9150 (a simple way to accomplish this is by opening the Tor Browser Bundle and leaving it open). Your connections should now be routed through Tor.
+### Importing subscriptions
+
+1. Go to the [Google takeout manager](https://takeout.google.com/takeout/custom/youtube).
+2. Log in if asked.
+3. Click on "All data included", then on "Deselect all", then select only "subscriptions" and click "OK".
+4. Click on "Next step" and then on "Create export".
+5. Click on the "Download" button after it appears.
+6. From the downloaded takeout zip extract the .csv file. It is usually located under `YouTube and YouTube Music/subscriptions/subscriptions.csv`
+7. Go to the subscriptions manager in yt-local. In the import area, select your .csv file, then press import.
+
+Supported subscriptions import formats:
+- NewPipe subscriptions export JSON
+- Google Takeout CSV
+- Old Google Takeout JSON
+- OPML format from now-removed YouTube subscriptions manager
+
+## Contributing
Pull requests and issues are welcome
+For coding guidelines and an overview of the software architecture, see the [HACKING.md](docs/HACKING.md) file.
+
+## GPG public KEY
+
+```bash
+72CFB264DFC43F63E098F926E607CE7149F4D71C
+```
+
+## Public instances
+
+yt-local is not made to work in public mode, however there is an instance of yt-local in public mode but with less features
+
+- <https://m.fridu.us/https://youtube.com>
+
## License
This project is licensed under the GNU Affero General Public License v3 (GNU AGPLv3) or any later version.
-Permission is hereby granted to the youtube-dl project at [https://github.com/rg3/youtube-dl](https://github.com/rg3/youtube-dl) to relicense any portion of this software under the Unlicense, public domain, or whichever license is in use by youtube-dl at the time of relicensing, for the purpose of inclusion of said portion into youtube-dl. Relicensing permission is not granted for any purpose outside of direct inclusion into the [official repository](https://github.com/rg3/youtube-dl) of youtube-dl. If inclusion happens during the process of a pull-request, relicensing happens at the moment the pull request is merged into youtube-dl; until that moment, any cloned repositories of youtube-dl which make use of this software are subject to the terms of the GNU AGPLv3.
+Permission is hereby granted to the youtube-dl project at [https://github.com/ytdl-org/youtube-dl](https://github.com/ytdl-org/youtube-dl) to relicense any portion of this software under the Unlicense, public domain, or whichever license is in use by youtube-dl at the time of relicensing, for the purpose of inclusion of said portion into youtube-dl. Relicensing permission is not granted for any purpose outside of direct inclusion into the [official repository](https://github.com/ytdl-org/youtube-dl) of youtube-dl. If inclusion happens during the process of a pull-request, relicensing happens at the moment the pull request is merged into youtube-dl; until that moment, any cloned repositories of youtube-dl which make use of this software are subject to the terms of the GNU AGPLv3.
+
+## Donate
+This project is completely free/Libre and will always be.
+
+#### Crypto:
+- **Bitcoin**: `1JrC3iqs3PP5Ge1m1vu7WE8LEf4S85eo7y`
## Similar projects
-- [youtube-dl](https://rg3.github.io/youtube-dl/), which this project is based off
-- [invidious](https://github.com/omarroth/invidious) Nearly identical to this project. More polished and with more features.
+- [invidious](https://github.com/iv-org/invidious) Similar to this project, but also allows it to be hosted as a server to serve many users
+- [Yotter](https://github.com/ytorg/Yotter) Similar to this project and to invidious. Also supports Twitter
+- [FreeTube](https://github.com/FreeTubeApp/FreeTube) (Similar to this project, but is an electron app outside the browser)
+- [youtube-local](https://github.com/user234683/youtube-local) first project on which yt-local is based
- [NewPipe](https://newpipe.schabi.org/) (app for android)
- [mps-youtube](https://github.com/mps-youtube/mps-youtube) (terminal-only program)
- [youtube-viewer](https://github.com/trizen/youtube-viewer)
@@ -112,4 +177,5 @@ Permission is hereby granted to the youtube-dl project at [https://github.com/rg
- [smtube](https://www.smtube.org/)
- [Minitube](https://flavio.tordini.org/minitube), [github here](https://github.com/flaviotordini/minitube)
- [toogles](https://github.com/mikecrittenden/toogles) (only embeds videos, doesn't use mp4)
-- [goyt](https://gitgud.io/m712/goyt/)
+- [YTLibre](https://git.sr.ht/~heckyel/ytlibre) only extract video
+- [youtube-dl](https://rg3.github.io/youtube-dl/), which this project was based off
diff --git a/docs/HACKING.md b/docs/HACKING.md
new file mode 100644
index 0000000..6e6b7fd
--- /dev/null
+++ b/docs/HACKING.md
@@ -0,0 +1,66 @@
+# Coding guidelines
+* Follow the [PEP 8 guidelines](https://www.python.org/dev/peps/pep-0008/) for all new Python code as best you can. Some old code doesn't follow PEP 8 yet. This includes limiting line length to 79 characters (with exception for long strings such as URLs that can't reasonably be broken across multiple lines) and using 4 spaces for indentation.
+
+* Do not use single letter or cryptic names for variables (except iterator variables or the like). When in doubt, choose the more verbose option.
+
+* For consistency, use ' instead of " for strings for all new code. Only use " when the string contains ' inside it. Exception: " is used for html attributes in Jinja templates.
+
+* Don't leave trailing whitespaces at the end of lines. Configure your editor the way you need to avoid this from happening.
+
+* Make commits highly descriptive, so that other people (and yourself in the future) know exactly why a change was made. The first line of the commit is a short summary. Add a blank line and then a more extensive summary. If it is a bug fix, this should include a description of what caused the bug and how this commit fixes it. There's a lot of knowledge you gather while solving a problem. Dump as much of it as possible into the commit for others and yourself to learn from. Mention the issue number (e.g. Fixes #23) in your commit if applicable. [Here](https://www.freecodecamp.org/news/writing-good-commit-messages-a-practical-guide/) are some useful guidelines.
+
+* The same guidelines apply to commenting code. If a piece of code is not self-explanatory, add a comment explaining what it does and why it's there.
+
+# Testing and releases
+* This project uses pytest. To install pytest and any future dependencies needed for development, run pip3 on the requirements-dev.txt file. To run tests, run `python3 -m pytest` rather than just `pytest` because the former will make sure the toplevel directory is in Python's import search path.
+
+* To build releases for Windows, run `python3 generate_release.py [intended python version here, without v infront]`. The required software (such as 7z, git) are listed in the `generate_release.py` file. For instance, wine is required if building on GNU+Linux. The build script will automatically download the embedded Python release to include. Use the latest release of Python 3.7.x so that Vista will be supported. See https://github.com/user234683/youtube-local/issues/6#issuecomment-672608388
+
+# Overview of the software architecture
+
+## server.py
+* This is the entry point, and sets up the HTTP server that listens for incoming requests. It delegates the request to the appropriate "site_handler". For instance, `localhost:8080/youtube.com/...` goes to the `youtube` site handler, whereas `localhost:8080/ytimg.com/...` (the url for video thumbnails) goes to the site handler for just fetching static resources such as images from youtube.
+
+* The reason for this architecture: the original design philosophy when I first conceived the project was that this would work for any site supported by youtube-dl, including YouTube, Vimeo, DailyMotion, etc. I've dropped this idea for now, though I might pick it up later. (youtube-dl is no longer used)
+
+* This file uses the raw [WSGI request](https://www.python.org/dev/peps/pep-3333/) format. The WSGI format is a Python standard for how HTTP servers (I use the stock server provided by gevent) should call HTTP applications. So that's why the file contains stuff like `env['REQUEST_METHOD']`.
+
+
+## Flask and Gevent
+* The `youtube` handler in server.py then delegates the request to the Flask yt_app object, which the rest of the project uses. [Flask](https://flask.palletsprojects.com/en/1.1.x/) is a web application framework that makes handling requests easier than accessing the raw WSGI requests. Flask (Werkzeug specifically) figures out which function to call for a particular url. Each request handling function is registered into Flask's routing table by using function annotations above it. The request handling functions are always at the bottom of the file for a particular youtube page (channel, watch, playlist, etc.), and they're where you want to look to see how the response gets constructed for a particular url. Miscellaneous request handlers that don't belong anywhere else are located in `__init__.py`, which is where the `yt_app` object is instantiated.
+
+* The actual html for yt-local is generated using Jinja templates. Jinja lets you embed a Python-like language inside html files so you can use constructs such as for loops to construct the html for a list of 30 videos given a dictionary with information for those videos. Jinja is included as part of Flask. It has some annoying differences from Python in a lot of details, so check the [docs here](https://jinja.palletsprojects.com/en/2.11.x/) when you use it. The request handling functions will pass the information that has been scraped from YouTube into these templates for the final result.
+* The project uses the gevent library for parallelism (such as for launching requests in parallel), as opposed to using the async keyword.
+
+## util.py
+* util.py is a grab-bag of miscellaneous things; admittedly I need to get around to refactoring it. The biggest thing it has is the `fetch_url` function which is what I use for sending out requests for YouTube. The Tor routing is managed here. `fetch_url` will raise an a `FetchError` exception if the request fails. The parameter `debug_name` in `fetch_url` is the filename that the response from YouTube will be saved to if the hidden debugging option is enabled in settings.txt. So if there's a bug when YouTube changes something, you can check the response from YouTube from that file.
+
+## Data extraction - protobuf, polymer, and yt_data_extract
+* proto.py is used for generating what are called ctokens needed when making requests to YouTube. These ctokens use Google's [protobuf](https://developers.google.com/protocol-buffers) format. Figuring out how to generate these in new instances requires some reverse engineering. I have a messy python file I use to make this convenient which you can find under ./youtube/proto_debug.py
+
+* The responses from YouTube are in a JSON format called polymer (polymer is the name of the 2017-present YouTube layout). The JSON consists of a bunch of nested dictionaries which basically specify the layout of the page via objects called renderers. A renderer represents an object on a page in a similar way to html tags; the renders often contain renders inside them. The Javascript on YouTube's page translates this JSON to HTML. Example: `compactVideoRenderer` represents a video item in you can click on such as in the related videos (so these are called "items" in the codebase). This JSON is very messy. You'll need a JSON prettifier or something that gives you a tree view in order to study it.
+
+* `yt_data_extract` is a module that parses this this raw JSON page layout and extracts the useful information from it into a standardized dictionary. So for instance, it can take the raw JSON response from the watch page and return a dictionary containing keys such as `title`, `description`,`related_videos (list)`, `likes`, etc. This module contains a lot of abstractions designed to make parsing the polymer format easier and more resilient towards changes from YouTube. (A lot of YouTube extractors just traverse the JSON tree like `response[1]['response']['continuation']['gridContinuationRenderer']['items']...` but this tends to break frequently when YouTube changes things.) If it fails to extract a piece of data, such as the like count, it will place `None` in that entry. Exceptions are not used in this module. So it uses functions which return None if there's a failure, such as `deep_get(response, 1, 'response', 'continuation', 'gridContinuationRenderer', 'items')` which returns None if any of those keys aren't present. The general purpose abstractions are located in `common.py`, while the functions for parsing specific responses (watch page, playlist, channel, etc.) are located in `watch_extraction.py` and `everything_else.py`.
+
+* Most of these abstractions are self-explanatory, except for `extract_items_from_renderer`, a function that performs a recursive search for the specified renderers. You give it a renderer which contains nested renderers, and a set of the renderer types you want to extract (by default, these are the video/playlist/channel preview items). It will search through the nested renderers and gather the specified items, in addition to the continuation token (ctoken) for the last list of items it finds if there is one. Using this function achieves resiliency against YouTube rearranging the items into a different hierarchy.
+
+* The `extract_items` function is similar but works on the response object, automatically finding the appropriate renderer to call `extract_items_from_renderer` on.
+
+
+## Other
+* subscriptions.py uses SQLite to store data.
+
+* Hidden settings only relevant to developers (such as for debugging) are not displayed on the settings page. They can be found in the settings.txt file.
+
+* Since I can't anticipate the things that will trip up beginners to the codebase, if you spend awhile figuring something out, go ahead and make a pull request adding a brief description of your findings to this document to help other beginners.
+
+## Development tips
+* When developing functionality to interact with YouTube in new ways, you'll want to use the network tab in your browser's devtools to inspect which requests get made under normal usage of YouTube. You'll also want a tool you can use to construct custom requests and specify headers to reverse engineer the request format. I use the [HeaderTool](https://github.com/loreii/HeaderTool) extension in Firefox, but there's probably a more streamlined program out there.
+
+* You'll want to have a utility or IDE that can perform full text search on a repository, since this is crucial for navigating unfamiliar codebases to figure out where certain strings appear or where things get defined.
+
+* If you're confused what the purpose of a particular line/section of code is, you can use the "git blame" feature on github (click the line number and then the three dots) to view the commit where the line of code was created and check the commit message. This will give you an idea of how it was put together.
+
+## OpenRC init
+
+You see [configuration with OpenRC](basic-script-openrc/README.md)
diff --git a/docs/basic-script-openrc/README.md b/docs/basic-script-openrc/README.md
new file mode 100644
index 0000000..1de623b
--- /dev/null
+++ b/docs/basic-script-openrc/README.md
@@ -0,0 +1,76 @@
+## Basic init yt-local for openrc
+
+1. Write `/etc/init.d/ytlocal` file.
+
+```
+#!/sbin/openrc-run
+# Distributed under the terms of the GNU General Public License v3 or later
+name="yt-local"
+pidfile="/var/run/ytlocal.pid"
+command="/usr/sbin/ytlocal"
+
+depend() {
+ use net
+}
+
+start_pre() {
+ if [ ! -f /usr/sbin/ytlocal ] ; then
+ eerror "Please create script file of ytlocal in '/usr/sbin/ytlocal'"
+ return 1
+ else
+ return 0
+ fi
+}
+
+start() {
+ ebegin "Starting yt-local"
+ start-stop-daemon --start --exec "${command}" --pidfile "${pidfile}"
+ eend $?
+}
+
+reload() {
+ ebegin "Reloading ${name}"
+ start-stop-daemon --signal HUP --pidfile "${pidfile}"
+ eend $?
+}
+
+stop() {
+ ebegin "Stopping ${name}"
+ start-stop-daemon --quiet --stop --exec "${command}" --pidfile "${pidfile}"
+ eend $?
+}
+```
+
+after, modified execute permissions:
+
+ $ doas chmod a+x /etc/init.d/ytlocal
+
+
+2. Write `/usr/sbin/ytlocal` and configure path.
+
+```
+#!/usr/bin/env bash
+
+cd /home/your-path/ytlocal/ # change me
+source venv/bin/activate
+python server.py > /dev/null 2>&1 &
+echo $! > /var/run/ytlocal.pid
+```
+
+after, modified execute permissions:
+
+ $ doas chmod a+x /usr/sbin/ytlocal
+
+
+3. OpenRC check
+
+- status: `doas rc-service ytlocal status`
+- start: `doas rc-service ytlocal start`
+- restart: `doas rc-service ytlocal restart`
+- stop: `doas rc-service ytlocal stop`
+
+- enable: `doas rc-update add ytlocal default`
+- disable: `doas rc-update del ytlocal`
+
+When yt-local is run with administrator privileges,
+the configuration file is stored in /root/.yt-local
diff --git a/generate_release.py b/generate_release.py
index 962fb00..8bec3a0 100644
--- a/generate_release.py
+++ b/generate_release.py
@@ -1,6 +1,8 @@
# Generate a windows release and a generated embedded distribution of python
-# Latest python version is the argument of the script
-# Only works on windows at the moment
+# Latest python version is the argument of the script (or oldwin for
+# vista, 7 and 32-bit versions)
+# Requirements: 7z, git
+# wine is required in order to build on Linux
import sys
import urllib
@@ -8,18 +10,27 @@ import urllib.request
import subprocess
import shutil
import os
+import hashlib
latest_version = sys.argv[1]
-if sys.argv[2] == '-nd':
- downloads_enabled = False
-elif sys.argv[2] == '-d':
- downloads_enabled = True
+if len(sys.argv) > 2:
+ bitness = sys.argv[2]
else:
- raise Exception('No download switch specified')
+ bitness = '64'
+
+if latest_version == 'oldwin':
+ bitness = '32'
+ latest_version = '3.7.9'
+ suffix = 'windows-vista-7-only'
+else:
+ suffix = 'windows'
def check(code):
if code != 0:
raise Exception('Got nonzero exit code from command')
+def check_subp(x):
+ if x.returncode != 0:
+ raise Exception('Got nonzero exit code from command')
def log(line):
print('[generate_release.py] ' + line)
@@ -31,6 +42,35 @@ def remove_files_with_extensions(path, extensions):
if os.path.splitext(file)[1] in extensions:
os.remove(os.path.join(root, file))
+def download_if_not_exists(file_name, url, sha256=None):
+ if not os.path.exists('./' + file_name):
+ log('Downloading ' + file_name + '..')
+ data = urllib.request.urlopen(url).read()
+ log('Finished downloading ' + file_name)
+ with open('./' + file_name, 'wb') as f:
+ f.write(data)
+ if sha256:
+ digest = hashlib.sha256(data).hexdigest()
+ if digest != sha256:
+ log('Error: ' + file_name + ' has wrong hash: ' + digest)
+ sys.exit(1)
+ else:
+ log('Using existing ' + file_name)
+
+def wine_run_shell(command):
+ if os.name == 'posix':
+ check(os.system('wine ' + command.replace('\\', '/')))
+ elif os.name == 'nt':
+ check(os.system(command))
+ else:
+ raise Exception('Unsupported OS')
+
+def wine_run(command_parts):
+ if os.name == 'posix':
+ command_parts = ['wine',] + command_parts
+ if subprocess.run(command_parts).returncode != 0:
+ raise Exception('Got nonzero exit code from command')
+
# ---------- Get current release version, for later ----------
log('Getting current release version')
describe_result = subprocess.run(['git', 'describe', '--tags'], stdout=subprocess.PIPE)
@@ -40,43 +80,54 @@ if describe_result.returncode != 0:
release_tag = describe_result.stdout.strip().decode('ascii')
-# ----------- Make copy of youtube-local files using git -----------
+# ----------- Make copy of yt-local files using git -----------
-if os.path.exists('./youtube-local'):
+if os.path.exists('./yt-local'):
log('Removing old release')
- shutil.rmtree('./youtube-local')
+ shutil.rmtree('./yt-local')
# Export git repository - this will ensure .git and things in gitignore won't
# be included. Git only supports exporting archive formats, not into
-# directories, so pipe into 7z to put it into .\youtube-local (not to be
+# directories, so pipe into 7z to put it into .\yt-local (not to be
# confused with working directory. I'm calling it the same thing so it will
# have that name when extracted from the final release zip archive)
-log('Making copy of youtube-local files')
-check(os.system('git archive --format tar master | 7z x -si -ttar -oyoutube-local'))
+log('Making copy of yt-local files')
+check(os.system('git archive --format tar master | 7z x -si -ttar -oyt-local'))
-if len(os.listdir('./youtube-local')) == 0:
- raise Exception('Failed to copy youtube-local files')
+if len(os.listdir('./yt-local')) == 0:
+ raise Exception('Failed to copy yt-local files')
# ----------- Generate embedded python distribution -----------
os.environ['PYTHONDONTWRITEBYTECODE'] = '1' # *.pyc files double the size of the distribution
get_pip_url = 'https://bootstrap.pypa.io/get-pip.py'
-latest_dist_url = 'https://www.python.org/ftp/python/' + latest_version + '/python-' + latest_version + '-embed-win32.zip'
-
-if downloads_enabled:
- log('Downloading get-pip.py...')
- get_pip = urllib.request.urlopen(get_pip_url).read()
- log('Finished downloading get-pip.py')
+latest_dist_url = 'https://www.python.org/ftp/python/' + latest_version + '/python-' + latest_version
+if bitness == '32':
+ latest_dist_url += '-embed-win32.zip'
+else:
+ latest_dist_url += '-embed-amd64.zip'
+
+# I've verified that all the dlls in the following are signed by Microsoft.
+# Using this because Microsoft only provides installers whose files can't be
+# extracted without a special tool.
+if bitness == '32':
+ visual_c_runtime_url = 'https://github.com/yuempek/vc-archive/raw/master/archives/vc15_(14.10.25017.0)_2017_x86.7z'
+ visual_c_runtime_sha256 = '2549eb4d2ce4cf3a87425ea01940f74368bf1cda378ef8a8a1f1a12ed59f1547'
+ visual_c_name = 'vc15_(14.10.25017.0)_2017_x86.7z'
+ visual_c_path_to_dlls = 'runtime_minimum/System'
+else:
+ visual_c_runtime_url = 'https://github.com/yuempek/vc-archive/raw/master/archives/vc15_(14.10.25017.0)_2017_x64.7z'
+ visual_c_runtime_sha256 = '4f00b824c37e1017a93fccbd5775e6ee54f824b6786f5730d257a87a3d9ce921'
+ visual_c_name = 'vc15_(14.10.25017.0)_2017_x64.7z'
+ visual_c_path_to_dlls = 'runtime_minimum/System64'
- with open('./get-pip.py', 'wb') as f:
- f.write(get_pip)
+download_if_not_exists('get-pip.py', get_pip_url)
- log('Downloading latest python distribution...')
- latest_dist= urllib.request.urlopen(latest_dist_url).read()
- log('Finished downloading python distribution')
+python_dist_name = 'python-dist-' + latest_version + '-' + bitness + '.zip'
- with open('./latest-dist.zip', 'wb') as f:
- f.write(latest_dist)
+download_if_not_exists(python_dist_name, latest_dist_url)
+download_if_not_exists(visual_c_name,
+ visual_c_runtime_url, sha256=visual_c_runtime_sha256)
if os.path.exists('./python'):
log('Removing old python distribution')
@@ -85,17 +136,17 @@ if os.path.exists('./python'):
log('Extracting python distribution')
-check(os.system(r'7z -y x -opython latest-dist.zip'))
+check(os.system(r'7z -y x -opython ' + python_dist_name))
log('Executing get-pip.py')
-os.system(r'.\python\python.exe -I get-pip.py')
+wine_run(['./python/python.exe', '-I', 'get-pip.py'])
'''
# Explanation of .pth, ._pth, and isolated mode
## Isolated mode
We want to run in what is called isolated mode, given by the switch -I.
-This mode prevents the embedded python distribution from searching in
+This mode prevents the embedded python distribution from searching in
global directories for imports
For example, if a user has `C:\Python37` and the embedded distribution is
@@ -133,35 +184,35 @@ and replaced with a .pth. Isolated mode will have to be specified manually.
log('Removing ._pth')
major_release = latest_version.split('.')[1]
-os.remove(r'.\python\python3' + major_release + '._pth')
+os.remove(r'./python/python3' + major_release + '._pth')
log('Adding path_fixes.pth')
-with open(r'.\python\path_fixes.pth', 'w', encoding='utf-8') as f:
+with open(r'./python/path_fixes.pth', 'w', encoding='utf-8') as f:
f.write("import sys; sys.path.insert(0, '')\n")
'''# python3x._pth file tells the python executable where to look for files
# Need to add the directory where packages are installed,
-# and the parent directory (which is where the youtube-local files are)
+# and the parent directory (which is where the yt-local files are)
major_release = latest_version.split('.')[1]
with open('./python/python3' + major_release + '._pth', 'a', encoding='utf-8') as f:
f.write('.\\Lib\\site-packages\n')
f.write('..\n')'''
log('Inserting Microsoft C Runtime')
-check(os.system(r'copy C:\Windows\SysWOW64\msvcp140.dll .\python\msvcp140.dll'))
+check_subp(subprocess.run([r'7z', '-y', 'e', '-opython', visual_c_name, visual_c_path_to_dlls]))
log('Installing dependencies')
-check(os.system(r'.\python\python.exe -I -m pip install --no-compile -r .\requirements.txt'))
+wine_run(['./python/python.exe', '-I', '-m', 'pip', 'install', '--no-compile', '-r', './requirements.txt'])
log('Uninstalling unnecessary gevent stuff')
-check(os.system(r'.\python\python.exe -I -m pip uninstall --yes cffi pycparser'))
+wine_run(['./python/python.exe', '-I', '-m', 'pip', 'uninstall', '--yes', 'cffi', 'pycparser'])
shutil.rmtree(r'./python/Lib/site-packages/gevent/tests')
shutil.rmtree(r'./python/Lib/site-packages/gevent/testing')
remove_files_with_extensions(r'./python/Lib/site-packages/gevent', ['.html']) # bloated html documentation
log('Uninstalling pip and others')
-check(os.system(r'.\python\python.exe -I -m pip uninstall --yes pip setuptools wheel'))
+wine_run(['./python/python.exe', '-I', '-m', 'pip', 'uninstall', '--yes', 'pip', 'wheel'])
log('Removing pyc files') # Have to do this because get-pip and some packages don't respect --no-compile
remove_files_with_extensions(r'./python', ['.pyc'])
@@ -182,15 +233,15 @@ log('Finished generating python distribution')
# ----------- Copy generated distribution into release folder -----------
log('Copying python distribution into release folder')
-shutil.copytree(r'.\python', r'.\youtube-local\python')
+shutil.copytree(r'./python', r'./yt-local/python')
# ----------- Create release zip -----------
-output_filename = 'youtube-local-' + release_tag + '-windows.zip'
+output_filename = 'yt-local-' + release_tag + '-' + suffix + '.zip'
if os.path.exists('./' + output_filename):
log('Removing previous zipped release')
- os.remove('.\\' + output_filename)
+ os.remove('./' + output_filename)
log('Zipping release')
-check(os.system(r'7z -mx=9 a ' + output_filename + ' .\youtube-local'))
+check(os.system(r'7z -mx=9 a ' + output_filename + ' ./yt-local'))
print('\n')
log('Finished')
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..fb67425
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+# pytest.ini
+[pytest]
+testpaths =
+ tests
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..7174282
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,5 @@
+# Include all production requirements
+-r requirements.txt
+
+# Development requirements
+pytest>=6.2.1
diff --git a/requirements.txt b/requirements.txt
index 3c03957..b54a1f2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,5 @@ Brotli>=1.0.7
PySocks>=1.6.8
urllib3>=1.24.1
defusedxml>=0.5.0
+cachetools>=4.0.0
+stem>=1.8.0
diff --git a/run.bat b/run.bat
index 1a5d479..26fb963 100644
--- a/run.bat
+++ b/run.bat
@@ -1,4 +1,18 @@
@echo off
+
+REM https://stackoverflow.com/a/25719250
+REM setlocal makes sure changing directory only applies inside this bat file,
+REM and not in the command shell.
+setlocal
+
+REM So this bat file can be called from a different working directory.
+REM %~dp0 is the directory with this bat file.
+cd /d "%~dp0"
+
+REM This is so brotli and gevent search in the python directory for the
+REM visual studio c++ runtime dlls
+set PATH=.\python;%PATH%
+
.\python\python.exe -I .\server.py
echo Press any key to quit...
PAUSE > nul
diff --git a/server.py b/server.py
index ac30156..2c53d0c 100644
--- a/server.py
+++ b/server.py
@@ -1,3 +1,4 @@
+#!/usr/bin/env python3
from gevent import monkey
monkey.patch_all()
import gevent.socket
@@ -6,7 +7,7 @@ from youtube import yt_app
from youtube import util
# these are just so the files get run - they import yt_app and add routes to it
-from youtube import watch, search, playlist, channel, local_playlist, comments, post_comment, subscriptions
+from youtube import watch, search, playlist, channel, local_playlist, comments, subscriptions
import settings
@@ -17,44 +18,164 @@ import socket
import socks, sockshandler
import subprocess
import re
-
-
+import sys
+import time
def youtu_be(env, start_response):
id = env['PATH_INFO'][1:]
env['PATH_INFO'] = '/watch'
- env['QUERY_STRING'] = 'v=' + id
+ if not env['QUERY_STRING']:
+ env['QUERY_STRING'] = 'v=' + id
+ else:
+ env['QUERY_STRING'] += '&v=' + id
yield from yt_app(env, start_response)
-def proxy_site(env, start_response):
- headers = {
+
+RANGE_RE = re.compile(r'bytes=(\d+-(?:\d+)?)')
+def parse_range(range_header, content_length):
+ # Range header can be like bytes=200-1000 or bytes=200-
+ # amount_received is the length of bytes from the range that have already
+ # been received
+ match = RANGE_RE.fullmatch(range_header.strip())
+ if not match:
+ print('Unsupported range header format:', range_header)
+ return None
+ start, end = match.group(1).split('-')
+ start_byte = int(start)
+ if not end:
+ end_byte = start_byte + content_length - 1
+ else:
+ end_byte = int(end)
+ return start_byte, end_byte
+
+
+def proxy_site(env, start_response, video=False):
+ send_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64)',
'Accept': '*/*',
}
+ current_range_start = 0
+ range_end = None
+ if 'HTTP_RANGE' in env:
+ send_headers['Range'] = env['HTTP_RANGE']
+
url = "https://" + env['SERVER_NAME'] + env['PATH_INFO']
+ # remove /name portion
+ if video and '/videoplayback/name/' in url:
+ url = url[0:url.rfind('/name/')]
if env['QUERY_STRING']:
url += '?' + env['QUERY_STRING']
+ try_num = 1
+ first_attempt = True
+ current_attempt_position = 0
+ while try_num <= 3: # Try a given byte position three times
+ if not first_attempt:
+ print('(Try %d)' % try_num, 'Trying with', send_headers['Range'])
- content, response = util.fetch_url(url, headers, return_response=True)
+ if video:
+ params = urllib.parse.parse_qs(env['QUERY_STRING'])
+ params_use_tor = int(params.get('use_tor', '0')[0])
+ use_tor = (settings.route_tor == 2) or params_use_tor
+ response, cleanup_func = util.fetch_url_response(url, send_headers,
+ use_tor=use_tor,
+ max_redirects=10)
+ else:
+ response, cleanup_func = util.fetch_url_response(url, send_headers)
- headers = response.getheaders()
- if isinstance(headers, urllib3._collections.HTTPHeaderDict):
- headers = headers.items()
+ response_headers = response.headers
+ if isinstance(response_headers, urllib3._collections.HTTPHeaderDict):
+ response_headers = response_headers.items()
+ if video:
+ response_headers = (list(response_headers)
+ +[('Access-Control-Allow-Origin', '*')])
+
+ if first_attempt:
+ start_response(str(response.status) + ' ' + response.reason,
+ response_headers)
+
+ content_length = int(dict(response_headers).get('Content-Length', 0))
+ if response.status >= 400:
+ print('Error: YouTube returned "%d %s" while routing %s' % (
+ response.status, response.reason, url.split('?')[0]))
+
+ total_received = 0
+ retry = False
+ while True:
+ # a bit over 3 seconds of 360p video
+ # we want each TCP packet to transmit in large multiples,
+ # such as 65,536, so we shouldn't read in small chunks
+ # such as 8192 lest that causes the socket library to limit the
+ # TCP window size
+ # Might need fine-tuning, since this gives us 4*65536
+ # The tradeoff is that larger values (such as 6 seconds) only
+ # allows video to buffer in those increments, meaning user must
+ # wait until the entire chunk is downloaded before video starts
+ # playing
+ content_part = response.read(32*8192)
+ total_received += len(content_part)
+ if not content_part:
+ # Sometimes YouTube closes the connection before sending all of
+ # the content. Retry with a range request for the missing
+ # content. See
+ # https://github.com/user234683/youtube-local/issues/40
+ if total_received < content_length:
+ if 'Range' in send_headers:
+ int_range = parse_range(send_headers['Range'],
+ content_length)
+ if not int_range: # give up b/c unrecognized range
+ break
+ start, end = int_range
+ else:
+ start, end = 0, (content_length - 1)
+
+ fail_byte = start + total_received
+ send_headers['Range'] = 'bytes=%d-%d' % (fail_byte, end)
+ print(
+ 'Warning: YouTube closed the connection before byte',
+ str(fail_byte) + '.', 'Expected', start+content_length,
+ 'bytes.'
+ )
+
+ retry = True
+ first_attempt = False
+ if fail_byte == current_attempt_position:
+ try_num += 1
+ else:
+ try_num = 1
+ current_attempt_position = fail_byte
+ break
+ yield content_part
+ cleanup_func(response)
+ if retry:
+ # YouTube will return 503 Service Unavailable if you do a bunch
+ # of range requests too quickly.
+ time.sleep(1)
+ continue
+ else:
+ break
+ else: # no break
+ print('Error: YouTube closed the connection before',
+ 'providing all content. Retried three times:', url.split('?')[0])
+
+
+def proxy_video(env, start_response):
+ yield from proxy_site(env, start_response, video=True)
- start_response('200 OK', headers )
- yield content
site_handlers = {
- 'youtube.com':yt_app,
- 'youtu.be':youtu_be,
+ 'youtube.com': yt_app,
+ 'youtube-nocookie.com': yt_app,
+ 'youtu.be': youtu_be,
'ytimg.com': proxy_site,
- 'yt3.ggpht.com': proxy_site,
- 'lh3.googleusercontent.com': proxy_site,
-
+ 'ggpht.com': proxy_site,
+ 'googleusercontent.com': proxy_site,
+ 'sponsor.ajay.app': proxy_site,
+ 'googlevideo.com': proxy_video,
}
+
def split_url(url):
''' Split https://sub.example.com/foo/bar.html into ('sub.example.com', '/foo/bar.html')'''
# XXX: Is this regex safe from REDOS?
@@ -62,22 +183,33 @@ def split_url(url):
match = re.match(r'(?:https?://)?([\w-]+(?:\.[\w-]+)+?)(/.*|$)', url)
if match is None:
raise ValueError('Invalid or unsupported url: ' + url)
-
+
return match.group(1), match.group(2)
-
def error_code(code, start_response):
start_response(code, ())
return code.encode()
+
def site_dispatch(env, start_response):
client_address = env['REMOTE_ADDR']
try:
+ # correct malformed query string with ? separators instead of &
+ env['QUERY_STRING'] = env['QUERY_STRING'].replace('?', '&')
+
+ # Fix PATH_INFO for UWSGI
+ if 'REQUEST_URI' in env:
+ env['PATH_INFO'] = urllib.parse.unquote(
+ env['REQUEST_URI'].split('?')[0]
+ )
+
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
- if method=="POST" and client_address not in ('127.0.0.1', '::1'):
+ if (method == "POST"
+ and client_address not in ('127.0.0.1', '::1')
+ and not settings.allow_foreign_post_requests):
yield error_code('403 Forbidden', start_response)
return
@@ -116,12 +248,38 @@ def site_dispatch(env, start_response):
return
+class FilteredRequestLog:
+ '''Don't log noisy thumbnail and avatar requests'''
+ filter_re = re.compile(r'''(?x)
+ "GET\ /https://(
+ i[.]ytimg[.]com/|
+ www[.]youtube[.]com/data/subscription_thumbnails/|
+ yt3[.]ggpht[.]com/|
+ www[.]youtube[.]com/api/timedtext|
+ [-\w]+[.]googlevideo[.]com/).*"\ (200|206)
+ ''')
+
+ def __init__(self):
+ pass
+
+ def write(self, s):
+ if not self.filter_re.search(s):
+ sys.stderr.write(s)
+
+if __name__ == '__main__':
+ if settings.allow_foreign_addresses:
+ server = WSGIServer(('0.0.0.0', settings.port_number), site_dispatch,
+ log=FilteredRequestLog())
+ ip_server = '0.0.0.0'
+ else:
+ server = WSGIServer(('127.0.0.1', settings.port_number), site_dispatch,
+ log=FilteredRequestLog())
+ ip_server = '127.0.0.1'
+ print('Starting httpserver at http://%s:%s/' %
+ (ip_server, settings.port_number))
+ server.serve_forever()
-if settings.allow_foreign_addresses:
- server = WSGIServer(('0.0.0.0', settings.port_number), site_dispatch)
-else:
- server = WSGIServer(('127.0.0.1', settings.port_number), site_dispatch)
-print('Started httpserver on port ' , settings.port_number)
-server.serve_forever()
+# for uwsgi, gunicorn, etc.
+application = site_dispatch
diff --git a/settings.py b/settings.py
index bd6710c..2de5efa 100644
--- a/settings.py
+++ b/settings.py
@@ -7,26 +7,73 @@ import collections
import flask
from flask import request
-settings_info = collections.OrderedDict([
- ('route_tor', {
+SETTINGS_INFO = collections.OrderedDict([
+ ('app_public', {
'type': bool,
'default': False,
+ 'comment': '''Set app public mode, disabled by default''',
+ 'hidden': True,
+ 'category': 'network',
+ }),
+ ('app_url', {
+ 'type': str,
+ 'default': 'http://localhost',
+ 'comment': '''Set URL of app 'http://localhost' by default''',
+ 'hidden': True,
+ 'category': 'network',
+ }),
+ ('route_tor', {
+ 'type': int,
+ 'default': 0,
'label': 'Route Tor',
+ 'comment': '''0 - Off
+1 - On, except video
+2 - On, including video (see warnings)''',
+ 'options': [
+ (0, 'Off'),
+ (1, 'On, except video'),
+ (2, 'On, including video (see warnings)'),
+ ],
+ 'category': 'network',
+ }),
+
+ ('tor_port', {
+ 'type': int,
+ 'default': 9050,
+ 'comment': '',
+ 'category': 'network',
+ }),
+
+ ('tor_control_port', {
+ 'type': int,
+ 'default': 9151,
'comment': '',
+ 'category': 'network',
}),
('port_number', {
'type': int,
- 'default': 8080,
+ 'default': 9010,
'comment': '',
+ 'category': 'network',
}),
('allow_foreign_addresses', {
'type': bool,
'default': False,
- 'comment': '''This will allow others to connect to your Youtube Local instance as a website.
+ 'comment': '''This will allow others to connect to your YT Local instance as a website.
For security reasons, enabling this is not recommended.''',
'hidden': True,
+ 'category': 'network',
+ }),
+
+ ('allow_foreign_post_requests', {
+ 'type': bool,
+ 'default': False,
+ 'comment': '''Enables requests from foreign addresses to make post requests.
+For security reasons, enabling this is not recommended.''',
+ 'hidden': True,
+ 'category': 'network',
}),
('subtitles_mode', {
@@ -41,12 +88,14 @@ For security reasons, enabling this is not recommended.''',
(1, 'Manually created only'),
(2, 'Automatic if manual unavailable'),
],
+ 'category': 'playback',
}),
('subtitles_language', {
'type': str,
'default': 'en',
'comment': '''ISO 639 language code: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes''',
+ 'category': 'playback',
}),
('related_videos_mode', {
@@ -60,6 +109,7 @@ For security reasons, enabling this is not recommended.''',
(1, 'Always shown'),
(2, 'Shown by clicking button'),
],
+ 'category': 'interface',
}),
('comments_mode', {
@@ -73,12 +123,14 @@ For security reasons, enabling this is not recommended.''',
(1, 'Always shown'),
(2, 'Shown by clicking button'),
],
+ 'category': 'interface',
}),
('enable_comment_avatars', {
'type': bool,
'default': True,
'comment': '',
+ 'category': 'interface',
}),
('default_comment_sorting', {
@@ -96,6 +148,14 @@ For security reasons, enabling this is not recommended.''',
'type': bool,
'default': True,
'comment': '',
+ 'category': 'interface',
+ }),
+
+ ('autoplay_videos', {
+ 'type': bool,
+ 'default': False,
+ 'comment': '',
+ 'category': 'playback',
}),
('default_resolution', {
@@ -103,9 +163,111 @@ For security reasons, enabling this is not recommended.''',
'default': 720,
'comment': '',
'options': [
+ (144, '144p'),
+ (240, '240p'),
(360, '360p'),
+ (480, '480p'),
(720, '720p'),
+ (1080, '1080p'),
+ (1440, '1440p'),
+ (2160, '2160p'),
],
+ 'category': 'playback',
+ }),
+
+ ('codec_rank_av1', {
+ 'type': int,
+ 'default': 1,
+ 'label': 'AV1 Codec Ranking',
+ 'comment': '',
+ 'options': [(1, '#1'), (2, '#2'), (3, '#3')],
+ 'category': 'playback',
+ }),
+
+ ('codec_rank_vp', {
+ 'type': int,
+ 'default': 2,
+ 'label': 'VP8/VP9 Codec Ranking',
+ 'comment': '',
+ 'options': [(1, '#1'), (2, '#2'), (3, '#3')],
+ 'category': 'playback',
+ }),
+
+ ('codec_rank_h264', {
+ 'type': int,
+ 'default': 3,
+ 'label': 'H.264 Codec Ranking',
+ 'comment': '',
+ 'options': [(1, '#1'), (2, '#2'), (3, '#3')],
+ 'category': 'playback',
+ 'description': (
+ 'Which video codecs to prefer. Codecs given the same '
+ 'ranking will use smaller file size as a tiebreaker.'
+ )
+ }),
+
+ ('prefer_uni_sources', {
+ 'label': 'Use integrated sources',
+ 'type': int,
+ 'default': 1,
+ 'comment': '',
+ 'options': [
+ (0, 'Prefer not'),
+ (1, 'Prefer'),
+ (2, 'Always'),
+ ],
+ 'category': 'playback',
+ 'description': 'If set to Prefer or Always and the default resolution is set to 360p or 720p, uses the unified (integrated) video files which contain audio and video, with buffering managed by the browser. If set to prefer not, uses the separate audio and video files through custom buffer management in av-merge via MediaSource unless they are unavailable.',
+ }),
+
+ ('use_video_player', {
+ 'type': int,
+ 'default': 1,
+ 'comment': '',
+ 'options': [
+ (0, 'Native'),
+ (1, 'Native with hotkeys'),
+ (2, 'Plyr'),
+ ],
+ 'category': 'interface',
+ }),
+
+ ('use_video_download', {
+ 'type': int,
+ 'default': 0,
+ 'comment': '',
+ 'options': [
+ (0, 'Disabled'),
+ (1, 'Enabled'),
+ ],
+ 'category': 'interface',
+ 'comment': '''If enabled, you may incur legal issues with RIAA. Disabled by default.
+More info: https://torrentfreak.com/riaa-thwarts-youts-attempt-to-declare-youtube-ripping-legal-221002/
+Archive: https://archive.ph/OZQbN''',
+ }),
+
+ ('proxy_images', {
+ 'label': 'Route images',
+ 'type': bool,
+ 'default': True,
+ 'comment': '',
+ 'category': 'network',
+ }),
+
+ ('use_comments_js', {
+ 'label': 'Enable comments.js',
+ 'type': bool,
+ 'default': True,
+ 'comment': '',
+ 'category': 'interface',
+ }),
+
+ ('use_sponsorblock_js', {
+ 'label': 'Enable SponsorBlock',
+ 'type': bool,
+ 'default': False,
+ 'comment': '',
+ 'category': 'playback',
}),
('theme', {
@@ -117,6 +279,29 @@ For security reasons, enabling this is not recommended.''',
(1, 'Gray'),
(2, 'Dark'),
],
+ 'category': 'interface',
+ }),
+
+ ('font', {
+ 'type': int,
+ 'default': 1,
+ 'comment': '',
+ 'options': [
+ (0, 'Browser default'),
+ (1, 'Liberation Serif'),
+ (2, 'Arial'),
+ (3, 'Verdana'),
+ (4, 'Tahoma'),
+ ],
+ 'category': 'interface',
+ }),
+
+ ('embed_page_mode', {
+ 'type': bool,
+ 'label': 'Enable embed page',
+ 'default': True,
+ 'comment': '',
+ 'category': 'interface',
}),
('autocheck_subscriptions', {
@@ -125,11 +310,16 @@ For security reasons, enabling this is not recommended.''',
'comment': '',
}),
- ('gather_googlevideo_domains', {
+ ('include_shorts_in_subscriptions', {
'type': bool,
- 'default': False,
- 'comment': '''Developer use to debug 403s''',
- 'hidden': True,
+ 'default': 0,
+ 'comment': '',
+ }),
+
+ ('include_shorts_in_channel', {
+ 'type': bool,
+ 'default': 1,
+ 'comment': '',
}),
('debugging_save_responses', {
@@ -141,13 +331,16 @@ For security reasons, enabling this is not recommended.''',
('settings_version', {
'type': int,
- 'default': 2,
+ 'default': 6,
'comment': '''Do not change, remove, or comment out this value, or else your settings may be lost or corrupted''',
'hidden': True,
}),
])
-acceptable_targets = settings_info.keys() | {'enable_comments', 'enable_related_videos'}
+program_directory = os.path.dirname(os.path.realpath(__file__))
+acceptable_targets = SETTINGS_INFO.keys() | {
+ 'enable_comments', 'enable_related_videos', 'preferred_video_codec'
+}
def comment_string(comment):
@@ -156,65 +349,98 @@ def comment_string(comment):
result += '# ' + line + '\n'
return result
-def save_settings(settings):
- with open(settings_file_path, 'w', encoding='utf-8') as file:
- for setting_name, default_setting_dict in settings_info.items():
- file.write(comment_string(default_setting_dict['comment']) + setting_name + ' = ' + repr(settings[setting_name]) + '\n\n')
+def save_settings(settings_dict):
+ with open(settings_file_path, 'w', encoding='utf-8') as file:
+ for setting_name, setting_info in SETTINGS_INFO.items():
+ file.write(comment_string(setting_info['comment']) + setting_name + ' = ' + repr(settings_dict[setting_name]) + '\n\n')
-def create_missing_settings_string(current_settings):
- result = ''
- for setting_name, setting_dict in settings_info.items():
- if setting_name not in current_settings:
- result += comment_string(setting_dict['comment']) + setting_name + ' = ' + repr(setting_dict['default']) + '\n\n'
- return result
-
-def create_default_settings_string():
- return settings_to_string({})
-def add_missing_settings(settings):
+def add_missing_settings(settings_dict):
result = default_settings()
- result.update(settings)
+ result.update(settings_dict)
return result
-def default_settings():
- return {key: setting_info['default'] for key, setting_info in settings_info.items()}
-def settings_to_string(settings):
- '''Given a dictionary with the setting names/setting values for the keys/values, outputs a settings file string.
- Fills in missing values from the defaults.'''
- result = ''
- for setting_name, default_setting_dict in settings_info.items():
- if setting_name in settings:
- value = settings[setting_name]
- else:
- value = default_setting_dict['default']
- result += comment_string(default_setting_dict['comment']) + setting_name + ' = ' + repr(value) + '\n\n'
- return result
+def default_settings():
+ return {key: setting_info['default'] for key, setting_info in SETTINGS_INFO.items()}
-def upgrade_to_2(current_settings):
+def upgrade_to_2(settings_dict):
'''Upgrade to settings version 2'''
- new_settings = current_settings.copy()
- if 'enable_comments' in current_settings:
- new_settings['comments_mode'] = int(current_settings['enable_comments'])
+ new_settings = settings_dict.copy()
+ if 'enable_comments' in settings_dict:
+ new_settings['comments_mode'] = int(settings_dict['enable_comments'])
del new_settings['enable_comments']
- if 'enable_related_videos' in current_settings:
- new_settings['related_videos_mode'] = int(current_settings['enable_related_videos'])
+ if 'enable_related_videos' in settings_dict:
+ new_settings['related_videos_mode'] = int(settings_dict['enable_related_videos'])
del new_settings['enable_related_videos']
+ new_settings['settings_version'] = 2
+ return new_settings
+
+
+def upgrade_to_3(settings_dict):
+ new_settings = settings_dict.copy()
+ if 'route_tor' in settings_dict:
+ new_settings['route_tor'] = int(settings_dict['route_tor'])
+ new_settings['settings_version'] = 3
return new_settings
+
+def upgrade_to_4(settings_dict):
+ new_settings = settings_dict.copy()
+ if 'preferred_video_codec' in settings_dict:
+ pref = settings_dict['preferred_video_codec']
+ if pref == 0:
+ new_settings['codec_rank_h264'] = 1
+ new_settings['codec_rank_vp'] = 2
+ new_settings['codec_rank_av1'] = 3
+ else:
+ new_settings['codec_rank_h264'] = 3
+ new_settings['codec_rank_vp'] = 2
+ new_settings['codec_rank_av1'] = 1
+ del new_settings['preferred_video_codec']
+ new_settings['settings_version'] = 4
+ return new_settings
+
+
+def upgrade_to_5(settings_dict):
+ new_settings = settings_dict.copy()
+ if 'prefer_uni_sources' in settings_dict:
+ new_settings['prefer_uni_sources'] = int(settings_dict['prefer_uni_sources'])
+ new_settings['settings_version'] = 5
+ return new_settings
+
+
+def upgrade_to_6(settings_dict):
+ new_settings = settings_dict.copy()
+ if 'gather_googlevideo_domains' in new_settings:
+ del new_settings['gather_googlevideo_domains']
+ new_settings['settings_version'] = 6
+ return new_settings
+
+
+upgrade_functions = {
+ 1: upgrade_to_2,
+ 2: upgrade_to_3,
+ 3: upgrade_to_4,
+ 4: upgrade_to_5,
+ 5: upgrade_to_6,
+}
+
+
def log_ignored_line(line_number, message):
print("WARNING: Ignoring settings.txt line " + str(node.lineno) + " (" + message + ")")
+
if os.path.isfile("settings.txt"):
print("Running in portable mode")
settings_dir = os.path.normpath('./')
data_dir = os.path.normpath('./data')
else:
print("Running in non-portable mode")
- settings_dir = os.path.expanduser(os.path.normpath("~/.youtube-local"))
- data_dir = os.path.expanduser(os.path.normpath("~/.youtube-local/data"))
+ settings_dir = os.path.expanduser(os.path.normpath("~/.yt-local"))
+ data_dir = os.path.expanduser(os.path.normpath("~/.yt-local/data"))
if not os.path.exists(settings_dir):
os.makedirs(settings_dir)
@@ -224,16 +450,17 @@ try:
with open(settings_file_path, 'r', encoding='utf-8') as file:
settings_text = file.read()
except FileNotFoundError:
- settings = default_settings()
- save_settings(settings)
+ current_settings_dict = default_settings()
+ save_settings(current_settings_dict)
else:
if re.fullmatch(r'\s*', settings_text): # blank file
- settings = default_settings()
- save_settings(settings)
+ current_settings_dict = default_settings()
+ save_settings(current_settings_dict)
else:
# parse settings in a safe way, without exec
- settings = {}
+ current_settings_dict = {}
attributes = {
+ ast.Constant: 'value',
ast.NameConstant: 'value',
ast.Num: 'n',
ast.Str: 's',
@@ -243,7 +470,7 @@ else:
if type(node) != ast.Assign:
log_ignored_line(node.lineno, "only assignments are allowed")
continue
-
+
if len(node.targets) > 1:
log_ignored_line(node.lineno, "only simple single-variable assignments allowed")
continue
@@ -252,73 +479,118 @@ else:
if type(target) != ast.Name:
log_ignored_line(node.lineno, "only simple single-variable assignments allowed")
continue
-
+
if target.id not in acceptable_targets:
log_ignored_line(node.lineno, target.id + " is not a valid setting")
continue
-
- if type(node.value) not in (ast.NameConstant, ast.Num, ast.Str):
+
+ if type(node.value) not in attributes:
log_ignored_line(node.lineno, "only literals allowed for values")
continue
- settings[target.id] = node.value.__getattribute__(attributes[type(node.value)])
-
+ current_settings_dict[target.id] = node.value.__getattribute__(attributes[type(node.value)])
- if 'settings_version' not in settings:
- print('Upgrading settings.txt')
- settings = add_missing_settings(upgrade_to_2(settings))
- save_settings(settings)
+ # upgrades
+ latest_version = SETTINGS_INFO['settings_version']['default']
+ while current_settings_dict.get('settings_version', 1) < latest_version:
+ current_version = current_settings_dict.get('settings_version', 1)
+ print('Upgrading settings.txt to version', current_version+1)
+ upgrade_func = upgrade_functions[current_version]
+ # Must add missing settings here rather than below because
+ # save_settings needs all settings to be present
+ current_settings_dict = add_missing_settings(
+ upgrade_func(current_settings_dict))
+ save_settings(current_settings_dict)
# some settings not in the file, add those missing settings to the file
- elif not settings.keys() >= settings_info.keys():
+ if not current_settings_dict.keys() >= SETTINGS_INFO.keys():
print('Adding missing settings to settings.txt')
- settings = add_missing_settings(settings)
- save_settings(settings)
-
-locals().update(settings)
-
+ current_settings_dict = add_missing_settings(current_settings_dict)
+ save_settings(current_settings_dict)
+globals().update(current_settings_dict)
if route_tor:
print("Tor routing is ON")
else:
- print("Tor routing is OFF - your Youtube activity is NOT anonymous")
+ print("Tor routing is OFF - your YouTube activity is NOT anonymous")
+
+
+hooks = {}
+
+
+def add_setting_changed_hook(setting, func):
+ '''Called right after new settings take effect'''
+ if setting in hooks:
+ hooks[setting].append(func)
+ else:
+ hooks[setting] = [func]
+
+
+def set_img_prefix(old_value=None, value=None):
+ global img_prefix
+ if value is None:
+ value = proxy_images
+ if value:
+ img_prefix = '/'
+ else:
+ img_prefix = ''
+
+
+set_img_prefix()
+
+add_setting_changed_hook('proxy_images', set_img_prefix)
+
+
+categories = ['network', 'interface', 'playback', 'other']
def settings_page():
if request.method == 'GET':
- return flask.render_template('settings.html',
- settings = [(setting_name, setting_info, settings[setting_name]) for setting_name, setting_info in settings_info.items()]
+ settings_by_category = {categ: [] for categ in categories}
+ for setting_name, setting_info in SETTINGS_INFO.items():
+ categ = setting_info.get('category', 'other')
+ settings_by_category[categ].append(
+ (setting_name, setting_info, current_settings_dict[setting_name])
+ )
+ return flask.render_template(
+ 'settings.html',
+ categories=categories,
+ settings_by_category=settings_by_category,
)
elif request.method == 'POST':
for key, value in request.values.items():
- if key in settings_info:
- if settings_info[key]['type'] is bool and value == 'on':
- settings[key] = True
+ if key in SETTINGS_INFO:
+ if SETTINGS_INFO[key]['type'] is bool and value == 'on':
+ current_settings_dict[key] = True
else:
- settings[key] = settings_info[key]['type'](value)
+ current_settings_dict[key] = SETTINGS_INFO[key]['type'](value)
else:
flask.abort(400)
# need this bullshit because browsers don't send anything when an input is unchecked
- expected_inputs = {setting_name for setting_name, setting_info in settings_info.items() if not settings_info[setting_name].get('hidden', False)}
+ expected_inputs = {setting_name for setting_name, setting_info in SETTINGS_INFO.items() if not SETTINGS_INFO[setting_name].get('hidden', False)}
missing_inputs = expected_inputs - set(request.values.keys())
for setting_name in missing_inputs:
- assert settings_info[setting_name]['type'] is bool, missing_inputs
- settings[setting_name] = False
-
- globals().update(settings)
- save_settings(settings)
- return flask.redirect(util.URL_ORIGIN + '/settings', 303)
- else:
- flask.abort(400)
-
-
-
-
-
+ assert SETTINGS_INFO[setting_name]['type'] is bool, missing_inputs
+ current_settings_dict[setting_name] = False
+ # find settings that have changed to prepare setting hook calls
+ to_call = []
+ for setting_name, value in current_settings_dict.items():
+ old_value = globals()[setting_name]
+ if value != old_value and setting_name in hooks:
+ for func in hooks[setting_name]:
+ to_call.append((func, old_value, value))
+ globals().update(current_settings_dict)
+ save_settings(current_settings_dict)
+ # call setting hooks
+ for func, old_value, value in to_call:
+ func(old_value, value)
+ return flask.redirect(util.URL_ORIGIN + '/settings', 303)
+ else:
+ flask.abort(400)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..2694317
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,14 @@
+import pytest
+import urllib3
+import urllib
+import urllib.request
+import socket
+
+# https://realpython.com/pytest-python-testing/
+@pytest.fixture(autouse=True)
+def disable_network_calls(monkeypatch):
+ def stunted_get(*args, **kwargs):
+ raise RuntimeError('Network access not allowed during testing!')
+ monkeypatch.setattr(urllib.request, 'Request', stunted_get)
+ monkeypatch.setattr(urllib3.PoolManager, 'request', stunted_get)
+ monkeypatch.setattr(socket, 'socket', stunted_get)
diff --git a/tests/test_responses/429.html b/tests/test_responses/429.html
new file mode 100644
index 0000000..9bde0f9
--- /dev/null
+++ b/tests/test_responses/429.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html>
+<head><meta http-equiv="content-type" content="text/html; charset=utf-8"><meta name="viewport" content="initial-scale=1"><title>https://m.youtube.com/watch?v=aaaaaaaaaaa&amp;pbj=1&amp;bpctr=9999999999</title></head>
+<body style="font-family: arial, sans-serif; background-color: #fff; color: #000; padding:20px; font-size:18px;" onload="e=document.getElementById('captcha');if(e){e.focus();}">
+<div style="max-width:400px;">
+<hr noshade size="1" style="color:#ccc; background-color:#ccc;"><br>
+<form id="captcha-form" action="index" method="post">
+<script src="https://www.google.com/recaptcha/api.js" async defer></script>
+<script>var submitCallback = function(response) {document.getElementById('captcha-form').submit();};</script>
+<div id="recaptcha" class="g-recaptcha" data-sitekey="6LfwuyUTAAAAAOAmoS0fdqijC2PbbdH4kjq62Y1b" data-callback="submitCallback" data-s="vJ20x5QPFGCo8r3XkMznOwMTCK8wPW_bLLhPDgo_I1cwF6xLuYZlq2G2wZPaSJiE8zx5YnaxJzFQGsyhY6NHQKMAaUTtSP6GAbPtueM35Jq3Hmk-gEAozXvvF0HIjK5oONT7F-06MwXDxA4HOqZyOEbsUG_8JjFcCklQjUNUVVItgyLpIbZ1dQ-IEtCXY5E3KDcgHGznfAyMGk_bby9uCpfxNTQwljGippKv1PIU7dI4d5LLpgBPWF0"></div>
+<input type='hidden' name='q' value='EhAgAUug_-oCrgAAAAAAAAoQGPe-9u8FIhkA8aeDS_-EXvhS86PaeaDvps8cqCssFqOzMgFy'><input type="hidden" name="continue" value="https://m.youtube.com/watch?v=aaaaaaaaaaa&amp;pbj=1&amp;bpctr=9999999999">
+</form>
+<hr noshade size="1" style="color:#ccc; background-color:#ccc;">
+
+<div style="font-size:13px;">
+<b>About this page</b><br><br>
+
+Our systems have detected unusual traffic from your computer network. This page checks to see if it&#39;s really you sending the requests, and not a robot. <a href="#" onclick="document.getElementById('infoDiv').style.display='block';">Why did this happen?</a><br><br>
+
+<div id="infoDiv" style="display:none; background-color:#eee; padding:10px; margin:0 0 15px 0; line-height:1.4em;">
+This page appears when Google automatically detects requests coming from your computer network which appear to be in violation of the <a href="//www.google.com/policies/terms/">Terms of Service</a>. The block will expire shortly after those requests stop. In the meantime, solving the above CAPTCHA will let you continue to use our services.<br><br>This traffic may have been sent by malicious software, a browser plug-in, or a script that sends automated requests. If you share your network connection, ask your administrator for help &mdash; a different computer using the same IP address may be responsible. <a href="//support.google.com/websearch/answer/86640">Learn more</a><br><br>Sometimes you may be asked to solve the CAPTCHA if you are using advanced terms that robots are known to use, or sending requests very quickly.
+</div>
+
+IP address: 2001:4ba0:ffea:2ae::a10<br>Time: 2019-12-21T04:28:41Z<br>URL: https://m.youtube.com/watch?v=aaaaaaaaaaa&amp;pbj=1&amp;bpctr=9999999999<br>
+</div>
+</div>
+</body>
+</html>
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000..bc10de4
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,76 @@
+from youtube import util
+import settings
+import pytest # overview: https://realpython.com/pytest-python-testing/
+import urllib3
+import io
+import os
+import stem
+
+
+def load_test_page(name):
+ with open(os.path.join('./tests/test_responses', name), 'rb') as f:
+ return f.read()
+
+
+html429 = load_test_page('429.html')
+
+
+class MockResponse(urllib3.response.HTTPResponse):
+ def __init__(self, body='success', headers=None, status=200, reason=''):
+ print(body[0:10])
+ headers = headers or {}
+ if isinstance(body, str):
+ body = body.encode('utf-8')
+ self.body_io = io.BytesIO(body)
+ self.read = self.body_io.read
+ urllib3.response.HTTPResponse.__init__(
+ self, body=body, headers=headers, status=status,
+ preload_content=False, decode_content=False, reason=reason
+ )
+
+
+class NewIdentityState():
+ MAX_TRIES = util.TorManager.MAX_TRIES
+ def __init__(self, new_identities_till_success):
+ self.new_identities_till_success = new_identities_till_success
+
+ def new_identity(self, *args, **kwargs):
+ print('newidentity')
+ self.new_identities_till_success -= 1
+
+ def fetch_url_response(self, *args, **kwargs):
+ cleanup_func = (lambda r: None)
+ if self.new_identities_till_success == 0:
+ return MockResponse(), cleanup_func
+ return MockResponse(body=html429, status=429), cleanup_func
+
+
+class MockController():
+ def authenticate(self, *args, **kwargs):
+ pass
+ @classmethod
+ def from_port(cls, *args, **kwargs):
+ return cls()
+ def __enter__(self, *args, **kwargs):
+ return self
+ def __exit__(self, *args, **kwargs):
+ pass
+
+
+@pytest.mark.parametrize('new_identities_till_success',
+ [i for i in range(0, NewIdentityState.MAX_TRIES+2)])
+def test_exit_node_retry(monkeypatch, new_identities_till_success):
+ new_identity_state = NewIdentityState(new_identities_till_success)
+ # https://docs.pytest.org/en/stable/monkeypatch.html
+ monkeypatch.setattr(settings, 'route_tor', 1)
+ monkeypatch.setattr(util, 'tor_manager', util.TorManager()) # fresh one
+ MockController.signal = new_identity_state.new_identity
+ monkeypatch.setattr(stem.control, 'Controller', MockController)
+ monkeypatch.setattr(util, 'fetch_url_response',
+ new_identity_state.fetch_url_response)
+ if new_identities_till_success <= NewIdentityState.MAX_TRIES:
+ assert util.fetch_url('url') == b'success'
+ else:
+ with pytest.raises(util.FetchError) as excinfo:
+ util.fetch_url('url')
+ assert int(excinfo.value.code) == 429
diff --git a/youtube/__init__.py b/youtube/__init__.py
index 0137e86..0072f74 100644
--- a/youtube/__init__.py
+++ b/youtube/__init__.py
@@ -1,14 +1,33 @@
+from youtube import util
+from .get_app_version import app_version
import flask
+from flask import request
+import jinja2
import settings
+import traceback
+import re
+from sys import exc_info
yt_app = flask.Flask(__name__)
+yt_app.config['TEMPLATES_AUTO_RELOAD'] = True
yt_app.url_map.strict_slashes = False
+# yt_app.jinja_env.trim_blocks = True
+# yt_app.jinja_env.lstrip_blocks = True
yt_app.add_url_rule('/settings', 'settings_page', settings.settings_page, methods=['POST', 'GET'])
+
@yt_app.route('/')
def homepage():
- return flask.render_template('home.html', title="Youtube local")
+ return flask.render_template('home.html', title="YT Local")
+
+
+@yt_app.route('/licenses')
+def licensepage():
+ return flask.render_template(
+ 'licenses.html',
+ title="Licenses - YT Local"
+ )
theme_names = {
@@ -17,9 +36,131 @@ theme_names = {
2: 'dark_theme',
}
+
@yt_app.context_processor
def inject_theme_preference():
return {
'theme_path': '/youtube.com/static/' + theme_names[settings.theme] + '.css',
+ 'settings': settings,
+ # Detect version
+ 'current_version': app_version()['version'],
+ 'current_branch': app_version()['branch'],
+ 'current_commit': app_version()['commit'],
}
+
+@yt_app.template_filter('commatize')
+def commatize(num):
+ if num is None:
+ return ''
+ if isinstance(num, str):
+ try:
+ num = int(num)
+ except ValueError:
+ return num
+ return '{:,}'.format(num)
+
+
+def timestamp_replacement(match):
+ time_seconds = 0
+ for part in match.group(0).split(':'):
+ time_seconds = 60*time_seconds + int(part)
+ return (
+ """
+ <a href="#" id="timestamp%s">%s</a>
+ <script>
+ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+ (function main() {
+ 'use strict';
+ const player = document.getElementById('js-video-player');
+ const a = document.getElementById('timestamp%s');
+ a.addEventListener('click', function(event) {
+ player.currentTime = %s
+ });
+ }());
+ // @license-end
+ </script>
+ """ % (
+ str(time_seconds),
+ match.group(0),
+ str(time_seconds),
+ str(time_seconds)
+ )
+ )
+
+
+TIMESTAMP_RE = re.compile(r'\b(\d?\d:)?\d?\d:\d\d\b')
+
+
+@yt_app.template_filter('timestamps')
+def timestamps(text):
+ return TIMESTAMP_RE.sub(timestamp_replacement, text)
+
+
+@yt_app.errorhandler(500)
+def error_page(e):
+ slim = request.args.get('slim', False) # whether it was an ajax request
+ if (exc_info()[0] == util.FetchError
+ and exc_info()[1].code == '429'
+ and settings.route_tor
+ ):
+ error_message = ('Error: YouTube blocked the request because the Tor'
+ ' exit node is overutilized. Try getting a new exit node by'
+ ' using the New Identity button in the Tor Browser.')
+ if exc_info()[1].error_message:
+ error_message += '\n\n' + exc_info()[1].error_message
+ if exc_info()[1].ip:
+ error_message += '\n\nExit node IP address: ' + exc_info()[1].ip
+ return flask.render_template('error.html', error_message=error_message, slim=slim), 502
+ elif exc_info()[0] == util.FetchError and exc_info()[1].error_message:
+ return (flask.render_template(
+ 'error.html',
+ error_message=exc_info()[1].error_message,
+ slim=slim
+ ), 502)
+ elif (exc_info()[0] == util.FetchError
+ and exc_info()[1].code == '404'
+ ):
+ error_message = ('Error: The page you are looking for isn\'t here.')
+ return flask.render_template('error.html',
+ error_code=exc_info()[1].code,
+ error_message=error_message,
+ slim=slim), 404
+ return flask.render_template('error.html', traceback=traceback.format_exc(),
+ error_code=exc_info()[1].code,
+ slim=slim), 500
+ # return flask.render_template('error.html', traceback=traceback.format_exc(), slim=slim), 500
+
+
+font_choices = {
+ 0: 'initial',
+ 1: '"liberation serif", "times new roman", calibri, carlito, serif',
+ 2: 'arial, "liberation sans", sans-serif',
+ 3: 'verdana, sans-serif',
+ 4: 'tahoma, sans-serif',
+}
+
+
+@yt_app.route('/shared.css')
+def get_css():
+ return flask.Response(
+ flask.render_template(
+ 'shared.css',
+ font_family=font_choices[settings.font]
+ ),
+ mimetype='text/css',
+ )
+
+
+# This is okay because the flask urlize function puts the href as the first
+# property
+YOUTUBE_LINK_RE = re.compile(r'<a href="(' + util.YOUTUBE_URL_RE_STR + ')"')
+old_urlize = jinja2.filters.urlize
+
+
+def prefix_urlize(*args, **kwargs):
+ result = old_urlize(*args, **kwargs)
+ return YOUTUBE_LINK_RE.sub(r'<a href="/\1"', result)
+
+
+jinja2.filters.urlize = prefix_urlize
diff --git a/youtube/accounts.py b/youtube/accounts.py
deleted file mode 100644
index d2e8a41..0000000
--- a/youtube/accounts.py
+++ /dev/null
@@ -1,336 +0,0 @@
-# Contains functions having to do with logging in
-from youtube import util
-from youtube import yt_app
-import settings
-
-import urllib
-import json
-import re
-import http.cookiejar
-import io
-import os
-
-import flask
-from flask import request
-
-try:
- with open(os.path.join(settings.data_dir, 'accounts.txt'), 'r', encoding='utf-8') as f:
- accounts = json.loads(f.read())
-except FileNotFoundError:
- # global var for temporary storage of account info
- accounts = {}
-
-def account_list_data():
- '''Returns iterable of (channel_id, account_display_name)'''
- return [ (channel_id, account['display_name']) for channel_id, account in accounts.items() ]
-
-def save_accounts():
- to_save = {channel_id: account for channel_id, account in accounts.items() if account['save']}
- with open(os.path.join(settings.data_dir, 'accounts.txt'), 'w', encoding='utf-8') as f:
- f.write(json.dumps(to_save, indent=4)+'\n')
-
-def cookiejar_from_lwp_str(lwp_str):
- lwp_str = "#LWP-Cookies-2.0\n" + lwp_str # header required by _really_load for reading from "file"
- cookiejar = http.cookiejar.LWPCookieJar()
- # HACK: cookiejar module insists on using filenames and reading files for you,
- # so present a StringIO to this internal method which takes a filelike object
- cookiejar._really_load(io.StringIO(lwp_str), "", False, False)
- return cookiejar
-
-def account_cookiejar(channel_id):
- return cookiejar_from_lwp_str('\n'.join(accounts[channel_id]['cookies']))
-
-def _add_account(username, password, save, use_tor):
- cookiejar = http.cookiejar.LWPCookieJar()
- result = _login(username, password, cookiejar, use_tor)
- if isinstance(result, dict):
- accounts[result["channel_id"]] = {
- "save":save,
- "username": username,
- "display_name": username,
- "cookies":cookiejar.as_lwp_str(ignore_discard=False, ignore_expires=False).split('\n'),
- }
- if save:
- save_accounts()
- return True
- return False
-
-@yt_app.route('/login', methods=['POST'])
-def add_account():
- save_account = request.values.get('save', 'off') == 'on'
- use_tor = request.values.get('use_tor', 'off') == 'on'
-
- if _add_account(request.values['username'], request.values['password'], save_account, use_tor ):
- return 'Account successfully added'
- else:
- return 'Failed to add account'
-
-
-@yt_app.route('/login', methods=['GET'])
-def get_account_login_page():
- return flask.render_template('login.html')
-
-
-
-# ---------------------------------
-# Code ported from youtube-dl
-# ---------------------------------
-from html.parser import HTMLParser as compat_HTMLParser
-import http.client as compat_http_client
-
-class HTMLAttributeParser(compat_HTMLParser):
- """Trivial HTML parser to gather the attributes for a single element"""
- def __init__(self):
- self.attrs = {}
- compat_HTMLParser.__init__(self)
-
- def handle_starttag(self, tag, attrs):
- self.attrs = dict(attrs)
-
-def extract_attributes(html_element):
- """Given a string for an HTML element such as
- <el
- a="foo" B="bar" c="&98;az" d=boz
- empty= noval entity="&amp;"
- sq='"' dq="'"
- >
- Decode and return a dictionary of attributes.
- {
- 'a': 'foo', 'b': 'bar', c: 'baz', d: 'boz',
- 'empty': '', 'noval': None, 'entity': '&',
- 'sq': '"', 'dq': '\''
- }.
- NB HTMLParser is stricter in Python 2.6 & 3.2 than in later versions,
- but the cases in the unit test will work for all of 2.6, 2.7, 3.2-3.5.
- """
- parser = HTMLAttributeParser()
- parser.feed(html_element)
- parser.close()
-
- return parser.attrs
-
-def _hidden_inputs(html):
- html = re.sub(r'<!--(?:(?!<!--).)*-->', '', html)
- hidden_inputs = {}
- for input in re.findall(r'(?i)(<input[^>]+>)', html):
- attrs = extract_attributes(input)
- if not input:
- continue
- if attrs.get('type') not in ('hidden', 'submit'):
- continue
- name = attrs.get('name') or attrs.get('id')
- value = attrs.get('value')
- if name and value is not None:
- hidden_inputs[name] = value
- return hidden_inputs
-
-def try_get(src, getter, expected_type=None):
- if not isinstance(getter, (list, tuple)):
- getter = [getter]
- for get in getter:
- try:
- v = get(src)
- except (AttributeError, KeyError, TypeError, IndexError):
- pass
- else:
- if expected_type is None or isinstance(v, expected_type):
- return v
-
-def remove_start(s, start):
- return s[len(start):] if s is not None and s.startswith(start) else s
-
-
-yt_dl_headers = {
- 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0 (Chrome)',
- 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'Accept-Language': 'en-us,en;q=0.5',
-}
-_LOGIN_URL = 'https://accounts.google.com/ServiceLogin'
-_TWOFACTOR_URL = 'https://accounts.google.com/signin/challenge'
-
-_LOOKUP_URL = 'https://accounts.google.com/_/signin/sl/lookup'
-_CHALLENGE_URL = 'https://accounts.google.com/_/signin/sl/challenge'
-_TFA_URL = 'https://accounts.google.com/_/signin/challenge?hl=en&TL={0}'
-_CHANNEL_ID_RE = re.compile(r'"channelUrl"\s*:\s*"\\/channel\\/(UC[\w-]{22})"')
-def _login(username, password, cookiejar, use_tor):
- """
- Attempt to log in to YouTube.
- True is returned if successful or skipped.
- False is returned if login failed.
-
- Taken from youtube-dl
- """
-
- login_page = util.fetch_url(_LOGIN_URL, yt_dl_headers, report_text='Downloaded login page', cookiejar_receive=cookiejar, use_tor=use_tor, debug_name='login_page').decode('utf-8')
-
- if login_page is False:
- return
-
- login_form = _hidden_inputs(login_page)
-
- def req(url, f_req, note, errnote):
- data = login_form.copy()
- data.update({
- 'pstMsg': 1,
- 'checkConnection': 'youtube',
- 'checkedDomains': 'youtube',
- 'hl': 'en',
- 'deviceinfo': '[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]',
- 'f.req': json.dumps(f_req),
- 'flowName': 'GlifWebSignIn',
- 'flowEntry': 'ServiceLogin',
- 'bgRequest': '["identifier",""]',
- })
- headers={
- 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
- 'Google-Accounts-XSRF': 1,
- }
- headers.update(yt_dl_headers)
- result = util.fetch_url(url, headers, report_text=note, data=data, cookiejar_send=cookiejar, cookiejar_receive=cookiejar, use_tor=use_tor, debug_name=note).decode('utf-8')
- result = re.sub(r'^[^\[]*', '', result)
- return json.loads(result)
-
- def warn(message):
- print("Login: " + message)
-
- lookup_req = [
- username,
- None, [], None, 'US', None, None, 2, False, True,
- [
- None, None,
- [2, 1, None, 1,
- 'https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn',
- None, [], 4],
- 1, [None, None, []], None, None, None, True
- ],
- username,
- ]
-
- lookup_results = req(
- _LOOKUP_URL, lookup_req,
- 'Looking up account info', 'Unable to look up account info')
-
- if lookup_results is False:
- return False
-
- user_hash = try_get(lookup_results, lambda x: x[0][2], str)
- if not user_hash:
- warn('Unable to extract user hash')
- return False
-
- challenge_req = [
- user_hash,
- None, 1, None, [1, None, None, None, [password, None, True]],
- [
- None, None, [2, 1, None, 1, 'https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn', None, [], 4],
- 1, [None, None, []], None, None, None, True
- ]]
-
- challenge_results = req(
- _CHALLENGE_URL, challenge_req,
- 'Logging in', 'Unable to log in')
-
- if challenge_results is False:
- return
-
- login_res = try_get(challenge_results, lambda x: x[0][5], list)
- if login_res:
- login_msg = try_get(login_res, lambda x: x[5], str)
- warn(
- 'Unable to login: %s' % 'Invalid password'
- if login_msg == 'INCORRECT_ANSWER_ENTERED' else login_msg)
- return False
-
- res = try_get(challenge_results, lambda x: x[0][-1], list)
- if not res:
- warn('Unable to extract result entry')
- return False
-
- login_challenge = try_get(res, lambda x: x[0][0], list)
- if login_challenge:
- challenge_str = try_get(login_challenge, lambda x: x[2], str)
- if challenge_str == 'TWO_STEP_VERIFICATION':
- # SEND_SUCCESS - TFA code has been successfully sent to phone
- # QUOTA_EXCEEDED - reached the limit of TFA codes
- status = try_get(login_challenge, lambda x: x[5], str)
- if status == 'QUOTA_EXCEEDED':
- warn('Exceeded the limit of TFA codes, try later')
- return False
-
- tl = try_get(challenge_results, lambda x: x[1][2], str)
- if not tl:
- warn('Unable to extract TL')
- return False
-
- tfa_code = self._get_tfa_info('2-step verification code')
-
- if not tfa_code:
- warn(
- 'Two-factor authentication required. Provide it either interactively or with --twofactor <code>'
- '(Note that only TOTP (Google Authenticator App) codes work at this time.)')
- return False
-
- tfa_code = remove_start(tfa_code, 'G-')
-
- tfa_req = [
- user_hash, None, 2, None,
- [
- 9, None, None, None, None, None, None, None,
- [None, tfa_code, True, 2]
- ]]
-
- tfa_results = req(
- _TFA_URL.format(tl), tfa_req,
- 'Submitting TFA code', 'Unable to submit TFA code')
-
- if tfa_results is False:
- return False
-
- tfa_res = try_get(tfa_results, lambda x: x[0][5], list)
- if tfa_res:
- tfa_msg = try_get(tfa_res, lambda x: x[5], str)
- warn(
- 'Unable to finish TFA: %s' % 'Invalid TFA code'
- if tfa_msg == 'INCORRECT_ANSWER_ENTERED' else tfa_msg)
- return False
-
- check_cookie_url = try_get(
- tfa_results, lambda x: x[0][-1][2], str)
- else:
- CHALLENGES = {
- 'LOGIN_CHALLENGE': "This device isn't recognized. For your security, Google wants to make sure it's really you.",
- 'USERNAME_RECOVERY': 'Please provide additional information to aid in the recovery process.',
- 'REAUTH': "There is something unusual about your activity. For your security, Google wants to make sure it's really you.",
- }
- challenge = CHALLENGES.get(
- challenge_str,
- '%s returned error %s.' % ('youtube', challenge_str))
- warn('%s\nGo to https://accounts.google.com/, login and solve a challenge.' % challenge)
- return False
- else:
- check_cookie_url = try_get(res, lambda x: x[2], str)
-
- if not check_cookie_url:
- warn('Unable to extract CheckCookie URL')
- return False
-
- try:
- check_cookie_results = util.fetch_url(check_cookie_url, headers=yt_dl_headers, report_text="Checked cookie", cookiejar_send=cookiejar, cookiejar_receive=cookiejar, use_tor=use_tor, debug_name='check_cookie_results').decode('utf-8')
- except (urllib.error.URLError, compat_http_client.HTTPException, socket.error) as err:
- return False
-
-
- if 'https://myaccount.google.com/' not in check_cookie_results:
- warn('Unable to log in')
- return False
-
- select_site_page = util.fetch_url('https://m.youtube.com/select_site', headers=util.mobile_ua, report_text="Retrieved page for channel id", cookiejar_send=cookiejar, use_tor=use_tor).decode('utf-8')
- match = _CHANNEL_ID_RE.search(select_site_page)
- if match is None:
- warn('Failed to find channel id')
- return False
-
- channel_id = match.group(1)
-
- return {'channel_id': channel_id}
diff --git a/youtube/channel.py b/youtube/channel.py
index 3a2a0b3..81881eb 100644
--- a/youtube/channel.py
+++ b/youtube/channel.py
@@ -1,6 +1,8 @@
import base64
-from youtube import util, yt_data_extract, local_playlist, subscriptions
+from youtube import (util, yt_data_extract, local_playlist, subscriptions,
+ playlist)
from youtube import yt_app
+import settings
import urllib
import json
@@ -10,51 +12,153 @@ import html
import math
import gevent
import re
-import functools
+import cachetools.func
+import traceback
import flask
from flask import request
-'''continuation = Proto(
- Field('optional', 'continuation', 80226972, Proto(
- Field('optional', 'browse_id', 2, String),
- Field('optional', 'params', 3, Base64(Proto(
- Field('optional', 'channel_tab', 2, String),
- Field('optional', 'sort', 3, ENUM
- Field('optional', 'page', 15, String),
- )))
- ))
-)'''
-
-
-'''channel_continuation = Proto(
- Field('optional', 'pointless_nest', 80226972, Proto(
- Field('optional', 'channel_id', 2, String),
- Field('optional', 'continuation_info', 3, Base64(Proto(
- Field('optional', 'channel_tab', 2, String),
- Field('optional', 'sort', 3, ENUM
- Field('optional', 'page', 15, String),
- )))
- ))
-)'''
-
-headers_1 = (
+headers_desktop = (
('Accept', '*/*'),
('Accept-Language', 'en-US,en;q=0.5'),
('X-YouTube-Client-Name', '1'),
('X-YouTube-Client-Version', '2.20180830'),
-)
-headers_pbj = (
+) + util.desktop_ua
+headers_mobile = (
('Accept', '*/*'),
('Accept-Language', 'en-US,en;q=0.5'),
('X-YouTube-Client-Name', '2'),
('X-YouTube-Client-Version', '2.20180830'),
-)
-# https://www.youtube.com/browse_ajax?action_continuation=1&direct_render=1&continuation=4qmFsgJAEhhVQzdVY3M0MkZaeTN1WXpqcnF6T0lIc3caJEVnWjJhV1JsYjNNZ0FEZ0JZQUZxQUhvQk1yZ0JBQSUzRCUzRA%3D%3D
-# https://www.youtube.com/browse_ajax?ctoken=4qmFsgJAEhhVQzdVY3M0MkZaeTN1WXpqcnF6T0lIc3caJEVnWjJhV1JsYjNNZ0FEZ0JZQUZxQUhvQk1yZ0JBQSUzRCUzRA%3D%3D&continuation=4qmFsgJAEhhVQzdVY3M0MkZaeTN1WXpqcnF6T0lIc3caJEVnWjJhV1JsYjNNZ0FEZ0JZQUZxQUhvQk1yZ0JBQSUzRCUzRA%3D%3D&itct=CDsQybcCIhMIhZi1krTc2wIVjMicCh2HXQnhKJsc
+) + util.mobile_ua
+real_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=8XihrAcN1l4'),)
+generic_cookie = (('Cookie', 'VISITOR_INFO1_LIVE=ST1Ti53r4fU'),)
+
+# added an extra nesting under the 2nd base64 compared to v4
+# added tab support
+# changed offset field to uint id 1
+def channel_ctoken_v5(channel_id, page, sort, tab, view=1):
+ new_sort = (2 if int(sort) == 1 else 1)
+ offset = 30*(int(page) - 1)
+ if tab == 'videos':
+ tab = 15
+ elif tab == 'shorts':
+ tab = 10
+ elif tab == 'streams':
+ tab = 14
+ pointless_nest = proto.string(80226972,
+ proto.string(2, channel_id)
+ + proto.string(3,
+ proto.percent_b64encode(
+ proto.string(110,
+ proto.string(3,
+ proto.string(tab,
+ proto.string(1,
+ proto.string(1,
+ proto.unpadded_b64encode(
+ proto.string(1,
+ proto.string(1,
+ proto.unpadded_b64encode(
+ proto.string(2,
+ b"ST:"
+ + proto.unpadded_b64encode(
+ proto.uint(1, offset)
+ )
+ )
+ )
+ )
+ )
+ )
+ )
+ # targetId, just needs to be present but
+ # doesn't need to be correct
+ + proto.string(2, "63faaff0-0000-23fe-80f0-582429d11c38")
+ )
+ # 1 - newest, 2 - popular
+ + proto.uint(3, new_sort)
+ )
+ )
+ )
+ )
+ )
+ )
+
+ return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
+
+
+def channel_about_ctoken(channel_id):
+ return proto.make_protobuf(
+ ('base64p',
+ [
+ [2, 80226972,
+ [
+ [2, 2, channel_id],
+ [2, 3,
+ ('base64p',
+ [
+ [2, 110,
+ [
+ [2, 3,
+ [
+ [2, 19,
+ [
+ [2, 1, b'66b0e9e9-0000-2820-9589-582429a83980'],
+ ]
+ ],
+ ]
+ ],
+ ]
+ ],
+ ]
+ )
+ ],
+ ]
+ ],
+ ]
+ )
+ )
+
+
+# https://github.com/user234683/youtube-local/issues/151
+def channel_ctoken_v4(channel_id, page, sort, tab, view=1):
+ new_sort = (2 if int(sort) == 1 else 1)
+ offset = str(30*(int(page) - 1))
+ pointless_nest = proto.string(80226972,
+ proto.string(2, channel_id)
+ + proto.string(3,
+ proto.percent_b64encode(
+ proto.string(110,
+ proto.string(3,
+ proto.string(15,
+ proto.string(1,
+ proto.string(1,
+ proto.unpadded_b64encode(
+ proto.string(1,
+ proto.unpadded_b64encode(
+ proto.string(2,
+ b"ST:"
+ + proto.unpadded_b64encode(
+ proto.string(2, offset)
+ )
+ )
+ )
+ )
+ )
+ )
+ # targetId, just needs to be present but
+ # doesn't need to be correct
+ + proto.string(2, "63faaff0-0000-23fe-80f0-582429d11c38")
+ )
+ # 1 - newest, 2 - popular
+ + proto.uint(3, new_sort)
+ )
+ )
+ )
+ )
+ )
+ )
+
+ return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
-# grid view: 4qmFsgJAEhhVQzdVY3M0MkZaeTN1WXpqcnF6T0lIc3caJEVnWjJhV1JsYjNNZ0FEZ0JZQUZxQUhvQk1yZ0JBQSUzRCUzRA
-# list view: 4qmFsgJCEhhVQzdVY3M0MkZaeTN1WXpqcnF6T0lIc3caJkVnWjJhV1JsYjNNWUF5QUFNQUk0QVdBQmFnQjZBVEs0QVFBJTNE
# SORT:
# videos:
# Popular - 1
@@ -68,72 +172,215 @@ headers_pbj = (
# view:
# grid: 0 or 1
# list: 2
-def channel_ctoken(channel_id, page, sort, tab, view=1):
-
- tab = proto.string(2, tab )
+def channel_ctoken_v3(channel_id, page, sort, tab, view=1):
+ # page > 1 doesn't work when sorting by oldest
+ offset = 30*(int(page) - 1)
+ page_token = proto.string(61, proto.unpadded_b64encode(
+ proto.string(1, proto.unpadded_b64encode(proto.uint(1,offset)))
+ ))
+
+ tab = proto.string(2, tab)
+ sort = proto.uint(3, int(sort))
+
+ shelf_view = proto.uint(4, 0)
+ view = proto.uint(6, int(view))
+ continuation_info = proto.string(3,
+ proto.percent_b64encode(tab + sort + shelf_view + view + page_token)
+ )
+
+ channel_id = proto.string(2, channel_id)
+ pointless_nest = proto.string(80226972, channel_id + continuation_info)
+
+ return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
+
+
+def channel_ctoken_v2(channel_id, page, sort, tab, view=1):
+ # see https://github.com/iv-org/invidious/issues/1319#issuecomment-671732646
+ # page > 1 doesn't work when sorting by oldest
+ offset = 30*(int(page) - 1)
+ schema_number = {
+ 3: 6307666885028338688,
+ 2: 17254859483345278706,
+ 1: 16570086088270825023,
+ }[int(sort)]
+ page_token = proto.string(61, proto.unpadded_b64encode(proto.string(1,
+ proto.uint(1, schema_number) + proto.string(2,
+ proto.string(1, proto.unpadded_b64encode(proto.uint(1,offset)))
+ )
+ )))
+
+ tab = proto.string(2, tab)
+ sort = proto.uint(3, int(sort))
+ #page = proto.string(15, str(page))
+
+ shelf_view = proto.uint(4, 0)
+ view = proto.uint(6, int(view))
+ continuation_info = proto.string(
+ 3,
+ proto.percent_b64encode(tab + sort + shelf_view + view + page_token)
+ )
+
+ channel_id = proto.string(2, channel_id)
+ pointless_nest = proto.string(80226972, channel_id + continuation_info)
+
+ return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
+
+
+def channel_ctoken_v1(channel_id, page, sort, tab, view=1):
+ tab = proto.string(2, tab)
sort = proto.uint(3, int(sort))
- page = proto.string(15, str(page) )
+ page = proto.string(15, str(page))
# example with shelves in videos tab: https://www.youtube.com/channel/UCNL1ZadSjHpjm4q9j2sVtOA/videos
shelf_view = proto.uint(4, 0)
view = proto.uint(6, int(view))
- continuation_info = proto.string( 3, proto.percent_b64encode(tab + view + sort + shelf_view + page) )
-
- channel_id = proto.string(2, channel_id )
+ continuation_info = proto.string(3, proto.percent_b64encode(tab + view + sort + shelf_view + page + proto.uint(23, 0)) )
+
+ channel_id = proto.string(2, channel_id)
pointless_nest = proto.string(80226972, channel_id + continuation_info)
return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
-def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1, print_status=True):
- ctoken = channel_ctoken(channel_id, page, sort, tab, view).replace('=', '%3D')
- url = "https://www.youtube.com/browse_ajax?ctoken=" + ctoken
- if print_status:
- print("Sending channel tab ajax request")
- content = util.fetch_url(url, util.desktop_ua + headers_1, debug_name='channel_tab')
- if print_status:
- print("Finished recieving channel tab response")
+def get_channel_tab(channel_id, page="1", sort=3, tab='videos', view=1,
+ ctoken=None, print_status=True):
+ message = 'Got channel tab' if print_status else None
+
+ if not ctoken:
+ if tab in ('videos', 'shorts', 'streams'):
+ ctoken = channel_ctoken_v5(channel_id, page, sort, tab, view)
+ else:
+ ctoken = channel_ctoken_v3(channel_id, page, sort, tab, view)
+ ctoken = ctoken.replace('=', '%3D')
+
+ # Not sure what the purpose of the key is or whether it will change
+ # For now it seems to be constant for the API endpoint, not dependent
+ # on the browsing session or channel
+ key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
+ url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key
+
+ data = {
+ 'context': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'WEB',
+ 'clientVersion': '2.20240327.00.00',
+ },
+ },
+ 'continuation': ctoken,
+ }
+
+ content_type_header = (('Content-Type', 'application/json'),)
+ content = util.fetch_url(
+ url, headers_desktop + content_type_header,
+ data=json.dumps(data), debug_name='channel_tab', report_text=message)
return content
-def get_number_of_videos(channel_id):
+
+# cache entries expire after 30 minutes
+number_of_videos_cache = cachetools.TTLCache(128, 30*60)
+@cachetools.cached(number_of_videos_cache)
+def get_number_of_videos_channel(channel_id):
+ if channel_id is None:
+ return 1000
+
# Uploads playlist
playlist_id = 'UU' + channel_id[2:]
url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
- print("Getting number of videos")
- # Sometimes retrieving playlist info fails with 403 for no discernable reason
try:
- response = util.fetch_url(url, util.mobile_ua + headers_pbj, debug_name='number_of_videos')
- except urllib.error.HTTPError as e:
- if e.code != 403:
- raise
+ response = util.fetch_url(url, headers_mobile,
+ debug_name='number_of_videos', report_text='Got number of videos')
+ except (urllib.error.HTTPError, util.FetchError) as e:
+ traceback.print_exc()
print("Couldn't retrieve number of videos")
return 1000
response = response.decode('utf-8')
- print("Got response for number of videos")
- match = re.search(r'"numVideosText":\s*{\s*"runs":\s*\[{"text":\s*"([\d,]*) videos"', response)
+ # match = re.search(r'"numVideosText":\s*{\s*"runs":\s*\[{"text":\s*"([\d,]*) videos"', response)
+ match = re.search(r'"numVideosText".*?([,\d]+)', response)
if match:
return int(match.group(1).replace(',',''))
else:
return 0
+def set_cached_number_of_videos(channel_id, num_videos):
+ @cachetools.cached(number_of_videos_cache)
+ def dummy_func_using_same_cache(channel_id):
+ return num_videos
+ dummy_func_using_same_cache(channel_id)
-@functools.lru_cache(maxsize=128)
-def get_channel_id(username):
- # method that gives the smallest possible response at ~10 kb
+
+channel_id_re = re.compile(r'videos\.xml\?channel_id=([a-zA-Z0-9_-]{24})"')
+@cachetools.func.lru_cache(maxsize=128)
+def get_channel_id(base_url):
+ # method that gives the smallest possible response at ~4 kb
# needs to be as fast as possible
- url = 'https://m.youtube.com/user/' + username + '/about?ajax=1&disable_polymer=true'
- response = util.fetch_url(url, util.mobile_ua + headers_1).decode('utf-8')
- return re.search(r'"channel_id":\s*"([a-zA-Z0-9_-]*)"', response).group(1)
+ base_url = base_url.replace('https://www', 'https://m') # avoid redirect
+ response = util.fetch_url(base_url + '/about?pbj=1', headers_mobile,
+ debug_name='get_channel_id', report_text='Got channel id').decode('utf-8')
+ match = channel_id_re.search(response)
+ if match:
+ return match.group(1)
+ return None
+
+
+metadata_cache = cachetools.LRUCache(128)
+@cachetools.cached(metadata_cache)
+def get_metadata(channel_id):
+ base_url = 'https://www.youtube.com/channel/' + channel_id
+ polymer_json = util.fetch_url(base_url + '/about?pbj=1',
+ headers_desktop,
+ debug_name='gen_channel_about',
+ report_text='Retrieved channel metadata')
+ info = yt_data_extract.extract_channel_info(json.loads(polymer_json),
+ 'about',
+ continuation=False)
+ return extract_metadata_for_caching(info)
+def set_cached_metadata(channel_id, metadata):
+ @cachetools.cached(metadata_cache)
+ def dummy_func_using_same_cache(channel_id):
+ return metadata
+ dummy_func_using_same_cache(channel_id)
+def extract_metadata_for_caching(channel_info):
+ metadata = {}
+ for key in ('approx_subscriber_count', 'short_description', 'channel_name',
+ 'avatar'):
+ metadata[key] = channel_info[key]
+ return metadata
+
+
+def get_number_of_videos_general(base_url):
+ return get_number_of_videos_channel(get_channel_id(base_url))
+
def get_channel_search_json(channel_id, query, page):
- params = proto.string(2, 'search') + proto.string(15, str(page))
+ offset = proto.unpadded_b64encode(proto.uint(3, (page-1)*30))
+ params = proto.string(2, 'search') + proto.string(15, offset)
params = proto.percent_b64encode(params)
ctoken = proto.string(2, channel_id) + proto.string(3, params) + proto.string(11, query)
ctoken = base64.urlsafe_b64encode(proto.nested(80226972, ctoken)).decode('ascii')
- polymer_json = util.fetch_url("https://www.youtube.com/browse_ajax?ctoken=" + ctoken, util.desktop_ua + headers_1, debug_name='channel_search')
+ key = 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
+ url = 'https://www.youtube.com/youtubei/v1/browse?key=' + key
+
+ data = {
+ 'context': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'WEB',
+ 'clientVersion': '2.20240327.00.00',
+ },
+ },
+ 'continuation': ctoken,
+ }
+
+ content_type_header = (('Content-Type', 'application/json'),)
+ polymer_json = util.fetch_url(
+ url, headers_desktop + content_type_header,
+ data=json.dumps(data), debug_name='channel_search')
return polymer_json
@@ -142,105 +389,190 @@ def post_process_channel_info(info):
info['avatar'] = util.prefix_url(info['avatar'])
info['channel_url'] = util.prefix_url(info['channel_url'])
for item in info['items']:
- yt_data_extract.prefix_urls(item)
- yt_data_extract.add_extra_html_info(item)
-
+ item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id'])
+ util.prefix_urls(item)
+ util.add_extra_html_info(item)
+ if info['current_tab'] == 'about':
+ for i, (text, url) in enumerate(info['links']):
+ if isinstance(url, str) and util.YOUTUBE_URL_RE.fullmatch(url):
+ info['links'][i] = (text, util.prefix_url(url))
+def get_channel_first_page(base_url=None, tab='videos', channel_id=None):
+ if channel_id:
+ base_url = 'https://www.youtube.com/channel/' + channel_id
+ return util.fetch_url(base_url + '/' + tab + '?pbj=1&view=0',
+ headers_desktop, debug_name='gen_channel_' + tab)
playlist_sort_codes = {'2': "da", '3': "dd", '4': "lad"}
-@yt_app.route('/channel/<channel_id>/')
-@yt_app.route('/channel/<channel_id>/<tab>')
-def get_channel_page(channel_id, tab='videos'):
+# youtube.com/[channel_id]/[tab]
+# youtube.com/user/[username]/[tab]
+# youtube.com/c/[custom]/[tab]
+# youtube.com/[custom]/[tab]
+def get_channel_page_general_url(base_url, tab, request, channel_id=None):
page_number = int(request.args.get('page', 1))
- sort = request.args.get('sort', '3')
+ # sort 1: views
+ # sort 2: oldest
+ # sort 3: newest
+ # sort 4: newest - no shorts (Just a kludge on our end, not internal to yt)
+ default_sort = '3' if settings.include_shorts_in_channel else '4'
+ sort = request.args.get('sort', default_sort)
view = request.args.get('view', '1')
query = request.args.get('query', '')
+ ctoken = request.args.get('ctoken', '')
+ include_shorts = (sort != '4')
+ default_params = (page_number == 1 and sort in ('3', '4') and view == '1')
+ continuation = bool(ctoken) # whether or not we're using a continuation
+ page_size = 30
+ try_channel_api = True
+ polymer_json = None
+
+ # Use the special UU playlist which contains all the channel's uploads
+ if tab == 'videos' and sort in ('3', '4'):
+ if not channel_id:
+ channel_id = get_channel_id(base_url)
+ if page_number == 1 and include_shorts:
+ tasks = (
+ gevent.spawn(playlist.playlist_first_page,
+ 'UU' + channel_id[2:],
+ report_text='Retrieved channel videos'),
+ gevent.spawn(get_metadata, channel_id),
+ )
+ gevent.joinall(tasks)
+ util.check_gevent_exceptions(*tasks)
+
+ # Ignore the metadata for now, it is cached and will be
+ # recalled later
+ pl_json = tasks[0].value
+ pl_info = yt_data_extract.extract_playlist_info(pl_json)
+ number_of_videos = pl_info['metadata']['video_count']
+ if number_of_videos is None:
+ number_of_videos = 1000
+ else:
+ set_cached_number_of_videos(channel_id, number_of_videos)
+ else:
+ tasks = (
+ gevent.spawn(playlist.get_videos, 'UU' + channel_id[2:],
+ page_number, include_shorts=include_shorts),
+ gevent.spawn(get_metadata, channel_id),
+ gevent.spawn(get_number_of_videos_channel, channel_id),
+ )
+ gevent.joinall(tasks)
+ util.check_gevent_exceptions(*tasks)
+
+ pl_json = tasks[0].value
+ pl_info = yt_data_extract.extract_playlist_info(pl_json)
+ number_of_videos = tasks[2].value
+
+ info = pl_info
+ info['channel_id'] = channel_id
+ info['current_tab'] = 'videos'
+ if info['items']: # Success
+ page_size = 100
+ try_channel_api = False
+ else: # Try the first-page method next
+ try_channel_api = True
+
+ # Use the regular channel API
+ if tab in ('shorts', 'streams') or (tab=='videos' and try_channel_api):
+ if channel_id:
+ num_videos_call = (get_number_of_videos_channel, channel_id)
+ else:
+ num_videos_call = (get_number_of_videos_general, base_url)
+
+ # Use ctoken method, which YouTube changes all the time
+ if channel_id and not default_params:
+ if sort == 4:
+ _sort = 3
+ else:
+ _sort = sort
+ page_call = (get_channel_tab, channel_id, page_number, _sort,
+ tab, view, ctoken)
+ # Use the first-page method, which won't break
+ else:
+ page_call = (get_channel_first_page, base_url, tab)
-
- if tab == 'videos':
tasks = (
- gevent.spawn(get_number_of_videos, channel_id ),
- gevent.spawn(get_channel_tab, channel_id, page_number, sort, 'videos', view)
+ gevent.spawn(*num_videos_call),
+ gevent.spawn(*page_call),
)
gevent.joinall(tasks)
+ util.check_gevent_exceptions(*tasks)
number_of_videos, polymer_json = tasks[0].value, tasks[1].value
elif tab == 'about':
- polymer_json = util.fetch_url('https://www.youtube.com/channel/' + channel_id + '/about?pbj=1', util.desktop_ua + headers_1, debug_name='channel_about')
+ # polymer_json = util.fetch_url(base_url + '/about?pbj=1', headers_desktop, debug_name='gen_channel_about')
+ channel_id = get_channel_id(base_url)
+ ctoken = channel_about_ctoken(channel_id)
+ polymer_json = util.call_youtube_api('web', 'browse', {
+ 'continuation': ctoken,
+ })
+ continuation=True
+ elif tab == 'playlists' and page_number == 1:
+ polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], headers_desktop, debug_name='gen_channel_playlists')
elif tab == 'playlists':
- polymer_json = util.fetch_url('https://www.youtube.com/channel/' + channel_id + '/playlists?pbj=1&view=1&sort=' + playlist_sort_codes[sort], util.desktop_ua + headers_1, debug_name='channel_playlists')
+ polymer_json = get_channel_tab(channel_id, page_number, sort,
+ 'playlists', view)
+ continuation = True
+ elif tab == 'search' and channel_id:
+ polymer_json = get_channel_search_json(channel_id, query, page_number)
elif tab == 'search':
- tasks = (
- gevent.spawn(get_number_of_videos, channel_id ),
- gevent.spawn(get_channel_search_json, channel_id, query, page_number)
- )
- gevent.joinall(tasks)
- number_of_videos, polymer_json = tasks[0].value, tasks[1].value
-
+ url = base_url + '/search?pbj=1&query=' + urllib.parse.quote(query, safe='')
+ polymer_json = util.fetch_url(url, headers_desktop, debug_name='gen_channel_search')
+ elif tab == 'videos':
+ pass
else:
flask.abort(404, 'Unknown channel tab: ' + tab)
+ if polymer_json is not None:
+ info = yt_data_extract.extract_channel_info(
+ json.loads(polymer_json), tab, continuation=continuation
+ )
- info = yt_data_extract.extract_channel_info(json.loads(polymer_json), tab)
- if info['errors']:
- return flask.render_template('error.html', error_message = '\n'.join(info['errors']))
- post_process_channel_info(info)
- if tab in ('videos', 'search'):
+ if info['error'] is not None:
+ return flask.render_template('error.html', error_message=info['error'])
+
+ if channel_id:
+ info['channel_url'] = 'https://www.youtube.com/channel/' + channel_id
+ info['channel_id'] = channel_id
+ else:
+ channel_id = info['channel_id']
+
+ # Will have microformat present, cache metadata while we have it
+ if channel_id and default_params and tab not in ('videos', 'about'):
+ metadata = extract_metadata_for_caching(info)
+ set_cached_metadata(channel_id, metadata)
+ # Otherwise, populate with our (hopefully cached) metadata
+ elif channel_id and info.get('channel_name') is None:
+ metadata = get_metadata(channel_id)
+ for key, value in metadata.items():
+ yt_data_extract.conservative_update(info, key, value)
+ # need to add this metadata to the videos/playlists
+ additional_info = {
+ 'author': info['channel_name'],
+ 'author_id': info['channel_id'],
+ 'author_url': info['channel_url'],
+ }
+ for item in info['items']:
+ item.update(additional_info)
+
+ if tab in ('videos', 'shorts', 'streams'):
info['number_of_videos'] = number_of_videos
- info['number_of_pages'] = math.ceil(number_of_videos/30)
+ info['number_of_pages'] = math.ceil(number_of_videos/page_size)
info['header_playlist_names'] = local_playlist.get_playlist_names()
- if tab in ('videos', 'playlists'):
+ if tab in ('videos', 'shorts', 'streams', 'playlists'):
info['current_sort'] = sort
elif tab == 'search':
info['search_box_value'] = query
+ info['header_playlist_names'] = local_playlist.get_playlist_names()
+ if tab in ('search', 'playlists'):
+ info['page_number'] = page_number
info['subscribed'] = subscriptions.is_subscribed(info['channel_id'])
- return flask.render_template('channel.html',
- parameters_dictionary = request.args,
- **info
- )
-
-
-# youtube.com/user/[username]/[tab]
-# youtube.com/c/[custom]/[tab]
-# youtube.com/[custom]/[tab]
-def get_channel_page_general_url(base_url, tab, request):
-
- page_number = int(request.args.get('page', 1))
- sort = request.args.get('sort', '3')
- view = request.args.get('view', '1')
- query = request.args.get('query', '')
-
- if tab == 'videos':
- polymer_json = util.fetch_url(base_url + '/videos?pbj=1&view=0', util.desktop_ua + headers_1, debug_name='gen_channel_videos')
- elif tab == 'about':
- polymer_json = util.fetch_url(base_url + '/about?pbj=1', util.desktop_ua + headers_1, debug_name='gen_channel_about')
- elif tab == 'playlists':
- polymer_json = util.fetch_url(base_url+ '/playlists?pbj=1&view=1', util.desktop_ua + headers_1, debug_name='gen_channel_playlists')
- elif tab == 'search':
- raise NotImplementedError()
- else:
- flask.abort(404, 'Unknown channel tab: ' + tab)
-
-
- info = yt_data_extract.extract_channel_info(json.loads(polymer_json), tab)
- if info['errors']:
- return flask.render_template('error.html', error_message = '\n'.join(info['errors']))
-
post_process_channel_info(info)
- if tab in ('videos', 'search'):
- info['number_of_videos'] = 1000
- info['number_of_pages'] = math.ceil(1000/30)
- info['header_playlist_names'] = local_playlist.get_playlist_names()
- if tab in ('videos', 'playlists'):
- info['current_sort'] = sort
- elif tab == 'search':
- info['search_box_value'] = query
- info['subscribed'] = subscriptions.is_subscribed(info['channel_id'])
return flask.render_template('channel.html',
parameters_dictionary = request.args,
@@ -248,18 +580,25 @@ def get_channel_page_general_url(base_url, tab, request):
)
+@yt_app.route('/channel/<channel_id>/')
+@yt_app.route('/channel/<channel_id>/<tab>')
+def get_channel_page(channel_id, tab='videos'):
+ return get_channel_page_general_url('https://www.youtube.com/channel/' + channel_id, tab, request, channel_id)
+
+
@yt_app.route('/user/<username>/')
@yt_app.route('/user/<username>/<tab>')
def get_user_page(username, tab='videos'):
return get_channel_page_general_url('https://www.youtube.com/user/' + username, tab, request)
+
@yt_app.route('/c/<custom>/')
@yt_app.route('/c/<custom>/<tab>')
def get_custom_c_page(custom, tab='videos'):
return get_channel_page_general_url('https://www.youtube.com/c/' + custom, tab, request)
+
@yt_app.route('/<custom>')
@yt_app.route('/<custom>/<tab>')
def get_toplevel_custom_page(custom, tab='videos'):
return get_channel_page_general_url('https://www.youtube.com/' + custom, tab, request)
-
diff --git a/youtube/comments.py b/youtube/comments.py
index 3b1ef86..1ff1a21 100644
--- a/youtube/comments.py
+++ b/youtube/comments.py
@@ -1,11 +1,13 @@
-from youtube import proto, util, yt_data_extract, accounts
+from youtube import proto, util, yt_data_extract
+from youtube.util import (
+ concat_or_none,
+ strip_non_ascii
+)
from youtube import yt_app
import settings
import json
import base64
-import urllib
-import re
import flask
from flask import request
@@ -23,12 +25,13 @@ from flask import request
# *Old ASJN's continue to work, and start at the same comment even if new comments have been posted since
# *The ASJN has no relation with any of the data in the response it came from
+
def make_comment_ctoken(video_id, sort=0, offset=0, lc='', secret_key=''):
video_id = proto.as_bytes(video_id)
secret_key = proto.as_bytes(secret_key)
-
- page_info = proto.string(4,video_id) + proto.uint(6, sort)
+
+ page_info = proto.string(4, video_id) + proto.uint(6, sort)
offset_information = proto.nested(4, page_info) + proto.uint(5, offset)
if secret_key:
offset_information = proto.string(1, secret_key) + offset_information
@@ -37,235 +40,192 @@ def make_comment_ctoken(video_id, sort=0, offset=0, lc='', secret_key=''):
if lc:
page_params += proto.string(6, proto.percent_b64encode(proto.string(15, lc)))
- result = proto.nested(2, page_params) + proto.uint(3,6) + proto.nested(6, offset_information)
- return base64.urlsafe_b64encode(result).decode('ascii')
-
-def comment_replies_ctoken(video_id, comment_id, max_results=500):
-
- params = proto.string(2, comment_id) + proto.uint(9, max_results)
- params = proto.nested(3, params)
-
- result = proto.nested(2, proto.string(2, video_id)) + proto.uint(3,6) + proto.nested(6, params)
+ result = proto.nested(2, page_params) + proto.uint(3, 6) + proto.nested(6, offset_information)
return base64.urlsafe_b64encode(result).decode('ascii')
-def ctoken_metadata(ctoken):
- result = dict()
- params = proto.parse(proto.b64_to_bytes(ctoken))
- result['video_id'] = proto.parse(params[2])[2].decode('ascii')
- offset_information = proto.parse(params[6])
- result['offset'] = offset_information.get(5, 0)
-
- result['is_replies'] = False
- if (3 in offset_information) and (2 in proto.parse(offset_information[3])):
- result['is_replies'] = True
- result['sort'] = None
- else:
- try:
- result['sort'] = proto.parse(offset_information[4])[6]
- except KeyError:
- result['sort'] = 0
- return result
-
-
-mobile_headers = {
- 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
- 'Accept': '*/*',
- 'Accept-Language': 'en-US,en;q=0.5',
- 'X-YouTube-Client-Name': '2',
- 'X-YouTube-Client-Version': '2.20180823',
-}
def request_comments(ctoken, replies=False):
- if replies: # let's make it use different urls for no reason despite all the data being encoded
- base_url = "https://m.youtube.com/watch_comment?action_get_comment_replies=1&ctoken="
- else:
- base_url = "https://m.youtube.com/watch_comment?action_get_comments=1&ctoken="
- url = base_url + ctoken.replace("=", "%3D") + "&pbj=1"
-
- for i in range(0,8): # don't retry more than 8 times
- content = util.fetch_url(url, headers=mobile_headers, report_text="Retrieved comments", debug_name='request_comments')
- if content[0:4] == b")]}'": # random closing characters included at beginning of response for some reason
- content = content[4:]
- elif content[0:10] == b'\n<!DOCTYPE': # occasionally returns html instead of json for no reason
- content = b''
- print("got <!DOCTYPE>, retrying")
- continue
- break
- return content
+ url = 'https://m.youtube.com/youtubei/v1/next'
+ url += '?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
+ data = json.dumps({
+ 'context': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'MWEB',
+ 'clientVersion': '2.20210804.02.00',
+ },
+ },
+ 'continuation': ctoken.replace('=', '%3D'),
+ })
+
+ content = util.fetch_url(
+ url, headers=util.mobile_xhr_headers + util.json_header, data=data,
+ report_text='Retrieved comments', debug_name='request_comments')
+ content = content.decode('utf-8')
+
+ polymer_json = json.loads(content)
+ return polymer_json
def single_comment_ctoken(video_id, comment_id):
- page_params = proto.string(2, video_id) + proto.string(6, proto.percent_b64encode(proto.string(15, comment_id)))
+ page_params = proto.string(2, video_id) + proto.string(
+ 6, proto.percent_b64encode(proto.string(15, comment_id)))
- result = proto.nested(2, page_params) + proto.uint(3,6)
+ result = proto.nested(2, page_params) + proto.uint(3, 6)
return base64.urlsafe_b64encode(result).decode('ascii')
-
-def parse_comments_polymer(content):
- try:
- video_title = ''
- content = json.loads(util.uppercase_escape(content.decode('utf-8')))
- url = content[1]['url']
- ctoken = urllib.parse.parse_qs(url[url.find('?')+1:])['ctoken'][0]
- metadata = ctoken_metadata(ctoken)
-
- try:
- comments_raw = content[1]['response']['continuationContents']['commentSectionContinuation']['items']
- except KeyError:
- comments_raw = content[1]['response']['continuationContents']['commentRepliesContinuation']['contents']
-
- ctoken = util.default_multi_get(content, 1, 'response', 'continuationContents', 'commentSectionContinuation', 'continuations', 0, 'nextContinuationData', 'continuation', default='')
-
- comments = []
- for comment_json in comments_raw:
- number_of_replies = 0
- try:
- comment_thread = comment_json['commentThreadRenderer']
- except KeyError:
- comment_renderer = comment_json['commentRenderer']
- else:
- if 'commentTargetTitle' in comment_thread:
- video_title = comment_thread['commentTargetTitle']['runs'][0]['text']
-
- if 'replies' in comment_thread:
- view_replies_text = yt_data_extract.get_plain_text(comment_thread['replies']['commentRepliesRenderer']['moreText'])
- view_replies_text = view_replies_text.replace(',', '')
- match = re.search(r'(\d+)', view_replies_text)
- if match is None:
- number_of_replies = 1
- else:
- number_of_replies = int(match.group(1))
- comment_renderer = comment_thread['comment']['commentRenderer']
-
- comment = {
- 'author_id': comment_renderer.get('authorId', ''),
- 'author_avatar': comment_renderer['authorThumbnail']['thumbnails'][0]['url'],
- 'likes': comment_renderer['likeCount'],
- 'published': yt_data_extract.get_plain_text(comment_renderer['publishedTimeText']),
- 'text': comment_renderer['contentText'].get('runs', ''),
- 'number_of_replies': number_of_replies,
- 'comment_id': comment_renderer['commentId'],
- }
-
- if 'authorText' in comment_renderer: # deleted channels have no name or channel link
- comment['author'] = yt_data_extract.get_plain_text(comment_renderer['authorText'])
- comment['author_url'] = comment_renderer['authorEndpoint']['commandMetadata']['webCommandMetadata']['url']
- comment['author_channel_id'] = comment_renderer['authorEndpoint']['browseEndpoint']['browseId']
- else:
- comment['author'] = ''
- comment['author_url'] = ''
- comment['author_channel_id'] = ''
-
- comments.append(comment)
- except Exception as e:
- print('Error parsing comments: ' + str(e))
- comments = ()
- ctoken = ''
-
- return {
- 'ctoken': ctoken,
- 'comments': comments,
- 'video_title': video_title,
- 'video_id': metadata['video_id'],
- 'offset': metadata['offset'],
- 'is_replies': metadata['is_replies'],
- 'sort': metadata['sort'],
- }
-
def post_process_comments_info(comments_info):
for comment in comments_info['comments']:
- comment['author_url'] = util.URL_ORIGIN + comment['author_url']
- comment['author_avatar'] = '/' + comment['author_avatar']
-
- comment['permalink'] = util.URL_ORIGIN + '/watch?v=' + comments_info['video_id'] + '&lc=' + comment['comment_id']
-
- if comment['author_channel_id'] in accounts.accounts:
- comment['delete_url'] = (util.URL_ORIGIN + '/delete_comment?video_id='
- + comments_info['video_id']
- + '&channel_id='+ comment['author_channel_id']
- + '&author_id=' + comment['author_id']
- + '&comment_id=' + comment['comment_id'])
-
- num_replies = comment['number_of_replies']
- if num_replies == 0:
- comment['replies_url'] = util.URL_ORIGIN + '/post_comment?parent_id=' + comment['comment_id'] + "&video_id=" + comments_info['video_id']
- else:
- comment['replies_url'] = util.URL_ORIGIN + '/comments?parent_id=' + comment['comment_id'] + "&video_id=" + comments_info['video_id']
-
- if num_replies == 0:
+ comment['author'] = strip_non_ascii(comment['author']) if comment.get('author') else ""
+ comment['author_url'] = concat_or_none(
+ '/', comment['author_url'])
+ comment['author_avatar'] = concat_or_none(
+ settings.img_prefix, comment['author_avatar'])
+
+ comment['permalink'] = concat_or_none(
+ util.URL_ORIGIN, '/watch?v=',
+ comments_info['video_id'],
+ '&lc=', comment['id']
+ )
+
+ reply_count = comment['reply_count']
+ comment['replies_url'] = None
+ if comment['reply_ctoken']:
+ # change max_replies field to 250 in ctoken
+ ctoken = comment['reply_ctoken']
+ ctoken, err = proto.set_protobuf_value(
+ ctoken,
+ 'base64p', 6, 3, 9, value=200)
+ if err:
+ print('Error setting ctoken value:')
+ print(err)
+ comment['replies_url'] = None
+ comment['replies_url'] = concat_or_none(
+ util.URL_ORIGIN,
+ '/comments?replies=1&ctoken=' + ctoken)
+
+ if reply_count == 0:
comment['view_replies_text'] = 'Reply'
- elif num_replies == 1:
+ elif reply_count == 1:
comment['view_replies_text'] = '1 reply'
else:
- comment['view_replies_text'] = str(num_replies) + ' replies'
-
+ comment['view_replies_text'] = str(reply_count) + ' replies'
- if comment['likes'] == 1:
+ if comment['approx_like_count'] == '1':
comment['likes_text'] = '1 like'
else:
- comment['likes_text'] = str(comment['likes']) + ' likes'
+ comment['likes_text'] = (str(comment['approx_like_count'])
+ + ' likes')
comments_info['include_avatars'] = settings.enable_comment_avatars
- if comments_info['ctoken'] != '':
- comments_info['more_comments_url'] = util.URL_ORIGIN + '/comments?ctoken=' + comments_info['ctoken']
+ if comments_info['ctoken']:
+ ctoken = comments_info['ctoken']
+ if comments_info['is_replies']:
+ replies_param = '&replies=1'
+ # change max_replies field to 250 in ctoken
+ new_ctoken, err = proto.set_protobuf_value(
+ ctoken,
+ 'base64p', 6, 3, 9, value=200)
+ if err:
+ print('Error setting ctoken value:')
+ print(err)
+ else:
+ ctoken = new_ctoken
+ else:
+ replies_param = ''
+ comments_info['more_comments_url'] = concat_or_none(
+ util.URL_ORIGIN, '/comments?ctoken=', ctoken, replies_param)
- comments_info['page_number'] = page_number = str(int(comments_info['offset']/20) + 1)
+ if comments_info['offset'] is None:
+ comments_info['page_number'] = None
+ else:
+ comments_info['page_number'] = int(comments_info['offset']/20) + 1
if not comments_info['is_replies']:
comments_info['sort_text'] = 'top' if comments_info['sort'] == 0 else 'newest'
-
- comments_info['video_url'] = util.URL_ORIGIN + '/watch?v=' + comments_info['video_id']
- comments_info['video_thumbnail'] = '/i.ytimg.com/vi/'+ comments_info['video_id'] + '/mqdefault.jpg'
+ comments_info['video_url'] = concat_or_none(
+ util.URL_ORIGIN, '/watch?v=', comments_info['video_id'])
+ comments_info['video_thumbnail'] = concat_or_none(
+ settings.img_prefix, 'https://i.ytimg.com/vi/',
+ comments_info['video_id'], '/hqdefault.jpg'
+ )
def video_comments(video_id, sort=0, offset=0, lc='', secret_key=''):
- if settings.comments_mode:
- comments_info = parse_comments_polymer(request_comments(make_comment_ctoken(video_id, sort, offset, lc, secret_key)))
- post_process_comments_info(comments_info)
-
- post_comment_url = util.URL_ORIGIN + "/post_comment?video_id=" + video_id
- other_sort_url = util.URL_ORIGIN + '/comments?ctoken=' + make_comment_ctoken(video_id, sort=1 - sort, lc=lc)
- other_sort_text = 'Sort by ' + ('newest' if sort == 0 else 'top')
- comments_info['comment_links'] = [('Post comment', post_comment_url), (other_sort_text, other_sort_url)]
+ try:
+ if settings.comments_mode:
+ comments_info = {'error': None}
+ other_sort_url = (
+ util.URL_ORIGIN + '/comments?ctoken='
+ + make_comment_ctoken(video_id, sort=1 - sort, lc=lc)
+ )
+ other_sort_text = 'Sort by ' + ('newest' if sort == 0 else 'top')
+
+ this_sort_url = (util.URL_ORIGIN
+ + '/comments?ctoken='
+ + make_comment_ctoken(video_id, sort=sort, lc=lc))
+
+ comments_info['comment_links'] = [
+ (other_sort_text, other_sort_url),
+ ('Direct link', this_sort_url)
+ ]
+
+ ctoken = make_comment_ctoken(video_id, sort, offset, lc)
+ comments_info.update(yt_data_extract.extract_comments_info(
+ request_comments(ctoken), ctoken=ctoken
+ ))
+ post_process_comments_info(comments_info)
+
+ return comments_info
+ else:
+ return {}
+ except util.FetchError as e:
+ if e.code == '429' and settings.route_tor:
+ comments_info['error'] = 'Error: YouTube blocked the request because the Tor exit node is overutilized.'
+ if e.error_message:
+ comments_info['error'] += '\n\n' + e.error_message
+ comments_info['error'] += '\n\nExit node IP address: %s' % e.ip
+ else:
+ comments_info['error'] = 'YouTube blocked the request. Error: %s' % str(e)
- return comments_info
+ except Exception as e:
+ comments_info['error'] = 'YouTube blocked the request. Error: %s' % str(e)
- return {}
+ if comments_info.get('error'):
+ print('Error retrieving comments for ' + str(video_id) + ':\n' +
+ comments_info['error'])
+ return comments_info
@yt_app.route('/comments')
def get_comments_page():
ctoken = request.args.get('ctoken', '')
- replies = False
- if not ctoken:
- video_id = request.args['video_id']
- parent_id = request.args['parent_id']
+ replies = request.args.get('replies', '0') == '1'
- ctoken = comment_replies_ctoken(video_id, parent_id)
- replies = True
-
- comments_info = parse_comments_polymer(request_comments(ctoken, replies))
+ comments_info = yt_data_extract.extract_comments_info(
+ request_comments(ctoken, replies), ctoken=ctoken
+ )
post_process_comments_info(comments_info)
if not replies:
- other_sort_url = util.URL_ORIGIN + '/comments?ctoken=' + make_comment_ctoken(comments_info['video_id'], sort=1 - comments_info['sort'])
+ if comments_info['sort'] is None or comments_info['video_id'] is None:
+ other_sort_url = None
+ else:
+ other_sort_url = (
+ util.URL_ORIGIN
+ + '/comments?ctoken='
+ + make_comment_ctoken(comments_info['video_id'],
+ sort=1-comments_info['sort'])
+ )
other_sort_text = 'Sort by ' + ('newest' if comments_info['sort'] == 0 else 'top')
comments_info['comment_links'] = [(other_sort_text, other_sort_url)]
-
- comment_posting_box_info = {
- 'form_action': '' if replies else util.URL_ORIGIN + '/post_comment',
- 'video_id': comments_info['video_id'],
- 'accounts': accounts.account_list_data(),
- 'include_video_id_input': not replies,
- 'replying': replies,
- }
-
- return flask.render_template('comments_page.html',
- comments_info = comments_info,
- comment_posting_box_info = comment_posting_box_info,
+ return flask.render_template(
+ 'comments_page.html',
+ comments_info=comments_info,
+ slim=request.args.get('slim', False)
)
-
diff --git a/youtube/get_app_version/__init__.py b/youtube/get_app_version/__init__.py
new file mode 100644
index 0000000..2d5290f
--- /dev/null
+++ b/youtube/get_app_version/__init__.py
@@ -0,0 +1 @@
+from .get_app_version import *
diff --git a/youtube/get_app_version/get_app_version.py b/youtube/get_app_version/get_app_version.py
new file mode 100644
index 0000000..4995bb7
--- /dev/null
+++ b/youtube/get_app_version/get_app_version.py
@@ -0,0 +1,47 @@
+from __future__ import unicode_literals
+from subprocess import (
+ call,
+ STDOUT
+)
+from ..version import __version__
+import os
+import subprocess
+
+
+def app_version():
+ def minimal_env_cmd(cmd):
+ # make minimal environment
+ env = {k: os.environ[k] for k in ['SYSTEMROOT', 'PATH'] if k in os.environ}
+ env.update({'LANGUAGE': 'C', 'LANG': 'C', 'LC_ALL': 'C'})
+
+ out = subprocess.Popen(cmd, stdout=subprocess.PIPE, env=env).communicate()[0]
+ return out
+
+ subst_list = {
+ "version": __version__,
+ "branch": None,
+ "commit": None
+ }
+
+ if os.system("command -v git > /dev/null 2>&1") != 0:
+ return subst_list
+
+ if call(["git", "branch"], stderr=STDOUT, stdout=open(os.devnull, 'w')) != 0:
+ return subst_list
+
+ describe = minimal_env_cmd(["git", "describe", "--tags", "--always"])
+ git_revision = describe.strip().decode('ascii')
+
+ branch = minimal_env_cmd(["git", "branch"])
+ git_branch = branch.strip().decode('ascii').replace('* ', '')
+
+ subst_list.update({
+ "branch": git_branch,
+ "commit": git_revision
+ })
+
+ return subst_list
+
+
+if __name__ == "__main__":
+ app_version()
diff --git a/youtube/local_playlist.py b/youtube/local_playlist.py
index 2375ba2..aa3ac27 100644
--- a/youtube/local_playlist.py
+++ b/youtube/local_playlist.py
@@ -15,6 +15,7 @@ from flask import request
playlists_directory = os.path.join(settings.data_dir, "playlists")
thumbnails_directory = os.path.join(settings.data_dir, "playlist_thumbnails")
+
def video_ids_in_playlist(name):
try:
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
@@ -23,6 +24,7 @@ def video_ids_in_playlist(name):
except FileNotFoundError:
return set()
+
def add_to_playlist(name, video_info_list):
if not os.path.exists(playlists_directory):
os.makedirs(playlists_directory)
@@ -35,36 +37,59 @@ def add_to_playlist(name, video_info_list):
file.write(info + "\n")
missing_thumbnails.append(id)
gevent.spawn(util.download_thumbnails, os.path.join(thumbnails_directory, name), missing_thumbnails)
-
-def get_local_playlist_videos(name, offset=0, amount=50):
+
+def add_extra_info_to_videos(videos, playlist_name):
+ '''Adds extra information necessary for rendering the video item HTML
+ Downloads missing thumbnails'''
try:
- thumbnails = set(os.listdir(os.path.join(thumbnails_directory, name)))
+ thumbnails = set(os.listdir(os.path.join(thumbnails_directory,
+ playlist_name)))
except FileNotFoundError:
thumbnails = set()
missing_thumbnails = []
+ for video in videos:
+ video['type'] = 'video'
+ util.add_extra_html_info(video)
+ if video['id'] + '.jpg' in thumbnails:
+ video['thumbnail'] = (
+ '/https://youtube.com/data/playlist_thumbnails/'
+ + playlist_name
+ + '/' + video['id'] + '.jpg')
+ else:
+ video['thumbnail'] = util.get_thumbnail_url(video['id'])
+ missing_thumbnails.append(video['id'])
+
+ gevent.spawn(util.download_thumbnails,
+ os.path.join(thumbnails_directory, playlist_name),
+ missing_thumbnails)
+
+
+def read_playlist(name):
+ '''Returns a list of videos for the given playlist name'''
+ playlist_path = os.path.join(playlists_directory, name + '.txt')
+ with open(playlist_path, 'r', encoding='utf-8') as f:
+ data = f.read()
+
videos = []
- with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
- data = file.read()
videos_json = data.splitlines()
for video_json in videos_json:
try:
info = json.loads(video_json)
- if info['id'] + ".jpg" in thumbnails:
- info['thumbnail'] = "/youtube.com/data/playlist_thumbnails/" + name + "/" + info['id'] + ".jpg"
- else:
- info['thumbnail'] = util.get_thumbnail_url(info['id'])
- missing_thumbnails.append(info['id'])
- info['type'] = 'video'
- yt_data_extract.add_extra_html_info(info)
videos.append(info)
except json.decoder.JSONDecodeError:
if not video_json.strip() == '':
print('Corrupt playlist video entry: ' + video_json)
- gevent.spawn(util.download_thumbnails, os.path.join(thumbnails_directory, name), missing_thumbnails)
+ return videos
+
+
+def get_local_playlist_videos(name, offset=0, amount=50):
+ videos = read_playlist(name)
+ add_extra_info_to_videos(videos, name)
return videos[offset:offset+amount], len(videos)
+
def get_playlist_names():
try:
items = os.listdir(playlists_directory)
@@ -75,6 +100,7 @@ def get_playlist_names():
if ext == '.txt':
yield name
+
def remove_from_playlist(name, video_info_list):
ids = [json.loads(video)['id'] for video in video_info_list]
with open(os.path.join(playlists_directory, name + ".txt"), 'r', encoding='utf-8') as file:
@@ -109,13 +135,16 @@ def get_local_playlist_page(playlist_name=None):
page = int(request.args.get('page', 1))
offset = 50*(page - 1)
videos, num_videos = get_local_playlist_videos(playlist_name, offset=offset, amount=50)
- return flask.render_template('local_playlist.html',
- playlist_name = playlist_name,
- videos = videos,
- num_pages = math.ceil(num_videos/50),
- parameters_dictionary = request.args,
+ return flask.render_template(
+ 'local_playlist.html',
+ header_playlist_names=get_playlist_names(),
+ playlist_name=playlist_name,
+ videos=videos,
+ num_pages=math.ceil(num_videos/50),
+ parameters_dictionary=request.args,
)
+
@yt_app.route('/playlists/<playlist_name>', methods=['POST'])
def path_edit_playlist(playlist_name):
'''Called when making changes to the playlist from that playlist's page'''
@@ -124,9 +153,38 @@ def path_edit_playlist(playlist_name):
number_of_videos_remaining = remove_from_playlist(playlist_name, videos_to_remove)
redirect_page_number = min(int(request.values.get('page', 1)), math.ceil(number_of_videos_remaining/50))
return flask.redirect(util.URL_ORIGIN + request.path + '?page=' + str(redirect_page_number))
+ elif request.values['action'] == 'remove_playlist':
+ try:
+ os.remove(os.path.join(playlists_directory, playlist_name + ".txt"))
+ except OSError:
+ pass
+ return flask.redirect(util.URL_ORIGIN + '/playlists')
+ elif request.values['action'] == 'export':
+ videos = read_playlist(playlist_name)
+ fmt = request.values['export_format']
+ if fmt in ('ids', 'urls'):
+ prefix = ''
+ if fmt == 'urls':
+ prefix = 'https://www.youtube.com/watch?v='
+ id_list = '\n'.join(prefix + v['id'] for v in videos)
+ id_list += '\n'
+ resp = flask.Response(id_list, mimetype='text/plain')
+ cd = 'attachment; filename="%s.txt"' % playlist_name
+ resp.headers['Content-Disposition'] = cd
+ return resp
+ elif fmt == 'json':
+ json_data = json.dumps({'videos': videos}, indent=2,
+ sort_keys=True)
+ resp = flask.Response(json_data, mimetype='text/json')
+ cd = 'attachment; filename="%s.json"' % playlist_name
+ resp.headers['Content-Disposition'] = cd
+ return resp
+ else:
+ flask.abort(400)
else:
flask.abort(400)
+
@yt_app.route('/edit_playlist', methods=['POST'])
def edit_playlist():
'''Called when adding videos to a playlist from elsewhere'''
@@ -136,7 +194,9 @@ def edit_playlist():
else:
flask.abort(400)
+
@yt_app.route('/data/playlist_thumbnails/<playlist_name>/<thumbnail>')
def serve_thumbnail(playlist_name, thumbnail):
# .. is necessary because flask always uses the application directory at ./youtube, not the working directory
- return flask.send_from_directory(os.path.join('..', thumbnails_directory, playlist_name), thumbnail)
+ return flask.send_from_directory(
+ os.path.join('..', thumbnails_directory, playlist_name), thumbnail)
diff --git a/youtube/opensearch.xml b/youtube/opensearch.xml
index 9f035a6..09d1cb7 100644
--- a/youtube/opensearch.xml
+++ b/youtube/opensearch.xml
@@ -1,11 +1,11 @@
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
-<ShortName>Youtube local</ShortName>
+<ShortName>YT Local</ShortName>
<Description>no CIA shit in the background</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="16" height="16">data:image/x-icon;base64,AAABAAEAEBAAAAEACAAlAgAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAexJREFUOI2lkzFPmlEUhp/73fshtCUCRtvQkJoKMrDQJvoHnBzUhc3EH0DUQf+As6tujo4M6mTiIDp0kGiMTRojTRNSW6o12iD4YYXv3g7Qr4O0ScM7npz7vOe+J0fk83lDF7K6eQygwkdHhI+P0bYNxmBXq5RmZui5vGQgn0f7fKi7O4oLC1gPD48BP9JpnpRKJFZXcQMB3m1u4vr9NHp76d/bo39/n4/z84ROThBa4/r91OJxMKb9BSn5mskAIOt1eq6uEFpjVyrEcjk+T0+TXlzkbTZLuFDAur9/nIFRipuREQCe7+zgBgK8mZvj/fIylVTKa/6UzXKbSnnuHkA0GnwbH/cA0a0takND3IyOEiwWAXBiMYTWjzLwtvB9bAyAwMUF8ZUVPiwtYTWbHqA6PIxoNv8OMLbN3eBga9TZWYQxaKX+AJJJhOv+AyAlT0slAG6TSX5n8+zszJugkzxA4PzcK9YSCQCk42DXaq1aGwqgfT5ebG9jpMQyUjKwu8vrtbWWqxC83NjAd31NsO2uleJnX58HCJ6eEjk8BGNQAA+RCOXJScpTU2AMwnUxlkXk4ACA+2iUSKGArNeRjkMsl6M8MYHQGtHpmIxSvFpfRzoORinQGqvZBCEwQoAxfMlkaIRCnQH/o66v8Re19MavaDNLfgAAAABJRU5ErkJggg==</Image>
-<Url type="text/html" method="GET" template="http://localhost:$port_number/youtube.com/search">
- <Param name="query" value="{searchTerms}"/>
+<Url type="text/html" method="GET" template="$main_url/youtube.com/results">
+ <Param name="search_query" value="{searchTerms}"/>
</Url>
-<SearchForm>http://localhost:$port_number/youtube.com/search</SearchForm>
+<SearchForm>$main_url/youtube.com/results</SearchForm>
</SearchPlugin>
diff --git a/youtube/playlist.py b/youtube/playlist.py
index 3e5b0d2..28b8149 100644
--- a/youtube/playlist.py
+++ b/youtube/playlist.py
@@ -1,5 +1,6 @@
-from youtube import util, yt_data_extract, proto
+from youtube import util, yt_data_extract, proto, local_playlist
from youtube import yt_app
+import settings
import base64
import urllib
@@ -11,63 +12,63 @@ from flask import request
import flask
+def playlist_ctoken(playlist_id, offset, include_shorts=True):
-
-
-def playlist_ctoken(playlist_id, offset):
-
offset = proto.uint(1, offset)
- # this is just obfuscation as far as I can tell. It doesn't even follow protobuf
offset = b'PT:' + proto.unpadded_b64encode(offset)
offset = proto.string(15, offset)
+ if not include_shorts:
+ offset += proto.string(104, proto.uint(2, 1))
- continuation_info = proto.string( 3, proto.percent_b64encode(offset) )
-
- playlist_id = proto.string(2, 'VL' + playlist_id )
+ continuation_info = proto.string(3, proto.percent_b64encode(offset))
+
+ playlist_id = proto.string(2, 'VL' + playlist_id)
pointless_nest = proto.string(80226972, playlist_id + continuation_info)
return base64.urlsafe_b64encode(pointless_nest).decode('ascii')
-# initial request types:
-# polymer_json: https://m.youtube.com/playlist?list=PLv3TTBr1W_9tppikBxAE_G6qjWdBljBHJ&pbj=1&lact=0
-# ajax json: https://m.youtube.com/playlist?list=PLv3TTBr1W_9tppikBxAE_G6qjWdBljBHJ&pbj=1&lact=0 with header X-YouTube-Client-Version: 1.20180418
-
-
-# continuation request types:
-# polymer_json: https://m.youtube.com/playlist?&ctoken=[...]&pbj=1
-# ajax json: https://m.youtube.com/playlist?action_continuation=1&ajax=1&ctoken=[...]
-
-
-headers_1 = (
- ('Accept', '*/*'),
- ('Accept-Language', 'en-US,en;q=0.5'),
- ('X-YouTube-Client-Name', '2'),
- ('X-YouTube-Client-Version', '2.20180614'),
-)
-def playlist_first_page(playlist_id, report_text = "Retrieved playlist"):
- url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
- content = util.fetch_url(url, util.mobile_ua + headers_1, report_text=report_text, debug_name='playlist_first_page')
- content = json.loads(util.uppercase_escape(content.decode('utf-8')))
+def playlist_first_page(playlist_id, report_text="Retrieved playlist",
+ use_mobile=False):
+ if use_mobile:
+ url = 'https://m.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
+ content = util.fetch_url(
+ url, util.mobile_xhr_headers,
+ report_text=report_text, debug_name='playlist_first_page'
+ )
+ content = json.loads(content.decode('utf-8'))
+ else:
+ url = 'https://www.youtube.com/playlist?list=' + playlist_id + '&pbj=1'
+ content = util.fetch_url(
+ url, util.desktop_xhr_headers,
+ report_text=report_text, debug_name='playlist_first_page'
+ )
+ content = json.loads(content.decode('utf-8'))
return content
-
-#https://m.youtube.com/playlist?itct=CBMQybcCIhMIptj9xJaJ2wIV2JKcCh3Idwu-&ctoken=4qmFsgI2EiRWTFBMT3kwajlBdmxWWlB0bzZJa2pLZnB1MFNjeC0tN1BHVEMaDmVnWlFWRHBEUWxFJTNE&pbj=1
-def get_videos(playlist_id, page):
- url = "https://m.youtube.com/playlist?ctoken=" + playlist_ctoken(playlist_id, (int(page)-1)*20) + "&pbj=1"
- headers = {
- 'User-Agent': ' Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
- 'Accept': '*/*',
- 'Accept-Language': 'en-US,en;q=0.5',
- 'X-YouTube-Client-Name': '2',
- 'X-YouTube-Client-Version': '2.20180508',
- }
-
- content = util.fetch_url(url, headers, report_text="Retrieved playlist", debug_name='playlist_videos')
-
- info = json.loads(util.uppercase_escape(content.decode('utf-8')))
+def get_videos(playlist_id, page, include_shorts=True, use_mobile=False,
+ report_text='Retrieved playlist'):
+ # mobile requests return 20 videos per page
+ if use_mobile:
+ page_size = 20
+ headers = util.mobile_xhr_headers
+ # desktop requests return 100 videos per page
+ else:
+ page_size = 100
+ headers = util.desktop_xhr_headers
+
+ url = "https://m.youtube.com/playlist?ctoken="
+ url += playlist_ctoken(playlist_id, (int(page)-1)*page_size,
+ include_shorts=include_shorts)
+ url += "&pbj=1"
+ content = util.fetch_url(
+ url, headers, report_text=report_text,
+ debug_name='playlist_videos'
+ )
+
+ info = json.loads(content.decode('utf-8'))
return info
@@ -84,33 +85,44 @@ def get_playlist_page():
this_page_json = first_page_json
else:
tasks = (
- gevent.spawn(playlist_first_page, playlist_id, report_text="Retrieved playlist info" ),
+ gevent.spawn(
+ playlist_first_page, playlist_id,
+ report_text="Retrieved playlist info", use_mobile=True
+ ),
gevent.spawn(get_videos, playlist_id, page)
)
gevent.joinall(tasks)
+ util.check_gevent_exceptions(*tasks)
first_page_json, this_page_json = tasks[0].value, tasks[1].value
-
- try: # first page
- video_list = this_page_json['response']['contents']['singleColumnBrowseResultsRenderer']['tabs'][0]['tabRenderer']['content']['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['playlistVideoListRenderer']['contents']
- except KeyError: # other pages
- video_list = this_page_json['response']['continuationContents']['playlistVideoListContinuation']['contents']
- parsed_video_list = [yt_data_extract.parse_info_prepare_for_html(video_json) for video_json in video_list]
+ info = yt_data_extract.extract_playlist_info(this_page_json)
+ if info['error']:
+ return flask.render_template('error.html', error_message=info['error'])
+ if page != '1':
+ info['metadata'] = yt_data_extract.extract_playlist_metadata(first_page_json)
- metadata = yt_data_extract.renderer_info(first_page_json['response']['header'])
- yt_data_extract.prefix_urls(metadata)
+ util.prefix_urls(info['metadata'])
+ for item in info.get('items', ()):
+ util.prefix_urls(item)
+ util.add_extra_html_info(item)
+ if 'id' in item:
+ item['thumbnail'] = f"{settings.img_prefix}https://i.ytimg.com/vi/{item['id']}/hqdefault.jpg"
- if 'description' not in metadata:
- metadata['description'] = ''
+ item['url'] += '&list=' + playlist_id
+ if item['index']:
+ item['url'] += '&index=' + str(item['index'])
- video_count = int(metadata['size'].replace(',', ''))
- metadata['size'] += ' videos'
+ video_count = yt_data_extract.deep_get(info, 'metadata', 'video_count')
+ if video_count is None:
+ video_count = 1000
- return flask.render_template('playlist.html',
- video_list = parsed_video_list,
- num_pages = math.ceil(video_count/20),
- parameters_dictionary = request.args,
+ return flask.render_template(
+ 'playlist.html',
+ header_playlist_names=local_playlist.get_playlist_names(),
+ video_list=info.get('items', []),
+ num_pages=math.ceil(video_count/100),
+ parameters_dictionary=request.args,
- **metadata
+ **info['metadata']
).encode('utf-8')
diff --git a/youtube/post_comment.py b/youtube/post_comment.py
deleted file mode 100644
index 25d0e3a..0000000
--- a/youtube/post_comment.py
+++ /dev/null
@@ -1,181 +0,0 @@
-# Contains functions having to do with posting/editing/deleting comments
-from youtube import util, proto, comments, accounts
-from youtube import yt_app
-import settings
-
-import urllib
-import json
-import re
-import traceback
-import os
-
-import flask
-from flask import request
-
-def _post_comment(text, video_id, session_token, cookiejar):
- headers = {
- 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
- 'Accept': '*/*',
- 'Accept-Language': 'en-US,en;q=0.5',
- 'X-YouTube-Client-Name': '2',
- 'X-YouTube-Client-Version': '2.20180823',
- 'Content-Type': 'application/x-www-form-urlencoded',
- }
-
- comment_params = proto.string(2, video_id) + proto.nested(5, proto.uint(1, 0)) + proto.uint(10, 1)
- comment_params = proto.percent_b64encode(comment_params).decode('ascii')
-
- sej = json.dumps({"clickTrackingParams":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "commandMetadata":{"webCommandMetadata":{"url":"/service_ajax","sendPost":True}},"createCommentEndpoint":{"createCommentParams": comment_params}})
-
- data_dict = {
- 'comment_text': text,
- 'sej': sej,
- 'session_token': session_token,
- }
- data = urllib.parse.urlencode(data_dict).encode()
-
-
- content = util.fetch_url("https://m.youtube.com/service_ajax?name=createCommentEndpoint", headers=headers, data=data, cookiejar_send=cookiejar, debug_name='post_comment')
-
- code = json.loads(content)['code']
- print("Comment posting code: " + code)
- return code
-
-
-def _post_comment_reply(text, video_id, parent_comment_id, session_token, cookiejar):
- headers = {
- 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
- 'Accept': '*/*',
- 'Accept-Language': 'en-US,en;q=0.5',
- 'X-YouTube-Client-Name': '2',
- 'X-YouTube-Client-Version': '2.20180823',
- 'Content-Type': 'application/x-www-form-urlencoded',
- }
-
- comment_params = proto.string(2, video_id) + proto.string(4, parent_comment_id) + proto.nested(5, proto.uint(1, 0)) + proto.uint(6,0) + proto.uint(10, 1)
- comment_params = proto.percent_b64encode(comment_params).decode('ascii')
-
- sej = json.dumps({"clickTrackingParams":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", "commandMetadata":{"webCommandMetadata":{"url":"/service_ajax","sendPost":True}},"createCommentReplyEndpoint":{"createReplyParams": comment_params}})
-
- data_dict = {
- 'comment_text': text,
- 'sej': sej,
- 'session_token': session_token,
- }
- data = urllib.parse.urlencode(data_dict).encode()
-
- content = util.fetch_url("https://m.youtube.com/service_ajax?name=createCommentReplyEndpoint", headers=headers, data=data, cookiejar_send=cookiejar, debug_name='post_reply')
-
- code = json.loads(content)['code']
- print("Comment posting code: " + code)
- return code
-
-def _delete_comment(video_id, comment_id, author_id, session_token, cookiejar):
- headers = {
- 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1',
- 'Accept': '*/*',
- 'Accept-Language': 'en-US,en;q=0.5',
- 'X-YouTube-Client-Name': '2',
- 'X-YouTube-Client-Version': '2.20180823',
- 'Content-Type': 'application/x-www-form-urlencoded',
- }
- action = proto.uint(1,6) + proto.string(3, comment_id) + proto.string(5, video_id) + proto.string(9, author_id)
- action = proto.percent_b64encode(action).decode('ascii')
-
- sej = json.dumps({"clickTrackingParams":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=","commandMetadata":{"webCommandMetadata":{"url":"/service_ajax","sendPost":True}},"performCommentActionEndpoint":{"action":action}})
-
- data_dict = {
- 'sej': sej,
- 'session_token': session_token,
- }
- data = urllib.parse.urlencode(data_dict).encode()
-
- content = util.fetch_url("https://m.youtube.com/service_ajax?name=performCommentActionEndpoint", headers=headers, data=data, cookiejar_send=cookiejar)
- code = json.loads(content)['code']
- print("Comment deletion code: " + code)
- return code
-
-xsrf_token_regex = re.compile(r'''XSRF_TOKEN"\s*:\s*"([\w-]*(?:=|%3D){0,2})"''')
-def get_session_token(video_id, cookiejar):
- ''' Get session token for a video. This is required in order to post/edit/delete comments. This will modify cookiejar with cookies from youtube required for commenting'''
- # youtube-dl uses disable_polymer=1 which uses a different request format which has an obfuscated javascript algorithm to generate a parameter called "bgr"
- # Tokens retrieved from disable_polymer pages only work with that format. Tokens retrieved on mobile only work using mobile requests
- # Additionally, tokens retrieved without sending the same cookie won't work. So this is necessary even if the bgr and stuff was reverse engineered.
- headers = {'User-Agent': util.mobile_user_agent}
- mobile_page = util.fetch_url('https://m.youtube.com/watch?v=' + video_id, headers, report_text="Retrieved session token for comment", cookiejar_send=cookiejar, cookiejar_receive=cookiejar).decode()
- match = xsrf_token_regex.search(mobile_page)
- if match:
- return match.group(1).replace("%3D", "=")
- else:
- raise Exception("Couldn't find xsrf_token")
-
-@yt_app.route('/delete_comment', methods=['POST'])
-def delete_comment():
- video_id = request.values['video_id']
- cookiejar = accounts.account_cookiejar(request.values['channel_id'])
- token = get_session_token(video_id, cookiejar)
-
- code = _delete_comment(video_id, request.values['comment_id'], request.values['author_id'], token, cookiejar)
-
- if code == "SUCCESS":
- return flask.redirect(util.URL_ORIGIN + '/comment_delete_success', 303)
- else:
- return flask.redirect(util.URL_ORIGIN + '/comment_delete_fail', 303)
-
-@yt_app.route('/comment_delete_success')
-def comment_delete_success():
- return flask.render_template('status.html', title='Success', message='Successfully deleted comment')
-
-@yt_app.route('/comment_delete_fail')
-def comment_delete_fail():
- return flask.render_template('status.html', title='Error', message='Failed to delete comment')
-
-@yt_app.route('/post_comment', methods=['POST'])
-@yt_app.route('/comments', methods=['POST'])
-def post_comment():
- video_id = request.values['video_id']
- channel_id = request.values['channel_id']
- cookiejar = accounts.account_cookiejar(channel_id)
- token = get_session_token(video_id, cookiejar)
-
- if 'parent_id' in request.values:
- code = _post_comment_reply(request.values['comment_text'], request.values['video_id'], request.values['parent_id'], token, cookiejar)
- return flask.redirect(util.URL_ORIGIN + '/comments?' + request.query_string.decode('utf-8'), 303)
- else:
- code = _post_comment(request.values['comment_text'], request.values['video_id'], token, cookiejar)
- return flask.redirect(util.URL_ORIGIN + '/comments?ctoken=' + comments.make_comment_ctoken(video_id, sort=1), 303)
-
-@yt_app.route('/delete_comment', methods=['GET'])
-def get_delete_comment_page():
- parameters = [(parameter_name, request.args[parameter_name]) for parameter_name in ('video_id', 'channel_id', 'author_id', 'comment_id')]
- return flask.render_template('delete_comment.html', parameters = parameters)
-
-
-@yt_app.route('/post_comment', methods=['GET'])
-def get_post_comment_page():
- video_id = request.args['video_id']
- parent_id = request.args.get('parent_id', '')
-
- if parent_id: # comment reply
- form_action = util.URL_ORIGIN + '/comments?parent_id=' + parent_id + "&video_id=" + video_id
- replying = True
- else:
- form_action = ''
- replying = False
-
-
- comment_posting_box_info = {
- 'form_action': form_action,
- 'video_id': video_id,
- 'accounts': accounts.account_list_data(),
- 'include_video_id_input': not replying,
- 'replying': replying,
- }
- return flask.render_template('post_comment.html',
- comment_posting_box_info = comment_posting_box_info,
- replying = replying,
- )
-
-
-
-
diff --git a/youtube/proto.py b/youtube/proto.py
index d966455..924e983 100644
--- a/youtube/proto.py
+++ b/youtube/proto.py
@@ -1,17 +1,19 @@
from math import ceil
import base64
import io
+import traceback
+
def byte(n):
return bytes((n,))
-
+
def varint_encode(offset):
'''In this encoding system, for each 8-bit byte, the first bit is 1 if there are more bytes, and 0 is this is the last one.
The next 7 bits are data. These 7-bit sections represent the data in Little endian order. For example, suppose the data is
aaaaaaabbbbbbbccccccc (each of these sections is 7 bits). It will be encoded as:
1ccccccc 1bbbbbbb 0aaaaaaa
-
+
This encoding is used in youtube parameters to encode offsets and to encode the length for length-prefixed data.
See https://developers.google.com/protocol-buffers/docs/encoding#varints for more info.'''
needed_bytes = ceil(offset.bit_length()/7) or 1 # (0).bit_length() returns 0, but we need 1 in that case.
@@ -19,45 +21,46 @@ def varint_encode(offset):
for i in range(0, needed_bytes - 1):
encoded_bytes[i] = (offset & 127) | 128 # 7 least significant bits
offset = offset >> 7
- encoded_bytes[-1] = offset & 127 # leave first bit as zero for last byte
-
+ encoded_bytes[-1] = offset & 127 # leave first bit as zero for last byte
+
return bytes(encoded_bytes)
-
+
def varint_decode(encoded):
decoded = 0
for i, byte in enumerate(encoded):
decoded |= (byte & 127) << 7*i
-
+
if not (byte & 128):
break
return decoded
-
+
def string(field_number, data):
data = as_bytes(data)
return _proto_field(2, field_number, varint_encode(len(data)) + data)
+
+
nested = string
+
def uint(field_number, value):
return _proto_field(0, field_number, varint_encode(value))
-
-
-
+
def _proto_field(wire_type, field_number, data):
''' See https://developers.google.com/protocol-buffers/docs/encoding#structure '''
- return varint_encode( (field_number << 3) | wire_type) + data
+ return varint_encode((field_number << 3) | wire_type) + data
-
def percent_b64encode(data):
return base64.urlsafe_b64encode(data).replace(b'=', b'%3D')
-
-
+
+
def unpadded_b64encode(data):
return base64.urlsafe_b64encode(data).replace(b'=', b'')
+
def as_bytes(value):
if isinstance(value, str):
return value.encode('utf-8')
@@ -81,7 +84,7 @@ def read_varint(data):
i += 1
return result
-
+
def read_group(data, end_sequence):
start = data.tell()
index = data.original.find(end_sequence, start)
@@ -101,7 +104,7 @@ def read_protobuf(data):
break
wire_type = tag & 7
field_number = tag >> 3
-
+
if wire_type == 0:
value = read_varint(data)
elif wire_type == 1:
@@ -118,12 +121,101 @@ def read_protobuf(data):
raise Exception("Unknown wire type: " + str(wire_type) + ", Tag: " + bytes_to_hex(succinct_encode(tag)) + ", at position " + str(data.tell()))
yield (wire_type, field_number, value)
-def parse(data):
- return {field_number: value for _, field_number, value in read_protobuf(data)}
+
+def parse(data, include_wire_type=False):
+ '''Returns a dict mapping field numbers to values
+
+ data is the protobuf structure, which must not be b64-encoded'''
+ if include_wire_type:
+ return {field_number: [wire_type, value]
+ for wire_type, field_number, value in read_protobuf(data)}
+ return {field_number: value
+ for _, field_number, value in read_protobuf(data)}
+
+
+base64_enc_funcs = {
+ 'base64': base64.urlsafe_b64encode,
+ 'base64s': unpadded_b64encode,
+ 'base64p': percent_b64encode,
+}
+
+
+def _make_protobuf(data):
+ '''
+ Input: Recursive list of protobuf objects or base-64 encodings
+ Output: Protobuf bytestring
+ Each protobuf object takes the form [wire_type, field_number, field_data]
+ If a string protobuf has a list/tuple of length 2, this has the form
+ (base64 type, data)
+ The base64 types are
+ - base64 means a base64 encode with equals sign paddings
+ - base64s means a base64 encode without padding
+ - base64p means a url base64 encode with equals signs replaced with %3D
+ '''
+ # must be dict mapping field_number to [wire_type, value]
+ if isinstance(data, dict):
+ new_data = []
+ for field_num, (wire_type, value) in sorted(data.items()):
+ new_data.append((wire_type, field_num, value))
+ data = new_data
+ if isinstance(data, str):
+ return data.encode('utf-8')
+ elif len(data) == 2 and data[0] in list(base64_enc_funcs.keys()):
+ return base64_enc_funcs[data[0]](_make_protobuf(data[1]))
+ elif isinstance(data, list):
+ result = b''
+ for field in data:
+ if field[0] == 0:
+ result += uint(field[1], field[2])
+ elif field[0] == 2:
+ result += string(field[1], _make_protobuf(field[2]))
+ else:
+ raise NotImplementedError('Wire type ' + str(field[0])
+ + ' not implemented')
+ return result
+ return data
+
+
+def make_protobuf(data):
+ return _make_protobuf(data).decode('ascii')
+
+
+def _set_protobuf_value(data, *path, value):
+ if not path:
+ return value
+ op = path[0]
+ if op in base64_enc_funcs:
+ inner_data = b64_to_bytes(data)
+ return base64_enc_funcs[op](
+ _set_protobuf_value(inner_data, *path[1:], value=value)
+ )
+ pb_dict = parse(data, include_wire_type=True)
+ pb_dict[op][1] = _set_protobuf_value(
+ pb_dict[op][1], *path[1:], value=value
+ )
+ return _make_protobuf(pb_dict)
+
+
+def set_protobuf_value(data, *path, value):
+ '''Set a field's value in a raw protobuf structure
+
+ path is a list of field numbers and/or base64 encoding directives
+
+ The directives are
+ base64: normal base64 encoding with equal signs padding
+ base64s ("stripped"): no padding
+ base64p: %3D instead of = for padding
+
+ return new_protobuf, err'''
+ try:
+ new_protobuf = _set_protobuf_value(data, *path, value=value)
+ return new_protobuf.decode('ascii'), None
+ except Exception:
+ return None, traceback.format_exc()
+
def b64_to_bytes(data):
if isinstance(data, bytes):
data = data.decode('ascii')
data = data.replace("%3D", "=")
- return base64.urlsafe_b64decode(data + "="*((4 - len(data)%4)%4) )
-
+ return base64.urlsafe_b64decode(data + "="*((4 - len(data) % 4) % 4))
diff --git a/youtube/proto_debug.py b/youtube/proto_debug.py
new file mode 100644
index 0000000..d793fe1
--- /dev/null
+++ b/youtube/proto_debug.py
@@ -0,0 +1,611 @@
+# TODO: clean this file up more and heavily refactor
+
+''' Helper functions for reverse engineering protobuf.
+
+Basic guide:
+
+Run interactively with python3 -i proto_debug.py
+
+The function dec will decode a base64 string
+(regardless of whether it includes = or %3D at the end) to a bytestring
+
+The function pb (parse_protobuf) will return a list of tuples.
+Each tuple is (wire_type, field_number, field_data)
+
+The function enc encodes as base64 (inverse of dec)
+The function uenc is like enc but replaces = with %3D
+
+See https://developers.google.com/protocol-buffers/docs/encoding#structure
+
+Example usage:
+>>> pb(dec('4qmFsgJcEhhVQ1lPX2phYl9lc3VGUlY0YjE3QUp0QXcaQEVnWjJhV1JsYjNNWUF5QUFNQUU0QWVvREdFTm5Ua1JSVlVWVFEzZHBYM2gwTTBaeFRuRkZiRFZqUWclM0QlM0Q%3D'))
+[(2, 80226972, b'\x12\x18UCYO_jab_esuFRV4b17AJtAw\x1a@EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D')]
+
+>>> pb(b'\x12\x18UCYO_jab_esuFRV4b17AJtAw\x1a@EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D')
+[(2, 2, b'UCYO_jab_esuFRV4b17AJtAw'), (2, 3, b'EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D')]
+
+>>> pb(dec(b'EgZ2aWRlb3MYAyAAMAE4AeoDGENnTkRRVUVTQ3dpX3h0M0ZxTnFFbDVjQg%3D%3D'))
+[(2, 2, b'videos'), (0, 3, 3), (0, 4, 0), (0, 6, 1), (0, 7, 1), (2, 61, b'CgNDQUESCwi_xt3FqNqEl5cB')]
+
+>>> pb(dec(b'CgNDQUESCwi_xt3FqNqEl5cB'))
+[(2, 1, b'CAA'), (2, 2, b'\x08\xbf\xc6\xdd\xc5\xa8\xda\x84\x97\x97\x01')]
+
+>>> pb(b'\x08\xbf\xc6\xdd\xc5\xa8\xda\x84\x97\x97\x01')
+[(0, 1, 10893665244101960511)]
+
+>>> pb(dec(b'CAA'))
+[(0, 1, 0)]
+
+The function recursive_pb will try to do dec/pb recursively automatically.
+It's a dumb function (so might try to dec or pb something that isn't really
+base64 or protobuf) so be careful.
+The function pp will pretty print the recursive structure:
+
+>>> pp(recursive_pb('4qmFsgJcEhhVQ1lPX2phYl9lc3VGUlY0YjE3QUp0QXcaQEVnWjJhV1JsYjNNWUF5QUFNQUU0QWVvREdFTm5Ua1JSVlVWVFEzZHBYM2gwTTBaeFRuRkZiRFZqUWclM0QlM0Q%3D'))
+
+('base64p',
+ [
+ [2, 80226972,
+ [
+ [2, 2, b'UCYO_jab_esuFRV4b17AJtAw'],
+ [2, 3,
+ ('base64p',
+ [
+ [2, 2, b'videos'],
+ [0, 3, 3],
+ [0, 4, 0],
+ [0, 6, 1],
+ [0, 7, 1],
+ [2, 61,
+ ('base64?',
+ [
+ [2, 1, b'CAA'],
+ [2, 2,
+ [
+ [0, 1, 10893665244101960511],
+ ]
+ ],
+ ]
+ )
+ ],
+ ]
+ )
+ ],
+ ]
+ ],
+ ]
+)
+
+
+- base64 means a base64 encode with equals sign paddings
+- base64s means a base64 encode without padding
+- base64p means a url base64 encode with equals signs replaced with %3D
+- base64? means the base64 type cannot be inferred because of the length
+
+make_proto is the inverse function. It will take a recursive_pb structure and
+make a ctoken out of it, so in general,
+x == make_proto(recursive_pb(x))
+
+There are some other functions I wrote while reverse engineering stuff
+that may or may not be useful.
+'''
+
+
+import urllib.request
+import urllib.parse
+import re
+import time
+import json
+import os
+import pprint
+
+
+# ------ from proto.py -----------------------------------------------
+from math import ceil
+import base64
+import io
+
+
+def byte(n):
+ return bytes((n,))
+
+
+def varint_encode(offset):
+ '''In this encoding system, for each 8-bit byte, the first bit is 1 if there are more bytes, and 0 is this is the last one.
+ The next 7 bits are data. These 7-bit sections represent the data in Little endian order. For example, suppose the data is
+ aaaaaaabbbbbbbccccccc (each of these sections is 7 bits). It will be encoded as:
+ 1ccccccc 1bbbbbbb 0aaaaaaa
+
+ This encoding is used in youtube parameters to encode offsets and to encode the length for length-prefixed data.
+ See https://developers.google.com/protocol-buffers/docs/encoding#varints for more info.'''
+ needed_bytes = ceil(offset.bit_length()/7) or 1 # (0).bit_length() returns 0, but we need 1 in that case.
+ encoded_bytes = bytearray(needed_bytes)
+ for i in range(0, needed_bytes - 1):
+ encoded_bytes[i] = (offset & 127) | 128 # 7 least significant bits
+ offset = offset >> 7
+ encoded_bytes[-1] = offset & 127 # leave first bit as zero for last byte
+
+ return bytes(encoded_bytes)
+
+
+def varint_decode(encoded):
+ decoded = 0
+ for i, byte in enumerate(encoded):
+ decoded |= (byte & 127) << 7*i
+
+ if not (byte & 128):
+ break
+ return decoded
+
+
+def string(field_number, data):
+ data = as_bytes(data)
+ return _proto_field(2, field_number, varint_encode(len(data)) + data)
+
+
+nested = string
+
+
+def uint(field_number, value):
+ return _proto_field(0, field_number, varint_encode(value))
+
+
+def _proto_field(wire_type, field_number, data):
+ ''' See https://developers.google.com/protocol-buffers/docs/encoding#structure '''
+ return varint_encode((field_number << 3) | wire_type) + data
+
+
+def percent_b64encode(data):
+ return base64.urlsafe_b64encode(data).replace(b'=', b'%3D')
+
+
+def unpadded_b64encode(data):
+ return base64.urlsafe_b64encode(data).replace(b'=', b'')
+
+
+def as_bytes(value):
+ if isinstance(value, str):
+ return value.encode('utf-8')
+ return value
+
+
+def read_varint(data):
+ result = 0
+ i = 0
+ while True:
+ try:
+ byte = data.read(1)[0]
+ except IndexError:
+ if i == 0:
+ raise EOFError()
+ raise Exception('Unterminated varint starting at ' + str(data.tell() - i))
+ result |= (byte & 127) << 7*i
+ if not byte & 128:
+ break
+
+ i += 1
+ return result
+
+
+def read_group(data, end_sequence):
+ start = data.tell()
+ index = data.original.find(end_sequence, start)
+ if index == -1:
+ raise Exception('Unterminated group')
+ data.seek(index + len(end_sequence))
+ return data.original[start:index]
+
+
+def parse(data, include_wire_type=False):
+ '''Returns a dict mapping field numbers to values
+
+ data is the protobuf structure, which must not be b64-encoded'''
+ if include_wire_type:
+ return {field_number: [wire_type, value]
+ for wire_type, field_number, value in read_protobuf(data)}
+ return {field_number: value
+ for _, field_number, value in read_protobuf(data)}
+
+
+base64_enc_funcs = {
+ 'base64': base64.urlsafe_b64encode,
+ 'base64s': unpadded_b64encode,
+ 'base64p': percent_b64encode,
+ 'base64?': base64.urlsafe_b64encode,
+}
+
+
+def _make_protobuf(data):
+ # must be dict mapping field_number to [wire_type, value]
+ if isinstance(data, dict):
+ new_data = []
+ for field_num, (wire_type, value) in sorted(data.items()):
+ new_data.append((wire_type, field_num, value))
+ data = new_data
+ if isinstance(data, str):
+ return data.encode('utf-8')
+ elif len(data) == 2 and data[0] in list(base64_enc_funcs.keys()):
+ return base64_enc_funcs[data[0]](_make_protobuf(data[1]))
+ elif isinstance(data, list):
+ result = b''
+ for field in data:
+ if field[0] == 0:
+ result += uint(field[1], field[2])
+ elif field[0] == 2:
+ result += string(field[1], _make_protobuf(field[2]))
+ else:
+ raise NotImplementedError('Wire type ' + str(field[0])
+ + ' not implemented')
+ return result
+ return data
+
+
+def make_protobuf(data):
+ return _make_protobuf(data).decode('ascii')
+
+
+make_proto = make_protobuf
+
+
+def _set_protobuf_value(data, *path, value):
+ if not path:
+ return value
+ op = path[0]
+ if op in base64_enc_funcs:
+ inner_data = b64_to_bytes(data)
+ return base64_enc_funcs[op](
+ _set_protobuf_value(inner_data, *path[1:], value=value)
+ )
+ pb_dict = parse(data, include_wire_type=True)
+ pb_dict[op][1] = _set_protobuf_value(
+ pb_dict[op][1], *path[1:], value=value
+ )
+ return _make_protobuf(pb_dict)
+
+
+def set_protobuf_value(data, *path, value):
+ '''Set a field's value in a raw protobuf structure
+
+ path is a list of field numbers and/or base64 encoding directives
+
+ The directives are
+ base64: normal base64 encoding with equal signs padding
+ base64s ("stripped"): no padding
+ base64p: %3D instead of = for padding
+
+ return new_protobuf, err'''
+ try:
+ new_protobuf = _set_protobuf_value(data, *path, value=value)
+ return new_protobuf.decode('ascii'), None
+ except Exception:
+ return None, traceback.format_exc()
+
+
+def b64_to_bytes(data):
+ if isinstance(data, bytes):
+ data = data.decode('ascii')
+ data = data.replace("%3D", "=")
+ return base64.urlsafe_b64decode(data + "="*((4 - len(data) % 4) % 4))
+# --------------------------------------------------------------------
+
+
+dec = b64_to_bytes
+
+
+def get_b64_type(data):
+ '''return base64, base64s, base64p, or base64?'''
+ if isinstance(data, str):
+ data = data.encode('ascii')
+ if data.endswith(b'='):
+ return 'base64'
+ if data.endswith(b'%3D'):
+ return 'base64p'
+ # Length of data means it wouldn't have an equals sign,
+ # so we can't tell which type it is.
+ if len(data) % 4 == 0:
+ return 'base64?'
+
+ return 'base64s'
+
+
+def enc(t):
+ return base64.urlsafe_b64encode(t).decode('ascii')
+
+
+def uenc(t):
+ return enc(t).replace("=", "%3D")
+
+
+def b64_to_ascii(t):
+ return base64.urlsafe_b64decode(t).decode('ascii', errors='replace')
+
+
+def b64_to_bin(t):
+ decoded = base64.urlsafe_b64decode(t)
+ # print(len(decoded)*8)
+ return " ".join(["{:08b}".format(x) for x in decoded])
+
+
+def bytes_to_bin(t):
+ return " ".join(["{:08b}".format(x) for x in t])
+
+
+def bin_to_bytes(t):
+ return int(t, 2).to_bytes((len(t) + 7) // 8, 'big')
+
+
+def bytes_to_hex(t):
+ return ' '.join(hex(n)[2:].zfill(2) for n in t)
+
+
+tohex = bytes_to_hex
+fromhex = bytes.fromhex
+
+
+def aligned_ascii(data):
+ return ' '.join(' ' + chr(n) if n in range(32, 128) else ' _' for n in data)
+
+
+def parse_protobuf(data, mutable=False, spec=()):
+ data_original = data
+ data = io.BytesIO(data)
+ data.original = data_original
+ while True:
+ try:
+ tag = read_varint(data)
+ except EOFError:
+ break
+ wire_type = tag & 7
+ field_number = tag >> 3
+
+ if wire_type == 0:
+ value = read_varint(data)
+ elif wire_type == 1:
+ value = data.read(8)
+ elif wire_type == 2:
+ length = read_varint(data)
+ value = data.read(length)
+ elif wire_type == 3:
+ end_bytes = varint_encode((field_number << 3) | 4)
+ value = read_group(data, end_bytes)
+ elif wire_type == 5:
+ value = data.read(4)
+ else:
+ raise Exception("Unknown wire type: " + str(wire_type) + ", Tag: " + bytes_to_hex(varint_encode(tag)) + ", at position " + str(data.tell()))
+ if mutable:
+ yield [wire_type, field_number, value]
+ else:
+ yield (wire_type, field_number, value)
+
+
+read_protobuf = parse_protobuf
+
+
+def pb(data, mutable=False):
+ return list(parse_protobuf(data, mutable=mutable))
+
+
+def bytes_to_base4(data):
+ result = ''
+ for b in data:
+ result += str(b >> 6) + str((b >> 4) & 0b11) + str((b >> 2) & 0b11) + str(b & 0b11)
+ return result
+
+
+import re
+import struct
+import binascii
+
+
+# Base32 encoding/decoding must be done in Python
+_b32alphabet = b'abcdefghijklmnopqrstuvwxyz012345'
+_b32tab2 = None
+_b32rev = None
+
+bytes_types = (bytes, bytearray) # Types acceptable as binary data
+
+
+def _bytes_from_decode_data(s):
+ if isinstance(s, str):
+ try:
+ return s.encode('ascii')
+ except UnicodeEncodeError:
+ raise ValueError('string argument should contain only ASCII characters')
+ if isinstance(s, bytes_types):
+ return s
+ try:
+ return memoryview(s).tobytes()
+ except TypeError:
+ raise TypeError("argument should be a bytes-like object or ASCII "
+ "string, not %r" % s.__class__.__name__) from None
+
+
+def b32decode(s, casefold=False, map01=None):
+ """Decode the Base32 encoded bytes-like object or ASCII string s.
+
+ Optional casefold is a flag specifying whether a lowercase alphabet is
+ acceptable as input. For security purposes, the default is False.
+
+ RFC 3548 allows for optional mapping of the digit 0 (zero) to the
+ letter O (oh), and for optional mapping of the digit 1 (one) to
+ either the letter I (eye) or letter L (el). The optional argument
+ map01 when not None, specifies which letter the digit 1 should be
+ mapped to (when map01 is not None, the digit 0 is always mapped to
+ the letter O). For security purposes the default is None, so that
+ 0 and 1 are not allowed in the input.
+
+ The result is returned as a bytes object. A binascii.Error is raised if
+ the input is incorrectly padded or if there are non-alphabet
+ characters present in the input.
+ """
+ global _b32rev
+ # Delay the initialization of the table to not waste memory
+ # if the function is never called
+ if _b32rev is None:
+ _b32rev = {v: k for k, v in enumerate(_b32alphabet)}
+ s = _bytes_from_decode_data(s)
+ if len(s) % 8:
+ raise binascii.Error('Incorrect padding')
+ # Handle section 2.4 zero and one mapping. The flag map01 will be either
+ # False, or the character to map the digit 1 (one) to. It should be
+ # either L (el) or I (eye).
+ if map01 is not None:
+ map01 = _bytes_from_decode_data(map01)
+ assert len(map01) == 1, repr(map01)
+ s = s.translate(bytes.maketrans(b'01', b'O' + map01))
+ if casefold:
+ s = s.upper()
+ # Strip off pad characters from the right. We need to count the pad
+ # characters because this will tell us how many null bytes to remove from
+ # the end of the decoded string.
+ l = len(s)
+ s = s.rstrip(b'=')
+ padchars = l - len(s)
+ # Now decode the full quanta
+ decoded = bytearray()
+ b32rev = _b32rev
+ for i in range(0, len(s), 8):
+ quanta = s[i: i + 8]
+ acc = 0
+ try:
+ for c in quanta:
+ acc = (acc << 5) + b32rev[c]
+ except KeyError:
+ raise binascii.Error('Non-base32 digit found') from None
+ decoded += acc.to_bytes(5, 'big')
+ # Process the last, partial quanta
+ if padchars:
+ acc <<= 5 * padchars
+ last = acc.to_bytes(5, 'big')
+ if padchars == 1:
+ decoded[-5:] = last[:-1]
+ elif padchars == 3:
+ decoded[-5:] = last[:-2]
+ elif padchars == 4:
+ decoded[-5:] = last[:-3]
+ elif padchars == 6:
+ decoded[-5:] = last[:-4]
+ else:
+ raise binascii.Error('Incorrect padding')
+ return bytes(decoded)
+
+
+def dec32(data):
+ if isinstance(data, bytes):
+ data = data.decode('ascii')
+ return b32decode(data + "="*((8 - len(data)%8)%8))
+
+
+_patterns = [
+ (b'UC', 24), # channel
+ (b'PL', 34), # playlist
+ (b'LL', 24), # liked videos playlist
+ (b'UU', 24), # user uploads playlist
+ (b'RD', 15), # radio mix
+ (b'RD', 43), # radio mix
+ (b'', 11), # video
+ (b'Ug', 26), # comment
+ (b'Ug', 49), # comment reply (of form parent_id.reply_id)
+ (b'9', 22), # comment reply id
+]
+def is_youtube_object_id(data):
+ try:
+ if isinstance(data, str):
+ data = data.encode('ascii')
+ except Exception:
+ return False
+
+ for start_sequence, length in _patterns:
+ if len(data) == length and data.startswith(start_sequence):
+ return True
+
+ return False
+
+
+def recursive_pb(data):
+ try:
+ # check if this fits the basic requirements for base64
+ if isinstance(data, str) or all(i > 32 for i in data):
+ if len(data) > 11 and not is_youtube_object_id(data):
+ raw_data = b64_to_bytes(data)
+ b64_type = get_b64_type(data)
+
+ rpb = recursive_pb(raw_data)
+ if rpb == raw_data:
+ # could not interpret as protobuf, probably not b64
+ return data
+ return (b64_type, rpb)
+ else:
+ return data
+ except Exception as e:
+ return data
+
+ try:
+ result = pb(data, mutable=True)
+ except Exception as e:
+ return data
+
+ for tuple in result:
+ if tuple[0] == 2:
+ tuple[2] = recursive_pb(tuple[2])
+
+ return result
+
+
+
+def indent_lines(lines, indent):
+ return re.sub(r'^', ' '*indent, lines, flags=re.MULTILINE)
+
+
+def _pp(obj, indent): # not my best work
+ if isinstance(obj, tuple):
+ if len(obj) == 3: # (wire_type, field_number, data)
+ return obj.__repr__()
+ else: # (base64, [...])
+ return ('(' + obj[0].__repr__() + ',\n'
+ + indent_lines(_pp(obj[1], indent), indent) + '\n'
+ + ')')
+ elif isinstance(obj, list):
+ # [wire_type, field_number, data]
+ if (len(obj) == 3
+ and not any(isinstance(x, (list, tuple)) for x in obj)
+ ):
+ return obj.__repr__()
+
+ # [wire_type, field_number, [...]]
+ elif (len(obj) == 3
+ and not any(isinstance(x, (list, tuple)) for x in obj[0:2])
+ ):
+ return ('[' + obj[0].__repr__() + ', ' + obj[1].__repr__() + ',\n'
+ + indent_lines(_pp(obj[2], indent), indent) + '\n'
+ + ']')
+ else:
+ s = '[\n'
+ for x in obj:
+ s += indent_lines(_pp(x, indent), indent) + ',\n'
+ s += ']'
+ return s
+ else:
+ return obj.__repr__()
+
+
+def pp(obj, indent=1):
+ '''Pretty prints the recursive pb structure'''
+ print(_pp(obj, indent))
+
+
+desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0'
+desktop_headers = (
+ ('Accept', '*/*'),
+ ('Accept-Language', 'en-US,en;q=0.5'),
+ ('X-YouTube-Client-Name', '1'),
+ ('X-YouTube-Client-Version', '2.20180830'),
+) + (('User-Agent', desktop_user_agent),)
+
+mobile_user_agent = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36'
+mobile_headers = (
+ ('Accept', '*/*'),
+ ('Accept-Language', 'en-US,en;q=0.5'),
+ ('X-YouTube-Client-Name', '2'),
+ ('X-YouTube-Client-Version', '2.20180830'),
+) + (('User-Agent', mobile_user_agent),)
diff --git a/youtube/search.py b/youtube/search.py
index 81a69f2..d586c62 100644
--- a/youtube/search.py
+++ b/youtube/search.py
@@ -8,6 +8,7 @@ import base64
import mimetypes
from flask import request
import flask
+import os
# Sort: 1
# Upload date: 2
@@ -57,15 +58,15 @@ def get_search_json(query, page, autocorrect, sort, filters):
return info
+@yt_app.route('/results')
@yt_app.route('/search')
def get_search_page():
- if len(request.args) == 0:
- return flask.render_template('base.html', title="Search")
+ query = request.args.get('search_query') or request.args.get('query')
+ if query is None:
+ return flask.render_template('home.html', title='Search')
+ elif query.startswith('https://www.youtube.com') or query.startswith('https://www.youtu.be'):
+ return flask.redirect(f'/{query}')
- if 'query' not in request.args:
- abort(400)
-
- query = request.args.get("query")
page = request.args.get("page", "1")
autocorrect = int(request.args.get("autocorrect", "1"))
sort = int(request.args.get("sort", "0"))
@@ -76,22 +77,44 @@ def get_search_page():
polymer_json = get_search_json(query, page, autocorrect, sort, filters)
search_info = yt_data_extract.extract_search_info(polymer_json)
- for item_info in search_info['items']:
- yt_data_extract.prefix_urls(item_info)
- yt_data_extract.add_extra_html_info(item_info)
+ if search_info['error']:
+ return flask.render_template('error.html', error_message=search_info['error'])
+
+ for extract_item_info in search_info['items']:
+ util.prefix_urls(extract_item_info)
+ util.add_extra_html_info(extract_item_info)
- return flask.render_template('search.html',
- header_playlist_names = local_playlist.get_playlist_names(),
- query = query,
- estimated_results = search_info['estimated_results'],
- estimated_pages = search_info['estimated_pages'],
- corrections = search_info['corrections'],
- results = search_info['items'],
- parameters_dictionary = request.args,
+ corrections = search_info['corrections']
+ if corrections['type'] == 'did_you_mean':
+ corrected_query_string = request.args.to_dict(flat=False)
+ corrected_query_string['search_query'] = [corrections['corrected_query']]
+ corrections['corrected_query_url'] = util.URL_ORIGIN + '/results?' + urllib.parse.urlencode(corrected_query_string, doseq=True)
+ elif corrections['type'] == 'showing_results_for':
+ no_autocorrect_query_string = request.args.to_dict(flat=False)
+ no_autocorrect_query_string['autocorrect'] = ['0']
+ no_autocorrect_query_url = util.URL_ORIGIN + '/results?' + urllib.parse.urlencode(no_autocorrect_query_string, doseq=True)
+ corrections['original_query_url'] = no_autocorrect_query_url
+
+ return flask.render_template(
+ 'search.html',
+ header_playlist_names=local_playlist.get_playlist_names(),
+ query=query,
+ estimated_results=search_info['estimated_results'],
+ estimated_pages=search_info['estimated_pages'],
+ corrections=search_info['corrections'],
+ results=search_info['items'],
+ parameters_dictionary=request.args,
)
+
@yt_app.route('/opensearch.xml')
def get_search_engine_xml():
- with open("youtube/opensearch.xml", 'rb') as f:
- content = f.read().replace(b'$port_number', str(settings.port_number).encode())
+ with open(os.path.join(settings.program_directory, 'youtube/opensearch.xml'), 'rb') as f:
+ if settings.app_public:
+ main_url = '%s' % settings.app_url
+ else:
+ main_url = '%s:%s' % (settings.app_url, settings.port_number)
+ content = f.read().replace(
+ b'$main_url', str(main_url).encode()
+ )
return flask.Response(content, mimetype='application/xml')
diff --git a/youtube/static/channel.css b/youtube/static/channel.css
new file mode 100644
index 0000000..e842beb
--- /dev/null
+++ b/youtube/static/channel.css
@@ -0,0 +1,572 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ z-index: 1;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* playlist */
+.playlist {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "play-box"
+ "play-hidden"
+ "play-add"
+ "play-clean";
+ grid-area: playlist;
+}
+.play-box {
+ grid-area: play-box;
+}
+
+.play-hidden {
+ grid-area: play-hidden;
+}
+
+.play-add {
+ grid-area: play-add;
+ cursor: pointer;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-add:hover {
+ background-color: var(--buttom-hover);
+}
+
+.play-clean {
+ display: grid;
+ grid-area: play-clean;
+}
+
+.play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-clean > button:hover {
+ background-color: var(--buttom-hover);
+}
+/* /playlist */
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.author-container {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+ grid-template-areas:
+ "author"
+ "summary"
+ "subscribe";
+}
+.author {
+ grid-area: author;
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ grid-column-gap: 1rem;
+ align-items: center;
+ justify-self: center;
+}
+.summary { grid-area: summary; }
+.summary p {
+ text-align: center;
+}
+.subscribe {
+ grid-area: subscribe;
+ justify-self: center;
+}
+.subscribe .btn-subscribe {
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ text-shadow: none;
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ border: 1px solid;
+ border-color: var(--button-border);
+ border-radius: 0.2rem;
+}
+
+/* Video list item */
+.video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.item-box {
+ display: grid;
+ grid-template-columns: 1.9fr 0.1fr;
+ grid-template-rows: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "item-video item-checkbox";
+}
+
+.item-video {
+ grid-area: item-video;
+
+ display: grid;
+ grid-template-columns: auto;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 0.4rem;
+ grid-template-areas:
+ "thumbnail-box"
+ "info-box";
+ align-items: center;
+
+ font-size: 0.7rem;
+}
+
+.item-video a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.item-video.channel-item {
+ border-radius: 50%;
+ width: 150px;
+ height: 150px;
+}
+
+.thumbnail-box {
+ grid-area: thumbnail-box;
+ position: relative;
+}
+
+.thumbnail {
+ padding: 28.125%;
+ position: relative;
+ box-sizing: border-box;
+}
+
+.thumbnail-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ object-fit: cover;
+ background-color: var(--thumb-background);
+}
+
+.length {
+ position: absolute;
+ background-color: rgba(35, 35, 35, 0.75);
+ color: #fff;
+ border-radius: 2px;
+ padding: 2px;
+ font-size: 16px;
+ right: 0.25em;
+ bottom: -0.75em;
+}
+
+.playlist-item .thumbnail-info {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ height: 100%;
+ width: 50%;
+ text-align: center;
+ white-space: pre-line;
+ opacity: .8;
+ color: var(--text);
+ font-size: 0.8125rem;
+ background: var(--secondary-background);
+ padding: 0;
+}
+
+.playlist-item .thumbnail-info span {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-transform: none;
+}
+
+.info-box {
+ grid-area: info-box;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto auto auto;
+ grid-gap: 1px;
+ grid-template-areas:
+ "."
+ "."
+ "."
+ "."
+ ".";
+}
+
+.title {
+ font-size: 0.8rem;
+ margin: 0px;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info-box address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.thumbnail-info {
+ background-color: var(--time-background);
+ color: #fff;
+ padding: 2px 5px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 12px;
+ position: absolute;
+ right: 0;
+ bottom: .2rem;
+}
+
+.item-checkbox {
+ grid-area: item-checkbox;
+ justify-self: start;
+ align-self: center;
+ min-width: 30px;
+ margin: 0px;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-between;
+}
+
+.horizontal-stats {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.horizontal-stats > li {
+ display: inline;
+}
+
+.horizontal-stats > li:first-child::after {
+ content: " | ";
+}
+
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ margin: 0.2rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+/* /video list item */
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 600px) {
+ .video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+
+ .author-container {
+ max-width: 50vw;
+ }
+
+ /* playlist */
+ .playlist {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". play-box play-add play-clean";
+ grid-area: playlist;
+ }
+ .play-clean {
+ grid-template-columns: 100px auto;
+ }
+ .play-clean > button {
+ padding-left: 0px;
+ padding-right: 0px;
+ padding-bottom: 6px;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .video-container {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ grid-row-gap: 1rem;
+ grid-column-gap: 1rem;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/comments.css b/youtube/static/comments.css
index 85f0cc1..aa279ef 100644
--- a/youtube/static/comments.css
+++ b/youtube/static/comments.css
@@ -1,128 +1,300 @@
-.video-metadata{
+body {
display: grid;
- grid-template-columns: auto 1fr;
- grid-template-rows: auto auto 1fr auto;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
}
- .video-metadata > .video-metadata-thumbnail-box{
- grid-row: 1 / span 3;
- }
- .video-metadata > .title{
- word-wrap:break-word;
- grid-row: 1;
- }
- .video-metadata > h2{
- grid-row: 2;
- font-size: 15px;
- }
- .video-metadata > span{
- grid-row:3;
- }
- .video-metadata > hr{
- grid-row: 4;
- grid-column: 1 / span 2;
- width: 100%;
- }
-.comment-form{
- display: grid;
- align-content: start;
- justify-items: start;
- align-items: start;
-}
- #comment-account-options{
- display:grid;
- grid-auto-flow: column;
- grid-column-gap: 10px;
- margin-top:10px;
- margin-bottom:10px;
- }
- #comment-account-options a{
- margin-left:10px;
- }
+h1, h2, h3, h4, h5, h6, div, button {
+ margin: 0;
+ padding: 0;
+}
-.comments-area{
- display:grid;
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
}
- .comments-area textarea{
- resize: vertical;
- justify-self:stretch;
- }
- .post-comment-button{
- margin-top:10px;
- justify-self:end;
- }
- .comment-links{
- display:grid;
- grid-auto-flow: column;
- grid-column-gap: 10px;
- justify-content:start;
- }
-.comments{
- margin-top:10px;
- grid-row-gap: 10px;
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ border-radius: 5px;
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
display: grid;
- align-content:start;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form";
+ grid-area: header;
}
-.comment{
- display:grid;
- grid-template-columns: auto auto 100px 1fr;
- grid-template-rows: 0fr 0fr 0fr 0fr;
- background-color: var(--interface-color);
- justify-content: start;
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
}
-.comment .author-avatar{
- grid-column: 1;
- grid-row: 1 / span 3;
- align-self: start;
- margin-right: 5px;
- height:32px;
- width:32px;
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
}
-.comment address{
- grid-column: 2;
- grid-row: 1;
- margin-right:15px;
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
white-space: nowrap;
- overflow:hidden;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
}
-.comment .text{
- grid-column: 2 / span 3;
- grid-row: 2;
- white-space: pre-wrap;
- min-width: 0;
- word-wrap: break-word;
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ padding-right: 4rem;
}
+.dropdown-label {
+ grid-area: dropdown-label;
-.comment .permalink{
- grid-column: 3;
- grid-row: 1;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
}
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
-.comment .likes{
- grid-column:2;
- grid-row:3;
- font-weight:bold;
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: inline-grid;
white-space: nowrap;
}
+/*- ----------- End Menu Mobile sin JS ------------- */
-.comment .bottom-row{
- grid-column:2 / span 3;
- grid-row:4;
- justify-self:start;
+.main {
+ grid-area: main;
+ margin: 0 auto;
+ max-width: 80ch;
+}
+
+/* comments */
+.comments-area {
display: grid;
- grid-auto-flow: column;
- grid-column-gap: 10px;
+ grid-row-gap: 0.5rem;
+}
+
+.comments-area textarea {
+ resize: vertical;
}
-.more-comments{
- justify-self:center;
- margin-top:10px;
+.video-metadata {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-column-gap: 0.5rem;
+ grid-template-rows: auto auto 1fr auto;
+ grid-template-areas:
+ "video-metadata-thumbnail-box ."
+ "video-metadata-thumbnail-box ."
+ "video-metadata-thumbnail-box .";
+}
+
+.video-metadata > h2 {
+ font-size: 0.875rem;
+}
+
+.video-metadata-thumbnail-box {
+ grid-area: video-metadata-thumbnail-box;
+}
+
+.comment-form {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.post-comment-button {
+ justify-self: end;
+}
+
+.comments {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.comment {
+ display: grid;
+ grid-template-columns: repeat(3, auto) 3fr;
+ grid-template-rows: repeat(4, auto);
+ grid-column-gap: 0.4rem;
+ grid-template-areas:
+ "author-avatar author-name permalink ."
+ "author-avatar comment-text comment-text comment-text"
+ ". comment-likes comment-likes comment-likes"
+ ". button-row button-row button-row";
+ background: var(--secondary-background);
+}
+
+.author-avatar { grid-area: author-avatar; }
+.author-name { grid-area: author-name; }
+.permalink { grid-area: permalink; }
+.comment-text { grid-area: comment-text; }
+.comment-likes { grid-area: comment-likes; }
+.button-row { grid-area: button-row; }
+
+.more-comments {
+ justify-self: center;
+ margin-top: 10px;
margin-bottom: 10px;
+ background: var(--secondary-background);
+ padding: 5px;
+
+ /* disable text selection */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 780px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: minmax(50px, 120px);
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ z-index: 1;
+ position: absolute;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ padding: 0rem 3rem 1rem 1rem;
+ width: 100%;
+ max-height: 45vh;
+ overflow-y: scroll;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
}
diff --git a/youtube/static/dark_theme.css b/youtube/static/dark_theme.css
index 7389aa5..38cdd63 100644
--- a/youtube/static/dark_theme.css
+++ b/youtube/static/dark_theme.css
@@ -1,38 +1,22 @@
-body{
- --interface-color: #333333;
- --text-color: #cccccc;
- --background-color: #000000;
+:root {
+ --background: #121113;
+ --text: #FFFFFF;
+ --secondary-hover: #222222;
+ --secondary-focus: #121113;
+ --secondary-inverse: #FFFFFF;
+ --primary-background: #242424;
+ --secondary-background: #222222;
+ --thumb-background: #222222;
+ --link: #00B0FF;
+ --link-visited: #40C4FF;
+ --border-bg: #222222;
+ --border-bg-settings: #000000;
+ --border-bg-license: #000000;
+ --buttom: #121113;
+ --buttom-text: #FFFFFF;
+ --button-border: #222222;
+ --buttom-hover: #222222;
+ --search-text: #FFFFFF;
+ --time-background: #121113;
+ --time-text: #FFFFFF;
}
-
-a:link {
- color: #22aaff;
-}
-
-a:visited {
- color: #7755ff;
-}
-
-a:not([href]){
- color: var(--text-color);
-}
-
-.comment .permalink{
- color: #ffffff;
-}
-
-.setting-item{
- background-color: #444444;
-}
-
-
-.muted{
- background-color: #111111;
- color: gray;
-}
-
-.muted a:link {
- color: #10547f;
-}
-
-
-
diff --git a/youtube/static/gray_theme.css b/youtube/static/gray_theme.css
index a5f3b23..2ae3efe 100644
--- a/youtube/static/gray_theme.css
+++ b/youtube/static/gray_theme.css
@@ -1,17 +1,22 @@
-body{
- --interface-color: #dadada;
- --text-color: #222222;
- --background-color: #bcbcbc;
-}
-
-.comment .permalink{
- color: #000000;
-}
-
-.setting-item{
- background-color: #eeeeee;
-}
-
-.muted{
- background-color: #888888;
+:root {
+ --background: #2D3743;
+ --text: #FFFFFF;
+ --secondary-hover: #73828C;
+ --secondary-focus: rgba(115, 130, 140, 0.125);
+ --secondary-inverse: #FFFFFF;
+ --primary-background: #2D3743;
+ --secondary-background: #102027;
+ --thumb-background: #35404D;
+ --link: #22AAFF;
+ --link-visited: #7755FF;
+ --border-bg: #FFFFFF;
+ --border-bg-settings: #FFFFFF;
+ --border-bg-license: #FFFFFF;
+ --buttom: #2D3743;
+ --buttom-text: #FFFFFF;
+ --button-border: #102027;
+ --buttom-hover: #102027;
+ --search-text: #FFFFFF;
+ --time-background: #212121;
+ --time-text: #FFFFFF;
}
diff --git a/youtube/static/home.css b/youtube/static/home.css
new file mode 100644
index 0000000..8923c57
--- /dev/null
+++ b/youtube/static/home.css
@@ -0,0 +1,218 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ border-radius: 5px;
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ margin: 0 auto;
+ max-width: 80ch;
+}
+
+.code-error {
+ background: var(--secondary-background);
+ padding: 1rem;
+}
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 780px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ background: var(--background);
+ padding-right: 4rem;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/js/av-merge.js b/youtube/static/js/av-merge.js
new file mode 100644
index 0000000..cfe9574
--- /dev/null
+++ b/youtube/static/js/av-merge.js
@@ -0,0 +1,1013 @@
+// Heavily modified from
+// https://github.com/nickdesaulniers/netfix/issues/4#issuecomment-578856471
+// which was in turn modified from
+// https://github.com/nickdesaulniers/netfix/blob/gh-pages/demo/bufferWhenNeeded.html
+
+// Useful reading:
+// https://stackoverflow.com/questions/35177797/what-exactly-is-fragmented-mp4fmp4-how-is-it-different-from-normal-mp4
+// https://axel.isouard.fr/blog/2016/05/24/streaming-webm-video-over-html5-with-media-source
+
+// We start by parsing the sidx (segment index) table in order to get the
+// byte ranges of the segments. The byte range of the sidx table is provided
+// by the indexRange variable by YouTube
+
+// Useful info, as well as segments vs sequence mode (we use segments mode)
+// https://joshuatz.com/posts/2020/appending-videos-in-javascript-with-mediasource-buffers/
+
+// SourceBuffer data limits:
+// https://developers.google.com/web/updates/2017/10/quotaexceedederror
+
+// TODO: Call abort to cancel in-progress appends?
+
+
+// Buffer sizes for different systems
+const BUFFER_CONFIG = {
+ default: 50 * 10**6, // 50 megabytes
+ webOS: 20 * 10**6, // 20 megabytes WebOS (LG)
+ samsungTizen: 20 * 10**6, // 20 megabytes Samsung Tizen OS
+ androidTV: 30 * 10**6, // 30 megabytes Android TV
+ desktop: 50 * 10**6, // 50 megabytes PC/Mac
+};
+
+function detectSystem() {
+ const userAgent = navigator.userAgent.toLowerCase();
+ if (/webos|lg browser/i.test(userAgent)) {
+ return "webOS";
+ } else if (/tizen/i.test(userAgent)) {
+ return "samsungTizen";
+ } else if (/android tv|smart-tv/i.test(userAgent)) {
+ return "androidTV";
+ } else if (/firefox|chrome|safari|edge/i.test(userAgent)) {
+ return "desktop";
+ } else {
+ return "default";
+ }
+}
+
+function AVMerge(video, srcInfo, startTime){
+ this.audioSource = null;
+ this.videoSource = null;
+ this.avRatio = null;
+ this.videoStream = null;
+ this.audioStream = null;
+ this.seeking = false;
+ this.startTime = startTime;
+ this.video = video;
+ this.mediaSource = null;
+ this.closed = false;
+ this.opened = false;
+ this.audioEndOfStreamCalled = false;
+ this.videoEndOfStreamCalled = false;
+ if (!('MediaSource' in window)) {
+ reportError('MediaSource not supported.');
+ return;
+ }
+
+ // Find supported video and audio sources
+ for (let src of srcInfo['videos']) {
+ if (MediaSource.isTypeSupported(src['mime_codec'])) {
+ reportDebug('Using video source', src['mime_codec'],
+ src['quality_string'], 'itag', src['itag']);
+ this.videoSource = src;
+ break;
+ }
+ }
+ for (let src of srcInfo['audios']) {
+ if (MediaSource.isTypeSupported(src['mime_codec'])) {
+ reportDebug('Using audio source', src['mime_codec'],
+ src['quality_string'], 'itag', src['itag']);
+ this.audioSource = src;
+ break;
+ }
+ }
+ if (this.videoSource === null)
+ reportError('No supported video MIME type or codec found: ',
+ srcInfo['videos'].map(s => s.mime_codec).join(', '));
+ if (this.audioSource === null)
+ reportError('No supported audio MIME type or codec found: ',
+ srcInfo['audios'].map(s => s.mime_codec).join(', '));
+ if (this.videoSource === null || this.audioSource === null)
+ return;
+
+ if (this.videoSource.bitrate && this.audioSource.bitrate)
+ this.avRatio = this.audioSource.bitrate/this.videoSource.bitrate;
+ else
+ this.avRatio = 1/10;
+
+ this.setup();
+}
+AVMerge.prototype.setup = function() {
+ this.mediaSource = new MediaSource();
+ this.video.src = URL.createObjectURL(this.mediaSource);
+ this.mediaSource.onsourceopen = this.sourceOpen.bind(this);
+}
+
+AVMerge.prototype.sourceOpen = function(_) {
+ // If after calling mediaSource.endOfStream, the user seeks back
+ // into the video, the sourceOpen event will be fired again. Do not
+ // overwrite the streams.
+ this.audioEndOfStreamCalled = false;
+ this.videoEndOfStreamCalled = false;
+ if (this.opened)
+ return;
+ this.opened = true;
+ this.videoStream = new Stream(this, this.videoSource, this.startTime,
+ this.avRatio);
+ this.audioStream = new Stream(this, this.audioSource, this.startTime,
+ this.avRatio);
+
+ this.videoStream.setup();
+ this.audioStream.setup();
+
+ this.timeUpdateEvt = addEvent(this.video, 'timeupdate',
+ this.checkBothBuffers.bind(this));
+ this.seekingEvt = addEvent(this.video, 'seeking',
+ debounce(this.seek.bind(this), 500));
+ //this.video.onseeked = function() {console.log('seeked')};
+}
+AVMerge.prototype.close = function() {
+ if (this.closed)
+ return;
+ this.closed = true;
+ this.videoStream.close();
+ this.audioStream.close();
+ this.timeUpdateEvt.remove();
+ this.seekingEvt.remove();
+ if (this.mediaSource.readyState == 'open')
+ this.mediaSource.endOfStream();
+}
+AVMerge.prototype.checkBothBuffers = function() {
+ this.audioStream.checkBuffer();
+ this.videoStream.checkBuffer();
+}
+AVMerge.prototype.seek = function(e) {
+ if (this.mediaSource.readyState === 'open') {
+ this.seeking = true;
+ this.audioStream.handleSeek();
+ this.videoStream.handleSeek();
+ this.seeking = false;
+ } else {
+ reportWarning('seek but not open? readyState:',
+ this.mediaSource.readyState);
+ }
+}
+AVMerge.prototype.audioEndOfStream = function() {
+ if (this.videoEndOfStreamCalled && !this.audioEndOfStreamCalled) {
+ reportDebug('Calling mediaSource.endOfStream()');
+ this.mediaSource.endOfStream();
+ }
+ this.audioEndOfStreamCalled = true;
+}
+AVMerge.prototype.videoEndOfStream = function() {
+ if (this.audioEndOfStreamCalled && !this.videoEndOfStreamCalled) {
+ reportDebug('Calling mediaSource.endOfStream()');
+ this.mediaSource.endOfStream();
+ }
+ this.videoEndOfStreamCalled = true;
+}
+AVMerge.prototype.printDebuggingInfo = function() {
+ reportDebug('videoSource:', this.videoSource);
+ reportDebug('audioSource:', this.videoSource);
+ reportDebug('video sidx:', this.videoStream.sidx);
+ reportDebug('audio sidx:', this.audioStream.sidx);
+ reportDebug('video updating', this.videoStream.sourceBuffer.updating);
+ reportDebug('audio updating', this.audioStream.sourceBuffer.updating);
+ reportDebug('video duration:', this.video.duration);
+ reportDebug('video current time:', this.video.currentTime);
+ reportDebug('mediaSource.readyState:', this.mediaSource.readyState);
+ reportDebug('videoEndOfStreamCalled', this.videoEndOfStreamCalled);
+ reportDebug('audioEndOfStreamCalled', this.audioEndOfStreamCalled);
+ for (let obj of [this.videoStream, this.audioStream]) {
+ reportDebug(obj.streamType, 'stream buffered times:');
+ for (let i=0; i<obj.sourceBuffer.buffered.length; i++) {
+ reportDebug(String(obj.sourceBuffer.buffered.start(i)) + '-'
+ + String(obj.sourceBuffer.buffered.end(i)));
+ }
+ }
+}
+
+function Stream(avMerge, source, startTime, avRatio) {
+ const selectedSystem = detectSystem();
+ let baseBufferTarget = BUFFER_CONFIG[selectedSystem] || BUFFER_CONFIG.default;
+ this.avMerge = avMerge;
+ this.video = avMerge.video;
+ this.url = source['url'];
+ this.ext = source['ext'];
+ this.fileSize = source['file_size'];
+ this.closed = false;
+ this.mimeCodec = source['mime_codec']
+ this.streamType = source['acodec'] ? 'audio' : 'video';
+ if (this.streamType == 'audio') {
+ this.bufferTarget = avRatio * baseBufferTarget;
+ } else {
+ this.bufferTarget = baseBufferTarget;
+ }
+ console.info(`Detected system: ${selectedSystem}. Applying bufferTarget of ${this.bufferTarget} bytes to ${this.streamType}.`);
+
+ this.initRange = source['init_range'];
+ this.indexRange = source['index_range'];
+
+ this.startTime = startTime;
+ this.mediaSource = avMerge.mediaSource;
+ this.sidx = null;
+ this.appendRetries = 0;
+ this.appendQueue = []; // list of [segmentIdx, data]
+ this.sourceBuffer = this.mediaSource.addSourceBuffer(this.mimeCodec);
+ this.sourceBuffer.mode = 'segments';
+ this.sourceBuffer.addEventListener('error', (e) => {
+ this.reportError('sourceBuffer error', e);
+ });
+ this.updateendEvt = addEvent(this.sourceBuffer, 'updateend', (e) => {
+ if (this.appendQueue.length != 0) {
+ this.appendSegment(...this.appendQueue.shift());
+ }
+ });
+}
+Stream.prototype.setup = async function(){
+ // Group requests together
+ if (this.initRange.end+1 == this.indexRange.start){
+ fetchRange(
+ this.url,
+ this.initRange.start,
+ this.indexRange.end,
+ 'Initialization+index segments',
+ ).then(
+ (buffer) => {
+ let init_end = this.initRange.end - this.initRange.start + 1;
+ let index_start = this.indexRange.start - this.initRange.start;
+ let index_end = this.indexRange.end - this.initRange.start + 1;
+ this.setupInitSegment(buffer.slice(0, init_end));
+ this.setupSegmentIndex(buffer.slice(index_start, index_end));
+ }
+ );
+ } else {
+ // initialization data
+ await fetchRange(
+ this.url,
+ this.initRange.start,
+ this.initRange.end,
+ 'Initialization segment',
+ ).then(this.setupInitSegment.bind(this));
+
+ // sidx (segment index) table
+ fetchRange(
+ this.url,
+ this.indexRange.start,
+ this.indexRange.end,
+ 'Index segment',
+ ).then(this.setupSegmentIndex.bind(this));
+ }
+}
+Stream.prototype.setupInitSegment = function(initSegment) {
+ if (this.ext == 'webm')
+ this.sidx = extractWebmInitializationInfo(initSegment);
+ this.appendSegment(null, initSegment);
+}
+Stream.prototype.setupSegmentIndex = async function(indexSegment){
+ if (this.ext == 'webm') {
+ this.sidx.entries = parseWebmCues(indexSegment, this.sidx);
+ if (this.fileSize) {
+ let lastIdx = this.sidx.entries.length - 1;
+ this.sidx.entries[lastIdx].end = this.fileSize - 1;
+ }
+ for (let entry of this.sidx.entries) {
+ entry.subSegmentDuration = entry.tickEnd - entry.tickStart + 1;
+ if (entry.end)
+ entry.referencedSize = entry.end - entry.start + 1;
+ }
+ } else {
+ let box = unbox(indexSegment);
+ this.sidx = sidx_parse(box.data, this.indexRange.end+1);
+ }
+ this.fetchSegmentIfNeeded(this.getSegmentIdx(this.startTime));
+}
+Stream.prototype.close = function() {
+ // Prevents appendSegment adding to buffer if request finishes
+ // after closing
+ this.closed = true;
+ if (this.sourceBuffer.updating)
+ this.sourceBuffer.abort();
+ this.mediaSource.removeSourceBuffer(this.sourceBuffer);
+ this.updateendEvt.remove();
+}
+Stream.prototype.appendSegment = function(segmentIdx, chunk) {
+ if (this.closed)
+ return;
+
+ this.reportDebug('Received segment', segmentIdx)
+
+ // cannot append right now, schedule for updateend
+ if (this.sourceBuffer.updating) {
+ this.reportDebug('sourceBuffer updating, queueing for later');
+ this.appendQueue.push([segmentIdx, chunk]);
+ if (this.appendQueue.length > 2){
+ this.reportWarning('appendQueue length:', this.appendQueue.length);
+ }
+ return;
+ }
+ try {
+ this.sourceBuffer.appendBuffer(chunk);
+ if (segmentIdx !== null)
+ this.sidx.entries[segmentIdx].have = true;
+ this.appendRetries = 0;
+ } catch (e) {
+ if (e.name !== 'QuotaExceededError') {
+ throw e;
+ }
+ this.reportWarning('QuotaExceededError.');
+
+ // Count how many bytes are in buffer to update buffering target,
+ // updating .have as well for when we need to delete segments
+ let bytesInBuffer = 0;
+ for (let i = 0; i < this.sidx.entries.length; i++) {
+ if (this.segmentInBuffer(i))
+ bytesInBuffer += this.sidx.entries[i].referencedSize;
+ else if (this.sidx.entries[i].have) {
+ this.sidx.entries[i].have = false;
+ this.sidx.entries[i].requested = false;
+ }
+ }
+ bytesInBuffer = Math.floor(4/5*bytesInBuffer);
+ if (bytesInBuffer < this.bufferTarget) {
+ this.bufferTarget = bytesInBuffer;
+ this.reportDebug('New buffer target:', this.bufferTarget);
+ }
+
+ // Delete 10 segments (arbitrary) from buffer, making sure
+ // not to delete current one
+ let currentSegment = this.getSegmentIdx(this.video.currentTime);
+ let numDeleted = 0;
+ let i = 0;
+ const DELETION_TARGET = 10;
+ let toDelete = []; // See below for why we have to schedule it
+ this.reportDebug('Deleting segments from beginning of buffer.');
+ while (numDeleted < DELETION_TARGET && i < currentSegment) {
+ if (this.sidx.entries[i].have) {
+ toDelete.push(i)
+ numDeleted++;
+ }
+ i++;
+ }
+ if (numDeleted < DELETION_TARGET)
+ this.reportDebug('Deleting segments from end of buffer.');
+
+ i = this.sidx.entries.length - 1;
+ while (numDeleted < DELETION_TARGET && i > currentSegment) {
+ if (this.sidx.entries[i].have) {
+ toDelete.push(i)
+ numDeleted++;
+ }
+ i--;
+ }
+
+ // When calling .remove, the sourceBuffer will go into updating=true
+ // state, and remove cannot be called until it is done. So we have
+ // to delete on the updateend event for subsequent ones.
+ let removeFinishedEvent;
+ let deletedStuff = (toDelete.length !== 0)
+ let deleteSegment = () => {
+ if (toDelete.length === 0) {
+ removeFinishedEvent.remove();
+ // If QuotaExceeded happened for current segment, retry the
+ // append
+ // Rescheduling will take care of updating=true problem.
+ // Also check that we found segments to delete, to avoid
+ // infinite looping if we can't delete anything
+ if (segmentIdx === currentSegment && deletedStuff) {
+ this.reportDebug('Retrying appendSegment for', segmentIdx);
+ this.appendSegment(segmentIdx, chunk);
+ } else {
+ this.reportDebug('Not retrying segment', segmentIdx);
+ this.sidx.entries[segmentIdx].requested = false;
+ }
+ return;
+ }
+ let idx = toDelete.shift();
+ let entry = this.sidx.entries[idx];
+ let start = entry.tickStart/this.sidx.timeScale;
+ let end = (entry.tickEnd+1)/this.sidx.timeScale;
+ this.reportDebug('Deleting segment', idx);
+ this.sourceBuffer.remove(start, end);
+ entry.have = false;
+ entry.requested = false;
+ }
+ removeFinishedEvent = addEvent(this.sourceBuffer, 'updateend',
+ deleteSegment);
+ if (!this.sourceBuffer.updating)
+ deleteSegment();
+ }
+}
+Stream.prototype.getSegmentIdx = function(videoTime) {
+ // get an estimate
+ let currentTick = videoTime * this.sidx.timeScale;
+ let firstSegmentDuration = this.sidx.entries[0].subSegmentDuration;
+ let index = 1 + Math.floor(currentTick / firstSegmentDuration);
+ index = clamp(index, 0, this.sidx.entries.length - 1);
+
+ let increment = 1;
+ if (currentTick < this.sidx.entries[index].tickStart){
+ increment = -1;
+ }
+
+ // go up or down to find correct index
+ while (index >= 0 && index < this.sidx.entries.length) {
+ let entry = this.sidx.entries[index];
+ if (entry.tickStart <= currentTick && (entry.tickEnd+1) > currentTick){
+ return index;
+ }
+ index = index + increment;
+ }
+ this.reportError('Could not find segment index for time', videoTime);
+ return 0;
+}
+Stream.prototype.checkBuffer = async function() {
+ if (this.avMerge.seeking) {
+ return;
+ }
+ // Find the first unbuffered segment, i
+ let currentSegmentIdx = this.getSegmentIdx(this.video.currentTime);
+ let bufferedBytesAhead = 0;
+ let i;
+ for (i = currentSegmentIdx; i < this.sidx.entries.length; i++) {
+ let entry = this.sidx.entries[i];
+ // check if we had it before, but it was deleted by the browser
+ if (entry.have && !this.segmentInBuffer(i)) {
+ this.reportDebug('segment', i, 'deleted by browser');
+ entry.have = false;
+ entry.requested = false;
+ }
+ if (!entry.have) {
+ break;
+ }
+ bufferedBytesAhead += entry.referencedSize;
+ if (bufferedBytesAhead > this.bufferTarget) {
+ return;
+ }
+ }
+
+ if (i < this.sidx.entries.length && !this.sidx.entries[i].requested) {
+ this.fetchSegment(i);
+ // We have all the segments until the end
+ // Signal the end of stream
+ } else if (i == this.sidx.entries.length) {
+ if (this.streamType == 'audio')
+ this.avMerge.audioEndOfStream();
+ else
+ this.avMerge.videoEndOfStream();
+ }
+}
+Stream.prototype.segmentInBuffer = function(segmentIdx) {
+ let entry = this.sidx.entries[segmentIdx];
+ // allow for 0.01 second error
+ let timeStart = entry.tickStart/this.sidx.timeScale + 0.01;
+
+ /* Some of YouTube's mp4 fragments are malformed, with half-frame
+ playback gaps. In this video at 240p (timeScale = 90000 ticks/second)
+ https://www.youtube.com/watch?v=ZhOQCwJvwlo
+ segment 4 (starting at 0) is claimed in the sidx table to have
+ a duration of 388500 ticks, but closer examination of the file using
+ Bento4 mp4dump shows that the segment has 129 frames at 3000 ticks
+ per frame, which gives an actual duration of 38700 (1500 less than
+ claimed). The file is 30 fps, so this error is exactly half a frame.
+
+ Note that the base_media_decode_time exactly matches the tickStart,
+ so the media decoder is being given a time gap of half a frame.
+
+ The practical result of this is that sourceBuffer.buffered reports
+ a timeRange.end that is less than expected for that segment, resulting in
+ a false determination that the browser has deleted a segment.
+
+ Segment 5 has the opposite issue, where it has a 1500 tick surplus of video
+ data compared to the sidx length. Segments 6 and 7 also have this
+ deficit-surplus pattern.
+
+ This might have something to do with the fact that the video also
+ has 60 fps formats. In order to allow for adaptive streaming and seamless
+ quality switching, YouTube likely encodes their formats to line up nicely.
+ Either there is a bug in their encoder, or this is intentional. Allow for
+ up to 1 frame-time of error to work around this issue. */
+ let endError;
+ if (this.streamType == 'video')
+ endError = 1/(this.avMerge.videoSource.fps || 30);
+ else
+ endError = 0.01
+ let timeEnd = (entry.tickEnd+1)/this.sidx.timeScale - endError;
+
+ let timeRanges = this.sourceBuffer.buffered;
+ for (let i=0; i < timeRanges.length; i++) {
+ if (timeRanges.start(i) <= timeStart && timeEnd <= timeRanges.end(i)) {
+ return true;
+ }
+ }
+ return false;
+}
+Stream.prototype.fetchSegment = function(segmentIdx) {
+ entry = this.sidx.entries[segmentIdx];
+ entry.requested = true;
+ this.reportDebug(
+ 'Fetching segment', segmentIdx, ', bytes',
+ entry.start, entry.end, ', seconds',
+ entry.tickStart/this.sidx.timeScale,
+ (entry.tickEnd+1)/this.sidx.timeScale
+ )
+ fetchRange(
+ this.url,
+ entry.start,
+ entry.end,
+ String(this.streamType) + ' segment ' + String(segmentIdx),
+ ).then(this.appendSegment.bind(this, segmentIdx));
+}
+Stream.prototype.fetchSegmentIfNeeded = function(segmentIdx) {
+ if (segmentIdx < 0 || segmentIdx >= this.sidx.entries.length){
+ return;
+ }
+ entry = this.sidx.entries[segmentIdx];
+ // check if we had it before, but it was deleted by the browser
+ if (entry.have && !this.segmentInBuffer(segmentIdx)) {
+ this.reportDebug('segment', segmentIdx, 'deleted by browser');
+ entry.have = false;
+ entry.requested = false;
+ }
+ if (entry.requested) {
+ return;
+ }
+
+ this.fetchSegment(segmentIdx);
+}
+Stream.prototype.handleSeek = function() {
+ let segmentIdx = this.getSegmentIdx(this.video.currentTime);
+ this.fetchSegmentIfNeeded(segmentIdx);
+}
+Stream.prototype.reportDebug = function(...args) {
+ reportDebug(String(this.streamType) + ':', ...args);
+}
+Stream.prototype.reportWarning = function(...args) {
+ reportWarning(String(this.streamType) + ':', ...args);
+}
+Stream.prototype.reportError = function(...args) {
+ reportError(String(this.streamType) + ':', ...args);
+}
+
+
+// Utility functions
+
+// https://gomakethings.com/promise-based-xhr/
+// https://stackoverflow.com/a/30008115
+// http://lofi.limo/blog/retry-xmlhttprequest-carefully
+function fetchRange(url, start, end, debugInfo) {
+ return new Promise((resolve, reject) => {
+ let retryCount = 0;
+ let xhr = new XMLHttpRequest();
+ function onFailure(err, message, maxRetries=5){
+ message = debugInfo + ': ' + message + ' - Err: ' + String(err);
+ retryCount++;
+ if (retryCount > maxRetries || xhr.status == 403){
+ reportError('fetchRange error while fetching ' + message);
+ reject(message);
+ return;
+ } else {
+ reportWarning('Failed to fetch ' + message
+ + '. Attempting retry '
+ + String(retryCount) +'/' + String(maxRetries));
+ }
+
+ // Retry in 1 second, doubled for each next retry
+ setTimeout(function(){
+ xhr.open('get',url);
+ xhr.send();
+ }, 1000*Math.pow(2,(retryCount-1)));
+ }
+ xhr.open('get', url);
+ xhr.timeout = 15000;
+ xhr.responseType = 'arraybuffer';
+ xhr.setRequestHeader('Range', 'bytes=' + start + '-' + end);
+ xhr.onload = function (e) {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(xhr.response);
+ } else {
+ onFailure(e,
+ 'Status '
+ + String(xhr.status) + ' ' + String(xhr.statusText)
+ );
+ }
+ };
+ xhr.onerror = function (event) {
+ onFailure(e, 'Network error');
+ };
+ xhr.ontimeout = function (event){
+ xhr.timeout += 5000;
+ onFailure(null, 'Timeout (15s)', maxRetries=5);
+ };
+ xhr.send();
+ });
+}
+
+function debounce(func, wait, immediate) {
+ let timeout;
+ return function() {
+ let context = this;
+ let args = arguments;
+ let later = function() {
+ timeout = null;
+ if (!immediate) func.apply(context, args);
+ };
+ let callNow = immediate && !timeout;
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ if (callNow) func.apply(context, args);
+ };
+}
+
+function clamp(number, min, max) {
+ return Math.max(min, Math.min(number, max));
+}
+
+// allow to remove an event listener without having a function reference
+function RegisteredEvent(obj, eventName, func) {
+ this.obj = obj;
+ this.eventName = eventName;
+ this.func = func;
+ obj.addEventListener(eventName, func);
+}
+RegisteredEvent.prototype.remove = function() {
+ this.obj.removeEventListener(this.eventName, this.func);
+}
+function addEvent(obj, eventName, func) {
+ return new RegisteredEvent(obj, eventName, func);
+}
+
+function reportWarning(...args){
+ console.warn(...args);
+}
+function reportError(...args){
+ console.error(...args);
+}
+function reportDebug(...args){
+ console.debug(...args);
+}
+
+function byteArrayToIntegerLittleEndian(unsignedByteArray){
+ let result = 0;
+ for (byte of unsignedByteArray){
+ result = result*256;
+ result += byte
+ }
+ return result;
+}
+function byteArrayToFloat(byteArray) {
+ let view = new DataView(byteArray.buffer);
+ if (byteArray.length == 4)
+ return view.getFloat32(byteArray.byteOffset);
+ else
+ return view.getFloat64(byteArray.byteOffset);
+}
+function ByteParser(data){
+ this.curIndex = 0;
+ this.data = new Uint8Array(data);
+}
+ByteParser.prototype.readInteger = function(nBytes){
+ let result = byteArrayToIntegerLittleEndian(
+ this.data.slice(this.curIndex, this.curIndex + nBytes)
+ );
+ this.curIndex += nBytes;
+ return result;
+}
+ByteParser.prototype.readBufferBytes = function(nBytes){
+ let result = this.data.slice(this.curIndex, this.curIndex + nBytes);
+ this.curIndex += nBytes;
+ return result;
+}
+
+// BEGIN iso-bmff-parser-stream/lib/box/sidx.js (modified)
+// https://github.com/necccc/iso-bmff-parser-stream/blob/master/lib/box/sidx.js
+/* The MIT License (MIT)
+
+Copyright (c) 2014 Szabolcs Szabolcsi-Toth
+
+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.*/
+function sidx_parse (data, offset) {
+ let bp = new ByteParser(data),
+ version = bp.readInteger(1),
+ flags = bp.readInteger(3),
+ referenceId = bp.readInteger(4),
+ timeScale = bp.readInteger(4),
+ earliestPresentationTime = bp.readInteger(version === 0 ? 4 : 8),
+ firstOffset = bp.readInteger(4),
+ __reserved = bp.readInteger(2),
+ entryCount = bp.readInteger(2),
+ entries = [];
+
+ let totalBytesOffset = firstOffset + offset;
+ let totalTicks = 0;
+ for (let i = entryCount; i > 0; i=i-1 ) {
+ let referencedSize = bp.readInteger(4),
+ subSegmentDuration = bp.readInteger(4),
+ unused = bp.readBufferBytes(4)
+ entries.push({
+ referencedSize: referencedSize,
+ subSegmentDuration: subSegmentDuration,
+ unused: unused,
+ start: totalBytesOffset,
+ end: totalBytesOffset + referencedSize - 1, // inclusive
+ tickStart: totalTicks,
+ tickEnd: totalTicks + subSegmentDuration - 1,
+ requested: false,
+ have: false,
+ });
+ totalBytesOffset = totalBytesOffset + referencedSize;
+ totalTicks = totalTicks + subSegmentDuration;
+ }
+
+ return {
+ version: version,
+ flags: flags,
+ referenceId: referenceId,
+ timeScale: timeScale,
+ earliestPresentationTime: earliestPresentationTime,
+ firstOffset: firstOffset,
+ entries: entries
+ };
+}
+// END sidx.js
+
+// BEGIN iso-bmff-parser-stream/lib/unbox.js (same license), modified
+function unbox(buf) {
+ let bp = new ByteParser(buf),
+ bufferLength = buf.length,
+ length,
+ typeData,
+ boxData
+
+ length = bp.readInteger(4); // length of entire box,
+ typeData = bp.readInteger(4);
+
+ if (bufferLength - length < 0) {
+ reportWarning('Warning: sidx table is cut off');
+ return {
+ currentLength: bufferLength,
+ length: length,
+ type: typeData,
+ data: bp.readBufferBytes(bufferLength)
+ };
+ }
+
+ boxData = bp.readBufferBytes(length - 8);
+
+ return {
+ length: length,
+ type: typeData,
+ data: boxData
+ };
+}
+// END unbox.js
+
+
+function extractWebmInitializationInfo(initializationSegment) {
+ let result = {
+ timeScale: null,
+ cuesOffset: null,
+ duration: null,
+ };
+ (new EbmlDecoder()).readTags(initializationSegment, (tagType, tag) => {
+ if (tag.name == 'TimecodeScale')
+ result.timeScale = byteArrayToIntegerLittleEndian(tag.data);
+ else if (tag.name == 'Duration')
+ // Integer represented as a float (why??); units of TimecodeScale
+ result.duration = byteArrayToFloat(tag.data);
+ // https://lists.matroska.org/pipermail/matroska-devel/2013-July/004549.html
+ // "CueClusterPosition in turn is relative to the segment's data start
+ // position" (the data start is the position after the bytes
+ // used to represent the tag ID and entry size)
+ else if (tagType == 'start' && tag.name == 'Segment')
+ result.cuesOffset = tag.dataStart;
+ });
+ if (result.timeScale === null) {
+ result.timeScale = 1000000;
+ }
+
+ // webm timecodeScale is the number of nanoseconds in a tick
+ // Convert it to number of ticks per second to match mp4 convention
+ result.timeScale = 10**9/result.timeScale;
+ return result;
+}
+function parseWebmCues(indexSegment, initInfo) {
+ let entries = [];
+ let currentEntry = {};
+ let cuesOffset = initInfo.cuesOffset;
+ (new EbmlDecoder()).readTags(indexSegment, (tagType, tag) => {
+ if (tag.name == 'CueTime') {
+ const tickStart = byteArrayToIntegerLittleEndian(tag.data);
+ currentEntry.tickStart = tickStart;
+ if (entries.length !== 0)
+ entries[entries.length - 1].tickEnd = tickStart - 1;
+ } else if (tag.name == 'CueClusterPosition') {
+ const byteStart = byteArrayToIntegerLittleEndian(tag.data);
+ currentEntry.start = cuesOffset + byteStart;
+ if (entries.length !== 0)
+ entries[entries.length - 1].end = cuesOffset + byteStart - 1;
+ } else if (tagType == 'end' && tag.name == 'CuePoint') {
+ entries.push(currentEntry);
+ currentEntry = {};
+ }
+ });
+ if (initInfo.duration)
+ entries[entries.length - 1].tickEnd = initInfo.duration - 1;
+ return entries;
+}
+
+// BEGIN node-ebml (modified) for parsing WEBM cues table
+// https://github.com/node-ebml/node-ebml
+
+/* Copyright (c) 2013-2018 Mark Schmale and 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.*/
+
+const schema = new Map([
+ [0x18538067, ['Segment', 'm']],
+ [0x1c53bb6b, ['Cues', 'm']],
+ [0xbb, ['CuePoint', 'm']],
+ [0xb3, ['CueTime', 'u']],
+ [0xb7, ['CueTrackPositions', 'm']],
+ [0xf7, ['CueTrack', 'u']],
+ [0xf1, ['CueClusterPosition', 'u']],
+ [0x1549a966, ['Info', 'm']],
+ [0x2ad7b1, ['TimecodeScale', 'u']],
+ [0x4489, ['Duration', 'f']],
+]);
+
+
+function EbmlDecoder() {
+ this.buffer = null;
+ this.emit = null;
+ this.tagStack = [];
+ this.cursor = 0;
+}
+EbmlDecoder.prototype.readTags = function(chunk, onParsedTag) {
+ this.buffer = new Uint8Array(chunk);
+ this.emit = onParsedTag;
+
+ while (this.cursor < this.buffer.length) {
+ if (!this.readTag() || !this.readSize() || !this.readContent()) {
+ break;
+ }
+ }
+}
+EbmlDecoder.prototype.getSchemaInfo = function(tag) {
+ if (Number.isInteger(tag) && schema.has(tag)) {
+ let name, type;
+ [name, type] = schema.get(tag);
+ return {name, type};
+ }
+ return {
+ type: null,
+ name: 'unknown',
+ };
+}
+EbmlDecoder.prototype.readTag = function() {
+ if (this.cursor >= this.buffer.length) {
+ return false;
+ }
+
+ const tag = readVint(this.buffer, this.cursor);
+ if (tag == null) {
+ return false;
+ }
+
+ const tagObj = {
+ tag: tag.value,
+ ...this.getSchemaInfo(tag.valueWithLeading1),
+ start: this.cursor,
+ end: this.cursor + tag.length, // exclusive; also overwritten below
+ };
+ this.tagStack.push(tagObj);
+
+ this.cursor += tag.length;
+ return true;
+}
+EbmlDecoder.prototype.readSize = function() {
+ const tagObj = this.tagStack[this.tagStack.length - 1];
+
+ if (this.cursor >= this.buffer.length) {
+ return false;
+ }
+
+ const size = readVint(this.buffer, this.cursor);
+ if (size == null) {
+ return false;
+ }
+
+ tagObj.dataSize = size.value;
+
+ // unknown size
+ if (size.value === -1) {
+ tagObj.end = -1;
+ } else {
+ tagObj.end += size.value + size.length;
+ }
+
+ this.cursor += size.length;
+ tagObj.dataStart = this.cursor;
+ return true;
+}
+EbmlDecoder.prototype.readContent = function() {
+ const { type, dataSize, ...rest } = this.tagStack[
+ this.tagStack.length - 1
+ ];
+
+ if (type === 'm') {
+ this.emit('start', { type, dataSize, ...rest });
+ return true;
+ }
+
+ if (this.buffer.length < this.cursor + dataSize) {
+ return false;
+ }
+
+ const data = this.buffer.subarray(this.cursor, this.cursor + dataSize);
+ this.cursor += dataSize;
+
+ this.tagStack.pop(); // remove the object from the stack
+
+ this.emit('tag', { type, dataSize, data, ...rest });
+
+ while (this.tagStack.length > 0) {
+ const topEle = this.tagStack[this.tagStack.length - 1];
+ if (this.cursor < topEle.end) {
+ break;
+ }
+ this.emit('end', topEle);
+ this.tagStack.pop();
+ }
+ return true;
+}
+
+
+// user234683 notes: The matroska variable integer format is as follows:
+// The first byte is where the length of the integer in bytes is determined.
+// The number of bytes for the integer is equal to the number of leading
+// zeroes in that first byte PLUS 1. Then there is a single 1 bit separator,
+// and the rest of the bits in the first byte and the rest of the bits in
+// the subsequent bytes are the value of the number. Note the 1-bit separator
+// is not part of the value, but by convention IS included in the value for the
+// EBML Tag IDs in the schema table above
+// The byte-length includes the first byte. So one could also say the number
+// of leading zeros is the number of subsequent bytes to include.
+function readVint(buffer, start = 0) {
+ const length = 8 - Math.floor(Math.log2(buffer[start]));
+
+ if (start + length > buffer.length) {
+ return null;
+ }
+
+ let value = buffer[start] & ((1 << (8 - length)) - 1);
+ let valueWithLeading1 = buffer[start] & ((1 << (8 - length + 1)) - 1);
+ for (let i = 1; i < length; i += 1) {
+ // user234683 notes: Bails out with -1 (unknown) if the value would
+ // exceed 53 bits, which is the limit since JavaScript stores all
+ // numbers as floating points. See
+ // https://github.com/node-ebml/node-ebml/issues/49
+ if (i === 7) {
+ if (value >= 2 ** 8 && buffer[start + 7] > 0) {
+ return { length, value: -1, valueWithLeading1: -1 };
+ }
+ }
+ value *= 2 ** 8;
+ value += buffer[start + i];
+ valueWithLeading1 *= 2 ** 8;
+ valueWithLeading1 += buffer[start + i];
+ }
+
+ return { length, value, valueWithLeading1 };
+}
+// END node-ebml
diff --git a/youtube/static/js/comments.js b/youtube/static/js/comments.js
new file mode 100644
index 0000000..14ba0c0
--- /dev/null
+++ b/youtube/static/js/comments.js
@@ -0,0 +1,20 @@
+function onClickReplies(e) {
+ let details = e.target.parentElement;
+ // e.preventDefault();
+ console.log("loading replies ..");
+ doXhr(details.getAttribute("data-src") + "&slim=1", (html) => {
+ let div = details.querySelector(".comment_page");
+ div.innerHTML = html;
+ });
+ details.removeEventListener('click', onClickReplies);
+}
+
+window.addEventListener('DOMContentLoaded', function() {
+ QA("details.replies").forEach(details => {
+ details.addEventListener('click', onClickReplies);
+ details.addEventListener('auxclick', (e) => {
+ if (e.target.parentElement !== details) return;
+ if (e.button == 1) window.open(details.getAttribute("data-src"));
+ });
+ });
+});
diff --git a/youtube/static/js/common.js b/youtube/static/js/common.js
new file mode 100644
index 0000000..599d578
--- /dev/null
+++ b/youtube/static/js/common.js
@@ -0,0 +1,116 @@
+const Q = document.querySelector.bind(document);
+const QA = document.querySelectorAll.bind(document);
+const QId = document.getElementById.bind(document);
+let seconds,
+ minutes,
+ hours;
+function text(msg) { return document.createTextNode(msg); }
+function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); }
+function toTimestamp(seconds) {
+ seconds = Math.floor(seconds);
+
+ minutes = Math.floor(seconds/60);
+ seconds = seconds % 60;
+
+ hours = Math.floor(minutes/60);
+ minutes = minutes % 60;
+
+ if (hours) {
+ return `0${hours}:`.slice(-3) + `0${minutes}:`.slice(-3) + `0${seconds}`.slice(-2);
+ }
+ return `0${minutes}:`.slice(-3) + `0${seconds}`.slice(-2);
+}
+
+let cur_track_idx = 0;
+function getActiveTranscriptTrackIdx() {
+ let textTracks = QId("js-video-player").textTracks;
+ if (!textTracks.length) return;
+ for (let i=0; i < textTracks.length; i++) {
+ if (textTracks[i].mode == "showing") {
+ cur_track_idx = i;
+ return cur_track_idx;
+ }
+ }
+ return cur_track_idx;
+}
+function getActiveTranscriptTrack() { return QId("js-video-player").textTracks[getActiveTranscriptTrackIdx()]; }
+
+function getDefaultTranscriptTrackIdx() {
+ let textTracks = QId("js-video-player").textTracks;
+ return textTracks.length - 1;
+}
+
+function doXhr(url, callback=null) {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", url);
+ xhr.onload = (e) => {
+ callback(e.currentTarget.response);
+ }
+ xhr.send();
+ return xhr;
+}
+
+// https://stackoverflow.com/a/30810322
+function copyTextToClipboard(text) {
+ let textArea = document.createElement("textarea");
+
+ //
+ // *** This styling is an extra step which is likely not required. ***
+ //
+ // Why is it here? To ensure:
+ // 1. the element is able to have focus and selection.
+ // 2. if element was to flash render it has minimal visual impact.
+ // 3. less flakyness with selection and copying which **might** occur if
+ // the textarea element is not visible.
+ //
+ // The likelihood is the element won't even render, not even a
+ // flash, so some of these are just precautions. However in
+ // Internet Explorer the element is visible whilst the popup
+ // box asking the user for permission for the web page to
+ // copy to the clipboard.
+ //
+
+ // Place in top-left corner of screen regardless of scroll position.
+ textArea.style.position = 'fixed';
+ textArea.style.top = 0;
+ textArea.style.left = 0;
+
+ // Ensure it has a small width and height. Setting to 1px / 1em
+ // doesn't work as this gives a negative w/h on some browsers.
+ textArea.style.width = '2em';
+ textArea.style.height = '2em';
+
+ // We don't need padding, reducing the size if it does flash render.
+ textArea.style.padding = 0;
+
+ // Clean up any borders.
+ textArea.style.border = 'none';
+ textArea.style.outline = 'none';
+ textArea.style.boxShadow = 'none';
+
+ // Avoid flash of white box if rendered for any reason.
+ textArea.style.background = 'transparent';
+
+
+ textArea.value = text;
+
+ let parent_el = video.parentElement;
+ parent_el.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ try {
+ let successful = document.execCommand('copy');
+ let msg = successful ? 'successful' : 'unsuccessful';
+ console.log('Copying text command was ' + msg);
+ } catch (err) {
+ console.log('Oops, unable to copy');
+ }
+
+ parent_el.removeChild(textArea);
+}
+
+
+window.addEventListener('DOMContentLoaded', function() {
+ cur_track_idx = getDefaultTranscriptTrackIdx();
+});
diff --git a/youtube/static/js/hotkeys.js b/youtube/static/js/hotkeys.js
new file mode 100644
index 0000000..b71972e
--- /dev/null
+++ b/youtube/static/js/hotkeys.js
@@ -0,0 +1,61 @@
+function onKeyDown(e) {
+ if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return false;
+
+ // console.log(e);
+ let v = QId("js-video-player");
+ if (!e.isTrusted) return; // plyr CustomEvent
+ let c = e.key.toLowerCase();
+ if (e.ctrlKey) return;
+ else if (c == "k") {
+ v.paused ? v.play() : v.pause();
+ }
+ else if (c == "arrowleft") {
+ e.preventDefault();
+ v.currentTime = v.currentTime - 5;
+ }
+ else if (c == "arrowright") {
+ e.preventDefault();
+ v.currentTime = v.currentTime + 5;
+ }
+ else if (c == "j") {
+ e.preventDefault();
+ v.currentTime = v.currentTime - 10;
+ }
+ else if (c == "l") {
+ e.preventDefault();
+ v.currentTime = v.currentTime + 10;
+ }
+ else if (c == "f") {
+ e.preventDefault();
+ if (data.settings.use_video_player == 2) {
+ player.fullscreen.toggle()
+ }
+ else {
+ if (document.fullscreen) {
+ document.exitFullscreen()
+ }
+ else {
+ v.requestFullscreen()
+ }
+ }
+ }
+ else if (c == "m") {
+ if (v.muted == false) {v.muted = true;}
+ else {v.muted = false;}
+ }
+ else if (c == "c") {
+ e.preventDefault();
+ let tt = getActiveTranscriptTrack();
+ if (tt == null) return;
+ if (tt.mode == "showing") tt.mode = "disabled";
+ else tt.mode = "showing";
+ }
+ else if (c == "t") {
+ let ts = Math.floor(QId("js-video-player").currentTime);
+ copyTextToClipboard(`https://youtu.be/${data.video_id}?t=${ts}`);
+ }
+}
+
+window.addEventListener('DOMContentLoaded', function() {
+ document.addEventListener('keydown', onKeyDown);
+});
diff --git a/youtube/static/js/playlistadd.js b/youtube/static/js/playlistadd.js
new file mode 100644
index 0000000..4b76ce1
--- /dev/null
+++ b/youtube/static/js/playlistadd.js
@@ -0,0 +1,86 @@
+(function main() {
+ /* Takes control of the form if javascript is enabled, so that adding stuff to a playlist will not cause things to stop loading, and will display a status message. If javascript is disabled, the form will still work using regular HTML methods, but causes things on the page (such as the video) to stop loading. */
+ const playlistAddForm = document.getElementById('playlist-edit');
+
+ function setStyle(element, property, value){
+ element.style[property] = value;
+ }
+ function removeMessage(messageBox){
+ messageBox.parentNode.removeChild(messageBox);
+ }
+
+ function displayMessage(text, error=false){
+ let currentMessageBox = document.getElementById('message-box');
+ if(currentMessageBox !== null){
+ currentMessageBox.parentNode.removeChild(currentMessageBox);
+ }
+ let messageBox = document.createElement('div');
+ if(error){
+ messageBox.setAttribute('role', 'alert');
+ } else {
+ messageBox.setAttribute('role', 'status');
+ }
+ messageBox.setAttribute('id', 'message-box');
+ let textNode = document.createTextNode(text);
+ messageBox.appendChild(textNode);
+ document.querySelector('main').appendChild(messageBox);
+ let currentstyle = window.getComputedStyle(messageBox);
+ let removalDelay;
+ if(error){
+ removalDelay = 5000;
+ } else {
+ removalDelay = 1500;
+ }
+ window.setTimeout(setStyle, 20, messageBox, 'opacity', 1);
+ window.setTimeout(setStyle, removalDelay, messageBox, 'opacity', 0);
+ window.setTimeout(removeMessage, removalDelay+300, messageBox);
+ }
+ // https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Sending_forms_through_JavaScript
+ function sendData(event){
+ let clicked_button = document.activeElement;
+ if(clicked_button === null || clicked_button.getAttribute('type') !== 'submit' || clicked_button.parentElement != event.target){
+ console.log('ERROR: clicked_button not valid');
+ return;
+ }
+ if(clicked_button.getAttribute('value') !== 'add'){
+ return; // video(s) are being removed from playlist, just let it refresh the page
+ }
+ event.preventDefault();
+ let XHR = new XMLHttpRequest();
+ let FD = new FormData(playlistAddForm);
+
+ if(FD.getAll('video_info_list').length === 0){
+ displayMessage('Error: No videos selected', true);
+ return;
+ }
+
+ if(FD.get('playlist_name') === ""){
+ displayMessage('Error: No playlist selected', true);
+ return;
+ }
+
+ // https://stackoverflow.com/questions/48322876/formdata-doesnt-include-value-of-buttons
+ FD.append('action', 'add');
+
+ XHR.addEventListener('load', function(event){
+ if(event.target.status == 204){
+ displayMessage('Added videos to playlist "' + FD.get('playlist_name') + '"');
+ } else {
+ displayMessage('Error adding videos to playlist: ' + event.target.status.toString(), true);
+ }
+ });
+
+ XHR.addEventListener('error', function(event){
+ if(event.target.status == 0){
+ displayMessage('XHR failed: Check that XHR requests are allowed', true);
+ } else {
+ displayMessage('XHR failed: Unknown error', true);
+ }
+ });
+
+ XHR.open('POST', playlistAddForm.getAttribute('action'));
+ XHR.send(FD);
+ }
+
+ playlistAddForm.addEventListener('submit', sendData);
+}());
diff --git a/youtube/static/js/plyr-start.js b/youtube/static/js/plyr-start.js
new file mode 100644
index 0000000..3838acc
--- /dev/null
+++ b/youtube/static/js/plyr-start.js
@@ -0,0 +1,136 @@
+(function main() {
+ 'use strict';
+
+ // Captions
+ let captionsActive = false;
+ if (data.settings.subtitles_mode === 2 || (data.settings.subtitles_mode === 1 && data.has_manual_captions)) {
+ captionsActive = true;
+ }
+
+ // AutoPlay
+ let autoplayActive = data.settings.autoplay_videos || false;
+
+ let qualityOptions = [];
+ let qualityDefault;
+
+ for (let src of data.uni_sources) {
+ qualityOptions.push(src.quality_string);
+ }
+
+ for (let src of data.pair_sources) {
+ qualityOptions.push(src.quality_string);
+ }
+
+ if (data.using_pair_sources) {
+ qualityDefault = data.pair_sources[data.pair_idx].quality_string;
+ } else if (data.uni_sources.length !== 0) {
+ qualityDefault = data.uni_sources[data.uni_idx].quality_string;
+ } else {
+ qualityDefault = 'None';
+ }
+
+ // Fix plyr refusing to work with qualities that are strings
+ Object.defineProperty(Plyr.prototype, 'quality', {
+ set: function (input) {
+ const config = this.config.quality;
+ const options = this.options.quality;
+ let quality = input;
+ let updateStorage = true;
+
+ if (!options.length) {
+ return;
+ }
+
+ if (!options.includes(quality)) {
+ return;
+ }
+
+ // Update config
+ config.selected = quality;
+
+ // Set quality
+ this.media.quality = quality;
+
+ // Save to storage
+ if (updateStorage) {
+ this.storage.set({ quality });
+ }
+ },
+ });
+
+ const playerOptions = {
+ // Learning about autoplay permission https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy/autoplay#syntax
+ autoplay: autoplayActive,
+ disableContextMenu: false,
+ captions: {
+ active: captionsActive,
+ language: data.settings.subtitles_language,
+ },
+ controls: [
+ 'play-large',
+ 'play',
+ 'progress',
+ 'current-time',
+ 'duration',
+ 'mute',
+ 'volume',
+ 'captions',
+ 'settings',
+ 'pip',
+ 'airplay',
+ 'fullscreen',
+ ],
+ iconUrl: '/youtube.com/static/modules/plyr/plyr.svg',
+ blankVideo: '/youtube.com/static/modules/plyr/blank.webm',
+ debug: false,
+ storage: { enabled: false },
+ quality: {
+ default: qualityDefault,
+ options: qualityOptions,
+ forced: true,
+ onChange: function (quality) {
+ if (quality == 'None') {
+ return;
+ }
+ if (quality.includes('(integrated)')) {
+ for (let i = 0; i < data.uni_sources.length; i++) {
+ if (data.uni_sources[i].quality_string == quality) {
+ changeQuality({ type: 'uni', index: i });
+ return;
+ }
+ }
+ } else {
+ for (let i = 0; i < data.pair_sources.length; i++) {
+ if (data.pair_sources[i].quality_string == quality) {
+ changeQuality({ type: 'pair', index: i });
+ return;
+ }
+ }
+ }
+ },
+ },
+ previewThumbnails: {
+ enabled: storyboard_url !== null,
+ src: [storyboard_url],
+ },
+ settings: ['captions', 'quality', 'speed', 'loop'],
+ tooltips: {
+ controls: true,
+ },
+ }
+
+ const player = new Plyr(document.getElementById('js-video-player'), playerOptions);
+
+ // disable double click to fullscreen
+ // https://github.com/sampotts/plyr/issues/1370#issuecomment-528966795
+ player.eventListeners.forEach(function(eventListener) {
+ if(eventListener.type === 'dblclick') {
+ eventListener.element.removeEventListener(eventListener.type, eventListener.callback, eventListener.options);
+ }
+ });
+
+ // Add .started property, true after the playback has been started
+ // Needed so controls won't be hidden before playback has started
+ player.started = false;
+ player.once('playing', function(){this.started = true});
+})();
diff --git a/youtube/static/js/sponsorblock.js b/youtube/static/js/sponsorblock.js
new file mode 100644
index 0000000..a929fc5
--- /dev/null
+++ b/youtube/static/js/sponsorblock.js
@@ -0,0 +1,40 @@
+"use strict";
+
+// from: https://git.gir.st/subscriptionfeed.git/blob/59a590d:/app/youtube/templates/watch.html.j2#l28
+
+let sha256=function a(b){function c(a,b){return a>>>b|a<<32-b}for(var d,e,f=Math.pow,g=f(2,32),h="length",i="",j=[],k=8*b[h],l=a.h=a.h||[],m=a.k=a.k||[],n=m[h],o={},p=2;64>n;p++)if(!o[p]){for(d=0;313>d;d+=p)o[d]=p;l[n]=f(p,.5)*g|0,m[n++]=f(p,1/3)*g|0}for(b+="\x80";b[h]%64-56;)b+="\x00";for(d=0;d<b[h];d++){if(e=b.charCodeAt(d),e>>8)return;j[d>>2]|=e<<(3-d)%4*8}for(j[j[h]]=k/g|0,j[j[h]]=k,e=0;e<j[h];){var q=j.slice(e,e+=16),r=l;for(l=l.slice(0,8),d=0;64>d;d++){var s=q[d-15],t=q[d-2],u=l[0],v=l[4],w=l[7]+(c(v,6)^c(v,11)^c(v,25))+(v&l[5]^~v&l[6])+m[d]+(q[d]=16>d?q[d]:q[d-16]+(c(s,7)^c(s,18)^s>>>3)+q[d-7]+(c(t,17)^c(t,19)^t>>>10)|0),x=(c(u,2)^c(u,13)^c(u,22))+(u&l[1]^u&l[2]^l[1]&l[2]);l=[w+x|0].concat(l),l[4]=l[4]+w|0}for(d=0;8>d;d++)l[d]=l[d]+r[d]|0}for(d=0;8>d;d++)for(e=3;e+1;e--){var y=l[d]>>8*e&255;i+=(16>y?0:"")+y.toString(16)}return i}; /*https://geraintluff.github.io/sha256/sha256.min.js (public domain)*/
+
+window.addEventListener("load", load_sponsorblock);
+document.addEventListener('DOMContentLoaded', ()=>{
+ const check = document.querySelector("#skip_sponsors");
+ check.addEventListener("change", () => {if (check.checked) load_sponsorblock()});
+});
+function load_sponsorblock(){
+ const info_elem = Q('#skip_n');
+ if (info_elem.innerText.length) return; // already fetched
+ const hash = sha256(data.video_id).substr(0,4);
+ const video_obj = QId("js-video-player");
+ let url = `/https://sponsor.ajay.app/api/skipSegments/${hash}`;
+ fetch(url)
+ .then(response => response.json())
+ .then(r => {
+ for (const video of r) {
+ if (video.videoID != data.video_id) continue;
+ info_elem.innerText = `(${video.segments.length} segments)`;
+ const cat_n = video.segments.map(e=>e.category).sort()
+ .reduce((acc,e) => (acc[e]=(acc[e]||0)+1, acc), {});
+ info_elem.title = Object.entries(cat_n).map(e=>e.join(': ')).join(', ');
+ for (const segment of video.segments) {
+ const [start, stop] = segment.segment;
+ if (segment.category != "sponsor") continue;
+ video_obj.addEventListener("timeupdate", function() {
+ if (Q("#skip_sponsors").checked &&
+ this.currentTime >= start &&
+ this.currentTime < stop-1) {
+ this.currentTime = stop;
+ }
+ });
+ }
+ }
+ });
+}
diff --git a/youtube/static/js/transcript-table.js b/youtube/static/js/transcript-table.js
new file mode 100644
index 0000000..5cee97e
--- /dev/null
+++ b/youtube/static/js/transcript-table.js
@@ -0,0 +1,151 @@
+let details_tt, select_tt, table_tt;
+
+function renderCues() {
+ const selectedTrack = QId("js-video-player").textTracks[select_tt.selectedIndex];
+ const cuesList = [...selectedTrack.cues];
+ const is_automatic = cuesList[0].text.startsWith(" \n");
+
+ // Firefox ignores cues starting with a blank line containing a space
+ // Automatic captions contain such a blank line in the first cue
+ let ff_bug = false;
+ if (!cuesList[0].text.length) { ff_bug = true; is_automatic = true };
+ let rows;
+
+ function forEachCue(callback) {
+ for (let i=0; i < cuesList.length; i++) {
+ let txt, startTime = selectedTrack.cues[i].startTime;
+ if (is_automatic) {
+ // Automatic captions repeat content. The new segment is displayed
+ // on the bottom row; the old one is displayed on the top row.
+ // So grab the bottom row only. Skip every other cue because the bottom
+ // row is empty.
+ if (i % 2) continue;
+ if (ff_bug && !selectedTrack.cues[i].text.length) {
+ txt = selectedTrack.cues[i+1].text;
+ } else {
+ txt = selectedTrack.cues[i].text.split('\n')[1].replace(/<[\d:.]*?><c>(.*?)<\/c>/g, "$1");
+ }
+ } else {
+ txt = selectedTrack.cues[i].text;
+ }
+ callback(startTime, txt);
+ }
+ }
+
+ function createTimestampLink(startTime, txt, title=null) {
+ a = document.createElement("a");
+ a.appendChild(text(txt));
+ a.href = "javascript:;"; // TODO: replace this with ?t parameter
+ if (title) a.title = title;
+ a.addEventListener("click", (e) => {
+ QId("js-video-player").currentTime = startTime;
+ })
+ return a;
+ }
+
+ clearNode(table_tt);
+ console.log("render cues..", selectedTrack.cues.length);
+ if (Q("input#transcript-use-table").checked) {
+ forEachCue((startTime, txt) => {
+ let tr, td, a;
+ tr = document.createElement("tr");
+
+ td = document.createElement("td")
+ td.appendChild(createTimestampLink(startTime, toTimestamp(startTime)));
+ tr.appendChild(td);
+
+ td = document.createElement("td")
+ td.appendChild(text(txt));
+ tr.appendChild(td);
+
+ table_tt.appendChild(tr);
+ });
+ rows = table_tt.rows;
+ }
+ else {
+ forEachCue((startTime, txt) => {
+ span = document.createElement("span");
+ let idx = txt.indexOf(" ", 1);
+ let [firstWord, rest] = [txt.slice(0, idx), txt.slice(idx)];
+
+ span.appendChild(createTimestampLink(startTime, firstWord, toTimestamp(startTime)));
+ if (rest) span.appendChild(text(rest + " "));
+ table_tt.appendChild(span);
+ });
+ rows = table_tt.childNodes;
+ }
+
+ let lastActiveRow = null;
+ let row;
+ function colorCurRow(e) {
+ // console.log("cuechange:", e);
+ let activeCueIdx = cuesList.findIndex((c) => c == selectedTrack.activeCues[0]);
+ let activeRowIdx = is_automatic ? Math.floor(activeCueIdx / 2) : activeCueIdx;
+
+ if (lastActiveRow) lastActiveRow.style.backgroundColor = "";
+ if (activeRowIdx < 0) return;
+ row = rows[activeRowIdx];
+ row.style.backgroundColor = "#0cc12e42";
+ lastActiveRow = row;
+ }
+ selectedTrack.addEventListener("cuechange", colorCurRow);
+}
+
+function loadCues() {
+ const textTracks = QId("js-video-player").textTracks;
+ const selectedTrack = textTracks[select_tt.selectedIndex];
+
+ // See https://developer.mozilla.org/en-US/docs/Web/API/TextTrack/mode
+ // This code will (I think) make sure that the selected track's cues
+ // are loaded even if the track subtitles aren't on (showing). Setting it
+ // to hidden will load them.
+ let selected_track_target_mode = "hidden";
+
+ for (let track of textTracks) {
+ // Want to avoid unshowing selected track if it's showing
+ if (track.mode === "showing") selected_track_target_mode = "showing";
+
+ if (track !== selectedTrack) track.mode = "disabled";
+ }
+ if (selectedTrack.mode == "disabled") {
+ selectedTrack.mode = selected_track_target_mode;
+ }
+
+ let intervalID = setInterval(() => {
+ if (selectedTrack.cues && selectedTrack.cues.length) {
+ clearInterval(intervalID);
+ renderCues();
+ }
+ }, 100);
+}
+
+window.addEventListener('DOMContentLoaded', function() {
+ const textTracks = QId("js-video-player").textTracks;
+ if (!textTracks.length) return;
+
+ details_tt = Q("details#transcript-details");
+ details_tt.addEventListener("toggle", () => {
+ if (details_tt.open) loadCues();
+ });
+
+ select_tt = Q("select#select-tt");
+ select_tt.selectedIndex = getDefaultTranscriptTrackIdx();
+ select_tt.addEventListener("change", loadCues);
+
+ table_tt = Q("table#transcript-table");
+ table_tt.appendChild(text("loading..."));
+
+ textTracks.addEventListener("change", (e) => {
+ // console.log(e);
+ let idx = getActiveTranscriptTrackIdx(); // sadly not provided by 'e'
+ if (textTracks[idx].mode == "showing") {
+ select_tt.selectedIndex = idx;
+ loadCues();
+ }
+ else if (details_tt.open && textTracks[idx].mode == "disabled") {
+ textTracks[idx].mode = "hidden"; // so we still receive 'oncuechange'
+ }
+ })
+
+ Q("input#transcript-use-table").addEventListener("change", renderCues);
+});
diff --git a/youtube/static/js/watch.js b/youtube/static/js/watch.js
new file mode 100644
index 0000000..00803cf
--- /dev/null
+++ b/youtube/static/js/watch.js
@@ -0,0 +1,200 @@
+const video = document.getElementById('js-video-player');
+
+function changeQuality(selection) {
+ let currentVideoTime = video.currentTime;
+ let videoPaused = video.paused;
+ let videoSpeed = video.playbackRate;
+ let srcInfo;
+ if (avMerge && typeof avMerge.close === 'function') {
+ avMerge.close();
+ }
+ if (selection.type == 'uni'){
+ srcInfo = data['uni_sources'][selection.index];
+ video.src = srcInfo.url;
+ } else {
+ srcInfo = data['pair_sources'][selection.index];
+ avMerge = new AVMerge(video, srcInfo, currentVideoTime);
+ }
+ video.currentTime = currentVideoTime;
+ if (!videoPaused){
+ video.play();
+ }
+ video.playbackRate = videoSpeed;
+}
+
+// Initialize av-merge
+let avMerge;
+if (data.using_pair_sources) {
+ let srcPair = data['pair_sources'][data['pair_idx']];
+ // Do it dynamically rather than as the default in jinja
+ // in case javascript is disabled
+ avMerge = new AVMerge(video, srcPair, 0);
+}
+
+// Quality selector
+const qs = document.getElementById('quality-select');
+if (qs) {
+ qs.addEventListener('change', function(e) {
+ changeQuality(JSON.parse(this.value))
+ });
+}
+
+// Set up video start time from &t parameter
+if (data.time_start != 0 && video) {video.currentTime = data.time_start};
+
+// External video speed control
+let speedInput = document.getElementById('speed-control');
+speedInput.addEventListener('keyup', (event) => {
+ if (event.key === 'Enter') {
+ let speed = parseFloat(speedInput.value);
+ if(!isNaN(speed)){
+ video.playbackRate = speed;
+ }
+ }
+});
+
+
+// Playlist lazy image loading
+if (data.playlist && data.playlist['id'] !== null) {
+ // lazy load playlist images
+ // copied almost verbatim from
+ // https://css-tricks.com/tips-for-rolling-your-own-lazy-loading/
+ // IntersectionObserver isn't supported in pre-quantum
+ // firefox versions, but the alternative of making it
+ // manually is a performance drain, so oh well
+ let observer = new IntersectionObserver(lazyLoad, {
+
+ // where in relation to the edge of the viewport, we are observing
+ rootMargin: "100px",
+
+ // how much of the element needs to have intersected
+ // in order to fire our loading function
+ threshold: 1.0
+
+ });
+
+ function lazyLoad(elements) {
+ elements.forEach(item => {
+ if (item.intersectionRatio > 0) {
+
+ // set the src attribute to trigger a load
+ item.target.src = item.target.dataset.src;
+
+ // stop observing this element. Our work here is done!
+ observer.unobserve(item.target);
+ };
+ });
+ };
+
+ // Tell our observer to observe all img elements with a "lazy" class
+ let lazyImages = document.querySelectorAll('img.lazy');
+ lazyImages.forEach(img => {
+ observer.observe(img);
+ });
+}
+
+
+// Autoplay
+if (data.settings.related_videos_mode !== 0 || data.playlist !== null) {
+ let playability_error = !!data.playability_error;
+ let isPlaylist = false;
+ if (data.playlist !== null && data.playlist['current_index'] !== null)
+ isPlaylist = true;
+
+ // read cookies on whether to autoplay
+ // https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie
+ let cookieValue;
+ let playlist_id;
+ if (isPlaylist) {
+ // from https://stackoverflow.com/a/6969486
+ function escapeRegExp(string) {
+ // $& means the whole matched string
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ }
+ playlist_id = data.playlist['id'];
+ playlist_id = escapeRegExp(playlist_id);
+
+ cookieValue = document.cookie.replace(new RegExp(
+ '(?:(?:^|.*;\\s*)autoplay_'
+ + playlist_id + '\\s*\\=\\s*([^;]*).*$)|^.*$'
+ ), '$1');
+ } else {
+ cookieValue = document.cookie.replace(new RegExp(
+ '(?:(?:^|.*;\\s*)autoplay\\s*\\=\\s*([^;]*).*$)|^.*$'
+ ),'$1');
+ }
+
+ let autoplayEnabled = 0;
+ if(cookieValue.length === 0){
+ autoplayEnabled = 0;
+ } else {
+ autoplayEnabled = Number(cookieValue);
+ }
+
+ // check the checkbox if autoplay is on
+ let checkbox = document.querySelector('.autoplay-toggle');
+ if(autoplayEnabled){
+ checkbox.checked = true;
+ }
+
+ // listen for checkbox to turn autoplay on and off
+ let cookie = 'autoplay'
+ if (isPlaylist)
+ cookie += '_' + playlist_id;
+
+ checkbox.addEventListener( 'change', function() {
+ if(this.checked) {
+ autoplayEnabled = 1;
+ document.cookie = cookie + '=1; SameSite=Strict';
+ } else {
+ autoplayEnabled = 0;
+ document.cookie = cookie + '=0; SameSite=Strict';
+ }
+ });
+
+ if(!playability_error){
+ // play the video if autoplay is on
+ if(autoplayEnabled){
+ video.play();
+ }
+ }
+
+ // determine next video url
+ let nextVideoUrl;
+ if (isPlaylist) {
+ let currentIndex = data.playlist['current_index'];
+ if (data.playlist['current_index']+1 == data.playlist['items'].length)
+ nextVideoUrl = null;
+ else
+ nextVideoUrl = data.playlist['items'][data.playlist['current_index']+1]['url'];
+
+ // scroll playlist to proper position
+ // item height + gap == 100
+ let pl = document.querySelector('.playlist-videos');
+ pl.scrollTop = 100*currentIndex;
+ } else {
+ if (data.related.length === 0)
+ nextVideoUrl = null;
+ else
+ nextVideoUrl = data.related[0]['url'];
+ }
+ let nextVideoDelay = 1000;
+
+ // go to next video when video ends
+ // https://stackoverflow.com/a/2880950
+ if (nextVideoUrl) {
+ if(playability_error){
+ videoEnded();
+ } else {
+ video.addEventListener('ended', videoEnded, false);
+ }
+ function nextVideo(){
+ if(autoplayEnabled){
+ window.location.href = nextVideoUrl;
+ }
+ }
+ function videoEnded(e) {
+ window.setTimeout(nextVideo, nextVideoDelay);
+ }
+ }
+}
diff --git a/youtube/static/license.css b/youtube/static/license.css
new file mode 100644
index 0000000..a86780c
--- /dev/null
+++ b/youtube/static/license.css
@@ -0,0 +1,293 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ border-radius: 5px;
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ margin: 0 auto;
+ max-width: 80ch;
+}
+
+.code-error {
+ background: var(--secondary-background);
+ padding: 1rem;
+}
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+/* ---- ---- Table ---- ---- */
+.table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.table caption {
+ margin: 1rem 0;
+ width: 100%;
+}
+
+.table td,.table th {
+ padding: 10px 10px;
+ border: 1px solid var(--border-bg-license);
+ text-align: center;
+}
+
+.table th {
+ background-color: var(--secondary-background);
+ color: var(--text);
+}
+
+.table tbody tr:nth-child(even) {
+ background-color: var(--secondary-focus);
+}
+
+.table tbody tr:nth-child(2n+1) {
+ background-color: var(--primary-background);
+}
+
+.table thead tr th:nth-last-child(1) {
+ padding: 0;
+}
+
+.table tbody tr td:nth-last-child(1) > button {
+ color: var(--text);
+ width: 40px;
+ height: 30px;
+ background-color: var(--secondary-background);
+ border: 1px solid var(--secondary-background);
+ cursor: pointer;
+}
+
+.table tbody tr td:nth-last-child(1) > a {
+ color: var(--text);
+ cursor: pointer;
+ padding: 2px 10px;
+}
+
+/* ---- ---- End table ---- ---- */
+
+/* ---- Table responsive ---- */
+@media (max-width: 580px) {
+ .table thead {
+ display: none;
+ }
+
+ .table tr{
+ margin-bottom:15px;
+ }
+
+ .table,
+ .table tbody,
+ .table tr,
+ .table td {
+ display: block;
+ width: auto;
+ text-align: justify;
+ }
+
+ .table td::before {
+ content: attr(data-label) ": ";
+ font-weight: bold;
+ }
+}
+/* End table responsive */
+
+@media (min-width: 780px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: minmax(50px, 120px);
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ z-index: 1;
+ position: absolute;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ padding: 0rem 3rem 1rem 1rem;
+ width: 100%;
+ max-height: 45vh;
+ overflow-y: scroll;
+ }
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/light_theme.css b/youtube/static/light_theme.css
index 9e37449..9dab076 100644
--- a/youtube/static/light_theme.css
+++ b/youtube/static/light_theme.css
@@ -1,18 +1,22 @@
-body{
- --interface-color: #ffffff;
- --text-color: #222222;
- --background-color: #f8f8f8;
+:root {
+ --background: #FFFFFF;
+ --text: #212121;
+ --secondary-hover: #212121;
+ --secondary-focus: #FAFAFA;
+ --secondary-inverse: #FFFFFF;
+ --primary-background: #F5F5F5;
+ --secondary-background: #EEEEEE;
+ --thumb-background: #F5F5F5;
+ --link: #212121;
+ --link-visited: #808080;
+ --border-bg: #212121;
+ --border-bg-settings: #91918C;
+ --border-bg-license: #91918C;
+ --buttom: #FFFFFF;
+ --buttom-text: #212121;
+ --button-border: #91918C;
+ --buttom-hover: #BBBBBB;
+ --search-text: #212121;
+ --time-background: #212121;
+ --time-text: #FFFFFF;
}
-
-.comment .permalink{
- color: #000000;
-}
-
-.setting-item{
- background-color: #f8f8f8;
-}
-
-.muted{
- background-color: #888888;
-}
-
diff --git a/youtube/static/local_playlist.css b/youtube/static/local_playlist.css
new file mode 100644
index 0000000..dedd2dd
--- /dev/null
+++ b/youtube/static/local_playlist.css
@@ -0,0 +1,538 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* playlist */
+.playlist {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "play-box"
+ "play-hidden"
+ "play-add"
+ "play-clean";
+ grid-area: playlist;
+}
+.play-box {
+ grid-area: play-box;
+}
+
+.play-hidden {
+ grid-area: play-hidden;
+}
+
+.play-add {
+ grid-area: play-add;
+ cursor: pointer;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-add:hover {
+ background-color: var(--buttom-hover);
+}
+
+.play-clean {
+ display: grid;
+ grid-area: play-clean;
+}
+
+.play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-clean > button:hover {
+ background-color: var(--buttom-hover);
+}
+/* /playlist */
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.playlist-metadata {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+ grid-template-areas:
+ "play-title"
+ "play-action";
+}
+.play-title {
+ grid-area: play-title;
+ text-align: center;
+}
+.play-action {
+ grid-area: play-action;
+}
+
+/* Video list item */
+.video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.item-box {
+ display: grid;
+ grid-template-columns: 1.9fr 0.1fr;
+ grid-template-rows: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "item-video item-checkbox";
+}
+
+.item-video {
+ grid-area: item-video;
+
+ display: grid;
+ grid-template-columns: auto;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 0.4rem;
+ grid-template-areas:
+ "thumbnail-box"
+ "info-box";
+ align-items: center;
+
+ font-size: 0.7rem;
+}
+
+.item-video a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.thumbnail-box {
+ grid-area: thumbnail-box;
+ position: relative;
+}
+
+.thumbnail {
+ padding: 28.125%;
+ position: relative;
+ box-sizing: border-box;
+}
+
+.thumbnail-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ object-fit: cover;
+ background-color: var(--thumb-background);
+}
+
+.length {
+ position: absolute;
+ background-color: rgba(35, 35, 35, 0.75);
+ color: #fff;
+ border-radius: 2px;
+ padding: 2px;
+ font-size: 16px;
+ right: 0.25em;
+ bottom: -0.75em;
+}
+
+.playlist-item .thumbnail-info {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ height: 100%;
+ width: 50%;
+ text-align: center;
+ white-space: pre-line;
+ opacity: .8;
+ color: var(--text);
+ font-size: 0.8125rem;
+ background: var(--secondary-background);
+ padding: 0;
+}
+
+.playlist-item .thumbnail-info span {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-transform: none;
+}
+
+.info-box {
+ grid-area: info-box;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto auto auto;
+ grid-gap: 1px;
+ grid-template-areas:
+ "."
+ "."
+ "."
+ "."
+ ".";
+}
+
+.title {
+ font-size: 0.8rem;
+ margin: 0px;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info-box address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.thumbnail-info {
+ background-color: var(--time-background);
+ color: #fff;
+ padding: 2px 5px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 12px;
+ position: absolute;
+ right: 0;
+ bottom: .2rem;
+}
+
+.item-checkbox {
+ grid-area: item-checkbox;
+ justify-self: start;
+ align-self: center;
+ min-width: 30px;
+ margin: 0px;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-between;
+}
+
+.horizontal-stats {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.horizontal-stats > li {
+ display: inline;
+}
+
+.horizontal-stats > li:first-child::after {
+ content: " | ";
+}
+
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ margin: 0.2rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+/* /video list item */
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 600px) {
+ .video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ .playlist-metadata {
+ max-width: 50vw;
+ }
+
+ /* playlist */
+ .playlist {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". play-box play-add play-clean";
+ grid-area: playlist;
+ }
+ .play-clean {
+ grid-template-columns: 100px auto;
+ }
+ .play-clean > button {
+ padding-left: 0px;
+ padding-right: 0px;
+ padding-bottom: 6px;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .video-container {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ grid-row-gap: 1rem;
+ grid-column-gap: 1rem;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/message_box.css b/youtube/static/message_box.css
new file mode 100644
index 0000000..72f4453
--- /dev/null
+++ b/youtube/static/message_box.css
@@ -0,0 +1,12 @@
+#message-box {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ border-style: outset;
+ padding: 20px;
+ background-color: var(--background);
+ opacity: 0;
+ transition-property: opacity;
+ transition-duration: 0.3s;
+}
diff --git a/youtube/static/modules/plyr/blank.webm b/youtube/static/modules/plyr/blank.webm
new file mode 100644
index 0000000..6ec17ef
--- /dev/null
+++ b/youtube/static/modules/plyr/blank.webm
Binary files differ
diff --git a/youtube/static/modules/plyr/build-instructions.md b/youtube/static/modules/plyr/build-instructions.md
new file mode 100644
index 0000000..4323281
--- /dev/null
+++ b/youtube/static/modules/plyr/build-instructions.md
@@ -0,0 +1,23 @@
+# Build steps for Plyr (3.6.8)
+
+Tested on Hyperbola GNU with Linux-libre.
+
+First install npm (node package manager).
+
+Clone the repo to a location of your choosing:
+```
+git clone https://git.sr.ht/~heckyel/plyr
+cd plyr
+```
+
+Install Plyr's dependencies:
+```
+npm install
+```
+
+Build with npm:
+```
+npm run build
+```
+
+plyr.js and other files will be in the `dist` directory.
diff --git a/youtube/static/modules/plyr/custom_plyr.css b/youtube/static/modules/plyr/custom_plyr.css
new file mode 100644
index 0000000..7a9f0f3
--- /dev/null
+++ b/youtube/static/modules/plyr/custom_plyr.css
@@ -0,0 +1,77 @@
+/* Prevent this div from blocking right-click menu for video
+e.g. Firefox playback speed options */
+.plyr__poster {
+ display: none;
+}
+
+/* plyr fix */
+.plyr:-moz-full-screen video {
+ max-height: initial;
+}
+
+.plyr:-webkit-full-screen video {
+ max-height: initial;
+}
+
+.plyr:-ms-fullscreen video {
+ max-height: initial;
+}
+
+.plyr:fullscreen video {
+ max-height: initial;
+}
+
+.plyr__preview-thumb__image-container {
+ width: 158px;
+ height: 90px;
+}
+
+.plyr__preview-thumb {
+ bottom: 100%;
+}
+
+.plyr__menu__container [role="menu"],
+.plyr__menu__container [role="menucaptions"] {
+ /* Set vertical scroll */
+ /* issue https://github.com/sampotts/plyr/issues/1420 */
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+/*
+* Custom styles similar to youtube
+*/
+.plyr__controls {
+ display: flex;
+ justify-content: center;
+}
+
+.plyr__progress__container {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ margin-bottom: -10px;
+}
+
+.plyr__controls .plyr__controls__item:first-child {
+ margin-left: 0;
+ margin-right: 0;
+ z-index: 5;
+}
+
+.plyr__controls .plyr__controls__item.plyr__volume {
+ margin-left: auto;
+}
+
+.plyr__controls .plyr__controls__item.plyr__progress__container {
+ padding-left: 10px;
+ padding-right: 10px;
+}
+
+.plyr__progress input[type="range"] {
+ margin-bottom: 50px;
+}
+
+/*
+* End custom styles
+*/
diff --git a/youtube/static/modules/plyr/plyr.css b/youtube/static/modules/plyr/plyr.css
new file mode 100644
index 0000000..7d98f20
--- /dev/null
+++ b/youtube/static/modules/plyr/plyr.css
@@ -0,0 +1 @@
+@keyframes plyr-progress{to{background-position:25px 0;background-position:var(--plyr-progress-loading-size,25px) 0}}@keyframes plyr-popup{0%{opacity:.5;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}@keyframes plyr-fade-in{from{opacity:0}to{opacity:1}}.plyr{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;align-items:center;direction:ltr;display:flex;flex-direction:column;font-family:inherit;font-family:var(--plyr-font-family,inherit);font-variant-numeric:tabular-nums;font-weight:400;font-weight:var(--plyr-font-weight-regular,400);height:100%;line-height:1.7;line-height:var(--plyr-line-height,1.7);max-width:100%;min-width:200px;position:relative;text-shadow:none;transition:box-shadow .3s ease;z-index:0}.plyr audio,.plyr iframe,.plyr video{display:block;height:100%;width:100%}.plyr button{font:inherit;line-height:inherit;width:auto}.plyr:focus{outline:0}.plyr--full-ui{box-sizing:border-box}.plyr--full-ui *,.plyr--full-ui ::after,.plyr--full-ui ::before{box-sizing:inherit}.plyr--full-ui a,.plyr--full-ui button,.plyr--full-ui input,.plyr--full-ui label{touch-action:manipulation}.plyr__badge{background:#4a5464;background:var(--plyr-badge-background,#4a5464);border-radius:2px;border-radius:var(--plyr-badge-border-radius,2px);color:#fff;color:var(--plyr-badge-text-color,#fff);font-size:9px;font-size:var(--plyr-font-size-badge,9px);line-height:1;padding:3px 4px}.plyr--full-ui ::-webkit-media-text-track-container{display:none}.plyr__captions{animation:plyr-fade-in .3s ease;bottom:0;display:none;font-size:13px;font-size:var(--plyr-font-size-small,13px);left:0;padding:10px;padding:var(--plyr-control-spacing,10px);position:absolute;text-align:center;transition:transform .4s ease-in-out;width:100%}.plyr__captions span:empty{display:none}@media (min-width:480px){.plyr__captions{font-size:15px;font-size:var(--plyr-font-size-base,15px);padding:calc(10px * 2);padding:calc(var(--plyr-control-spacing,10px) * 2)}}@media (min-width:768px){.plyr__captions{font-size:18px;font-size:var(--plyr-font-size-large,18px)}}.plyr--captions-active .plyr__captions{display:block}.plyr:not(.plyr--hide-controls) .plyr__controls:not(:empty)~.plyr__captions{transform:translateY(calc(10px * -4));transform:translateY(calc(var(--plyr-control-spacing,10px) * -4))}.plyr__caption{background:rgba(0,0,0,.8);background:var(--plyr-captions-background,rgba(0,0,0,.8));border-radius:2px;-webkit-box-decoration-break:clone;box-decoration-break:clone;color:#fff;color:var(--plyr-captions-text-color,#fff);line-height:185%;padding:.2em .5em;white-space:pre-wrap}.plyr__caption div{display:inline}.plyr__control{background:0 0;border:0;border-radius:3px;border-radius:var(--plyr-control-radius,3px);color:inherit;cursor:pointer;flex-shrink:0;overflow:visible;padding:calc(10px * .7);padding:calc(var(--plyr-control-spacing,10px) * .7);position:relative;transition:all .3s ease}.plyr__control svg{display:block;fill:currentColor;height:18px;height:var(--plyr-control-icon-size,18px);pointer-events:none;width:18px;width:var(--plyr-control-icon-size,18px)}.plyr__control:focus{outline:0}.plyr__control.plyr__tab-focus{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}a.plyr__control{text-decoration:none}a.plyr__control::after,a.plyr__control::before{display:none}.plyr__control.plyr__control--pressed .icon--not-pressed,.plyr__control.plyr__control--pressed .label--not-pressed,.plyr__control:not(.plyr__control--pressed) .icon--pressed,.plyr__control:not(.plyr__control--pressed) .label--pressed{display:none}.plyr--full-ui ::-webkit-media-controls{display:none}.plyr__controls{align-items:center;display:flex;justify-content:flex-end;text-align:center}.plyr__controls .plyr__progress__container{flex:1;min-width:0}.plyr__controls .plyr__controls__item{margin-left:calc(10px / 4);margin-left:calc(var(--plyr-control-spacing,10px)/ 4)}.plyr__controls .plyr__controls__item:first-child{margin-left:0;margin-right:auto}.plyr__controls .plyr__controls__item.plyr__progress__container{padding-left:calc(10px / 4);padding-left:calc(var(--plyr-control-spacing,10px)/ 4)}.plyr__controls .plyr__controls__item.plyr__time{padding:0 calc(10px / 2);padding:0 calc(var(--plyr-control-spacing,10px)/ 2)}.plyr__controls .plyr__controls__item.plyr__progress__container:first-child,.plyr__controls .plyr__controls__item.plyr__time+.plyr__time,.plyr__controls .plyr__controls__item.plyr__time:first-child{padding-left:0}.plyr__controls:empty{display:none}.plyr [data-plyr=airplay],.plyr [data-plyr=captions],.plyr [data-plyr=fullscreen],.plyr [data-plyr=pip]{display:none}.plyr--airplay-supported [data-plyr=airplay],.plyr--captions-enabled [data-plyr=captions],.plyr--fullscreen-enabled [data-plyr=fullscreen],.plyr--pip-supported [data-plyr=pip]{display:inline-block}.plyr__menu{display:flex;position:relative}.plyr__menu .plyr__control svg{transition:transform .3s ease}.plyr__menu .plyr__control[aria-expanded=true] svg{transform:rotate(90deg)}.plyr__menu .plyr__control[aria-expanded=true] .plyr__tooltip{display:none}.plyr__menu__container{animation:plyr-popup .2s ease;background:rgba(255,255,255,.9);background:var(--plyr-menu-background,rgba(255,255,255,.9));border-radius:4px;bottom:100%;box-shadow:0 1px 2px rgba(0,0,0,.15);box-shadow:var(--plyr-menu-shadow,0 1px 2px rgba(0,0,0,.15));color:#4a5464;color:var(--plyr-menu-color,#4a5464);font-size:15px;font-size:var(--plyr-font-size-base,15px);margin-bottom:10px;position:absolute;right:-3px;text-align:left;white-space:nowrap;z-index:3}.plyr__menu__container>div{overflow:hidden;transition:height .35s cubic-bezier(.4,0,.2,1),width .35s cubic-bezier(.4,0,.2,1)}.plyr__menu__container::after{border:4px solid transparent;border:var(--plyr-menu-arrow-size,4px) solid transparent;border-top-color:rgba(255,255,255,.9);border-top-color:var(--plyr-menu-background,rgba(255,255,255,.9));content:'';height:0;position:absolute;right:calc(((18px / 2) + calc(10px * .7)) - (4px / 2));right:calc(((var(--plyr-control-icon-size,18px)/ 2) + calc(var(--plyr-control-spacing,10px) * .7)) - (var(--plyr-menu-arrow-size,4px)/ 2));top:100%;width:0}.plyr__menu__container [role=menu]{padding:calc(10px * .7);padding:calc(var(--plyr-control-spacing,10px) * .7)}.plyr__menu__container [role=menucaptions]{padding:calc(10px * .7);padding:calc(var(--plyr-control-spacing,10px) * .7);max-height:320px;overflow-y:auto}.plyr__menu__container [role=menuitem],.plyr__menu__container [role=menuitemradio]{margin-top:2px}.plyr__menu__container [role=menuitem]:first-child,.plyr__menu__container [role=menuitemradio]:first-child{margin-top:0}.plyr__menu__container .plyr__control{align-items:center;color:#4a5464;color:var(--plyr-menu-color,#4a5464);display:flex;font-size:13px;font-size:var(--plyr-font-size-menu,var(--plyr-font-size-small,13px));padding-bottom:calc(calc(10px * .7)/ 1.5);padding-bottom:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 1.5);padding-left:calc(calc(10px * .7) * 1.5);padding-left:calc(calc(var(--plyr-control-spacing,10px) * .7) * 1.5);padding-right:calc(calc(10px * .7) * 1.5);padding-right:calc(calc(var(--plyr-control-spacing,10px) * .7) * 1.5);padding-top:calc(calc(10px * .7)/ 1.5);padding-top:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 1.5);-webkit-user-select:none;-ms-user-select:none;user-select:none;width:100%}.plyr__menu__container .plyr__control>span{align-items:inherit;display:flex;width:100%}.plyr__menu__container .plyr__control::after{border:4px solid transparent;border:var(--plyr-menu-item-arrow-size,4px) solid transparent;content:'';position:absolute;top:50%;transform:translateY(-50%)}.plyr__menu__container .plyr__control--forward{padding-right:calc(calc(10px * .7) * 4);padding-right:calc(calc(var(--plyr-control-spacing,10px) * .7) * 4)}.plyr__menu__container .plyr__control--forward::after{border-left-color:#728197;border-left-color:var(--plyr-menu-arrow-color,#728197);right:calc((calc(10px * .7) * 1.5) - 4px);right:calc((calc(var(--plyr-control-spacing,10px) * .7) * 1.5) - var(--plyr-menu-item-arrow-size,4px))}.plyr__menu__container .plyr__control--forward.plyr__tab-focus::after,.plyr__menu__container .plyr__control--forward:hover::after{border-left-color:currentColor}.plyr__menu__container .plyr__control--back{font-weight:400;font-weight:var(--plyr-font-weight-regular,400);margin:calc(10px * .7);margin:calc(var(--plyr-control-spacing,10px) * .7);margin-bottom:calc(calc(10px * .7)/ 2);margin-bottom:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 2);padding-left:calc(calc(10px * .7) * 4);padding-left:calc(calc(var(--plyr-control-spacing,10px) * .7) * 4);position:relative;width:calc(100% - (calc(10px * .7) * 2));width:calc(100% - (calc(var(--plyr-control-spacing,10px) * .7) * 2))}.plyr__menu__container .plyr__control--back::after{border-right-color:#728197;border-right-color:var(--plyr-menu-arrow-color,#728197);left:calc((calc(10px * .7) * 1.5) - 4px);left:calc((calc(var(--plyr-control-spacing,10px) * .7) * 1.5) - var(--plyr-menu-item-arrow-size,4px))}.plyr__menu__container .plyr__control--back::before{background:#dcdfe5;background:var(--plyr-menu-back-border-color,#dcdfe5);box-shadow:0 1px 0 #fff;box-shadow:0 1px 0 var(--plyr-menu-back-border-shadow-color,#fff);content:'';height:1px;left:0;margin-top:calc(calc(10px * .7)/ 2);margin-top:calc(calc(var(--plyr-control-spacing,10px) * .7)/ 2);overflow:hidden;position:absolute;right:0;top:100%}.plyr__menu__container .plyr__control--back.plyr__tab-focus::after,.plyr__menu__container .plyr__control--back:hover::after{border-right-color:currentColor}.plyr__menu__container .plyr__control[role=menuitemradio]{padding-left:calc(10px * .7);padding-left:calc(var(--plyr-control-spacing,10px) * .7)}.plyr__menu__container .plyr__control[role=menuitemradio]::after,.plyr__menu__container .plyr__control[role=menuitemradio]::before{border-radius:100%}.plyr__menu__container .plyr__control[role=menuitemradio]::before{background:rgba(0,0,0,.1);content:'';display:block;flex-shrink:0;height:16px;margin-right:10px;margin-right:var(--plyr-control-spacing,10px);transition:all .3s ease;width:16px}.plyr__menu__container .plyr__control[role=menuitemradio]::after{background:#fff;border:0;height:6px;left:12px;opacity:0;top:50%;transform:translateY(-50%) scale(0);transition:transform .3s ease,opacity .3s ease;width:6px}.plyr__menu__container .plyr__control[role=menuitemradio][aria-checked=true]::before{background:#00b3ff;background:var(--plyr-control-toggle-checked-background,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)))}.plyr__menu__container .plyr__control[role=menuitemradio][aria-checked=true]::after{opacity:1;transform:translateY(-50%) scale(1)}.plyr__menu__container .plyr__control[role=menuitemradio].plyr__tab-focus::before,.plyr__menu__container .plyr__control[role=menuitemradio]:hover::before{background:rgba(35,40,47,.1)}.plyr__menu__container .plyr__menu__value{align-items:center;display:flex;margin-left:auto;margin-right:calc((calc(10px * .7) - 2) * -1);margin-right:calc((calc(var(--plyr-control-spacing,10px) * .7) - 2) * -1);overflow:hidden;padding-left:calc(calc(10px * .7) * 3.5);padding-left:calc(calc(var(--plyr-control-spacing,10px) * .7) * 3.5);pointer-events:none}.plyr--full-ui input[type=range]{-webkit-appearance:none;background:0 0;border:0;border-radius:calc(13px * 2);border-radius:calc(var(--plyr-range-thumb-height,13px) * 2);color:#00b3ff;color:var(--plyr-range-fill-background,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));display:block;height:calc((3px * 2) + 13px);height:calc((var(--plyr-range-thumb-active-shadow-width,3px) * 2) + var(--plyr-range-thumb-height,13px));margin:0;padding:0;transition:box-shadow .3s ease;width:100%}.plyr--full-ui input[type=range]::-webkit-slider-runnable-track{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-webkit-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-webkit-user-select:none;user-select:none;background-image:linear-gradient(to right,currentColor 0,transparent 0);background-image:linear-gradient(to right,currentColor var(--value,0),transparent var(--value,0))}.plyr--full-ui input[type=range]::-webkit-slider-thumb{background:#fff;background:var(--plyr-range-thumb-background,#fff);border:0;border-radius:100%;box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2));height:13px;height:var(--plyr-range-thumb-height,13px);position:relative;-webkit-transition:all .2s ease;transition:all .2s ease;width:13px;width:var(--plyr-range-thumb-height,13px);-webkit-appearance:none;margin-top:calc(((13px - 5px)/ 2) * -1);margin-top:calc(((var(--plyr-range-thumb-height,13px) - var(--plyr-range-track-height,5px))/ 2) * -1)}.plyr--full-ui input[type=range]::-moz-range-track{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-moz-transition:box-shadow .3s ease;transition:box-shadow .3s ease;user-select:none}.plyr--full-ui input[type=range]::-moz-range-thumb{background:#fff;background:var(--plyr-range-thumb-background,#fff);border:0;border-radius:100%;box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2));height:13px;height:var(--plyr-range-thumb-height,13px);position:relative;-moz-transition:all .2s ease;transition:all .2s ease;width:13px;width:var(--plyr-range-thumb-height,13px)}.plyr--full-ui input[type=range]::-moz-range-progress{background:currentColor;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px)}.plyr--full-ui input[type=range]::-ms-track{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-ms-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-ms-user-select:none;user-select:none;color:transparent}.plyr--full-ui input[type=range]::-ms-fill-upper{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-ms-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-ms-user-select:none;user-select:none}.plyr--full-ui input[type=range]::-ms-fill-lower{background:0 0;border:0;border-radius:calc(5px / 2);border-radius:calc(var(--plyr-range-track-height,5px)/ 2);height:5px;height:var(--plyr-range-track-height,5px);-ms-transition:box-shadow .3s ease;transition:box-shadow .3s ease;-ms-user-select:none;user-select:none;background:currentColor}.plyr--full-ui input[type=range]::-ms-thumb{background:#fff;background:var(--plyr-range-thumb-background,#fff);border:0;border-radius:100%;box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2));height:13px;height:var(--plyr-range-thumb-height,13px);position:relative;-ms-transition:all .2s ease;transition:all .2s ease;width:13px;width:var(--plyr-range-thumb-height,13px);margin-top:0}.plyr--full-ui input[type=range]::-ms-tooltip{display:none}.plyr--full-ui input[type=range]:focus{outline:0}.plyr--full-ui input[type=range]::-moz-focus-outer{border:0}.plyr--full-ui input[type=range].plyr__tab-focus::-webkit-slider-runnable-track{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}.plyr--full-ui input[type=range].plyr__tab-focus::-moz-range-track{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}.plyr--full-ui input[type=range].plyr__tab-focus::-ms-track{outline-color:#00b3ff;outline-color:var(--plyr-tab-focus-color,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));outline-offset:2px;outline-style:dotted;outline-width:3px}.plyr__poster{background-color:#000;background-position:50% 50%;background-repeat:no-repeat;background-size:contain;height:100%;left:0;opacity:0;position:absolute;top:0;transition:opacity .2s ease;width:100%;z-index:1}.plyr--stopped.plyr__poster-enabled .plyr__poster{opacity:1}.plyr__time{font-size:13px;font-size:var(--plyr-font-size-time,var(--plyr-font-size-small,13px))}.plyr__time+.plyr__time::before{content:'\2044';margin-right:10px;margin-right:var(--plyr-control-spacing,10px)}@media (max-width:calc(768px - 1)){.plyr__time+.plyr__time{display:none}}.plyr__tooltip{background:rgba(255,255,255,.9);background:var(--plyr-tooltip-background,rgba(255,255,255,.9));border-radius:3px;border-radius:var(--plyr-tooltip-radius,3px);bottom:100%;box-shadow:0 1px 2px rgba(0,0,0,.15);box-shadow:var(--plyr-tooltip-shadow,0 1px 2px rgba(0,0,0,.15));color:#4a5464;color:var(--plyr-tooltip-color,#4a5464);font-size:13px;font-size:var(--plyr-font-size-small,13px);font-weight:400;font-weight:var(--plyr-font-weight-regular,400);left:50%;line-height:1.3;margin-bottom:calc(calc(10px / 2) * 2);margin-bottom:calc(calc(var(--plyr-control-spacing,10px)/ 2) * 2);opacity:0;padding:calc(10px / 2) calc(calc(10px / 2) * 1.5);padding:calc(var(--plyr-control-spacing,10px)/ 2) calc(calc(var(--plyr-control-spacing,10px)/ 2) * 1.5);pointer-events:none;position:absolute;transform:translate(-50%,10px) scale(.8);transform-origin:50% 100%;transition:transform .2s .1s ease,opacity .2s .1s ease;white-space:nowrap;z-index:2}.plyr__tooltip::before{border-left:4px solid transparent;border-left:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-right:4px solid transparent;border-right:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-top:4px solid rgba(255,255,255,.9);border-top:var(--plyr-tooltip-arrow-size,4px) solid var(--plyr-tooltip-background,rgba(255,255,255,.9));bottom:calc(4px * -1);bottom:calc(var(--plyr-tooltip-arrow-size,4px) * -1);content:'';height:0;left:50%;position:absolute;transform:translateX(-50%);width:0;z-index:2}.plyr .plyr__control.plyr__tab-focus .plyr__tooltip,.plyr .plyr__control:hover .plyr__tooltip,.plyr__tooltip--visible{opacity:1;transform:translate(-50%,0) scale(1)}.plyr .plyr__control:hover .plyr__tooltip{z-index:3}.plyr__controls>.plyr__control:first-child .plyr__tooltip,.plyr__controls>.plyr__control:first-child+.plyr__control .plyr__tooltip{left:0;transform:translate(0,10px) scale(.8);transform-origin:0 100%}.plyr__controls>.plyr__control:first-child .plyr__tooltip::before,.plyr__controls>.plyr__control:first-child+.plyr__control .plyr__tooltip::before{left:calc((18px / 2) + calc(10px * .7));left:calc((var(--plyr-control-icon-size,18px)/ 2) + calc(var(--plyr-control-spacing,10px) * .7))}.plyr__controls>.plyr__control:last-child .plyr__tooltip{left:auto;right:0;transform:translate(0,10px) scale(.8);transform-origin:100% 100%}.plyr__controls>.plyr__control:last-child .plyr__tooltip::before{left:auto;right:calc((18px / 2) + calc(10px * .7));right:calc((var(--plyr-control-icon-size,18px)/ 2) + calc(var(--plyr-control-spacing,10px) * .7));transform:translateX(50%)}.plyr__controls>.plyr__control:first-child .plyr__tooltip--visible,.plyr__controls>.plyr__control:first-child+.plyr__control .plyr__tooltip--visible,.plyr__controls>.plyr__control:first-child+.plyr__control.plyr__tab-focus .plyr__tooltip,.plyr__controls>.plyr__control:first-child+.plyr__control:hover .plyr__tooltip,.plyr__controls>.plyr__control:first-child.plyr__tab-focus .plyr__tooltip,.plyr__controls>.plyr__control:first-child:hover .plyr__tooltip,.plyr__controls>.plyr__control:last-child .plyr__tooltip--visible,.plyr__controls>.plyr__control:last-child.plyr__tab-focus .plyr__tooltip,.plyr__controls>.plyr__control:last-child:hover .plyr__tooltip{transform:translate(0,0) scale(1)}.plyr__progress{left:calc(13px * .5);left:calc(var(--plyr-range-thumb-height,13px) * .5);margin-right:13px;margin-right:var(--plyr-range-thumb-height,13px);position:relative}.plyr__progress input[type=range],.plyr__progress__buffer{margin-left:calc(13px * -.5);margin-left:calc(var(--plyr-range-thumb-height,13px) * -.5);margin-right:calc(13px * -.5);margin-right:calc(var(--plyr-range-thumb-height,13px) * -.5);width:calc(100% + 13px);width:calc(100% + var(--plyr-range-thumb-height,13px))}.plyr__progress input[type=range]{position:relative;z-index:2}.plyr__progress .plyr__tooltip{font-size:13px;font-size:var(--plyr-font-size-time,var(--plyr-font-size-small,13px));left:0}.plyr__progress__buffer{-webkit-appearance:none;background:0 0;border:0;border-radius:100px;height:5px;height:var(--plyr-range-track-height,5px);left:0;margin-top:calc((5px / 2) * -1);margin-top:calc((var(--plyr-range-track-height,5px)/ 2) * -1);padding:0;position:absolute;top:50%}.plyr__progress__buffer::-webkit-progress-bar{background:0 0}.plyr__progress__buffer::-webkit-progress-value{background:currentColor;border-radius:100px;min-width:5px;min-width:var(--plyr-range-track-height,5px);-webkit-transition:width .2s ease;transition:width .2s ease}.plyr__progress__buffer::-moz-progress-bar{background:currentColor;border-radius:100px;min-width:5px;min-width:var(--plyr-range-track-height,5px);-moz-transition:width .2s ease;transition:width .2s ease}.plyr__progress__buffer::-ms-fill{border-radius:100px;-ms-transition:width .2s ease;transition:width .2s ease}.plyr--loading .plyr__progress__buffer{animation:plyr-progress 1s linear infinite;background-image:linear-gradient(-45deg,rgba(35,40,47,.6) 25%,transparent 25%,transparent 50%,rgba(35,40,47,.6) 50%,rgba(35,40,47,.6) 75%,transparent 75%,transparent);background-image:linear-gradient(-45deg,var(--plyr-progress-loading-background,rgba(35,40,47,.6)) 25%,transparent 25%,transparent 50%,var(--plyr-progress-loading-background,rgba(35,40,47,.6)) 50%,var(--plyr-progress-loading-background,rgba(35,40,47,.6)) 75%,transparent 75%,transparent);background-repeat:repeat-x;background-size:25px 25px;background-size:var(--plyr-progress-loading-size,25px) var(--plyr-progress-loading-size,25px);color:transparent}.plyr--video.plyr--loading .plyr__progress__buffer{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25))}.plyr--audio.plyr--loading .plyr__progress__buffer{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6))}.plyr__volume{align-items:center;display:flex;max-width:110px;min-width:80px;position:relative;width:20%}.plyr__volume input[type=range]{margin-left:calc(10px / 2);margin-left:calc(var(--plyr-control-spacing,10px)/ 2);margin-right:calc(10px / 2);margin-right:calc(var(--plyr-control-spacing,10px)/ 2);position:relative;z-index:2}.plyr--is-ios .plyr__volume{min-width:0;width:auto}.plyr--audio{display:block}.plyr--audio .plyr__controls{background:#fff;background:var(--plyr-audio-controls-background,#fff);border-radius:inherit;color:#4a5464;color:var(--plyr-audio-control-color,#4a5464);padding:10px;padding:var(--plyr-control-spacing,10px)}.plyr--audio .plyr__control.plyr__tab-focus,.plyr--audio .plyr__control:hover,.plyr--audio .plyr__control[aria-expanded=true]{background:#00b3ff;background:var(--plyr-audio-control-background-hover,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));color:#fff;color:var(--plyr-audio-control-color-hover,#fff)}.plyr--full-ui.plyr--audio input[type=range]::-webkit-slider-runnable-track{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-range-track-background,var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6)))}.plyr--full-ui.plyr--audio input[type=range]::-moz-range-track{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-range-track-background,var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6)))}.plyr--full-ui.plyr--audio input[type=range]::-ms-track{background-color:rgba(193,200,209,.6);background-color:var(--plyr-audio-range-track-background,var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6)))}.plyr--full-ui.plyr--audio input[type=range]:active::-webkit-slider-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(35,40,47,.1);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(35,40,47,.1))}.plyr--full-ui.plyr--audio input[type=range]:active::-moz-range-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(35,40,47,.1);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(35,40,47,.1))}.plyr--full-ui.plyr--audio input[type=range]:active::-ms-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(35,40,47,.1);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(35,40,47,.1))}.plyr--audio .plyr__progress__buffer{color:rgba(193,200,209,.6);color:var(--plyr-audio-progress-buffered-background,rgba(193,200,209,.6))}.plyr--video{background:#000;overflow:hidden}.plyr--video.plyr--menu-open{overflow:visible}.plyr__video-wrapper{background:#000;height:100%;margin:auto;overflow:hidden;position:relative;width:100%}.plyr__video-embed,.plyr__video-wrapper--fixed-ratio{height:0;padding-bottom:56.25%}.plyr__video-embed iframe,.plyr__video-wrapper--fixed-ratio video{border:0;left:0;position:absolute;top:0}.plyr--full-ui .plyr__video-embed>.plyr__video-embed__container{padding-bottom:240%;position:relative;transform:translateY(-38.28125%)}.plyr--video .plyr__controls{background:linear-gradient(rgba(0,0,0,0),rgba(0,0,0,.75));background:var(--plyr-video-controls-background,linear-gradient(rgba(0,0,0,0),rgba(0,0,0,.75)));border-bottom-left-radius:inherit;border-bottom-right-radius:inherit;bottom:0;color:#fff;color:var(--plyr-video-control-color,#fff);left:0;padding:calc(10px / 2);padding:calc(var(--plyr-control-spacing,10px)/ 2);padding-top:calc(10px * 2);padding-top:calc(var(--plyr-control-spacing,10px) * 2);position:absolute;right:0;transition:opacity .4s ease-in-out,transform .4s ease-in-out;z-index:3}@media (min-width:480px){.plyr--video .plyr__controls{padding:10px;padding:var(--plyr-control-spacing,10px);padding-top:calc(10px * 3.5);padding-top:calc(var(--plyr-control-spacing,10px) * 3.5)}}.plyr--video.plyr--hide-controls .plyr__controls{opacity:0;pointer-events:none;transform:translateY(100%)}.plyr--video .plyr__control.plyr__tab-focus,.plyr--video .plyr__control:hover,.plyr--video .plyr__control[aria-expanded=true]{background:#00b3ff;background:var(--plyr-video-control-background-hover,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));color:#fff;color:var(--plyr-video-control-color-hover,#fff)}.plyr__control--overlaid{background:#00b3ff;background:var(--plyr-video-control-background-hover,var(--plyr-color-main,var(--plyr-color-main,#00b3ff)));border:0;border-radius:100%;color:#fff;color:var(--plyr-video-control-color,#fff);display:none;left:50%;opacity:.9;padding:calc(10px * 1.5);padding:calc(var(--plyr-control-spacing,10px) * 1.5);position:absolute;top:50%;transform:translate(-50%,-50%);transition:.3s;z-index:2}.plyr__control--overlaid svg{left:2px;position:relative}.plyr__control--overlaid:focus,.plyr__control--overlaid:hover{opacity:1}.plyr--playing .plyr__control--overlaid{opacity:0;visibility:hidden}.plyr--full-ui.plyr--video .plyr__control--overlaid{display:block}.plyr--full-ui.plyr--video input[type=range]::-webkit-slider-runnable-track{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-range-track-background,var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25)))}.plyr--full-ui.plyr--video input[type=range]::-moz-range-track{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-range-track-background,var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25)))}.plyr--full-ui.plyr--video input[type=range]::-ms-track{background-color:rgba(255,255,255,.25);background-color:var(--plyr-video-range-track-background,var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25)))}.plyr--full-ui.plyr--video input[type=range]:active::-webkit-slider-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(255,255,255,.5);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(255,255,255,.5))}.plyr--full-ui.plyr--video input[type=range]:active::-moz-range-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(255,255,255,.5);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(255,255,255,.5))}.plyr--full-ui.plyr--video input[type=range]:active::-ms-thumb{box-shadow:0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2),0 0 0 3px rgba(255,255,255,.5);box-shadow:var(--plyr-range-thumb-shadow,0 1px 1px rgba(35,40,47,.15),0 0 0 1px rgba(35,40,47,.2)),0 0 0 var(--plyr-range-thumb-active-shadow-width,3px) var(--plyr-audio-range-thumb-active-shadow-color,rgba(255,255,255,.5))}.plyr--video .plyr__progress__buffer{color:rgba(255,255,255,.25);color:var(--plyr-video-progress-buffered-background,rgba(255,255,255,.25))}.plyr:-webkit-full-screen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-ms-fullscreen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:fullscreen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-webkit-full-screen video{height:100%}.plyr:-ms-fullscreen video{height:100%}.plyr:fullscreen video{height:100%}.plyr:-webkit-full-screen .plyr__video-wrapper{height:100%;position:static}.plyr:-ms-fullscreen .plyr__video-wrapper{height:100%;position:static}.plyr:fullscreen .plyr__video-wrapper{height:100%;position:static}.plyr:-webkit-full-screen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-ms-fullscreen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:fullscreen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen{display:block}.plyr:fullscreen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:fullscreen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-webkit-full-screen.plyr--hide-controls{cursor:none}.plyr:-ms-fullscreen.plyr--hide-controls{cursor:none}.plyr:fullscreen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-webkit-full-screen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}.plyr:-ms-fullscreen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}.plyr:fullscreen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr:-webkit-full-screen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-webkit-full-screen video{height:100%}.plyr:-webkit-full-screen .plyr__video-wrapper{height:100%;position:static}.plyr:-webkit-full-screen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-webkit-full-screen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-webkit-full-screen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-webkit-full-screen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr:-moz-full-screen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-moz-full-screen video{height:100%}.plyr:-moz-full-screen .plyr__video-wrapper{height:100%;position:static}.plyr:-moz-full-screen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-moz-full-screen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-moz-full-screen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-moz-full-screen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-moz-full-screen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr:-ms-fullscreen{background:#000;border-radius:0!important;height:100%;margin:0;width:100%}.plyr:-ms-fullscreen video{height:100%}.plyr:-ms-fullscreen .plyr__video-wrapper{height:100%;position:static}.plyr:-ms-fullscreen.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen{display:block}.plyr:-ms-fullscreen .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr:-ms-fullscreen.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr:-ms-fullscreen .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr--fullscreen-fallback{background:#000;border-radius:0!important;height:100%;margin:0;width:100%;bottom:0;display:block;left:0;position:fixed;right:0;top:0;z-index:10000000}.plyr--fullscreen-fallback video{height:100%}.plyr--fullscreen-fallback .plyr__video-wrapper{height:100%;position:static}.plyr--fullscreen-fallback.plyr--vimeo .plyr__video-wrapper{height:0;position:relative}.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen{display:block}.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen+svg{display:none}.plyr--fullscreen-fallback.plyr--hide-controls{cursor:none}@media (min-width:1024px){.plyr--fullscreen-fallback .plyr__captions{font-size:21px;font-size:var(--plyr-font-size-xlarge,21px)}}.plyr__ads{border-radius:inherit;bottom:0;cursor:pointer;left:0;overflow:hidden;position:absolute;right:0;top:0;z-index:-1}.plyr__ads>div,.plyr__ads>div iframe{height:100%;position:absolute;width:100%}.plyr__ads::after{background:#23282f;border-radius:2px;bottom:10px;bottom:var(--plyr-control-spacing,10px);color:#fff;content:attr(data-badge-text);font-size:11px;padding:2px 6px;pointer-events:none;position:absolute;right:10px;right:var(--plyr-control-spacing,10px);z-index:3}.plyr__ads::after:empty{display:none}.plyr__cues{background:currentColor;display:block;height:5px;height:var(--plyr-range-track-height,5px);left:0;margin:-var(--plyr-range-track-height,5px)/2 0 0;opacity:.8;position:absolute;top:50%;width:3px;z-index:3}.plyr__preview-thumb{background-color:rgba(255,255,255,.9);background-color:var(--plyr-tooltip-background,rgba(255,255,255,.9));border-radius:3px;bottom:100%;box-shadow:0 1px 2px rgba(0,0,0,.15);box-shadow:var(--plyr-tooltip-shadow,0 1px 2px rgba(0,0,0,.15));margin-bottom:calc(calc(10px / 2) * 2);margin-bottom:calc(calc(var(--plyr-control-spacing,10px)/ 2) * 2);opacity:0;padding:3px;padding:var(--plyr-tooltip-radius,3px);pointer-events:none;position:absolute;transform:translate(0,10px) scale(.8);transform-origin:50% 100%;transition:transform .2s .1s ease,opacity .2s .1s ease;z-index:2}.plyr__preview-thumb--is-shown{opacity:1;transform:translate(0,0) scale(1)}.plyr__preview-thumb::before{border-left:4px solid transparent;border-left:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-right:4px solid transparent;border-right:var(--plyr-tooltip-arrow-size,4px) solid transparent;border-top:4px solid rgba(255,255,255,.9);border-top:var(--plyr-tooltip-arrow-size,4px) solid var(--plyr-tooltip-background,rgba(255,255,255,.9));bottom:calc(4px * -1);bottom:calc(var(--plyr-tooltip-arrow-size,4px) * -1);content:'';height:0;left:50%;position:absolute;transform:translateX(-50%);width:0;z-index:2}.plyr__preview-thumb__image-container{background:#c1c8d1;border-radius:calc(3px - 1px);border-radius:calc(var(--plyr-tooltip-radius,3px) - 1px);overflow:hidden;position:relative;z-index:0}.plyr__preview-thumb__image-container img{height:100%;left:0;max-height:none;max-width:none;position:absolute;top:0;width:100%}.plyr__preview-thumb__time-container{bottom:6px;left:0;position:absolute;right:0;white-space:nowrap;z-index:3}.plyr__preview-thumb__time-container span{background-color:rgba(0,0,0,.55);border-radius:calc(3px - 1px);border-radius:calc(var(--plyr-tooltip-radius,3px) - 1px);color:#fff;font-size:13px;font-size:var(--plyr-font-size-time,var(--plyr-font-size-small,13px));padding:3px 6px}.plyr__preview-scrubbing{bottom:0;filter:blur(1px);height:100%;left:0;margin:auto;opacity:0;overflow:hidden;pointer-events:none;position:absolute;right:0;top:0;transition:opacity .3s ease;width:100%;z-index:1}.plyr__preview-scrubbing--is-shown{opacity:1}.plyr__preview-scrubbing img{height:100%;left:0;max-height:none;max-width:none;object-fit:contain;position:absolute;top:0;width:100%}.plyr--no-transition{transition:none!important}.plyr__sr-only{clip:rect(1px,1px,1px,1px);overflow:hidden;border:0!important;height:1px!important;padding:0!important;position:absolute!important;width:1px!important}.plyr [hidden]{display:none!important} \ No newline at end of file
diff --git a/youtube/static/modules/plyr/plyr.js b/youtube/static/modules/plyr/plyr.js
new file mode 100644
index 0000000..d5cc84e
--- /dev/null
+++ b/youtube/static/modules/plyr/plyr.js
@@ -0,0 +1,9320 @@
+typeof navigator === "object" && (function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
+ typeof define === 'function' && define.amd ? define('Plyr', factory) :
+ (global = global || self, global.Plyr = factory());
+}(this, (function () { 'use strict';
+
+ function _classCallCheck(instance, Constructor) {
+ if (!(instance instanceof Constructor)) {
+ throw new TypeError("Cannot call a class as a function");
+ }
+ }
+
+ function _defineProperties(target, props) {
+ for (var i = 0; i < props.length; i++) {
+ var descriptor = props[i];
+ descriptor.enumerable = descriptor.enumerable || false;
+ descriptor.configurable = true;
+ if ("value" in descriptor) descriptor.writable = true;
+ Object.defineProperty(target, descriptor.key, descriptor);
+ }
+ }
+
+ function _createClass(Constructor, protoProps, staticProps) {
+ if (protoProps) _defineProperties(Constructor.prototype, protoProps);
+ if (staticProps) _defineProperties(Constructor, staticProps);
+ return Constructor;
+ }
+
+ function _defineProperty(obj, key, value) {
+ if (key in obj) {
+ Object.defineProperty(obj, key, {
+ value: value,
+ enumerable: true,
+ configurable: true,
+ writable: true
+ });
+ } else {
+ obj[key] = value;
+ }
+
+ return obj;
+ }
+
+ function ownKeys(object, enumerableOnly) {
+ var keys = Object.keys(object);
+
+ if (Object.getOwnPropertySymbols) {
+ var symbols = Object.getOwnPropertySymbols(object);
+ if (enumerableOnly) symbols = symbols.filter(function (sym) {
+ return Object.getOwnPropertyDescriptor(object, sym).enumerable;
+ });
+ keys.push.apply(keys, symbols);
+ }
+
+ return keys;
+ }
+
+ function _objectSpread2(target) {
+ for (var i = 1; i < arguments.length; i++) {
+ var source = arguments[i] != null ? arguments[i] : {};
+
+ if (i % 2) {
+ ownKeys(Object(source), true).forEach(function (key) {
+ _defineProperty(target, key, source[key]);
+ });
+ } else if (Object.getOwnPropertyDescriptors) {
+ Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
+ } else {
+ ownKeys(Object(source)).forEach(function (key) {
+ Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
+ });
+ }
+ }
+
+ return target;
+ }
+
+ function _objectWithoutPropertiesLoose(source, excluded) {
+ if (source == null) return {};
+ var target = {};
+ var sourceKeys = Object.keys(source);
+ var key, i;
+
+ for (i = 0; i < sourceKeys.length; i++) {
+ key = sourceKeys[i];
+ if (excluded.indexOf(key) >= 0) continue;
+ target[key] = source[key];
+ }
+
+ return target;
+ }
+
+ function _objectWithoutProperties(source, excluded) {
+ if (source == null) return {};
+
+ var target = _objectWithoutPropertiesLoose(source, excluded);
+
+ var key, i;
+
+ if (Object.getOwnPropertySymbols) {
+ var sourceSymbolKeys = Object.getOwnPropertySymbols(source);
+
+ for (i = 0; i < sourceSymbolKeys.length; i++) {
+ key = sourceSymbolKeys[i];
+ if (excluded.indexOf(key) >= 0) continue;
+ if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue;
+ target[key] = source[key];
+ }
+ }
+
+ return target;
+ }
+
+ function _slicedToArray(arr, i) {
+ return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest();
+ }
+
+ function _toConsumableArray(arr) {
+ return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread();
+ }
+
+ function _arrayWithoutHoles(arr) {
+ if (Array.isArray(arr)) return _arrayLikeToArray(arr);
+ }
+
+ function _arrayWithHoles(arr) {
+ if (Array.isArray(arr)) return arr;
+ }
+
+ function _iterableToArray(iter) {
+ if (typeof Symbol !== "undefined" && Symbol.iterator in Object(iter)) return Array.from(iter);
+ }
+
+ function _iterableToArrayLimit(arr, i) {
+ if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return;
+ var _arr = [];
+ var _n = true;
+ var _d = false;
+ var _e = undefined;
+
+ try {
+ for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) {
+ _arr.push(_s.value);
+
+ if (i && _arr.length === i) break;
+ }
+ } catch (err) {
+ _d = true;
+ _e = err;
+ } finally {
+ try {
+ if (!_n && _i["return"] != null) _i["return"]();
+ } finally {
+ if (_d) throw _e;
+ }
+ }
+
+ return _arr;
+ }
+
+ function _unsupportedIterableToArray(o, minLen) {
+ if (!o) return;
+ if (typeof o === "string") return _arrayLikeToArray(o, minLen);
+ var n = Object.prototype.toString.call(o).slice(8, -1);
+ if (n === "Object" && o.constructor) n = o.constructor.name;
+ if (n === "Map" || n === "Set") return Array.from(o);
+ if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen);
+ }
+
+ function _arrayLikeToArray(arr, len) {
+ if (len == null || len > arr.length) len = arr.length;
+
+ for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i];
+
+ return arr2;
+ }
+
+ function _nonIterableSpread() {
+ throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
+ }
+
+ function _nonIterableRest() {
+ throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
+ }
+
+ function _classCallCheck$1(e, t) {
+ if (!(e instanceof t)) throw new TypeError("Cannot call a class as a function");
+ }
+
+ function _defineProperties$1(e, t) {
+ for (var n = 0; n < t.length; n++) {
+ var r = t[n];
+ r.enumerable = r.enumerable || !1, r.configurable = !0, "value" in r && (r.writable = !0), Object.defineProperty(e, r.key, r);
+ }
+ }
+
+ function _createClass$1(e, t, n) {
+ return t && _defineProperties$1(e.prototype, t), n && _defineProperties$1(e, n), e;
+ }
+
+ function _defineProperty$1(e, t, n) {
+ return t in e ? Object.defineProperty(e, t, {
+ value: n,
+ enumerable: !0,
+ configurable: !0,
+ writable: !0
+ }) : e[t] = n, e;
+ }
+
+ function ownKeys$1(e, t) {
+ var n = Object.keys(e);
+
+ if (Object.getOwnPropertySymbols) {
+ var r = Object.getOwnPropertySymbols(e);
+ t && (r = r.filter(function (t) {
+ return Object.getOwnPropertyDescriptor(e, t).enumerable;
+ })), n.push.apply(n, r);
+ }
+
+ return n;
+ }
+
+ function _objectSpread2$1(e) {
+ for (var t = 1; t < arguments.length; t++) {
+ var n = null != arguments[t] ? arguments[t] : {};
+ t % 2 ? ownKeys$1(Object(n), !0).forEach(function (t) {
+ _defineProperty$1(e, t, n[t]);
+ }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) : ownKeys$1(Object(n)).forEach(function (t) {
+ Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t));
+ });
+ }
+
+ return e;
+ }
+
+ var defaults = {
+ addCSS: !0,
+ thumbWidth: 15,
+ watch: !0
+ };
+
+ function matches(e, t) {
+ return function () {
+ return Array.from(document.querySelectorAll(t)).includes(this);
+ }.call(e, t);
+ }
+
+ function trigger(e, t) {
+ if (e && t) {
+ var n = new Event(t, {
+ bubbles: !0
+ });
+ e.dispatchEvent(n);
+ }
+ }
+
+ var getConstructor = function getConstructor(e) {
+ return null != e ? e.constructor : null;
+ },
+ instanceOf = function instanceOf(e, t) {
+ return !!(e && t && e instanceof t);
+ },
+ isNullOrUndefined = function isNullOrUndefined(e) {
+ return null == e;
+ },
+ isObject = function isObject(e) {
+ return getConstructor(e) === Object;
+ },
+ isNumber = function isNumber(e) {
+ return getConstructor(e) === Number && !Number.isNaN(e);
+ },
+ isString = function isString(e) {
+ return getConstructor(e) === String;
+ },
+ isBoolean = function isBoolean(e) {
+ return getConstructor(e) === Boolean;
+ },
+ isFunction = function isFunction(e) {
+ return getConstructor(e) === Function;
+ },
+ isArray = function isArray(e) {
+ return Array.isArray(e);
+ },
+ isNodeList = function isNodeList(e) {
+ return instanceOf(e, NodeList);
+ },
+ isElement = function isElement(e) {
+ return instanceOf(e, Element);
+ },
+ isEvent = function isEvent(e) {
+ return instanceOf(e, Event);
+ },
+ isEmpty = function isEmpty(e) {
+ return isNullOrUndefined(e) || (isString(e) || isArray(e) || isNodeList(e)) && !e.length || isObject(e) && !Object.keys(e).length;
+ },
+ is = {
+ nullOrUndefined: isNullOrUndefined,
+ object: isObject,
+ number: isNumber,
+ string: isString,
+ boolean: isBoolean,
+ function: isFunction,
+ array: isArray,
+ nodeList: isNodeList,
+ element: isElement,
+ event: isEvent,
+ empty: isEmpty
+ };
+
+ function getDecimalPlaces(e) {
+ var t = "".concat(e).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);
+ return t ? Math.max(0, (t[1] ? t[1].length : 0) - (t[2] ? +t[2] : 0)) : 0;
+ }
+
+ function round(e, t) {
+ if (1 > t) {
+ var n = getDecimalPlaces(t);
+ return parseFloat(e.toFixed(n));
+ }
+
+ return Math.round(e / t) * t;
+ }
+
+ var RangeTouch = function () {
+ function e(t, n) {
+ _classCallCheck$1(this, e), is.element(t) ? this.element = t : is.string(t) && (this.element = document.querySelector(t)), is.element(this.element) && is.empty(this.element.rangeTouch) && (this.config = _objectSpread2$1({}, defaults, {}, n), this.init());
+ }
+
+ return _createClass$1(e, [{
+ key: "init",
+ value: function value() {
+ e.enabled && (this.config.addCSS && (this.element.style.userSelect = "none", this.element.style.webKitUserSelect = "none", this.element.style.touchAction = "manipulation"), this.listeners(!0), this.element.rangeTouch = this);
+ }
+ }, {
+ key: "destroy",
+ value: function value() {
+ e.enabled && (this.config.addCSS && (this.element.style.userSelect = "", this.element.style.webKitUserSelect = "", this.element.style.touchAction = ""), this.listeners(!1), this.element.rangeTouch = null);
+ }
+ }, {
+ key: "listeners",
+ value: function value(e) {
+ var t = this,
+ n = e ? "addEventListener" : "removeEventListener";
+ ["touchstart", "touchmove", "touchend"].forEach(function (e) {
+ t.element[n](e, function (e) {
+ return t.set(e);
+ }, !1);
+ });
+ }
+ }, {
+ key: "get",
+ value: function value(t) {
+ if (!e.enabled || !is.event(t)) return null;
+ var n,
+ r = t.target,
+ i = t.changedTouches[0],
+ o = parseFloat(r.getAttribute("min")) || 0,
+ s = parseFloat(r.getAttribute("max")) || 100,
+ u = parseFloat(r.getAttribute("step")) || 1,
+ c = r.getBoundingClientRect(),
+ a = 100 / c.width * (this.config.thumbWidth / 2) / 100;
+ return 0 > (n = 100 / c.width * (i.clientX - c.left)) ? n = 0 : 100 < n && (n = 100), 50 > n ? n -= (100 - 2 * n) * a : 50 < n && (n += 2 * (n - 50) * a), o + round(n / 100 * (s - o), u);
+ }
+ }, {
+ key: "set",
+ value: function value(t) {
+ e.enabled && is.event(t) && !t.target.disabled && (t.preventDefault(), t.target.value = this.get(t), trigger(t.target, "touchend" === t.type ? "change" : "input"));
+ }
+ }], [{
+ key: "setup",
+ value: function value(t) {
+ var n = 1 < arguments.length && void 0 !== arguments[1] ? arguments[1] : {},
+ r = null;
+ if (is.empty(t) || is.string(t) ? r = Array.from(document.querySelectorAll(is.string(t) ? t : 'input[type="range"]')) : is.element(t) ? r = [t] : is.nodeList(t) ? r = Array.from(t) : is.array(t) && (r = t.filter(is.element)), is.empty(r)) return null;
+
+ var i = _objectSpread2$1({}, defaults, {}, n);
+
+ if (is.string(t) && i.watch) {
+ var o = new MutationObserver(function (n) {
+ Array.from(n).forEach(function (n) {
+ Array.from(n.addedNodes).forEach(function (n) {
+ is.element(n) && matches(n, t) && new e(n, i);
+ });
+ });
+ });
+ o.observe(document.body, {
+ childList: !0,
+ subtree: !0
+ });
+ }
+
+ return r.map(function (t) {
+ return new e(t, n);
+ });
+ }
+ }, {
+ key: "enabled",
+ get: function get() {
+ return "ontouchstart" in document.documentElement;
+ }
+ }]), e;
+ }();
+
+ // ==========================================================================
+ // Type checking utils
+ // ==========================================================================
+ var getConstructor$1 = function getConstructor(input) {
+ return input !== null && typeof input !== 'undefined' ? input.constructor : null;
+ };
+
+ var instanceOf$1 = function instanceOf(input, constructor) {
+ return Boolean(input && constructor && input instanceof constructor);
+ };
+
+ var isNullOrUndefined$1 = function isNullOrUndefined(input) {
+ return input === null || typeof input === 'undefined';
+ };
+
+ var isObject$1 = function isObject(input) {
+ return getConstructor$1(input) === Object;
+ };
+
+ var isNumber$1 = function isNumber(input) {
+ return getConstructor$1(input) === Number && !Number.isNaN(input);
+ };
+
+ var isString$1 = function isString(input) {
+ return getConstructor$1(input) === String;
+ };
+
+ var isBoolean$1 = function isBoolean(input) {
+ return getConstructor$1(input) === Boolean;
+ };
+
+ var isFunction$1 = function isFunction(input) {
+ return getConstructor$1(input) === Function;
+ };
+
+ var isArray$1 = function isArray(input) {
+ return Array.isArray(input);
+ };
+
+ var isWeakMap = function isWeakMap(input) {
+ return instanceOf$1(input, WeakMap);
+ };
+
+ var isNodeList$1 = function isNodeList(input) {
+ return instanceOf$1(input, NodeList);
+ };
+
+ var isElement$1 = function isElement(input) {
+ return instanceOf$1(input, Element);
+ };
+
+ var isTextNode = function isTextNode(input) {
+ return getConstructor$1(input) === Text;
+ };
+
+ var isEvent$1 = function isEvent(input) {
+ return instanceOf$1(input, Event);
+ };
+
+ var isKeyboardEvent = function isKeyboardEvent(input) {
+ return instanceOf$1(input, KeyboardEvent);
+ };
+
+ var isCue = function isCue(input) {
+ return instanceOf$1(input, window.TextTrackCue) || instanceOf$1(input, window.VTTCue);
+ };
+
+ var isTrack = function isTrack(input) {
+ return instanceOf$1(input, TextTrack) || !isNullOrUndefined$1(input) && isString$1(input.kind);
+ };
+
+ var isPromise = function isPromise(input) {
+ return instanceOf$1(input, Promise) && isFunction$1(input.then);
+ };
+
+ var isEmpty$1 = function isEmpty(input) {
+ return isNullOrUndefined$1(input) || (isString$1(input) || isArray$1(input) || isNodeList$1(input)) && !input.length || isObject$1(input) && !Object.keys(input).length;
+ };
+
+ var isUrl = function isUrl(input) {
+ // Accept a URL object
+ if (instanceOf$1(input, window.URL)) {
+ return true;
+ } // Must be string from here
+
+
+ if (!isString$1(input)) {
+ return false;
+ } // Add the protocol if required
+
+
+ var string = input;
+
+ if (!input.startsWith('http://') || !input.startsWith('https://')) {
+ string = "http://".concat(input);
+ }
+
+ try {
+ return !isEmpty$1(new URL(string).hostname);
+ } catch (e) {
+ return false;
+ }
+ };
+
+ var is$1 = {
+ nullOrUndefined: isNullOrUndefined$1,
+ object: isObject$1,
+ number: isNumber$1,
+ string: isString$1,
+ boolean: isBoolean$1,
+ function: isFunction$1,
+ array: isArray$1,
+ weakMap: isWeakMap,
+ nodeList: isNodeList$1,
+ element: isElement$1,
+ textNode: isTextNode,
+ event: isEvent$1,
+ keyboardEvent: isKeyboardEvent,
+ cue: isCue,
+ track: isTrack,
+ promise: isPromise,
+ url: isUrl,
+ empty: isEmpty$1
+ };
+
+ // ==========================================================================
+ var transitionEndEvent = function () {
+ var element = document.createElement('span');
+ var events = {
+ WebkitTransition: 'webkitTransitionEnd',
+ MozTransition: 'transitionend',
+ OTransition: 'oTransitionEnd otransitionend',
+ transition: 'transitionend'
+ };
+ var type = Object.keys(events).find(function (event) {
+ return element.style[event] !== undefined;
+ });
+ return is$1.string(type) ? events[type] : false;
+ }(); // Force repaint of element
+
+ function repaint(element, delay) {
+ setTimeout(function () {
+ try {
+ // eslint-disable-next-line no-param-reassign
+ element.hidden = true; // eslint-disable-next-line no-unused-expressions
+
+ element.offsetHeight; // eslint-disable-next-line no-param-reassign
+
+ element.hidden = false;
+ } catch (e) {// Do nothing
+ }
+ }, delay);
+ }
+
+ // ==========================================================================
+ // Browser sniffing
+ // Unfortunately, due to mixed support, UA sniffing is required
+ // ==========================================================================
+ var browser = {
+ isIE:
+ /* @cc_on!@ */
+ !!document.documentMode,
+ isEdge: window.navigator.userAgent.includes('Edge'),
+ isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent),
+ isIPhone: /(iPhone|iPod)/gi.test(navigator.platform),
+ isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform)
+ };
+
+ function cloneDeep(object) {
+ return JSON.parse(JSON.stringify(object));
+ } // Get a nested value in an object
+
+ function getDeep(object, path) {
+ return path.split('.').reduce(function (obj, key) {
+ return obj && obj[key];
+ }, object);
+ } // Deep extend destination object with N more objects
+
+ function extend() {
+ var target = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+
+ for (var _len = arguments.length, sources = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ sources[_key - 1] = arguments[_key];
+ }
+
+ if (!sources.length) {
+ return target;
+ }
+
+ var source = sources.shift();
+
+ if (!is$1.object(source)) {
+ return target;
+ }
+
+ Object.keys(source).forEach(function (key) {
+ if (is$1.object(source[key])) {
+ if (!Object.keys(target).includes(key)) {
+ Object.assign(target, _defineProperty({}, key, {}));
+ }
+
+ extend(target[key], source[key]);
+ } else {
+ Object.assign(target, _defineProperty({}, key, source[key]));
+ }
+ });
+ return extend.apply(void 0, [target].concat(sources));
+ }
+
+ function wrap(elements, wrapper) {
+ // Convert `elements` to an array, if necessary.
+ var targets = elements.length ? elements : [elements]; // Loops backwards to prevent having to clone the wrapper on the
+ // first element (see `child` below).
+
+ Array.from(targets).reverse().forEach(function (element, index) {
+ var child = index > 0 ? wrapper.cloneNode(true) : wrapper; // Cache the current parent and sibling.
+
+ var parent = element.parentNode;
+ var sibling = element.nextSibling; // Wrap the element (is automatically removed from its current
+ // parent).
+
+ child.appendChild(element); // If the element had a sibling, insert the wrapper before
+ // the sibling to maintain the HTML structure; otherwise, just
+ // append it to the parent.
+
+ if (sibling) {
+ parent.insertBefore(child, sibling);
+ } else {
+ parent.appendChild(child);
+ }
+ });
+ } // Set attributes
+
+ function setAttributes(element, attributes) {
+ if (!is$1.element(element) || is$1.empty(attributes)) {
+ return;
+ } // Assume null and undefined attributes should be left out,
+ // Setting them would otherwise convert them to "null" and "undefined"
+
+
+ Object.entries(attributes).filter(function (_ref) {
+ var _ref2 = _slicedToArray(_ref, 2),
+ value = _ref2[1];
+
+ return !is$1.nullOrUndefined(value);
+ }).forEach(function (_ref3) {
+ var _ref4 = _slicedToArray(_ref3, 2),
+ key = _ref4[0],
+ value = _ref4[1];
+
+ return element.setAttribute(key, value);
+ });
+ } // Create a DocumentFragment
+
+ function createElement(type, attributes, text) {
+ // Create a new <element>
+ var element = document.createElement(type); // Set all passed attributes
+
+ if (is$1.object(attributes)) {
+ setAttributes(element, attributes);
+ } // Add text node
+
+
+ if (is$1.string(text)) {
+ element.innerText = text;
+ } // Return built element
+
+
+ return element;
+ } // Inaert an element after another
+
+ function insertAfter(element, target) {
+ if (!is$1.element(element) || !is$1.element(target)) {
+ return;
+ }
+
+ target.parentNode.insertBefore(element, target.nextSibling);
+ } // Insert a DocumentFragment
+
+ function insertElement(type, parent, attributes, text) {
+ if (!is$1.element(parent)) {
+ return;
+ }
+
+ parent.appendChild(createElement(type, attributes, text));
+ } // Remove element(s)
+
+ function removeElement(element) {
+ if (is$1.nodeList(element) || is$1.array(element)) {
+ Array.from(element).forEach(removeElement);
+ return;
+ }
+
+ if (!is$1.element(element) || !is$1.element(element.parentNode)) {
+ return;
+ }
+
+ element.parentNode.removeChild(element);
+ } // Remove all child elements
+
+ function emptyElement(element) {
+ if (!is$1.element(element)) {
+ return;
+ }
+
+ var length = element.childNodes.length;
+
+ while (length > 0) {
+ element.removeChild(element.lastChild);
+ length -= 1;
+ }
+ } // Replace element
+
+ function replaceElement(newChild, oldChild) {
+ if (!is$1.element(oldChild) || !is$1.element(oldChild.parentNode) || !is$1.element(newChild)) {
+ return null;
+ }
+
+ oldChild.parentNode.replaceChild(newChild, oldChild);
+ return newChild;
+ } // Get an attribute object from a string selector
+
+ function getAttributesFromSelector(sel, existingAttributes) {
+ // For example:
+ // '.test' to { class: 'test' }
+ // '#test' to { id: 'test' }
+ // '[data-test="test"]' to { 'data-test': 'test' }
+ if (!is$1.string(sel) || is$1.empty(sel)) {
+ return {};
+ }
+
+ var attributes = {};
+ var existing = extend({}, existingAttributes);
+ sel.split(',').forEach(function (s) {
+ // Remove whitespace
+ var selector = s.trim();
+ var className = selector.replace('.', '');
+ var stripped = selector.replace(/[[\]]/g, ''); // Get the parts and value
+
+ var parts = stripped.split('=');
+
+ var _parts = _slicedToArray(parts, 1),
+ key = _parts[0];
+
+ var value = parts.length > 1 ? parts[1].replace(/["']/g, '') : ''; // Get the first character
+
+ var start = selector.charAt(0);
+
+ switch (start) {
+ case '.':
+ // Add to existing classname
+ if (is$1.string(existing.class)) {
+ attributes.class = "".concat(existing.class, " ").concat(className);
+ } else {
+ attributes.class = className;
+ }
+
+ break;
+
+ case '#':
+ // ID selector
+ attributes.id = selector.replace('#', '');
+ break;
+
+ case '[':
+ // Attribute selector
+ attributes[key] = value;
+ break;
+ }
+ });
+ return extend(existing, attributes);
+ } // Toggle hidden
+
+ function toggleHidden(element, hidden) {
+ if (!is$1.element(element)) {
+ return;
+ }
+
+ var hide = hidden;
+
+ if (!is$1.boolean(hide)) {
+ hide = !element.hidden;
+ } // eslint-disable-next-line no-param-reassign
+
+
+ element.hidden = hide;
+ } // Mirror Element.classList.toggle, with IE compatibility for "force" argument
+
+ function toggleClass(element, className, force) {
+ if (is$1.nodeList(element)) {
+ return Array.from(element).map(function (e) {
+ return toggleClass(e, className, force);
+ });
+ }
+
+ if (is$1.element(element)) {
+ var method = 'toggle';
+
+ if (typeof force !== 'undefined') {
+ method = force ? 'add' : 'remove';
+ }
+
+ element.classList[method](className);
+ return element.classList.contains(className);
+ }
+
+ return false;
+ } // Has class name
+
+ function hasClass(element, className) {
+ return is$1.element(element) && element.classList.contains(className);
+ } // Element matches selector
+
+ function matches$1(element, selector) {
+ var _Element = Element,
+ prototype = _Element.prototype;
+
+ function match() {
+ return Array.from(document.querySelectorAll(selector)).includes(this);
+ }
+
+ var method = prototype.matches || prototype.webkitMatchesSelector || prototype.mozMatchesSelector || prototype.msMatchesSelector || match;
+ return method.call(element, selector);
+ } // Closest ancestor element matching selector (also tests element itself)
+
+ function closest(element, selector) {
+ var _Element2 = Element,
+ prototype = _Element2.prototype; // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
+
+ function closestElement() {
+ var el = this;
+
+ do {
+ if (matches$1.matches(el, selector)) return el;
+ el = el.parentElement || el.parentNode;
+ } while (el !== null && el.nodeType === 1);
+
+ return null;
+ }
+
+ var method = prototype.closest || closestElement;
+ return method.call(element, selector);
+ } // Find all elements
+
+ function getElements(selector) {
+ return this.elements.container.querySelectorAll(selector);
+ } // Find a single element
+
+ function getElement(selector) {
+ return this.elements.container.querySelector(selector);
+ } // Set focus and tab focus class
+
+ function setFocus() {
+ var element = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
+ var tabFocus = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+
+ if (!is$1.element(element)) {
+ return;
+ } // Set regular focus
+
+
+ element.focus({
+ preventScroll: true
+ }); // If we want to mimic keyboard focus via tab
+
+ if (tabFocus) {
+ toggleClass(element, this.config.classNames.tabFocus);
+ }
+ }
+
+ var defaultCodecs = {
+ 'audio/ogg': 'vorbis',
+ 'audio/wav': '1',
+ 'video/webm': 'vp8, vorbis',
+ 'video/mp4': 'avc1.42E01E, mp4a.40.2',
+ 'video/ogg': 'theora'
+ }; // Check for feature support
+
+ var support = {
+ // Basic support
+ audio: 'canPlayType' in document.createElement('audio'),
+ video: 'canPlayType' in document.createElement('video'),
+ // Check for support
+ // Basic functionality vs full UI
+ check: function check(type, provider, playsinline) {
+ var canPlayInline = browser.isIPhone && playsinline && support.playsinline;
+ var api = support[type] || provider !== 'html5';
+ var ui = api && support.rangeInput && (type !== 'video' || !browser.isIPhone || canPlayInline);
+ return {
+ api: api,
+ ui: ui
+ };
+ },
+ // Picture-in-picture support
+ // Safari & Chrome only currently
+ pip: function () {
+ if (browser.isIPhone) {
+ return false;
+ } // Safari
+ // https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls
+
+
+ if (is$1.function(createElement('video').webkitSetPresentationMode)) {
+ return true;
+ } // Chrome
+ // https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture
+
+
+ if (document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture) {
+ return true;
+ }
+
+ return false;
+ }(),
+ // Airplay support
+ // Safari only currently
+ airplay: is$1.function(window.WebKitPlaybackTargetAvailabilityEvent),
+ // Inline playback support
+ // https://webkit.org/blog/6784/new-video-policies-for-ios/
+ playsinline: 'playsInline' in document.createElement('video'),
+ // Check for mime type support against a player instance
+ // Credits: http://diveintohtml5.info/everything.html
+ // Related: http://www.leanbackplayer.com/test/h5mt.html
+ mime: function mime(input) {
+ if (is$1.empty(input)) {
+ return false;
+ }
+
+ var _input$split = input.split('/'),
+ _input$split2 = _slicedToArray(_input$split, 1),
+ mediaType = _input$split2[0];
+
+ var type = input; // Verify we're using HTML5 and there's no media type mismatch
+
+ if (!this.isHTML5 || mediaType !== this.type) {
+ return false;
+ } // Add codec if required
+
+
+ if (Object.keys(defaultCodecs).includes(type)) {
+ type += "; codecs=\"".concat(defaultCodecs[input], "\"");
+ }
+
+ try {
+ return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));
+ } catch (e) {
+ return false;
+ }
+ },
+ // Check for textTracks support
+ textTracks: 'textTracks' in document.createElement('video'),
+ // <input type="range"> Sliders
+ rangeInput: function () {
+ var range = document.createElement('input');
+ range.type = 'range';
+ return range.type === 'range';
+ }(),
+ // Touch
+ // NOTE: Remember a device can be mouse + touch enabled so we check on first touch event
+ touch: 'ontouchstart' in document.documentElement,
+ // Detect transitions support
+ transitions: transitionEndEvent !== false,
+ // Reduced motion iOS & MacOS setting
+ // https://webkit.org/blog/7551/responsive-design-for-motion/
+ reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches
+ };
+
+ // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
+ // https://www.youtube.com/watch?v=NPM6172J22g
+
+ var supportsPassiveListeners = function () {
+ // Test via a getter in the options object to see if the passive property is accessed
+ var supported = false;
+
+ try {
+ var options = Object.defineProperty({}, 'passive', {
+ get: function get() {
+ supported = true;
+ return null;
+ }
+ });
+ window.addEventListener('test', null, options);
+ window.removeEventListener('test', null, options);
+ } catch (e) {// Do nothing
+ }
+
+ return supported;
+ }(); // Toggle event listener
+
+
+ function toggleListener(element, event, callback) {
+ var _this = this;
+
+ var toggle = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
+ var passive = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : true;
+ var capture = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : false;
+
+ // Bail if no element, event, or callback
+ if (!element || !('addEventListener' in element) || is$1.empty(event) || !is$1.function(callback)) {
+ return;
+ } // Allow multiple events
+
+
+ var events = event.split(' '); // Build options
+ // Default to just the capture boolean for browsers with no passive listener support
+
+ var options = capture; // If passive events listeners are supported
+
+ if (supportsPassiveListeners) {
+ options = {
+ // Whether the listener can be passive (i.e. default never prevented)
+ passive: passive,
+ // Whether the listener is a capturing listener or not
+ capture: capture
+ };
+ } // If a single node is passed, bind the event listener
+
+
+ events.forEach(function (type) {
+ if (_this && _this.eventListeners && toggle) {
+ // Cache event listener
+ _this.eventListeners.push({
+ element: element,
+ type: type,
+ callback: callback,
+ options: options
+ });
+ }
+
+ element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);
+ });
+ } // Bind event handler
+
+ function on(element) {
+ var events = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
+ var callback = arguments.length > 2 ? arguments[2] : undefined;
+ var passive = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true;
+ var capture = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
+ toggleListener.call(this, element, events, callback, true, passive, capture);
+ } // Unbind event handler
+
+ function off(element) {
+ var events = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
+ var callback = arguments.length > 2 ? arguments[2] : undefined;
+ var passive = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true;
+ var capture = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
+ toggleListener.call(this, element, events, callback, false, passive, capture);
+ } // Bind once-only event handler
+
+ function once(element) {
+ var _this2 = this;
+
+ var events = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
+ var callback = arguments.length > 2 ? arguments[2] : undefined;
+ var passive = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : true;
+ var capture = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
+
+ var onceCallback = function onceCallback() {
+ off(element, events, onceCallback, passive, capture);
+
+ for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
+ args[_key] = arguments[_key];
+ }
+
+ callback.apply(_this2, args);
+ };
+
+ toggleListener.call(this, element, events, onceCallback, true, passive, capture);
+ } // Trigger event
+
+ function triggerEvent(element) {
+ var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
+ var bubbles = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
+ var detail = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
+
+ // Bail if no element
+ if (!is$1.element(element) || is$1.empty(type)) {
+ return;
+ } // Create and dispatch the event
+
+
+ var event = new CustomEvent(type, {
+ bubbles: bubbles,
+ detail: _objectSpread2(_objectSpread2({}, detail), {}, {
+ plyr: this
+ })
+ }); // Dispatch the event
+
+ element.dispatchEvent(event);
+ } // Unbind all cached event listeners
+
+ function unbindListeners() {
+ if (this && this.eventListeners) {
+ this.eventListeners.forEach(function (item) {
+ var element = item.element,
+ type = item.type,
+ callback = item.callback,
+ options = item.options;
+ element.removeEventListener(type, callback, options);
+ });
+ this.eventListeners = [];
+ }
+ } // Run method when / if player is ready
+
+ function ready() {
+ var _this3 = this;
+
+ return new Promise(function (resolve) {
+ return _this3.ready ? setTimeout(resolve, 0) : on.call(_this3, _this3.elements.container, 'ready', resolve);
+ }).then(function () {});
+ }
+
+ /**
+ * Silence a Promise-like object.
+ * This is useful for avoiding non-harmful, but potentially confusing "uncaught
+ * play promise" rejection error messages.
+ * @param {Object} value An object that may or may not be `Promise`-like.
+ */
+
+ function silencePromise(value) {
+ if (is$1.promise(value)) {
+ value.then(null, function () {});
+ }
+ }
+
+ function validateRatio(input) {
+ if (!is$1.array(input) && (!is$1.string(input) || !input.includes(':'))) {
+ return false;
+ }
+
+ var ratio = is$1.array(input) ? input : input.split(':');
+ return ratio.map(Number).every(is$1.number);
+ }
+ function reduceAspectRatio(ratio) {
+ if (!is$1.array(ratio) || !ratio.every(is$1.number)) {
+ return null;
+ }
+
+ var _ratio = _slicedToArray(ratio, 2),
+ width = _ratio[0],
+ height = _ratio[1];
+
+ var getDivider = function getDivider(w, h) {
+ return h === 0 ? w : getDivider(h, w % h);
+ };
+
+ var divider = getDivider(width, height);
+ return [width / divider, height / divider];
+ }
+ function getAspectRatio(input) {
+ var parse = function parse(ratio) {
+ return validateRatio(ratio) ? ratio.split(':').map(Number) : null;
+ }; // Try provided ratio
+
+
+ var ratio = parse(input); // Get from config
+
+ if (ratio === null) {
+ ratio = parse(this.config.ratio);
+ } // Get from embed
+
+
+ if (ratio === null && !is$1.empty(this.embed) && is$1.array(this.embed.ratio)) {
+ ratio = this.embed.ratio;
+ } // Get from HTML5 video
+
+
+ if (ratio === null && this.isHTML5) {
+ var _this$media = this.media,
+ videoWidth = _this$media.videoWidth,
+ videoHeight = _this$media.videoHeight;
+ ratio = reduceAspectRatio([videoWidth, videoHeight]);
+ }
+
+ return ratio;
+ } // Set aspect ratio for responsive container
+
+ function setAspectRatio(input) {
+ if (!this.isVideo) {
+ return {};
+ }
+
+ var wrapper = this.elements.wrapper;
+ var ratio = getAspectRatio.call(this, input);
+
+ var _ref = is$1.array(ratio) ? ratio : [0, 0],
+ _ref2 = _slicedToArray(_ref, 2),
+ w = _ref2[0],
+ h = _ref2[1];
+
+ var padding = 100 / w * h;
+ wrapper.style.paddingBottom = "".concat(padding, "%"); // For Vimeo we have an extra <div> to hide the standard controls and UI
+
+ if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) {
+ var height = 100 / this.media.offsetWidth * parseInt(window.getComputedStyle(this.media).paddingBottom, 10);
+ var offset = (height - padding) / (height / 50);
+ this.media.style.transform = "translateY(-".concat(offset, "%)");
+ } else if (this.isHTML5) {
+ wrapper.classList.toggle(this.config.classNames.videoFixedRatio, ratio !== null);
+ }
+
+ return {
+ padding: padding,
+ ratio: ratio
+ };
+ }
+
+ // ==========================================================================
+ var html5 = {
+ getSources: function getSources() {
+ var _this = this;
+
+ if (!this.isHTML5) {
+ return [];
+ }
+
+ var sources = Array.from(this.media.querySelectorAll('source')); // Filter out unsupported sources (if type is specified)
+
+ return sources.filter(function (source) {
+ var type = source.getAttribute('type');
+
+ if (is$1.empty(type)) {
+ return true;
+ }
+
+ return support.mime.call(_this, type);
+ });
+ },
+ // Get quality levels
+ getQualityOptions: function getQualityOptions() {
+ // Whether we're forcing all options (e.g. for streaming)
+ if (this.config.quality.forced) {
+ return this.config.quality.options;
+ } // Get sizes from <source> elements
+
+
+ return html5.getSources.call(this).map(function (source) {
+ return Number(source.getAttribute('data-res'));
+ }).filter(Boolean);
+ },
+ setup: function setup() {
+ if (!this.isHTML5) {
+ return;
+ }
+
+ var player = this; // Set speed options from config
+
+ player.options.speed = player.config.speed.options; // Set aspect ratio if fixed
+
+ if (!is$1.empty(this.config.ratio)) {
+ setAspectRatio.call(player);
+ } // Quality
+
+
+ Object.defineProperty(player.media, 'quality', {
+ get: function get() {
+ // Get sources
+ var sources = html5.getSources.call(player);
+ var source = sources.find(function (s) {
+ return s.getAttribute('src') === player.source;
+ }); // Return size, if match is found
+
+ return source && Number(source.getAttribute('data-res'));
+ },
+ set: function set(input) {
+ if (player.quality === input) {
+ return;
+ } // If we're using an an external handler...
+
+
+ if (player.config.quality.forced && is$1.function(player.config.quality.onChange)) {
+ player.config.quality.onChange(input);
+ } else {
+ // Get sources
+ var sources = html5.getSources.call(player); // Get first match for requested size
+
+ var source = sources.find(function (s) {
+ return Number(s.getAttribute('data-res')) === input;
+ }); // No matching source found
+
+ if (!source) {
+ return;
+ } // Get current state
+
+
+ var _player$media = player.media,
+ currentTime = _player$media.currentTime,
+ paused = _player$media.paused,
+ preload = _player$media.preload,
+ readyState = _player$media.readyState,
+ playbackRate = _player$media.playbackRate; // Set new source
+
+ player.media.src = source.getAttribute('src'); // Prevent loading if preload="none" and the current source isn't loaded (#1044)
+
+ if (preload !== 'none' || readyState) {
+ // Restore time
+ player.once('loadedmetadata', function () {
+ player.speed = playbackRate;
+ player.currentTime = currentTime; // Resume playing
+
+ if (!paused) {
+ silencePromise(player.play());
+ }
+ }); // Load new source
+
+ player.media.load();
+ }
+ } // Trigger change event
+
+
+ triggerEvent.call(player, player.media, 'qualitychange', false, {
+ quality: input
+ });
+ }
+ });
+ },
+ // Cancel current network requests
+ // See https://github.com/sampotts/plyr/issues/174
+ cancelRequests: function cancelRequests() {
+ if (!this.isHTML5) {
+ return;
+ } // Remove child sources
+
+
+ removeElement(html5.getSources.call(this)); // Set blank video src attribute
+ // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error
+ // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection
+
+ this.media.setAttribute('src', this.config.blankVideo); // Load the new empty source
+ // This will cancel existing requests
+ // See https://github.com/sampotts/plyr/issues/174
+
+ this.media.load(); // Debugging
+
+ this.debug.log('Cancelled network requests');
+ }
+ };
+
+ // ==========================================================================
+
+ function dedupe(array) {
+ if (!is$1.array(array)) {
+ return array;
+ }
+
+ return array.filter(function (item, index) {
+ return array.indexOf(item) === index;
+ });
+ } // Get the closest value in an array
+
+ function closest$1(array, value) {
+ if (!is$1.array(array) || !array.length) {
+ return null;
+ }
+
+ return array.reduce(function (prev, curr) {
+ return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev;
+ });
+ }
+
+ // ==========================================================================
+
+ function generateId(prefix) {
+ return "".concat(prefix, "-").concat(Math.floor(Math.random() * 10000));
+ } // Format string
+
+ function format(input) {
+ for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ args[_key - 1] = arguments[_key];
+ }
+
+ if (is$1.empty(input)) {
+ return input;
+ }
+
+ return input.toString().replace(/{(\d+)}/g, function (match, i) {
+ return args[i].toString();
+ });
+ } // Get percentage
+
+ function getPercentage(current, max) {
+ if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {
+ return 0;
+ }
+
+ return (current / max * 100).toFixed(2);
+ } // Replace all occurances of a string in a string
+
+ var replaceAll = function replaceAll() {
+ var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
+ var find = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : '';
+ var replace = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : '';
+ return input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString());
+ }; // Convert to title case
+
+ var toTitleCase = function toTitleCase() {
+ var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
+ return input.toString().replace(/\w\S*/g, function (text) {
+ return text.charAt(0).toUpperCase() + text.substr(1).toLowerCase();
+ });
+ }; // Convert string to pascalCase
+
+ function toPascalCase() {
+ var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
+ var string = input.toString(); // Convert kebab case
+
+ string = replaceAll(string, '-', ' '); // Convert snake case
+
+ string = replaceAll(string, '_', ' '); // Convert to title case
+
+ string = toTitleCase(string); // Convert to pascal case
+
+ return replaceAll(string, ' ', '');
+ } // Convert string to pascalCase
+
+ function toCamelCase() {
+ var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
+ var string = input.toString(); // Convert to pascal case
+
+ string = toPascalCase(string); // Convert first character to lowercase
+
+ return string.charAt(0).toLowerCase() + string.slice(1);
+ } // Remove HTML from a string
+
+ function stripHTML(source) {
+ var fragment = document.createDocumentFragment();
+ var element = document.createElement('div');
+ fragment.appendChild(element);
+ element.innerHTML = source;
+ return fragment.firstChild.innerText;
+ } // Like outerHTML, but also works for DocumentFragment
+
+ function getHTML(element) {
+ var wrapper = document.createElement('div');
+ wrapper.appendChild(element);
+ return wrapper.innerHTML;
+ }
+
+ var resources = {
+ pip: 'PIP',
+ airplay: 'AirPlay',
+ html5: 'HTML5',
+ vimeo: 'Vimeo',
+ youtube: 'YouTube'
+ };
+ var i18n = {
+ get: function get() {
+ var key = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
+ var config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+
+ if (is$1.empty(key) || is$1.empty(config)) {
+ return '';
+ }
+
+ var string = getDeep(config.i18n, key);
+
+ if (is$1.empty(string)) {
+ if (Object.keys(resources).includes(key)) {
+ return resources[key];
+ }
+
+ return '';
+ }
+
+ var replace = {
+ '{seektime}': config.seekTime,
+ '{title}': config.title
+ };
+ Object.entries(replace).forEach(function (_ref) {
+ var _ref2 = _slicedToArray(_ref, 2),
+ k = _ref2[0],
+ v = _ref2[1];
+
+ string = replaceAll(string, k, v);
+ });
+ return string;
+ }
+ };
+
+ var Storage = /*#__PURE__*/function () {
+ function Storage(player) {
+ _classCallCheck(this, Storage);
+
+ this.enabled = player.config.storage.enabled;
+ this.key = player.config.storage.key;
+ } // Check for actual support (see if we can use it)
+
+
+ _createClass(Storage, [{
+ key: "get",
+ value: function get(key) {
+ if (!Storage.supported || !this.enabled) {
+ return null;
+ }
+
+ var store = window.localStorage.getItem(this.key);
+
+ if (is$1.empty(store)) {
+ return null;
+ }
+
+ var json = JSON.parse(store);
+ return is$1.string(key) && key.length ? json[key] : json;
+ }
+ }, {
+ key: "set",
+ value: function set(object) {
+ // Bail if we don't have localStorage support or it's disabled
+ if (!Storage.supported || !this.enabled) {
+ return;
+ } // Can only store objectst
+
+
+ if (!is$1.object(object)) {
+ return;
+ } // Get current storage
+
+
+ var storage = this.get(); // Default to empty object
+
+ if (is$1.empty(storage)) {
+ storage = {};
+ } // Update the working copy of the values
+
+
+ extend(storage, object); // Update storage
+
+ window.localStorage.setItem(this.key, JSON.stringify(storage));
+ }
+ }], [{
+ key: "supported",
+ get: function get() {
+ try {
+ if (!('localStorage' in window)) {
+ return false;
+ }
+
+ var test = '___test'; // Try to use it (it might be disabled, e.g. user is in private mode)
+ // see: https://github.com/sampotts/plyr/issues/131
+
+ window.localStorage.setItem(test, test);
+ window.localStorage.removeItem(test);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+ }]);
+
+ return Storage;
+ }();
+
+ // ==========================================================================
+ // Fetch wrapper
+ // Using XHR to avoid issues with older browsers
+ // ==========================================================================
+ function fetch(url) {
+ var responseType = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'text';
+ return new Promise(function (resolve, reject) {
+ try {
+ var request = new XMLHttpRequest(); // Check for CORS support
+
+ if (!('withCredentials' in request)) {
+ return;
+ }
+
+ request.addEventListener('load', function () {
+ if (responseType === 'text') {
+ try {
+ resolve(JSON.parse(request.responseText));
+ } catch (e) {
+ resolve(request.responseText);
+ }
+ } else {
+ resolve(request.response);
+ }
+ });
+ request.addEventListener('error', function () {
+ throw new Error(request.status);
+ });
+ request.open('GET', url, true); // Set the required response type
+
+ request.responseType = responseType;
+ request.send();
+ } catch (e) {
+ reject(e);
+ }
+ });
+ }
+
+ // ==========================================================================
+
+ function loadSprite(url, id) {
+ if (!is$1.string(url)) {
+ return;
+ }
+
+ var prefix = 'cache';
+ var hasId = is$1.string(id);
+ var isCached = false;
+
+ var exists = function exists() {
+ return document.getElementById(id) !== null;
+ };
+
+ var update = function update(container, data) {
+ // eslint-disable-next-line no-param-reassign
+ container.innerHTML = data; // Check again incase of race condition
+
+ if (hasId && exists()) {
+ return;
+ } // Inject the SVG to the body
+
+
+ document.body.insertAdjacentElement('afterbegin', container);
+ }; // Only load once if ID set
+
+
+ if (!hasId || !exists()) {
+ var useStorage = Storage.supported; // Create container
+
+ var container = document.createElement('div');
+ container.setAttribute('hidden', '');
+
+ if (hasId) {
+ container.setAttribute('id', id);
+ } // Check in cache
+
+
+ if (useStorage) {
+ var cached = window.localStorage.getItem("".concat(prefix, "-").concat(id));
+ isCached = cached !== null;
+
+ if (isCached) {
+ var data = JSON.parse(cached);
+ update(container, data.content);
+ }
+ } // Get the sprite
+
+
+ fetch(url).then(function (result) {
+ if (is$1.empty(result)) {
+ return;
+ }
+
+ if (useStorage) {
+ window.localStorage.setItem("".concat(prefix, "-").concat(id), JSON.stringify({
+ content: result
+ }));
+ }
+
+ update(container, result);
+ }).catch(function () {});
+ }
+ }
+
+ // ==========================================================================
+
+ var getHours = function getHours(value) {
+ return Math.trunc(value / 60 / 60 % 60, 10);
+ };
+ var getMinutes = function getMinutes(value) {
+ return Math.trunc(value / 60 % 60, 10);
+ };
+ var getSeconds = function getSeconds(value) {
+ return Math.trunc(value % 60, 10);
+ }; // Format time to UI friendly string
+
+ function formatTime() {
+ var time = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
+ var displayHours = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+ var inverted = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
+
+ // Bail if the value isn't a number
+ if (!is$1.number(time)) {
+ return formatTime(undefined, displayHours, inverted);
+ } // Format time component to add leading zero
+
+
+ var format = function format(value) {
+ return "0".concat(value).slice(-2);
+ }; // Breakdown to hours, mins, secs
+
+
+ var hours = getHours(time);
+ var mins = getMinutes(time);
+ var secs = getSeconds(time); // Do we need to display hours?
+
+ if (displayHours || hours > 0) {
+ hours = "".concat(hours, ":");
+ } else {
+ hours = '';
+ } // Render
+
+
+ return "".concat(inverted && time > 0 ? '-' : '').concat(hours).concat(format(mins), ":").concat(format(secs));
+ }
+
+ var controls = {
+ // Get icon URL
+ getIconUrl: function getIconUrl() {
+ var url = new URL(this.config.iconUrl, window.location);
+ var cors = url.host !== window.location.host || browser.isIE && !window.svg4everybody;
+ return {
+ url: this.config.iconUrl,
+ cors: cors
+ };
+ },
+ // Find the UI controls
+ findElements: function findElements() {
+ try {
+ this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper); // Buttons
+
+ this.elements.buttons = {
+ play: getElements.call(this, this.config.selectors.buttons.play),
+ pause: getElement.call(this, this.config.selectors.buttons.pause),
+ restart: getElement.call(this, this.config.selectors.buttons.restart),
+ rewind: getElement.call(this, this.config.selectors.buttons.rewind),
+ fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),
+ mute: getElement.call(this, this.config.selectors.buttons.mute),
+ pip: getElement.call(this, this.config.selectors.buttons.pip),
+ airplay: getElement.call(this, this.config.selectors.buttons.airplay),
+ settings: getElement.call(this, this.config.selectors.buttons.settings),
+ captions: getElement.call(this, this.config.selectors.buttons.captions),
+ fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen)
+ }; // Progress
+
+ this.elements.progress = getElement.call(this, this.config.selectors.progress); // Inputs
+
+ this.elements.inputs = {
+ seek: getElement.call(this, this.config.selectors.inputs.seek),
+ volume: getElement.call(this, this.config.selectors.inputs.volume)
+ }; // Display
+
+ this.elements.display = {
+ buffer: getElement.call(this, this.config.selectors.display.buffer),
+ currentTime: getElement.call(this, this.config.selectors.display.currentTime),
+ duration: getElement.call(this, this.config.selectors.display.duration)
+ }; // Seek tooltip
+
+ if (is$1.element(this.elements.progress)) {
+ this.elements.display.seekTooltip = this.elements.progress.querySelector(".".concat(this.config.classNames.tooltip));
+ }
+
+ return true;
+ } catch (error) {
+ // Log it
+ this.debug.warn('It looks like there is a problem with your custom controls HTML', error); // Restore native video controls
+
+ this.toggleNativeControls(true);
+ return false;
+ }
+ },
+ // Create <svg> icon
+ createIcon: function createIcon(type, attributes) {
+ var namespace = 'http://www.w3.org/2000/svg';
+ var iconUrl = controls.getIconUrl.call(this);
+ var iconPath = "".concat(!iconUrl.cors ? iconUrl.url : '', "#").concat(this.config.iconPrefix); // Create <svg>
+
+ var icon = document.createElementNS(namespace, 'svg');
+ setAttributes(icon, extend(attributes, {
+ 'aria-hidden': 'true',
+ focusable: 'false'
+ })); // Create the <use> to reference sprite
+
+ var use = document.createElementNS(namespace, 'use');
+ var path = "".concat(iconPath, "-").concat(type); // Set `href` attributes
+ // https://github.com/sampotts/plyr/issues/460
+ // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
+
+ if ('href' in use) {
+ use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path);
+ } // Always set the older attribute even though it's "deprecated" (it'll be around for ages)
+
+
+ use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path); // Add <use> to <svg>
+
+ icon.appendChild(use);
+ return icon;
+ },
+ // Create hidden text label
+ createLabel: function createLabel(key) {
+ var attr = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+ var text = i18n.get(key, this.config);
+
+ var attributes = _objectSpread2(_objectSpread2({}, attr), {}, {
+ class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ')
+ });
+
+ return createElement('span', attributes, text);
+ },
+ // Create a badge
+ createBadge: function createBadge(text) {
+ if (is$1.empty(text)) {
+ return null;
+ }
+
+ var badge = createElement('span', {
+ class: this.config.classNames.menu.value
+ });
+ badge.appendChild(createElement('span', {
+ class: this.config.classNames.menu.badge
+ }, text));
+ return badge;
+ },
+ // Create a <button>
+ createButton: function createButton(buttonType, attr) {
+ var _this = this;
+
+ var attributes = extend({}, attr);
+ var type = toCamelCase(buttonType);
+ var props = {
+ element: 'button',
+ toggle: false,
+ label: null,
+ icon: null,
+ labelPressed: null,
+ iconPressed: null
+ };
+ ['element', 'icon', 'label'].forEach(function (key) {
+ if (Object.keys(attributes).includes(key)) {
+ props[key] = attributes[key];
+ delete attributes[key];
+ }
+ }); // Default to 'button' type to prevent form submission
+
+ if (props.element === 'button' && !Object.keys(attributes).includes('type')) {
+ attributes.type = 'button';
+ } // Set class name
+
+
+ if (Object.keys(attributes).includes('class')) {
+ if (!attributes.class.split(' ').some(function (c) {
+ return c === _this.config.classNames.control;
+ })) {
+ extend(attributes, {
+ class: "".concat(attributes.class, " ").concat(this.config.classNames.control)
+ });
+ }
+ } else {
+ attributes.class = this.config.classNames.control;
+ } // Large play button
+
+
+ switch (buttonType) {
+ case 'play':
+ props.toggle = true;
+ props.label = 'play';
+ props.labelPressed = 'pause';
+ props.icon = 'play';
+ props.iconPressed = 'pause';
+ break;
+
+ case 'mute':
+ props.toggle = true;
+ props.label = 'mute';
+ props.labelPressed = 'unmute';
+ props.icon = 'volume';
+ props.iconPressed = 'muted';
+ break;
+
+ case 'captions':
+ props.toggle = true;
+ props.label = 'enableCaptions';
+ props.labelPressed = 'disableCaptions';
+ props.icon = 'captions-off';
+ props.iconPressed = 'captions-on';
+ break;
+
+ case 'fullscreen':
+ props.toggle = true;
+ props.label = 'enterFullscreen';
+ props.labelPressed = 'exitFullscreen';
+ props.icon = 'enter-fullscreen';
+ props.iconPressed = 'exit-fullscreen';
+ break;
+
+ case 'play-large':
+ attributes.class += " ".concat(this.config.classNames.control, "--overlaid");
+ type = 'play';
+ props.label = 'play';
+ props.icon = 'play';
+ break;
+
+ default:
+ if (is$1.empty(props.label)) {
+ props.label = type;
+ }
+
+ if (is$1.empty(props.icon)) {
+ props.icon = buttonType;
+ }
+
+ }
+
+ var button = createElement(props.element); // Setup toggle icon and labels
+
+ if (props.toggle) {
+ // Icon
+ button.appendChild(controls.createIcon.call(this, props.iconPressed, {
+ class: 'icon--pressed'
+ }));
+ button.appendChild(controls.createIcon.call(this, props.icon, {
+ class: 'icon--not-pressed'
+ })); // Label/Tooltip
+
+ button.appendChild(controls.createLabel.call(this, props.labelPressed, {
+ class: 'label--pressed'
+ }));
+ button.appendChild(controls.createLabel.call(this, props.label, {
+ class: 'label--not-pressed'
+ }));
+ } else {
+ button.appendChild(controls.createIcon.call(this, props.icon));
+ button.appendChild(controls.createLabel.call(this, props.label));
+ } // Merge and set attributes
+
+
+ extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
+ setAttributes(button, attributes); // We have multiple play buttons
+
+ if (type === 'play') {
+ if (!is$1.array(this.elements.buttons[type])) {
+ this.elements.buttons[type] = [];
+ }
+
+ this.elements.buttons[type].push(button);
+ } else {
+ this.elements.buttons[type] = button;
+ }
+
+ return button;
+ },
+ // Create an <input type='range'>
+ createRange: function createRange(type, attributes) {
+ // Seek input
+ var input = createElement('input', extend(getAttributesFromSelector(this.config.selectors.inputs[type]), {
+ type: 'range',
+ min: 0,
+ max: 100,
+ step: 0.01,
+ value: 0,
+ autocomplete: 'off',
+ // A11y fixes for https://github.com/sampotts/plyr/issues/905
+ role: 'slider',
+ 'aria-label': i18n.get(type, this.config),
+ 'aria-valuemin': 0,
+ 'aria-valuemax': 100,
+ 'aria-valuenow': 0
+ }, attributes));
+ this.elements.inputs[type] = input; // Set the fill for webkit now
+
+ controls.updateRangeFill.call(this, input); // Improve support on touch devices
+
+ RangeTouch.setup(input);
+ return input;
+ },
+ // Create a <progress>
+ createProgress: function createProgress(type, attributes) {
+ var progress = createElement('progress', extend(getAttributesFromSelector(this.config.selectors.display[type]), {
+ min: 0,
+ max: 100,
+ value: 0,
+ role: 'progressbar',
+ 'aria-hidden': true
+ }, attributes)); // Create the label inside
+
+ if (type !== 'volume') {
+ progress.appendChild(createElement('span', null, '0'));
+ var suffixKey = {
+ played: 'played',
+ buffer: 'buffered'
+ }[type];
+ var suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
+ progress.innerText = "% ".concat(suffix.toLowerCase());
+ }
+
+ this.elements.display[type] = progress;
+ return progress;
+ },
+ // Create time display
+ createTime: function createTime(type, attrs) {
+ var attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);
+ var container = createElement('div', extend(attributes, {
+ class: "".concat(attributes.class ? attributes.class : '', " ").concat(this.config.classNames.display.time, " ").trim(),
+ 'aria-label': i18n.get(type, this.config)
+ }), '00:00'); // Reference for updates
+
+ this.elements.display[type] = container;
+ return container;
+ },
+ // Bind keyboard shortcuts for a menu item
+ // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
+ bindMenuItemShortcuts: function bindMenuItemShortcuts(menuItem, type) {
+ var _this2 = this;
+
+ // Navigate through menus via arrow keys and space
+ on.call(this, menuItem, 'keydown keyup', function (event) {
+ // We only care about space and ⬆️ ⬇️️ ➡️
+ if (![32, 38, 39, 40].includes(event.which)) {
+ return;
+ } // Prevent play / seek
+
+
+ event.preventDefault();
+ event.stopPropagation(); // We're just here to prevent the keydown bubbling
+
+ if (event.type === 'keydown') {
+ return;
+ }
+
+ var isRadioButton = matches$1(menuItem, '[role="menuitemradio"]'); // Show the respective menu
+
+ if (!isRadioButton && [32, 39].includes(event.which)) {
+ controls.showMenuPanel.call(_this2, type, true);
+ } else {
+ var target;
+
+ if (event.which !== 32) {
+ if (event.which === 40 || isRadioButton && event.which === 39) {
+ target = menuItem.nextElementSibling;
+
+ if (!is$1.element(target)) {
+ target = menuItem.parentNode.firstElementChild;
+ }
+ } else {
+ target = menuItem.previousElementSibling;
+
+ if (!is$1.element(target)) {
+ target = menuItem.parentNode.lastElementChild;
+ }
+ }
+
+ setFocus.call(_this2, target, true);
+ }
+ }
+ }, false); // Enter will fire a `click` event but we still need to manage focus
+ // So we bind to keyup which fires after and set focus here
+
+ on.call(this, menuItem, 'keyup', function (event) {
+ if (event.which !== 13) {
+ return;
+ }
+
+ controls.focusFirstMenuItem.call(_this2, null, true);
+ });
+ },
+ // Create a settings menu item
+ createMenuItem: function createMenuItem(_ref) {
+ var _this3 = this;
+
+ var value = _ref.value,
+ list = _ref.list,
+ type = _ref.type,
+ title = _ref.title,
+ _ref$badge = _ref.badge,
+ badge = _ref$badge === void 0 ? null : _ref$badge,
+ _ref$checked = _ref.checked,
+ checked = _ref$checked === void 0 ? false : _ref$checked;
+ var attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);
+ var menuItem = createElement('button', extend(attributes, {
+ type: 'button',
+ role: 'menuitemradio',
+ class: "".concat(this.config.classNames.control, " ").concat(attributes.class ? attributes.class : '').trim(),
+ 'aria-checked': checked,
+ value: value
+ }));
+ var flex = createElement('span'); // We have to set as HTML incase of special characters
+
+ flex.innerHTML = title;
+
+ if (is$1.element(badge)) {
+ flex.appendChild(badge);
+ }
+
+ menuItem.appendChild(flex); // Replicate radio button behaviour
+
+ Object.defineProperty(menuItem, 'checked', {
+ enumerable: true,
+ get: function get() {
+ return menuItem.getAttribute('aria-checked') === 'true';
+ },
+ set: function set(check) {
+ // Ensure exclusivity
+ if (check) {
+ Array.from(menuItem.parentNode.children).filter(function (node) {
+ return matches$1(node, '[role="menuitemradio"]');
+ }).forEach(function (node) {
+ return node.setAttribute('aria-checked', 'false');
+ });
+ }
+
+ menuItem.setAttribute('aria-checked', check ? 'true' : 'false');
+ }
+ });
+ this.listeners.bind(menuItem, 'click keyup', function (event) {
+ if (is$1.keyboardEvent(event) && event.which !== 32) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ menuItem.checked = true;
+
+ switch (type) {
+ case 'language':
+ _this3.currentTrack = Number(value);
+ break;
+
+ case 'quality':
+ _this3.quality = value;
+ break;
+
+ case 'speed':
+ _this3.speed = parseFloat(value);
+ break;
+ }
+
+ controls.showMenuPanel.call(_this3, 'home', is$1.keyboardEvent(event));
+ }, type, false);
+ controls.bindMenuItemShortcuts.call(this, menuItem, type);
+ list.appendChild(menuItem);
+ },
+ // Format a time for display
+ formatTime: function formatTime$1() {
+ var time = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
+ var inverted = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+
+ // Bail if the value isn't a number
+ if (!is$1.number(time)) {
+ return time;
+ } // Always display hours if duration is over an hour
+
+
+ var forceHours = getHours(this.duration) > 0;
+ return formatTime(time, forceHours, inverted);
+ },
+ // Update the displayed time
+ updateTimeDisplay: function updateTimeDisplay() {
+ var target = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null;
+ var time = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
+ var inverted = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
+
+ // Bail if there's no element to display or the value isn't a number
+ if (!is$1.element(target) || !is$1.number(time)) {
+ return;
+ } // eslint-disable-next-line no-param-reassign
+
+
+ target.innerText = controls.formatTime(time, inverted);
+ },
+ // Update volume UI and storage
+ updateVolume: function updateVolume() {
+ if (!this.supported.ui) {
+ return;
+ } // Update range
+
+
+ if (is$1.element(this.elements.inputs.volume)) {
+ controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
+ } // Update mute state
+
+
+ if (is$1.element(this.elements.buttons.mute)) {
+ this.elements.buttons.mute.pressed = this.muted || this.volume === 0;
+ }
+ },
+ // Update seek value and lower fill
+ setRange: function setRange(target) {
+ var value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
+
+ if (!is$1.element(target)) {
+ return;
+ } // eslint-disable-next-line
+
+
+ target.value = value; // Webkit range fill
+
+ controls.updateRangeFill.call(this, target);
+ },
+ // Update <progress> elements
+ updateProgress: function updateProgress(event) {
+ var _this4 = this;
+
+ if (!this.supported.ui || !is$1.event(event)) {
+ return;
+ }
+
+ var value = 0;
+
+ var setProgress = function setProgress(target, input) {
+ var val = is$1.number(input) ? input : 0;
+ var progress = is$1.element(target) ? target : _this4.elements.display.buffer; // Update value and label
+
+ if (is$1.element(progress)) {
+ progress.value = val; // Update text label inside
+
+ var label = progress.getElementsByTagName('span')[0];
+
+ if (is$1.element(label)) {
+ label.childNodes[0].nodeValue = val;
+ }
+ }
+ };
+
+ if (event) {
+ switch (event.type) {
+ // Video playing
+ case 'timeupdate':
+ case 'seeking':
+ case 'seeked':
+ value = getPercentage(this.currentTime, this.duration); // Set seek range value only if it's a 'natural' time event
+
+ if (event.type === 'timeupdate') {
+ controls.setRange.call(this, this.elements.inputs.seek, value);
+ }
+
+ break;
+ // Check buffer status
+
+ case 'playing':
+ case 'progress':
+ setProgress(this.elements.display.buffer, this.buffered * 100);
+ break;
+ }
+ }
+ },
+ // Webkit polyfill for lower fill range
+ updateRangeFill: function updateRangeFill(target) {
+ // Get range from event if event passed
+ var range = is$1.event(target) ? target.target : target; // Needs to be a valid <input type='range'>
+
+ if (!is$1.element(range) || range.getAttribute('type') !== 'range') {
+ return;
+ } // Set aria values for https://github.com/sampotts/plyr/issues/905
+
+
+ if (matches$1(range, this.config.selectors.inputs.seek)) {
+ range.setAttribute('aria-valuenow', this.currentTime);
+ var currentTime = controls.formatTime(this.currentTime);
+ var duration = controls.formatTime(this.duration);
+ var format = i18n.get('seekLabel', this.config);
+ range.setAttribute('aria-valuetext', format.replace('{currentTime}', currentTime).replace('{duration}', duration));
+ } else if (matches$1(range, this.config.selectors.inputs.volume)) {
+ var percent = range.value * 100;
+ range.setAttribute('aria-valuenow', percent);
+ range.setAttribute('aria-valuetext', "".concat(percent.toFixed(1), "%"));
+ } else {
+ range.setAttribute('aria-valuenow', range.value);
+ } // WebKit only
+
+
+ if (!browser.isWebkit) {
+ return;
+ } // Set CSS custom property
+
+
+ range.style.setProperty('--value', "".concat(range.value / range.max * 100, "%"));
+ },
+ // Update hover tooltip for seeking
+ updateSeekTooltip: function updateSeekTooltip(event) {
+ var _this5 = this;
+
+ // Bail if setting not true
+ if (!this.config.tooltips.seek || !is$1.element(this.elements.inputs.seek) || !is$1.element(this.elements.display.seekTooltip) || this.duration === 0) {
+ return;
+ }
+
+ var visible = "".concat(this.config.classNames.tooltip, "--visible");
+
+ var toggle = function toggle(show) {
+ return toggleClass(_this5.elements.display.seekTooltip, visible, show);
+ }; // Hide on touch
+
+
+ if (this.touch) {
+ toggle(false);
+ return;
+ } // Determine percentage, if already visible
+
+
+ var percent = 0;
+ var clientRect = this.elements.progress.getBoundingClientRect();
+
+ if (is$1.event(event)) {
+ percent = 100 / clientRect.width * (event.pageX - clientRect.left);
+ } else if (hasClass(this.elements.display.seekTooltip, visible)) {
+ percent = parseFloat(this.elements.display.seekTooltip.style.left, 10);
+ } else {
+ return;
+ } // Set bounds
+
+
+ if (percent < 0) {
+ percent = 0;
+ } else if (percent > 100) {
+ percent = 100;
+ } // Display the time a click would seek to
+
+
+ controls.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent); // Set position
+
+ this.elements.display.seekTooltip.style.left = "".concat(percent, "%"); // Show/hide the tooltip
+ // If the event is a moues in/out and percentage is inside bounds
+
+ if (is$1.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
+ toggle(event.type === 'mouseenter');
+ }
+ },
+ // Handle time change event
+ timeUpdate: function timeUpdate(event) {
+ // Only invert if only one time element is displayed and used for both duration and currentTime
+ var invert = !is$1.element(this.elements.display.duration) && this.config.invertTime; // Duration
+
+ controls.updateTimeDisplay.call(this, this.elements.display.currentTime, invert ? this.duration - this.currentTime : this.currentTime, invert); // Ignore updates while seeking
+
+ if (event && event.type === 'timeupdate' && this.media.seeking) {
+ return;
+ } // Playing progress
+
+
+ controls.updateProgress.call(this, event);
+ },
+ // Show the duration on metadataloaded or durationchange events
+ durationUpdate: function durationUpdate() {
+ // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false
+ if (!this.supported.ui || !this.config.invertTime && this.currentTime) {
+ return;
+ } // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar.
+ // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415
+ // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062
+ // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338
+
+
+ if (this.duration >= Math.pow(2, 32)) {
+ toggleHidden(this.elements.display.currentTime, true);
+ toggleHidden(this.elements.progress, true);
+ return;
+ } // Update ARIA values
+
+
+ if (is$1.element(this.elements.inputs.seek)) {
+ this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
+ } // If there's a spot to display duration
+
+
+ var hasDuration = is$1.element(this.elements.display.duration); // If there's only one time display, display duration there
+
+ if (!hasDuration && this.config.displayDuration && this.paused) {
+ controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);
+ } // If there's a duration element, update content
+
+
+ if (hasDuration) {
+ controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
+ } // Update the tooltip (if visible)
+
+
+ controls.updateSeekTooltip.call(this);
+ },
+ // Hide/show a tab
+ toggleMenuButton: function toggleMenuButton(setting, toggle) {
+ toggleHidden(this.elements.settings.buttons[setting], !toggle);
+ },
+ // Update the selected setting
+ updateSetting: function updateSetting(setting, container, input) {
+ var pane = this.elements.settings.panels[setting];
+ var value = null;
+ var list = container;
+
+ if (setting === 'captions') {
+ value = this.currentTrack;
+ } else {
+ value = !is$1.empty(input) ? input : this[setting]; // Get default
+
+ if (is$1.empty(value)) {
+ value = this.config[setting].default;
+ } // Unsupported value
+
+
+ if (!is$1.empty(this.options[setting]) && !this.options[setting].includes(value)) {
+ this.debug.warn("Unsupported value of '".concat(value, "' for ").concat(setting));
+ return;
+ } // Disabled value
+
+
+ if (!this.config[setting].options.includes(value)) {
+ this.debug.warn("Disabled value of '".concat(value, "' for ").concat(setting));
+ return;
+ }
+ } // Get the list if we need to
+
+
+ if (!is$1.element(list)) {
+ list = pane && pane.querySelector('[role="menu"]');
+ } // If there's no list it means it's not been rendered...
+
+
+ if (!is$1.element(list)) {
+ return;
+ } // Update the label
+
+
+ var label = this.elements.settings.buttons[setting].querySelector(".".concat(this.config.classNames.menu.value));
+ label.innerHTML = controls.getLabel.call(this, setting, value); // Find the radio option and check it
+
+ var target = list && list.querySelector("[value=\"".concat(value, "\"]"));
+
+ if (is$1.element(target)) {
+ target.checked = true;
+ }
+ },
+ // Translate a value into a nice label
+ getLabel: function getLabel(setting, value) {
+ switch (setting) {
+ case 'speed':
+ return value === 1 ? i18n.get('normal', this.config) : "".concat(value, "&times;");
+
+ case 'quality':
+ if (is$1.number(value)) {
+ var label = i18n.get("qualityLabel.".concat(value), this.config);
+
+ if (!label.length) {
+ return "".concat(value, "p");
+ }
+
+ return label;
+ }
+
+ return toTitleCase(value);
+
+ case 'captions':
+ return captions.getLabel.call(this);
+
+ default:
+ return null;
+ }
+ },
+ // Set the quality menu
+ setQualityMenu: function setQualityMenu(options) {
+ var _this6 = this;
+
+ // Menu required
+ if (!is$1.element(this.elements.settings.panels.quality)) {
+ return;
+ }
+
+ var type = 'quality';
+ var list = this.elements.settings.panels.quality.querySelector('[role="menu"]'); // Set options if passed and filter based on uniqueness and config
+
+ if (is$1.array(options)) {
+ this.options.quality = dedupe(options).filter(function (quality) {
+ return _this6.config.quality.options.includes(quality);
+ });
+ } // Toggle the pane and tab
+
+
+ var toggle = !is$1.empty(this.options.quality) && this.options.quality.length > 1;
+ controls.toggleMenuButton.call(this, type, toggle); // Empty the menu
+
+ emptyElement(list); // Check if we need to toggle the parent
+
+ controls.checkMenu.call(this); // If we're hiding, nothing more to do
+
+ if (!toggle) {
+ return;
+ } // Get the badge HTML for HD, 4K etc
+
+
+ var getBadge = function getBadge(quality) {
+ var label = i18n.get("qualityBadge.".concat(quality), _this6.config);
+
+ if (!label.length) {
+ return null;
+ }
+
+ return controls.createBadge.call(_this6, label);
+ }; // Sort options by the config and then render options
+
+
+ this.options.quality.sort(function (a, b) {
+ var sorting = _this6.config.quality.options;
+ return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
+ }).forEach(function (quality) {
+ controls.createMenuItem.call(_this6, {
+ value: quality,
+ list: list,
+ type: type,
+ title: controls.getLabel.call(_this6, 'quality', quality),
+ badge: getBadge(quality)
+ });
+ });
+ controls.updateSetting.call(this, type, list);
+ },
+ // Set the looping options
+
+ /* setLoopMenu() {
+ // Menu required
+ if (!is.element(this.elements.settings.panels.loop)) {
+ return;
+ }
+ const options = ['start', 'end', 'all', 'reset'];
+ const list = this.elements.settings.panels.loop.querySelector('[role="menu"]');
+ // Show the pane and tab
+ toggleHidden(this.elements.settings.buttons.loop, false);
+ toggleHidden(this.elements.settings.panels.loop, false);
+ // Toggle the pane and tab
+ const toggle = !is.empty(this.loop.options);
+ controls.toggleMenuButton.call(this, 'loop', toggle);
+ // Empty the menu
+ emptyElement(list);
+ options.forEach(option => {
+ const item = createElement('li');
+ const button = createElement(
+ 'button',
+ extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {
+ type: 'button',
+ class: this.config.classNames.control,
+ 'data-plyr-loop-action': option,
+ }),
+ i18n.get(option, this.config)
+ );
+ if (['start', 'end'].includes(option)) {
+ const badge = controls.createBadge.call(this, '00:00');
+ button.appendChild(badge);
+ }
+ item.appendChild(button);
+ list.appendChild(item);
+ });
+ }, */
+ // Get current selected caption language
+ // TODO: rework this to user the getter in the API?
+ // Set a list of available captions languages
+ setCaptionsMenu: function setCaptionsMenu() {
+ var _this7 = this;
+
+ // Menu required
+ if (!is$1.element(this.elements.settings.panels.captions)) {
+ return;
+ } // TODO: Captions or language? Currently it's mixed
+
+
+ var type = 'captions';
+ var list = this.elements.settings.panels.captions.querySelector('[role="menucaptions"]');
+ var tracks = captions.getTracks.call(this);
+ var toggle = Boolean(tracks.length); // Toggle the pane and tab
+
+ controls.toggleMenuButton.call(this, type, toggle); // Empty the menu
+
+ emptyElement(list); // Check if we need to toggle the parent
+
+ controls.checkMenu.call(this); // If there's no captions, bail
+
+ if (!toggle) {
+ return;
+ } // Generate options data
+
+
+ var options = tracks.map(function (track, value) {
+ return {
+ value: value,
+ checked: _this7.captions.toggled && _this7.currentTrack === value,
+ title: captions.getLabel.call(_this7, track),
+ badge: track.language && controls.createBadge.call(_this7, track.language.toUpperCase()),
+ list: list,
+ type: 'language'
+ };
+ }); // Add the "Disabled" option to turn off captions
+
+ options.unshift({
+ value: -1,
+ checked: !this.captions.toggled,
+ title: i18n.get('disabled', this.config),
+ list: list,
+ type: 'language'
+ }); // Generate options
+
+ options.forEach(controls.createMenuItem.bind(this));
+ controls.updateSetting.call(this, type, list);
+ },
+ // Set a list of available captions languages
+ setSpeedMenu: function setSpeedMenu() {
+ var _this8 = this;
+
+ // Menu required
+ if (!is$1.element(this.elements.settings.panels.speed)) {
+ return;
+ }
+
+ var type = 'speed';
+ var list = this.elements.settings.panels.speed.querySelector('[role="menu"]'); // Filter out invalid speeds
+
+ this.options.speed = this.options.speed.filter(function (o) {
+ return o >= _this8.minimumSpeed && o <= _this8.maximumSpeed;
+ }); // Toggle the pane and tab
+
+ var toggle = !is$1.empty(this.options.speed) && this.options.speed.length > 1;
+ controls.toggleMenuButton.call(this, type, toggle); // Empty the menu
+
+ emptyElement(list); // Check if we need to toggle the parent
+
+ controls.checkMenu.call(this); // If we're hiding, nothing more to do
+
+ if (!toggle) {
+ return;
+ } // Create items
+
+
+ this.options.speed.forEach(function (speed) {
+ controls.createMenuItem.call(_this8, {
+ value: speed,
+ list: list,
+ type: type,
+ title: controls.getLabel.call(_this8, 'speed', speed)
+ });
+ });
+ controls.updateSetting.call(this, type, list);
+ },
+ // Check if we need to hide/show the settings menu
+ checkMenu: function checkMenu() {
+ var buttons = this.elements.settings.buttons;
+ var visible = !is$1.empty(buttons) && Object.values(buttons).some(function (button) {
+ return !button.hidden;
+ });
+ toggleHidden(this.elements.settings.menu, !visible);
+ },
+ // Focus the first menu item in a given (or visible) menu
+ focusFirstMenuItem: function focusFirstMenuItem(pane) {
+ var tabFocus = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+
+ if (this.elements.settings.popup.hidden) {
+ return;
+ }
+
+ var target = pane;
+
+ if (!is$1.element(target)) {
+ target = Object.values(this.elements.settings.panels).find(function (p) {
+ return !p.hidden;
+ });
+ }
+
+ var firstItem = target.querySelector('[role^="menuitem"]');
+ setFocus.call(this, firstItem, tabFocus);
+ },
+ // Show/hide menu
+ toggleMenu: function toggleMenu(input) {
+ var popup = this.elements.settings.popup;
+ var button = this.elements.buttons.settings; // Menu and button are required
+
+ if (!is$1.element(popup) || !is$1.element(button)) {
+ return;
+ } // True toggle by default
+
+
+ var hidden = popup.hidden;
+ var show = hidden;
+
+ if (is$1.boolean(input)) {
+ show = input;
+ } else if (is$1.keyboardEvent(input) && input.which === 27) {
+ show = false;
+ } else if (is$1.event(input)) {
+ // If Plyr is in a shadowDOM, the event target is set to the component, instead of the
+ // Element in the shadowDOM. The path, if available, is complete.
+ var target = is$1.function(input.composedPath) ? input.composedPath()[0] : input.target;
+ var isMenuItem = popup.contains(target); // If the click was inside the menu or if the click
+ // wasn't the button or menu item and we're trying to
+ // show the menu (a doc click shouldn't show the menu)
+
+ if (isMenuItem || !isMenuItem && input.target !== button && show) {
+ return;
+ }
+ } // Set button attributes
+
+
+ button.setAttribute('aria-expanded', show); // Show the actual popup
+
+ toggleHidden(popup, !show); // Add class hook
+
+ toggleClass(this.elements.container, this.config.classNames.menu.open, show); // Focus the first item if key interaction
+
+ if (show && is$1.keyboardEvent(input)) {
+ controls.focusFirstMenuItem.call(this, null, true);
+ } else if (!show && !hidden) {
+ // If closing, re-focus the button
+ setFocus.call(this, button, is$1.keyboardEvent(input));
+ }
+ },
+ // Get the natural size of a menu panel
+ getMenuSize: function getMenuSize(tab) {
+ var clone = tab.cloneNode(true);
+ clone.style.position = 'absolute';
+ clone.style.opacity = 0;
+ clone.removeAttribute('hidden'); // Append to parent so we get the "real" size
+
+ tab.parentNode.appendChild(clone); // Get the sizes before we remove
+
+ var width = clone.scrollWidth;
+ var height = clone.scrollHeight; // Remove from the DOM
+
+ removeElement(clone);
+ return {
+ width: width,
+ height: height
+ };
+ },
+ // Show a panel in the menu
+ showMenuPanel: function showMenuPanel() {
+ var _this9 = this;
+
+ var type = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
+ var tabFocus = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+ var target = this.elements.container.querySelector("#plyr-settings-".concat(this.id, "-").concat(type)); // Nothing to show, bail
+
+ if (!is$1.element(target)) {
+ return;
+ } // Hide all other panels
+
+
+ var container = target.parentNode;
+ var current = Array.from(container.children).find(function (node) {
+ return !node.hidden;
+ }); // If we can do fancy animations, we'll animate the height/width
+
+ if (support.transitions && !support.reducedMotion) {
+ // Set the current width as a base
+ container.style.width = "".concat(current.scrollWidth, "px");
+ container.style.height = "".concat(current.scrollHeight, "px"); // Get potential sizes
+
+ var size = controls.getMenuSize.call(this, target); // Restore auto height/width
+
+ var restore = function restore(event) {
+ // We're only bothered about height and width on the container
+ if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
+ return;
+ } // Revert back to auto
+
+
+ container.style.width = '';
+ container.style.height = ''; // Only listen once
+
+ off.call(_this9, container, transitionEndEvent, restore);
+ }; // Listen for the transition finishing and restore auto height/width
+
+
+ on.call(this, container, transitionEndEvent, restore); // Set dimensions to target
+
+ container.style.width = "".concat(size.width, "px");
+ container.style.height = "".concat(size.height, "px");
+ } // Set attributes on current tab
+
+
+ toggleHidden(current, true); // Set attributes on target
+
+ toggleHidden(target, false); // Focus the first item
+
+ controls.focusFirstMenuItem.call(this, target, tabFocus);
+ },
+ // Set the download URL
+ setDownloadUrl: function setDownloadUrl() {
+ var button = this.elements.buttons.download; // Bail if no button
+
+ if (!is$1.element(button)) {
+ return;
+ } // Set attribute
+
+
+ button.setAttribute('href', this.download);
+ },
+ // Build the default HTML
+ create: function create(data) {
+ var _this10 = this;
+
+ var bindMenuItemShortcuts = controls.bindMenuItemShortcuts,
+ createButton = controls.createButton,
+ createProgress = controls.createProgress,
+ createRange = controls.createRange,
+ createTime = controls.createTime,
+ setQualityMenu = controls.setQualityMenu,
+ setSpeedMenu = controls.setSpeedMenu,
+ showMenuPanel = controls.showMenuPanel;
+ this.elements.controls = null; // Larger overlaid play button
+
+ if (is$1.array(this.config.controls) && this.config.controls.includes('play-large')) {
+ this.elements.container.appendChild(createButton.call(this, 'play-large'));
+ } // Create the container
+
+
+ var container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
+ this.elements.controls = container; // Default item attributes
+
+ var defaultAttributes = {
+ class: 'plyr__controls__item'
+ }; // Loop through controls in order
+
+ dedupe(is$1.array(this.config.controls) ? this.config.controls : []).forEach(function (control) {
+ // Restart button
+ if (control === 'restart') {
+ container.appendChild(createButton.call(_this10, 'restart', defaultAttributes));
+ } // Rewind button
+
+
+ if (control === 'rewind') {
+ container.appendChild(createButton.call(_this10, 'rewind', defaultAttributes));
+ } // Play/Pause button
+
+
+ if (control === 'play') {
+ container.appendChild(createButton.call(_this10, 'play', defaultAttributes));
+ } // Fast forward button
+
+
+ if (control === 'fast-forward') {
+ container.appendChild(createButton.call(_this10, 'fast-forward', defaultAttributes));
+ } // Progress
+
+
+ if (control === 'progress') {
+ var progressContainer = createElement('div', {
+ class: "".concat(defaultAttributes.class, " plyr__progress__container")
+ });
+ var progress = createElement('div', getAttributesFromSelector(_this10.config.selectors.progress)); // Seek range slider
+
+ progress.appendChild(createRange.call(_this10, 'seek', {
+ id: "plyr-seek-".concat(data.id)
+ })); // Buffer progress
+
+ progress.appendChild(createProgress.call(_this10, 'buffer')); // TODO: Add loop display indicator
+ // Seek tooltip
+
+ if (_this10.config.tooltips.seek) {
+ var tooltip = createElement('span', {
+ class: _this10.config.classNames.tooltip
+ }, '00:00');
+ progress.appendChild(tooltip);
+ _this10.elements.display.seekTooltip = tooltip;
+ }
+
+ _this10.elements.progress = progress;
+ progressContainer.appendChild(_this10.elements.progress);
+ container.appendChild(progressContainer);
+ } // Media current time display
+
+
+ if (control === 'current-time') {
+ container.appendChild(createTime.call(_this10, 'currentTime', defaultAttributes));
+ } // Media duration display
+
+
+ if (control === 'duration') {
+ container.appendChild(createTime.call(_this10, 'duration', defaultAttributes));
+ } // Volume controls
+
+
+ if (control === 'mute' || control === 'volume') {
+ var volume = _this10.elements.volume; // Create the volume container if needed
+
+ if (!is$1.element(volume) || !container.contains(volume)) {
+ volume = createElement('div', extend({}, defaultAttributes, {
+ class: "".concat(defaultAttributes.class, " plyr__volume").trim()
+ }));
+ _this10.elements.volume = volume;
+ container.appendChild(volume);
+ } // Toggle mute button
+
+
+ if (control === 'mute') {
+ volume.appendChild(createButton.call(_this10, 'mute'));
+ } // Volume range control
+ // Ignored on iOS as it's handled globally
+ // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
+
+
+ if (control === 'volume' && !browser.isIos) {
+ // Set the attributes
+ var attributes = {
+ max: 1,
+ step: 0.05,
+ value: _this10.config.volume
+ }; // Create the volume range slider
+
+ volume.appendChild(createRange.call(_this10, 'volume', extend(attributes, {
+ id: "plyr-volume-".concat(data.id)
+ })));
+ }
+ } // Toggle captions button
+
+
+ if (control === 'captions') {
+ container.appendChild(createButton.call(_this10, 'captions', defaultAttributes));
+ } // Settings button / menu
+
+
+ if (control === 'settings' && !is$1.empty(_this10.config.settings)) {
+ var wrapper = createElement('div', extend({}, defaultAttributes, {
+ class: "".concat(defaultAttributes.class, " plyr__menu").trim(),
+ hidden: ''
+ }));
+ wrapper.appendChild(createButton.call(_this10, 'settings', {
+ 'aria-haspopup': true,
+ 'aria-controls': "plyr-settings-".concat(data.id),
+ 'aria-expanded': false
+ }));
+ var popup = createElement('div', {
+ class: 'plyr__menu__container',
+ id: "plyr-settings-".concat(data.id),
+ hidden: ''
+ });
+ var inner = createElement('div');
+ var home = createElement('div', {
+ id: "plyr-settings-".concat(data.id, "-home")
+ }); // Create the menu
+
+ var menu = createElement('div', {
+ role: 'menu'
+ });
+ home.appendChild(menu);
+ inner.appendChild(home);
+ _this10.elements.settings.panels.home = home; // Build the menu items
+
+ _this10.config.settings.forEach(function (type) {
+ // TODO: bundle this with the createMenuItem helper and bindings
+ var menuItem = createElement('button', extend(getAttributesFromSelector(_this10.config.selectors.buttons.settings), {
+ type: 'button',
+ class: "".concat(_this10.config.classNames.control, " ").concat(_this10.config.classNames.control, "--forward"),
+ role: 'menuitem',
+ 'aria-haspopup': true,
+ hidden: ''
+ })); // Bind menu shortcuts for keyboard users
+
+ bindMenuItemShortcuts.call(_this10, menuItem, type); // Show menu on click
+
+ on.call(_this10, menuItem, 'click', function () {
+ showMenuPanel.call(_this10, type, false);
+ });
+ var flex = createElement('span', null, i18n.get(type, _this10.config));
+ var value = createElement('span', {
+ class: _this10.config.classNames.menu.value
+ }); // Speed contains HTML entities
+
+ value.innerHTML = data[type];
+ flex.appendChild(value);
+ menuItem.appendChild(flex);
+ menu.appendChild(menuItem); // Build the panes
+
+ var pane = createElement('div', {
+ id: "plyr-settings-".concat(data.id, "-").concat(type),
+ hidden: ''
+ }); // Back button
+
+ var backButton = createElement('button', {
+ type: 'button',
+ class: "".concat(_this10.config.classNames.control, " ").concat(_this10.config.classNames.control, "--back")
+ }); // Visible label
+
+ backButton.appendChild(createElement('span', {
+ 'aria-hidden': true
+ }, i18n.get(type, _this10.config))); // Screen reader label
+
+ backButton.appendChild(createElement('span', {
+ class: _this10.config.classNames.hidden
+ }, i18n.get('menuBack', _this10.config))); // Go back via keyboard
+
+ on.call(_this10, pane, 'keydown', function (event) {
+ // We only care about <-
+ if (event.which !== 37) {
+ return;
+ } // Prevent seek
+
+
+ event.preventDefault();
+ event.stopPropagation(); // Show the respective menu
+
+ showMenuPanel.call(_this10, 'home', true);
+ }, false); // Go back via button click
+
+ on.call(_this10, backButton, 'click', function () {
+ showMenuPanel.call(_this10, 'home', false);
+ }); // Add to pane
+
+ pane.appendChild(backButton); // Menu
+
+ pane.appendChild(createElement('div', {
+ role: 'menu'
+ })); // Menu Captions
+
+ pane.appendChild(createElement('div', {
+ role: 'menucaptions'
+ }));
+ inner.appendChild(pane);
+ _this10.elements.settings.buttons[type] = menuItem;
+ _this10.elements.settings.panels[type] = pane;
+ });
+
+ popup.appendChild(inner);
+ wrapper.appendChild(popup);
+ container.appendChild(wrapper);
+ _this10.elements.settings.popup = popup;
+ _this10.elements.settings.menu = wrapper;
+ } // Picture in picture button
+
+
+ if (control === 'pip' && support.pip) {
+ container.appendChild(createButton.call(_this10, 'pip', defaultAttributes));
+ } // Airplay button
+
+
+ if (control === 'airplay' && support.airplay) {
+ container.appendChild(createButton.call(_this10, 'airplay', defaultAttributes));
+ } // Download button
+
+
+ if (control === 'download') {
+ var _attributes = extend({}, defaultAttributes, {
+ element: 'a',
+ href: _this10.download,
+ target: '_blank'
+ }); // Set download attribute for HTML5 only
+
+
+ if (_this10.isHTML5) {
+ _attributes.download = '';
+ }
+
+ var download = _this10.config.urls.download;
+
+ if (!is$1.url(download) && _this10.isEmbed) {
+ extend(_attributes, {
+ icon: "logo-".concat(_this10.provider),
+ label: _this10.provider
+ });
+ }
+
+ container.appendChild(createButton.call(_this10, 'download', _attributes));
+ } // Toggle fullscreen button
+
+
+ if (control === 'fullscreen') {
+ container.appendChild(createButton.call(_this10, 'fullscreen', defaultAttributes));
+ }
+ }); // Set available quality levels
+
+ if (this.isHTML5) {
+ setQualityMenu.call(this, html5.getQualityOptions.call(this));
+ }
+
+ setSpeedMenu.call(this);
+ return container;
+ },
+ // Insert controls
+ inject: function inject() {
+ var _this11 = this;
+
+ // Sprite
+ if (this.config.loadSprite) {
+ var icon = controls.getIconUrl.call(this); // Only load external sprite using AJAX
+
+ if (icon.cors) {
+ loadSprite(icon.url, 'sprite-plyr');
+ }
+ } // Create a unique ID
+
+
+ this.id = Math.floor(Math.random() * 10000); // Null by default
+
+ var container = null;
+ this.elements.controls = null; // Set template properties
+
+ var props = {
+ id: this.id,
+ seektime: this.config.seekTime,
+ title: this.config.title
+ };
+ var update = true; // If function, run it and use output
+
+ if (is$1.function(this.config.controls)) {
+ this.config.controls = this.config.controls.call(this, props);
+ } // Convert falsy controls to empty array (primarily for empty strings)
+
+
+ if (!this.config.controls) {
+ this.config.controls = [];
+ }
+
+ if (is$1.element(this.config.controls) || is$1.string(this.config.controls)) {
+ // HTMLElement or Non-empty string passed as the option
+ container = this.config.controls;
+ } else {
+ // Create controls
+ container = controls.create.call(this, {
+ id: this.id,
+ seektime: this.config.seekTime,
+ speed: this.speed,
+ quality: this.quality,
+ captions: captions.getLabel.call(this) // TODO: Looping
+ // loop: 'None',
+
+ });
+ update = false;
+ } // Replace props with their value
+
+
+ var replace = function replace(input) {
+ var result = input;
+ Object.entries(props).forEach(function (_ref2) {
+ var _ref3 = _slicedToArray(_ref2, 2),
+ key = _ref3[0],
+ value = _ref3[1];
+
+ result = replaceAll(result, "{".concat(key, "}"), value);
+ });
+ return result;
+ }; // Update markup
+
+
+ if (update) {
+ if (is$1.string(this.config.controls)) {
+ container = replace(container);
+ }
+ } // Controls container
+
+
+ var target; // Inject to custom location
+
+ if (is$1.string(this.config.selectors.controls.container)) {
+ target = document.querySelector(this.config.selectors.controls.container);
+ } // Inject into the container by default
+
+
+ if (!is$1.element(target)) {
+ target = this.elements.container;
+ } // Inject controls HTML (needs to be before captions, hence "afterbegin")
+
+
+ var insertMethod = is$1.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';
+ target[insertMethod]('afterbegin', container); // Find the elements if need be
+
+ if (!is$1.element(this.elements.controls)) {
+ controls.findElements.call(this);
+ } // Add pressed property to buttons
+
+
+ if (!is$1.empty(this.elements.buttons)) {
+ var addProperty = function addProperty(button) {
+ var className = _this11.config.classNames.controlPressed;
+ Object.defineProperty(button, 'pressed', {
+ enumerable: true,
+ get: function get() {
+ return hasClass(button, className);
+ },
+ set: function set() {
+ var pressed = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+ toggleClass(button, className, pressed);
+ }
+ });
+ }; // Toggle classname when pressed property is set
+
+
+ Object.values(this.elements.buttons).filter(Boolean).forEach(function (button) {
+ if (is$1.array(button) || is$1.nodeList(button)) {
+ Array.from(button).filter(Boolean).forEach(addProperty);
+ } else {
+ addProperty(button);
+ }
+ });
+ } // Edge sometimes doesn't finish the paint so force a repaint
+
+
+ if (browser.isEdge) {
+ repaint(target);
+ } // Setup tooltips
+
+
+ if (this.config.tooltips.controls) {
+ var _this$config = this.config,
+ classNames = _this$config.classNames,
+ selectors = _this$config.selectors;
+ var selector = "".concat(selectors.controls.wrapper, " ").concat(selectors.labels, " .").concat(classNames.hidden);
+ var labels = getElements.call(this, selector);
+ Array.from(labels).forEach(function (label) {
+ toggleClass(label, _this11.config.classNames.hidden, false);
+ toggleClass(label, _this11.config.classNames.tooltip, true);
+ });
+ }
+ }
+ };
+
+ /**
+ * Parse a string to a URL object
+ * @param {String} input - the URL to be parsed
+ * @param {Boolean} safe - failsafe parsing
+ */
+
+ function parseUrl(input) {
+ var safe = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
+ var url = input;
+
+ if (safe) {
+ var parser = document.createElement('a');
+ parser.href = url;
+ url = parser.href;
+ }
+
+ try {
+ return new URL(url);
+ } catch (e) {
+ return null;
+ }
+ } // Convert object to URLSearchParams
+
+ function buildUrlParams(input) {
+ var params = new URLSearchParams();
+
+ if (is$1.object(input)) {
+ Object.entries(input).forEach(function (_ref) {
+ var _ref2 = _slicedToArray(_ref, 2),
+ key = _ref2[0],
+ value = _ref2[1];
+
+ params.set(key, value);
+ });
+ }
+
+ return params;
+ }
+
+ var captions = {
+ // Setup captions
+ setup: function setup() {
+ // Requires UI support
+ if (!this.supported.ui) {
+ return;
+ } // Only Vimeo and HTML5 video supported at this point
+
+
+ if (!this.isVideo || this.isYouTube || this.isHTML5 && !support.textTracks) {
+ // Clear menu and hide
+ if (is$1.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
+ controls.setCaptionsMenu.call(this);
+ }
+
+ return;
+ } // Inject the container
+
+
+ if (!is$1.element(this.elements.captions)) {
+ this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));
+ insertAfter(this.elements.captions, this.elements.wrapper);
+ } // Fix IE captions if CORS is used
+ // Fetch captions and inject as blobs instead (data URIs not supported!)
+
+
+ if (browser.isIE && window.URL) {
+ var elements = this.media.querySelectorAll('track');
+ Array.from(elements).forEach(function (track) {
+ var src = track.getAttribute('src');
+ var url = parseUrl(src);
+
+ if (url !== null && url.hostname !== window.location.href.hostname && ['http:', 'https:'].includes(url.protocol)) {
+ fetch(src, 'blob').then(function (blob) {
+ track.setAttribute('src', window.URL.createObjectURL(blob));
+ }).catch(function () {
+ removeElement(track);
+ });
+ }
+ });
+ } // Get and set initial data
+ // The "preferred" options are not realized unless / until the wanted language has a match
+ // * languages: Array of user's browser languages.
+ // * language: The language preferred by user settings or config
+ // * active: The state preferred by user settings or config
+ // * toggled: The real captions state
+
+
+ var browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];
+ var languages = dedupe(browserLanguages.map(function (language) {
+ return language.split('-')[0];
+ }));
+ var language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase(); // Use first browser language when language is 'auto'
+
+ if (language === 'auto') {
+ var _languages = _slicedToArray(languages, 1);
+
+ language = _languages[0];
+ }
+
+ var active = this.storage.get('captions');
+
+ if (!is$1.boolean(active)) {
+ active = this.config.captions.active;
+ }
+
+ Object.assign(this.captions, {
+ toggled: false,
+ active: active,
+ language: language,
+ languages: languages
+ }); // Watch changes to textTracks and update captions menu
+
+ if (this.isHTML5) {
+ var trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
+ on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));
+ } // Update available languages in list next tick (the event must not be triggered before the listeners)
+
+
+ setTimeout(captions.update.bind(this), 0);
+ },
+ // Update available language options in settings based on tracks
+ update: function update() {
+ var _this = this;
+
+ var tracks = captions.getTracks.call(this, true); // Get the wanted language
+
+ var _this$captions = this.captions,
+ active = _this$captions.active,
+ language = _this$captions.language,
+ meta = _this$captions.meta,
+ currentTrackNode = _this$captions.currentTrackNode;
+ var languageExists = Boolean(tracks.find(function (track) {
+ return track.language === language;
+ })); // Handle tracks (add event listener and "pseudo"-default)
+
+ if (this.isHTML5 && this.isVideo) {
+ tracks.filter(function (track) {
+ return !meta.get(track);
+ }).forEach(function (track) {
+ _this.debug.log('Track added', track); // Attempt to store if the original dom element was "default"
+
+
+ meta.set(track, {
+ default: track.mode === 'showing'
+ }); // Turn off native caption rendering to avoid double captions
+ // Note: mode='hidden' forces a track to download. To ensure every track
+ // isn't downloaded at once, only 'showing' tracks should be reassigned
+ // eslint-disable-next-line no-param-reassign
+
+ if (track.mode === 'showing') {
+ // eslint-disable-next-line no-param-reassign
+ track.mode = 'hidden';
+ } // Add event listener for cue changes
+
+
+ on.call(_this, track, 'cuechange', function () {
+ return captions.updateCues.call(_this);
+ });
+ });
+ } // Update language first time it matches, or if the previous matching track was removed
+
+
+ if (languageExists && this.language !== language || !tracks.includes(currentTrackNode)) {
+ captions.setLanguage.call(this, language);
+ captions.toggle.call(this, active && languageExists);
+ } // Enable or disable captions based on track length
+
+
+ toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is$1.empty(tracks)); // Update available languages in list
+
+ if (is$1.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
+ controls.setCaptionsMenu.call(this);
+ }
+ },
+ // Toggle captions display
+ // Used internally for the toggleCaptions method, with the passive option forced to false
+ toggle: function toggle(input) {
+ var _this2 = this;
+
+ var passive = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
+
+ // If there's no full support
+ if (!this.supported.ui) {
+ return;
+ }
+
+ var toggled = this.captions.toggled; // Current state
+
+ var activeClass = this.config.classNames.captions.active; // Get the next state
+ // If the method is called without parameter, toggle based on current value
+
+ var active = is$1.nullOrUndefined(input) ? !toggled : input; // Update state and trigger event
+
+ if (active !== toggled) {
+ // When passive, don't override user preferences
+ if (!passive) {
+ this.captions.active = active;
+ this.storage.set({
+ captions: active
+ });
+ } // Force language if the call isn't passive and there is no matching language to toggle to
+
+
+ if (!this.language && active && !passive) {
+ var tracks = captions.getTracks.call(this);
+ var track = captions.findTrack.call(this, [this.captions.language].concat(_toConsumableArray(this.captions.languages)), true); // Override user preferences to avoid switching languages if a matching track is added
+
+ this.captions.language = track.language; // Set caption, but don't store in localStorage as user preference
+
+ captions.set.call(this, tracks.indexOf(track));
+ return;
+ } // Toggle button if it's enabled
+
+
+ if (this.elements.buttons.captions) {
+ this.elements.buttons.captions.pressed = active;
+ } // Add class hook
+
+
+ toggleClass(this.elements.container, activeClass, active);
+ this.captions.toggled = active; // Update settings menu
+
+ controls.updateSetting.call(this, 'captions'); // Trigger event (not used internally)
+
+ triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');
+ } // Wait for the call stack to clear before setting mode='hidden'
+ // on the active track - forcing the browser to download it
+
+
+ setTimeout(function () {
+ if (active && _this2.captions.toggled) {
+ _this2.captions.currentTrackNode.mode = 'hidden';
+ }
+ });
+ },
+ // Set captions by track index
+ // Used internally for the currentTrack setter with the passive option forced to false
+ set: function set(index) {
+ var passive = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
+ var tracks = captions.getTracks.call(this); // Disable captions if setting to -1
+
+ if (index === -1) {
+ captions.toggle.call(this, false, passive);
+ return;
+ }
+
+ if (!is$1.number(index)) {
+ this.debug.warn('Invalid caption argument', index);
+ return;
+ }
+
+ if (!(index in tracks)) {
+ this.debug.warn('Track not found', index);
+ return;
+ }
+
+ if (this.captions.currentTrack !== index) {
+ this.captions.currentTrack = index;
+ var track = tracks[index];
+
+ var _ref = track || {},
+ language = _ref.language; // Store reference to node for invalidation on remove
+
+
+ this.captions.currentTrackNode = track; // Update settings menu
+
+ controls.updateSetting.call(this, 'captions'); // When passive, don't override user preferences
+
+ if (!passive) {
+ this.captions.language = language;
+ this.storage.set({
+ language: language
+ });
+ } // Handle Vimeo captions
+
+
+ if (this.isVimeo) {
+ this.embed.enableTextTrack(language);
+ } // Trigger event
+
+
+ triggerEvent.call(this, this.media, 'languagechange');
+ } // Show captions
+
+
+ captions.toggle.call(this, true, passive);
+
+ if (this.isHTML5 && this.isVideo) {
+ // If we change the active track while a cue is already displayed we need to update it
+ captions.updateCues.call(this);
+ }
+ },
+ // Set captions by language
+ // Used internally for the language setter with the passive option forced to false
+ setLanguage: function setLanguage(input) {
+ var passive = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
+
+ if (!is$1.string(input)) {
+ this.debug.warn('Invalid language argument', input);
+ return;
+ } // Normalize
+
+
+ var language = input.toLowerCase();
+ this.captions.language = language; // Set currentTrack
+
+ var tracks = captions.getTracks.call(this);
+ var track = captions.findTrack.call(this, [language]);
+ captions.set.call(this, tracks.indexOf(track), passive);
+ },
+ // Get current valid caption tracks
+ // If update is false it will also ignore tracks without metadata
+ // This is used to "freeze" the language options when captions.update is false
+ getTracks: function getTracks() {
+ var _this3 = this;
+
+ var update = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+ // Handle media or textTracks missing or null
+ var tracks = Array.from((this.media || {}).textTracks || []); // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)
+ // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)
+
+ return tracks.filter(function (track) {
+ return !_this3.isHTML5 || update || _this3.captions.meta.has(track);
+ }).filter(function (track) {
+ return ['captions', 'subtitles'].includes(track.kind);
+ });
+ },
+ // Match tracks based on languages and get the first
+ findTrack: function findTrack(languages) {
+ var _this4 = this;
+
+ var force = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+ var tracks = captions.getTracks.call(this);
+
+ var sortIsDefault = function sortIsDefault(track) {
+ return Number((_this4.captions.meta.get(track) || {}).default);
+ };
+
+ var sorted = Array.from(tracks).sort(function (a, b) {
+ return sortIsDefault(b) - sortIsDefault(a);
+ });
+ var track;
+ languages.every(function (language) {
+ track = sorted.find(function (t) {
+ return t.language === language;
+ });
+ return !track; // Break iteration if there is a match
+ }); // If no match is found but is required, get first
+
+ return track || (force ? sorted[0] : undefined);
+ },
+ // Get the current track
+ getCurrentTrack: function getCurrentTrack() {
+ return captions.getTracks.call(this)[this.currentTrack];
+ },
+ // Get UI label for track
+ getLabel: function getLabel(track) {
+ var currentTrack = track;
+
+ if (!is$1.track(currentTrack) && support.textTracks && this.captions.toggled) {
+ currentTrack = captions.getCurrentTrack.call(this);
+ }
+
+ if (is$1.track(currentTrack)) {
+ if (!is$1.empty(currentTrack.label)) {
+ return currentTrack.label;
+ }
+
+ if (!is$1.empty(currentTrack.language)) {
+ return track.language.toUpperCase();
+ }
+
+ return i18n.get('enabled', this.config);
+ }
+
+ return i18n.get('disabled', this.config);
+ },
+ // Update captions using current track's active cues
+ // Also optional array argument in case there isn't any track (ex: vimeo)
+ updateCues: function updateCues(input) {
+ // Requires UI
+ if (!this.supported.ui) {
+ return;
+ }
+
+ if (!is$1.element(this.elements.captions)) {
+ this.debug.warn('No captions element to render to');
+ return;
+ } // Only accept array or empty input
+
+
+ if (!is$1.nullOrUndefined(input) && !Array.isArray(input)) {
+ this.debug.warn('updateCues: Invalid input', input);
+ return;
+ }
+
+ var cues = input; // Get cues from track
+
+ if (!cues) {
+ var track = captions.getCurrentTrack.call(this);
+ cues = Array.from((track || {}).activeCues || []).map(function (cue) {
+ return cue.getCueAsHTML();
+ }).map(getHTML);
+ } // Set new caption text
+
+
+ var content = cues.map(function (cueText) {
+ return cueText.trim();
+ }).join('\n');
+ var changed = content !== this.elements.captions.innerHTML;
+
+ if (changed) {
+ // Empty the container and create a new child element
+ emptyElement(this.elements.captions);
+ var caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
+ caption.innerHTML = content;
+ this.elements.captions.appendChild(caption); // Trigger event
+
+ triggerEvent.call(this, this.media, 'cuechange');
+ }
+ }
+ };
+
+ // ==========================================================================
+ // Plyr default config
+ // ==========================================================================
+ var defaults$1 = {
+ // Disable
+ enabled: true,
+ // Custom media title
+ title: '',
+ // Logging to console
+ debug: false,
+ // Auto play (if supported)
+ autoplay: false,
+ // Only allow one media playing at once (vimeo only)
+ autopause: true,
+ // Allow inline playback on iOS (this effects YouTube/Vimeo - HTML5 requires the attribute present)
+ // TODO: Remove iosNative fullscreen option in favour of this (logic needs work)
+ playsinline: true,
+ // Default time to skip when rewind/fast forward
+ seekTime: 10,
+ // Default volume
+ volume: 1,
+ muted: false,
+ // Pass a custom duration
+ duration: null,
+ // Display the media duration on load in the current time position
+ // If you have opted to display both duration and currentTime, this is ignored
+ displayDuration: true,
+ // Invert the current time to be a countdown
+ invertTime: true,
+ // Clicking the currentTime inverts it's value to show time left rather than elapsed
+ toggleInvert: true,
+ // Force an aspect ratio
+ // The format must be `'w:h'` (e.g. `'16:9'`)
+ ratio: null,
+ // Click video container to play/pause
+ clickToPlay: true,
+ // Auto hide the controls
+ hideControls: true,
+ // Reset to start when playback ended
+ resetOnEnd: false,
+ // Disable the standard context menu
+ disableContextMenu: true,
+ // Sprite (for icons)
+ loadSprite: true,
+ iconPrefix: 'plyr',
+ iconUrl: '/theme/modules/plyr/plyr.svg',
+ // Blank video (used to prevent errors on source change)
+ blankVideo: '/theme/modules/plyr/blank.webm',
+ // Quality default
+ quality: {
+ default: 576,
+ // The options to display in the UI, if available for the source media
+ options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240],
+ forced: false,
+ onChange: null
+ },
+ // Set loops
+ loop: {
+ active: false // start: null,
+ // end: null,
+
+ },
+ // Speed default and options to display
+ speed: {
+ selected: 1,
+ // The options to display in the UI, if available for the source media (e.g. Vimeo and YouTube only support 0.5x-4x)
+ options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4]
+ },
+ // Keyboard shortcut settings
+ keyboard: {
+ focused: true,
+ global: false
+ },
+ // Display tooltips
+ tooltips: {
+ controls: false,
+ seek: true
+ },
+ // Captions settings
+ captions: {
+ active: false,
+ language: 'auto',
+ // Listen to new tracks added after Plyr is initialized.
+ // This is needed for streaming captions, but may result in unselectable options
+ update: false
+ },
+ // Fullscreen settings
+ fullscreen: {
+ enabled: true,
+ // Allow fullscreen?
+ fallback: true,
+ // Fallback using full viewport/window
+ iosNative: false // Use the native fullscreen in iOS (disables custom controls)
+ // Selector for the fullscreen container so contextual / non-player content can remain visible in fullscreen mode
+ // Non-ancestors of the player element will be ignored
+ // container: null, // defaults to the player element
+
+ },
+ // Local storage
+ storage: {
+ enabled: true,
+ key: 'plyr'
+ },
+ // Default controls
+ controls: ['play-large', // 'restart',
+ // 'rewind',
+ 'play', // 'fast-forward',
+ 'progress', 'current-time', // 'duration',
+ 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay', // 'download',
+ 'fullscreen'],
+ settings: ['captions', 'quality', 'speed'],
+ // Localisation
+ i18n: {
+ restart: 'Restart',
+ rewind: 'Rewind {seektime}s',
+ play: 'Play',
+ pause: 'Pause',
+ fastForward: 'Forward {seektime}s',
+ seek: 'Seek',
+ seekLabel: '{currentTime} of {duration}',
+ played: 'Played',
+ buffered: 'Buffered',
+ currentTime: 'Current time',
+ duration: 'Duration',
+ volume: 'Volume',
+ mute: 'Mute',
+ unmute: 'Unmute',
+ enableCaptions: 'Enable captions',
+ disableCaptions: 'Disable captions',
+ download: 'Download',
+ enterFullscreen: 'Enter fullscreen',
+ exitFullscreen: 'Exit fullscreen',
+ frameTitle: 'Player for {title}',
+ captions: 'Captions',
+ settings: 'Settings',
+ pip: 'PIP',
+ menuBack: 'Go back to previous menu',
+ speed: 'Speed',
+ normal: 'Normal',
+ quality: 'Quality',
+ loop: 'Loop',
+ start: 'Start',
+ end: 'End',
+ all: 'All',
+ reset: 'Reset',
+ disabled: 'Disabled',
+ enabled: 'Enabled',
+ advertisement: 'Ad',
+ qualityBadge: {
+ 2160: '4K',
+ 1440: 'HD',
+ 1080: 'HD',
+ 720: 'HD',
+ 576: 'SD',
+ 480: 'SD'
+ }
+ },
+ // URLs
+ urls: {
+ download: null,
+ vimeo: {
+ sdk: 'https://player.vimeo.com/api/player.js',
+ iframe: 'https://player.vimeo.com/video/{0}?{1}',
+ api: 'https://vimeo.com/api/v2/video/{0}.json'
+ },
+ youtube: {
+ sdk: 'https://www.youtube.com/iframe_api',
+ api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}'
+ },
+ googleIMA: {
+ sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js'
+ }
+ },
+ // Custom control listeners
+ listeners: {
+ seek: null,
+ play: null,
+ pause: null,
+ restart: null,
+ rewind: null,
+ fastForward: null,
+ mute: null,
+ volume: null,
+ captions: null,
+ download: null,
+ fullscreen: null,
+ pip: null,
+ airplay: null,
+ speed: null,
+ quality: null,
+ loop: null,
+ language: null
+ },
+ // Events to watch and bubble
+ events: [// Events to watch on HTML5 media elements and bubble
+ // https://developer.mozilla.org/en/docs/Web/Guide/Events/Media_events
+ 'ended', 'progress', 'stalled', 'playing', 'waiting', 'canplay', 'canplaythrough', 'loadstart', 'loadeddata', 'loadedmetadata', 'timeupdate', 'volumechange', 'play', 'pause', 'error', 'seeking', 'seeked', 'emptied', 'ratechange', 'cuechange', // Custom events
+ 'download', 'enterfullscreen', 'exitfullscreen', 'captionsenabled', 'captionsdisabled', 'languagechange', 'controlshidden', 'controlsshown', 'ready', // YouTube
+ 'statechange', // Quality
+ 'qualitychange', // Ads
+ 'adsloaded', 'adscontentpause', 'adscontentresume', 'adstarted', 'adsmidpoint', 'adscomplete', 'adsallcomplete', 'adsimpression', 'adsclick'],
+ // Selectors
+ // Change these to match your template if using custom HTML
+ selectors: {
+ editable: 'input, textarea, select, [contenteditable]',
+ container: '.plyr',
+ controls: {
+ container: null,
+ wrapper: '.plyr__controls'
+ },
+ labels: '[data-plyr]',
+ buttons: {
+ play: '[data-plyr="play"]',
+ pause: '[data-plyr="pause"]',
+ restart: '[data-plyr="restart"]',
+ rewind: '[data-plyr="rewind"]',
+ fastForward: '[data-plyr="fast-forward"]',
+ mute: '[data-plyr="mute"]',
+ captions: '[data-plyr="captions"]',
+ download: '[data-plyr="download"]',
+ fullscreen: '[data-plyr="fullscreen"]',
+ pip: '[data-plyr="pip"]',
+ airplay: '[data-plyr="airplay"]',
+ settings: '[data-plyr="settings"]',
+ loop: '[data-plyr="loop"]'
+ },
+ inputs: {
+ seek: '[data-plyr="seek"]',
+ volume: '[data-plyr="volume"]',
+ speed: '[data-plyr="speed"]',
+ language: '[data-plyr="language"]',
+ quality: '[data-plyr="quality"]'
+ },
+ display: {
+ currentTime: '.plyr__time--current',
+ duration: '.plyr__time--duration',
+ buffer: '.plyr__progress__buffer',
+ loop: '.plyr__progress__loop',
+ // Used later
+ volume: '.plyr__volume--display'
+ },
+ progress: '.plyr__progress',
+ captions: '.plyr__captions',
+ caption: '.plyr__caption'
+ },
+ // Class hooks added to the player in different states
+ classNames: {
+ type: 'plyr--{0}',
+ provider: 'plyr--{0}',
+ video: 'plyr__video-wrapper',
+ embed: 'plyr__video-embed',
+ videoFixedRatio: 'plyr__video-wrapper--fixed-ratio',
+ embedContainer: 'plyr__video-embed__container',
+ poster: 'plyr__poster',
+ posterEnabled: 'plyr__poster-enabled',
+ ads: 'plyr__ads',
+ control: 'plyr__control',
+ controlPressed: 'plyr__control--pressed',
+ playing: 'plyr--playing',
+ paused: 'plyr--paused',
+ stopped: 'plyr--stopped',
+ loading: 'plyr--loading',
+ hover: 'plyr--hover',
+ tooltip: 'plyr__tooltip',
+ cues: 'plyr__cues',
+ hidden: 'plyr__sr-only',
+ hideControls: 'plyr--hide-controls',
+ isIos: 'plyr--is-ios',
+ isTouch: 'plyr--is-touch',
+ uiSupported: 'plyr--full-ui',
+ noTransition: 'plyr--no-transition',
+ display: {
+ time: 'plyr__time'
+ },
+ menu: {
+ value: 'plyr__menu__value',
+ badge: 'plyr__badge',
+ open: 'plyr--menu-open'
+ },
+ captions: {
+ enabled: 'plyr--captions-enabled',
+ active: 'plyr--captions-active'
+ },
+ fullscreen: {
+ enabled: 'plyr--fullscreen-enabled',
+ fallback: 'plyr--fullscreen-fallback'
+ },
+ pip: {
+ supported: 'plyr--pip-supported',
+ active: 'plyr--pip-active'
+ },
+ airplay: {
+ supported: 'plyr--airplay-supported',
+ active: 'plyr--airplay-active'
+ },
+ tabFocus: 'plyr__tab-focus',
+ previewThumbnails: {
+ // Tooltip thumbs
+ thumbContainer: 'plyr__preview-thumb',
+ thumbContainerShown: 'plyr__preview-thumb--is-shown',
+ imageContainer: 'plyr__preview-thumb__image-container',
+ timeContainer: 'plyr__preview-thumb__time-container',
+ // Scrubbing
+ scrubbingContainer: 'plyr__preview-scrubbing',
+ scrubbingContainerShown: 'plyr__preview-scrubbing--is-shown'
+ }
+ },
+ // Embed attributes
+ attributes: {
+ embed: {
+ provider: 'data-plyr-provider',
+ id: 'data-plyr-embed-id'
+ }
+ },
+ // Advertisements plugin
+ // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio
+ ads: {
+ enabled: false,
+ publisherId: '',
+ tagUrl: ''
+ },
+ // Preview Thumbnails plugin
+ previewThumbnails: {
+ enabled: false,
+ src: ''
+ },
+ // Vimeo plugin
+ vimeo: {
+ byline: false,
+ portrait: false,
+ title: false,
+ speed: true,
+ transparent: false,
+ // Whether the owner of the video has a Pro or Business account
+ // (which allows us to properly hide controls without CSS hacks, etc)
+ premium: false,
+ // Custom settings from Plyr
+ referrerPolicy: null // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy
+
+ },
+ // YouTube plugin
+ youtube: {
+ noCookie: true,
+ // Whether to use an alternative version of YouTube without cookies
+ rel: 0,
+ // No related vids
+ showinfo: 0,
+ // Hide info
+ iv_load_policy: 3,
+ // Hide annotations
+ modestbranding: 1 // Hide logos as much as possible (they still show one in the corner when paused)
+
+ }
+ };
+
+ // ==========================================================================
+ // Plyr states
+ // ==========================================================================
+ var pip = {
+ active: 'picture-in-picture',
+ inactive: 'inline'
+ };
+
+ // ==========================================================================
+ // Plyr supported types and providers
+ // ==========================================================================
+ var providers = {
+ html5: 'html5',
+ youtube: 'youtube',
+ vimeo: 'vimeo'
+ };
+ var types = {
+ audio: 'audio',
+ video: 'video'
+ };
+ /**
+ * Get provider by URL
+ * @param {String} url
+ */
+
+ function getProviderByUrl(url) {
+ // YouTube
+ if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtube-nocookie\.com|youtu\.?be)\/.+$/.test(url)) {
+ return providers.youtube;
+ } // Vimeo
+
+
+ if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) {
+ return providers.vimeo;
+ }
+
+ return null;
+ }
+
+ // ==========================================================================
+ // Console wrapper
+ // ==========================================================================
+ var noop = function noop() {};
+
+ var Console = /*#__PURE__*/function () {
+ function Console() {
+ var enabled = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+
+ _classCallCheck(this, Console);
+
+ this.enabled = window.console && enabled;
+
+ if (this.enabled) {
+ this.log('Debugging enabled');
+ }
+ }
+
+ _createClass(Console, [{
+ key: "log",
+ get: function get() {
+ // eslint-disable-next-line no-console
+ return this.enabled ? Function.prototype.bind.call(console.log, console) : noop;
+ }
+ }, {
+ key: "warn",
+ get: function get() {
+ // eslint-disable-next-line no-console
+ return this.enabled ? Function.prototype.bind.call(console.warn, console) : noop;
+ }
+ }, {
+ key: "error",
+ get: function get() {
+ // eslint-disable-next-line no-console
+ return this.enabled ? Function.prototype.bind.call(console.error, console) : noop;
+ }
+ }]);
+
+ return Console;
+ }();
+
+ var Fullscreen = /*#__PURE__*/function () {
+ function Fullscreen(player) {
+ var _this = this;
+
+ _classCallCheck(this, Fullscreen);
+
+ // Keep reference to parent
+ this.player = player; // Get prefix
+
+ this.prefix = Fullscreen.prefix;
+ this.property = Fullscreen.property; // Scroll position
+
+ this.scrollPosition = {
+ x: 0,
+ y: 0
+ }; // Force the use of 'full window/browser' rather than fullscreen
+
+ this.forceFallback = player.config.fullscreen.fallback === 'force'; // Get the fullscreen element
+ // Checks container is an ancestor, defaults to null
+
+ this.player.elements.fullscreen = player.config.fullscreen.container && closest(this.player.elements.container, player.config.fullscreen.container); // Register event listeners
+ // Handle event (incase user presses escape etc)
+
+ on.call(this.player, document, this.prefix === 'ms' ? 'MSFullscreenChange' : "".concat(this.prefix, "fullscreenchange"), function () {
+ // TODO: Filter for target??
+ _this.onChange();
+ }); // Fullscreen toggle on double click
+
+ on.call(this.player, this.player.elements.container, 'dblclick', function (event) {
+ // Ignore double click in controls
+ if (is$1.element(_this.player.elements.controls) && _this.player.elements.controls.contains(event.target)) {
+ return;
+ }
+
+ _this.toggle();
+ }); // Tap focus when in fullscreen
+
+ on.call(this, this.player.elements.container, 'keydown', function (event) {
+ return _this.trapFocus(event);
+ }); // Update the UI
+
+ this.update();
+ } // Determine if native supported
+
+
+ _createClass(Fullscreen, [{
+ key: "onChange",
+ value: function onChange() {
+ if (!this.enabled) {
+ return;
+ } // Update toggle button
+
+
+ var button = this.player.elements.buttons.fullscreen;
+
+ if (is$1.element(button)) {
+ button.pressed = this.active;
+ } // Trigger an event
+
+
+ triggerEvent.call(this.player, this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);
+ }
+ }, {
+ key: "toggleFallback",
+ value: function toggleFallback() {
+ var toggle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+
+ // Store or restore scroll position
+ if (toggle) {
+ this.scrollPosition = {
+ x: window.scrollX || 0,
+ y: window.scrollY || 0
+ };
+ } else {
+ window.scrollTo(this.scrollPosition.x, this.scrollPosition.y);
+ } // Toggle scroll
+
+
+ document.body.style.overflow = toggle ? 'hidden' : ''; // Toggle class hook
+
+ toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle); // Force full viewport on iPhone X+
+
+ if (browser.isIos) {
+ var viewport = document.head.querySelector('meta[name="viewport"]');
+ var property = 'viewport-fit=cover'; // Inject the viewport meta if required
+
+ if (!viewport) {
+ viewport = document.createElement('meta');
+ viewport.setAttribute('name', 'viewport');
+ } // Check if the property already exists
+
+
+ var hasProperty = is$1.string(viewport.content) && viewport.content.includes(property);
+
+ if (toggle) {
+ this.cleanupViewport = !hasProperty;
+
+ if (!hasProperty) {
+ viewport.content += ",".concat(property);
+ }
+ } else if (this.cleanupViewport) {
+ viewport.content = viewport.content.split(',').filter(function (part) {
+ return part.trim() !== property;
+ }).join(',');
+ }
+ } // Toggle button and fire events
+
+
+ this.onChange();
+ } // Trap focus inside container
+
+ }, {
+ key: "trapFocus",
+ value: function trapFocus(event) {
+ // Bail if iOS, not active, not the tab key
+ if (browser.isIos || !this.active || event.key !== 'Tab' || event.keyCode !== 9) {
+ return;
+ } // Get the current focused element
+
+
+ var focused = document.activeElement;
+ var focusable = getElements.call(this.player, 'a[href], button:not(:disabled), input:not(:disabled), [tabindex]');
+
+ var _focusable = _slicedToArray(focusable, 1),
+ first = _focusable[0];
+
+ var last = focusable[focusable.length - 1];
+
+ if (focused === last && !event.shiftKey) {
+ // Move focus to first element that can be tabbed if Shift isn't used
+ first.focus();
+ event.preventDefault();
+ } else if (focused === first && event.shiftKey) {
+ // Move focus to last element that can be tabbed if Shift is used
+ last.focus();
+ event.preventDefault();
+ }
+ } // Update UI
+
+ }, {
+ key: "update",
+ value: function update() {
+ if (this.enabled) {
+ var mode;
+
+ if (this.forceFallback) {
+ mode = 'Fallback (forced)';
+ } else if (Fullscreen.native) {
+ mode = 'Native';
+ } else {
+ mode = 'Fallback';
+ }
+
+ this.player.debug.log("".concat(mode, " fullscreen enabled"));
+ } else {
+ this.player.debug.log('Fullscreen not supported and fallback disabled');
+ } // Add styling hook to show button
+
+
+ toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.enabled);
+ } // Make an element fullscreen
+
+ }, {
+ key: "enter",
+ value: function enter() {
+ if (!this.enabled) {
+ return;
+ } // iOS native fullscreen doesn't need the request step
+
+
+ if (browser.isIos && this.player.config.fullscreen.iosNative) {
+ this.target.webkitEnterFullscreen();
+ } else if (!Fullscreen.native || this.forceFallback) {
+ this.toggleFallback(true);
+ } else if (!this.prefix) {
+ this.target.requestFullscreen({
+ navigationUI: 'hide'
+ });
+ } else if (!is$1.empty(this.prefix)) {
+ this.target["".concat(this.prefix, "Request").concat(this.property)]();
+ }
+ } // Bail from fullscreen
+
+ }, {
+ key: "exit",
+ value: function exit() {
+ if (!this.enabled) {
+ return;
+ } // iOS native fullscreen
+
+
+ if (browser.isIos && this.player.config.fullscreen.iosNative) {
+ this.target.webkitExitFullscreen();
+ silencePromise(this.player.play());
+ } else if (!Fullscreen.native || this.forceFallback) {
+ this.toggleFallback(false);
+ } else if (!this.prefix) {
+ (document.cancelFullScreen || document.exitFullscreen).call(document);
+ } else if (!is$1.empty(this.prefix)) {
+ var action = this.prefix === 'moz' ? 'Cancel' : 'Exit';
+ document["".concat(this.prefix).concat(action).concat(this.property)]();
+ }
+ } // Toggle state
+
+ }, {
+ key: "toggle",
+ value: function toggle() {
+ if (!this.active) {
+ this.enter();
+ } else {
+ this.exit();
+ }
+ }
+ }, {
+ key: "usingNative",
+ // If we're actually using native
+ get: function get() {
+ return Fullscreen.native && !this.forceFallback;
+ } // Get the prefix for handlers
+
+ }, {
+ key: "enabled",
+ // Determine if fullscreen is enabled
+ get: function get() {
+ return (Fullscreen.native || this.player.config.fullscreen.fallback) && this.player.config.fullscreen.enabled && this.player.supported.ui && this.player.isVideo;
+ } // Get active state
+
+ }, {
+ key: "active",
+ get: function get() {
+ if (!this.enabled) {
+ return false;
+ } // Fallback using classname
+
+
+ if (!Fullscreen.native || this.forceFallback) {
+ return hasClass(this.target, this.player.config.classNames.fullscreen.fallback);
+ }
+
+ var element = !this.prefix ? document.fullscreenElement : document["".concat(this.prefix).concat(this.property, "Element")];
+ return element && element.shadowRoot ? element === this.target.getRootNode().host : element === this.target;
+ } // Get target element
+
+ }, {
+ key: "target",
+ get: function get() {
+ return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.fullscreen || this.player.elements.container;
+ }
+ }], [{
+ key: "native",
+ get: function get() {
+ return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled);
+ }
+ }, {
+ key: "prefix",
+ get: function get() {
+ // No prefix
+ if (is$1.function(document.exitFullscreen)) {
+ return '';
+ } // Check for fullscreen support by vendor prefix
+
+
+ var value = '';
+ var prefixes = ['webkit', 'moz', 'ms'];
+ prefixes.some(function (pre) {
+ if (is$1.function(document["".concat(pre, "ExitFullscreen")]) || is$1.function(document["".concat(pre, "CancelFullScreen")])) {
+ value = pre;
+ return true;
+ }
+
+ return false;
+ });
+ return value;
+ }
+ }, {
+ key: "property",
+ get: function get() {
+ return this.prefix === 'moz' ? 'FullScreen' : 'Fullscreen';
+ }
+ }]);
+
+ return Fullscreen;
+ }();
+
+ // ==========================================================================
+ // Load image avoiding xhr/fetch CORS issues
+ // Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded
+ // By default it checks if it is at least 1px, but you can add a second argument to change this
+ // ==========================================================================
+ function loadImage(src) {
+ var minWidth = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1;
+ return new Promise(function (resolve, reject) {
+ var image = new Image();
+
+ var handler = function handler() {
+ delete image.onload;
+ delete image.onerror;
+ (image.naturalWidth >= minWidth ? resolve : reject)(image);
+ };
+
+ Object.assign(image, {
+ onload: handler,
+ onerror: handler,
+ src: src
+ });
+ });
+ }
+
+ var ui = {
+ addStyleHook: function addStyleHook() {
+ toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
+ toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
+ },
+ // Toggle native HTML5 media controls
+ toggleNativeControls: function toggleNativeControls() {
+ var toggle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+
+ if (toggle && this.isHTML5) {
+ this.media.setAttribute('controls', '');
+ } else {
+ this.media.removeAttribute('controls');
+ }
+ },
+ // Setup the UI
+ build: function build() {
+ var _this = this;
+
+ // Re-attach media element listeners
+ // TODO: Use event bubbling?
+ this.listeners.media(); // Don't setup interface if no support
+
+ if (!this.supported.ui) {
+ this.debug.warn("Basic support only for ".concat(this.provider, " ").concat(this.type)); // Restore native controls
+
+ ui.toggleNativeControls.call(this, true); // Bail
+
+ return;
+ } // Inject custom controls if not present
+
+
+ if (!is$1.element(this.elements.controls)) {
+ // Inject custom controls
+ controls.inject.call(this); // Re-attach control listeners
+
+ this.listeners.controls();
+ } // Remove native controls
+
+
+ ui.toggleNativeControls.call(this); // Setup captions for HTML5
+
+ if (this.isHTML5) {
+ captions.setup.call(this);
+ } // Reset volume
+
+
+ this.volume = null; // Reset mute state
+
+ this.muted = null; // Reset loop state
+
+ this.loop = null; // Reset quality setting
+
+ this.quality = null; // Reset speed
+
+ this.speed = null; // Reset volume display
+
+ controls.updateVolume.call(this); // Reset time display
+
+ controls.timeUpdate.call(this); // Update the UI
+
+ ui.checkPlaying.call(this); // Check for picture-in-picture support
+
+ toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo); // Check for airplay support
+
+ toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5); // Add iOS class
+
+ toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos); // Add touch class
+
+ toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch); // Ready for API calls
+
+ this.ready = true; // Ready event at end of execution stack
+
+ setTimeout(function () {
+ triggerEvent.call(_this, _this.media, 'ready');
+ }, 0); // Set the title
+
+ ui.setTitle.call(this); // Assure the poster image is set, if the property was added before the element was created
+
+ if (this.poster) {
+ ui.setPoster.call(this, this.poster, false).catch(function () {});
+ } // Manually set the duration if user has overridden it.
+ // The event listeners for it doesn't get called if preload is disabled (#701)
+
+
+ if (this.config.duration) {
+ controls.durationUpdate.call(this);
+ }
+ },
+ // Setup aria attribute for play and iframe title
+ setTitle: function setTitle() {
+ // Find the current text
+ var label = i18n.get('play', this.config); // If there's a media title set, use that for the label
+
+ if (is$1.string(this.config.title) && !is$1.empty(this.config.title)) {
+ label += ", ".concat(this.config.title);
+ } // If there's a play button, set label
+
+
+ Array.from(this.elements.buttons.play || []).forEach(function (button) {
+ button.setAttribute('aria-label', label);
+ }); // Set iframe title
+ // https://github.com/sampotts/plyr/issues/124
+
+ if (this.isEmbed) {
+ var iframe = getElement.call(this, 'iframe');
+
+ if (!is$1.element(iframe)) {
+ return;
+ } // Default to media type
+
+
+ var title = !is$1.empty(this.config.title) ? this.config.title : 'video';
+ var format = i18n.get('frameTitle', this.config);
+ iframe.setAttribute('title', format.replace('{title}', title));
+ }
+ },
+ // Toggle poster
+ togglePoster: function togglePoster(enable) {
+ toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
+ },
+ // Set the poster image (async)
+ // Used internally for the poster setter, with the passive option forced to false
+ setPoster: function setPoster(poster) {
+ var _this2 = this;
+
+ var passive = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
+
+ // Don't override if call is passive
+ if (passive && this.poster) {
+ return Promise.reject(new Error('Poster already set'));
+ } // Set property synchronously to respect the call order
+
+
+ this.media.setAttribute('data-poster', poster); // Wait until ui is ready
+
+ return ready.call(this) // Load image
+ .then(function () {
+ return loadImage(poster);
+ }).catch(function (err) {
+ // Hide poster on error unless it's been set by another call
+ if (poster === _this2.poster) {
+ ui.togglePoster.call(_this2, false);
+ } // Rethrow
+
+
+ throw err;
+ }).then(function () {
+ // Prevent race conditions
+ if (poster !== _this2.poster) {
+ throw new Error('setPoster cancelled by later call to setPoster');
+ }
+ }).then(function () {
+ Object.assign(_this2.elements.poster.style, {
+ backgroundImage: "url('".concat(poster, "')"),
+ // Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube)
+ backgroundSize: ''
+ });
+ ui.togglePoster.call(_this2, true);
+ return poster;
+ });
+ },
+ // Check playing state
+ checkPlaying: function checkPlaying(event) {
+ var _this3 = this;
+
+ // Class hooks
+ toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
+ toggleClass(this.elements.container, this.config.classNames.paused, this.paused);
+ toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped); // Set state
+
+ Array.from(this.elements.buttons.play || []).forEach(function (target) {
+ Object.assign(target, {
+ pressed: _this3.playing
+ });
+ target.setAttribute('aria-label', i18n.get(_this3.playing ? 'pause' : 'play', _this3.config));
+ }); // Only update controls on non timeupdate events
+
+ if (is$1.event(event) && event.type === 'timeupdate') {
+ return;
+ } // Toggle controls
+
+
+ ui.toggleControls.call(this);
+ },
+ // Check if media is loading
+ checkLoading: function checkLoading(event) {
+ var _this4 = this;
+
+ this.loading = ['stalled', 'waiting'].includes(event.type); // Clear timer
+
+ clearTimeout(this.timers.loading); // Timer to prevent flicker when seeking
+
+ this.timers.loading = setTimeout(function () {
+ // Update progress bar loading class state
+ toggleClass(_this4.elements.container, _this4.config.classNames.loading, _this4.loading); // Update controls visibility
+
+ ui.toggleControls.call(_this4);
+ }, this.loading ? 250 : 0);
+ },
+ // Toggle controls based on state and `force` argument
+ toggleControls: function toggleControls(force) {
+ var controlsElement = this.elements.controls;
+
+ if (controlsElement && this.config.hideControls) {
+ // Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.)
+ var recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now(); // Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide
+
+ this.toggleControls(Boolean(force || this.loading || this.paused || controlsElement.pressed || controlsElement.hover || recentTouchSeek));
+ }
+ },
+ // Migrate any custom properties from the media to the parent
+ migrateStyles: function migrateStyles() {
+ var _this5 = this;
+
+ // Loop through values (as they are the keys when the object is spread 🤔)
+ Object.values(_objectSpread2({}, this.media.style)) // We're only fussed about Plyr specific properties
+ .filter(function (key) {
+ return !is$1.empty(key) && key.startsWith('--plyr');
+ }).forEach(function (key) {
+ // Set on the container
+ _this5.elements.container.style.setProperty(key, _this5.media.style.getPropertyValue(key)); // Clean up from media element
+
+
+ _this5.media.style.removeProperty(key);
+ }); // Remove attribute if empty
+
+ if (is$1.empty(this.media.style)) {
+ this.media.removeAttribute('style');
+ }
+ }
+ };
+
+ var Listeners = /*#__PURE__*/function () {
+ function Listeners(player) {
+ _classCallCheck(this, Listeners);
+
+ this.player = player;
+ this.lastKey = null;
+ this.focusTimer = null;
+ this.lastKeyDown = null;
+ this.handleKey = this.handleKey.bind(this);
+ this.toggleMenu = this.toggleMenu.bind(this);
+ this.setTabFocus = this.setTabFocus.bind(this);
+ this.firstTouch = this.firstTouch.bind(this);
+ } // Handle key presses
+
+
+ _createClass(Listeners, [{
+ key: "handleKey",
+ value: function handleKey(event) {
+ var player = this.player;
+ var elements = player.elements;
+ var code = event.keyCode ? event.keyCode : event.which;
+ var pressed = event.type === 'keydown';
+ var repeat = pressed && code === this.lastKey; // Bail if a modifier key is set
+
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ } // If the event is bubbled from the media element
+ // Firefox doesn't get the keycode for whatever reason
+
+
+ if (!is$1.number(code)) {
+ return;
+ } // Seek by the number keys
+
+
+ var seekByKey = function seekByKey() {
+ // Divide the max duration into 10th's and times by the number value
+ player.currentTime = player.duration / 10 * (code - 48);
+ }; // Handle the key on keydown
+ // Reset on keyup
+
+
+ if (pressed) {
+ // Check focused element
+ // and if the focused element is not editable (e.g. text input)
+ // and any that accept key input http://webaim.org/techniques/keyboard/
+ var focused = document.activeElement;
+
+ if (is$1.element(focused)) {
+ var editable = player.config.selectors.editable;
+ var seek = elements.inputs.seek;
+
+ if (focused !== seek && matches$1(focused, editable)) {
+ return;
+ }
+
+ if (event.which === 32 && matches$1(focused, 'button, [role^="menuitem"]')) {
+ return;
+ }
+ } // Which keycodes should we prevent default
+
+
+ var preventDefault = [32, 37, 38, 39, 40, 48, 49, 50, 51, 52, 53, 54, 56, 57, 67, 70, 73, 75, 76, 77, 79]; // If the code is found prevent default (e.g. prevent scrolling for arrows)
+
+ if (preventDefault.includes(code)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ switch (code) {
+ case 48:
+ case 49:
+ case 50:
+ case 51:
+ case 52:
+ case 53:
+ case 54:
+ case 55:
+ case 56:
+ case 57:
+ // 0-9
+ if (!repeat) {
+ seekByKey();
+ }
+
+ break;
+
+ case 32:
+ case 75:
+ // Space and K key
+ if (!repeat) {
+ silencePromise(player.togglePlay());
+ }
+
+ break;
+
+ case 38:
+ // Arrow up
+ player.increaseVolume(0.1);
+ break;
+
+ case 40:
+ // Arrow down
+ player.decreaseVolume(0.1);
+ break;
+
+ case 77:
+ // M key
+ if (!repeat) {
+ player.muted = !player.muted;
+ }
+
+ break;
+
+ case 39:
+ // Arrow forward
+ player.forward();
+ break;
+
+ case 37:
+ // Arrow back
+ player.rewind();
+ break;
+
+ case 70:
+ // F key
+ player.fullscreen.toggle();
+ break;
+
+ case 67:
+ // C key
+ if (!repeat) {
+ player.toggleCaptions();
+ }
+
+ break;
+
+ case 76:
+ // L key
+ player.loop = !player.loop;
+ break;
+ } // Escape is handle natively when in full screen
+ // So we only need to worry about non native
+
+
+ if (code === 27 && !player.fullscreen.usingNative && player.fullscreen.active) {
+ player.fullscreen.toggle();
+ } // Store last code for next cycle
+
+
+ this.lastKey = code;
+ } else {
+ this.lastKey = null;
+ }
+ } // Toggle menu
+
+ }, {
+ key: "toggleMenu",
+ value: function toggleMenu(event) {
+ controls.toggleMenu.call(this.player, event);
+ } // Device is touch enabled
+
+ }, {
+ key: "firstTouch",
+ value: function firstTouch() {
+ var player = this.player;
+ var elements = player.elements;
+ player.touch = true; // Add touch class
+
+ toggleClass(elements.container, player.config.classNames.isTouch, true);
+ }
+ }, {
+ key: "setTabFocus",
+ value: function setTabFocus(event) {
+ var player = this.player;
+ var elements = player.elements;
+ clearTimeout(this.focusTimer); // Ignore any key other than tab
+
+ if (event.type === 'keydown' && event.which !== 9) {
+ return;
+ } // Store reference to event timeStamp
+
+
+ if (event.type === 'keydown') {
+ this.lastKeyDown = event.timeStamp;
+ } // Remove current classes
+
+
+ var removeCurrent = function removeCurrent() {
+ var className = player.config.classNames.tabFocus;
+ var current = getElements.call(player, ".".concat(className));
+ toggleClass(current, className, false);
+ }; // Determine if a key was pressed to trigger this event
+
+
+ var wasKeyDown = event.timeStamp - this.lastKeyDown <= 20; // Ignore focus events if a key was pressed prior
+
+ if (event.type === 'focus' && !wasKeyDown) {
+ return;
+ } // Remove all current
+
+
+ removeCurrent(); // Delay the adding of classname until the focus has changed
+ // This event fires before the focusin event
+
+ if (event.type !== 'focusout') {
+ this.focusTimer = setTimeout(function () {
+ var focused = document.activeElement; // Ignore if current focus element isn't inside the player
+
+ if (!elements.container.contains(focused)) {
+ return;
+ }
+
+ toggleClass(document.activeElement, player.config.classNames.tabFocus, true);
+ }, 10);
+ }
+ } // Global window & document listeners
+
+ }, {
+ key: "global",
+ value: function global() {
+ var toggle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
+ var player = this.player; // Keyboard shortcuts
+
+ if (player.config.keyboard.global) {
+ toggleListener.call(player, window, 'keydown keyup', this.handleKey, toggle, false);
+ } // Click anywhere closes menu
+
+
+ toggleListener.call(player, document.body, 'click', this.toggleMenu, toggle); // Detect touch by events
+
+ once.call(player, document.body, 'touchstart', this.firstTouch); // Tab focus detection
+
+ toggleListener.call(player, document.body, 'keydown focus blur focusout', this.setTabFocus, toggle, false, true);
+ } // Container listeners
+
+ }, {
+ key: "container",
+ value: function container() {
+ var player = this.player;
+ var config = player.config,
+ elements = player.elements,
+ timers = player.timers; // Keyboard shortcuts
+
+ if (!config.keyboard.global && config.keyboard.focused) {
+ on.call(player, elements.container, 'keydown keyup', this.handleKey, false);
+ } // Toggle controls on mouse events and entering fullscreen
+
+
+ on.call(player, elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', function (event) {
+ var controlsElement = elements.controls; // Remove button states for fullscreen
+
+ if (controlsElement && event.type === 'enterfullscreen') {
+ controlsElement.pressed = false;
+ controlsElement.hover = false;
+ } // Show, then hide after a timeout unless another control event occurs
+
+
+ var show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);
+ var delay = 0;
+
+ if (show) {
+ ui.toggleControls.call(player, true); // Use longer timeout for touch devices
+
+ delay = player.touch ? 3000 : 2000;
+ } // Clear timer
+
+
+ clearTimeout(timers.controls); // Set new timer to prevent flicker when seeking
+
+ timers.controls = setTimeout(function () {
+ return ui.toggleControls.call(player, false);
+ }, delay);
+ }); // Set a gutter for Vimeo
+
+ var setGutter = function setGutter(ratio, padding, toggle) {
+ if (!player.isVimeo || player.config.vimeo.premium) {
+ return;
+ }
+
+ var target = player.elements.wrapper.firstChild;
+
+ var _ratio = _slicedToArray(ratio, 2),
+ y = _ratio[1];
+
+ var _getAspectRatio$call = getAspectRatio.call(player),
+ _getAspectRatio$call2 = _slicedToArray(_getAspectRatio$call, 2),
+ videoX = _getAspectRatio$call2[0],
+ videoY = _getAspectRatio$call2[1];
+
+ target.style.maxWidth = toggle ? "".concat(y / videoY * videoX, "px") : null;
+ target.style.margin = toggle ? '0 auto' : null;
+ }; // Resize on fullscreen change
+
+
+ var setPlayerSize = function setPlayerSize(measure) {
+ // If we don't need to measure the viewport
+ if (!measure) {
+ return setAspectRatio.call(player);
+ }
+
+ var rect = elements.container.getBoundingClientRect();
+ var width = rect.width,
+ height = rect.height;
+ return setAspectRatio.call(player, "".concat(width, ":").concat(height));
+ };
+
+ var resized = function resized() {
+ clearTimeout(timers.resized);
+ timers.resized = setTimeout(setPlayerSize, 50);
+ };
+
+ on.call(player, elements.container, 'enterfullscreen exitfullscreen', function (event) {
+ var _player$fullscreen = player.fullscreen,
+ target = _player$fullscreen.target,
+ usingNative = _player$fullscreen.usingNative; // Ignore events not from target
+
+ if (target !== elements.container) {
+ return;
+ } // If it's not an embed and no ratio specified
+
+
+ if (!player.isEmbed && is$1.empty(player.config.ratio)) {
+ return;
+ }
+
+ var isEnter = event.type === 'enterfullscreen'; // Set the player size when entering fullscreen to viewport size
+
+ var _setPlayerSize = setPlayerSize(isEnter),
+ padding = _setPlayerSize.padding,
+ ratio = _setPlayerSize.ratio; // Set Vimeo gutter
+
+
+ setGutter(ratio, padding, isEnter); // If not using native browser fullscreen API, we need to check for resizes of viewport
+
+ if (!usingNative) {
+ if (isEnter) {
+ on.call(player, window, 'resize', resized);
+ } else {
+ off.call(player, window, 'resize', resized);
+ }
+ }
+ });
+ } // Listen for media events
+
+ }, {
+ key: "media",
+ value: function media() {
+ var _this = this;
+
+ var player = this.player;
+ var elements = player.elements; // Time change on media
+
+ on.call(player, player.media, 'timeupdate seeking seeked', function (event) {
+ return controls.timeUpdate.call(player, event);
+ }); // Display duration
+
+ on.call(player, player.media, 'durationchange loadeddata loadedmetadata', function (event) {
+ return controls.durationUpdate.call(player, event);
+ }); // Handle the media finishing
+
+ on.call(player, player.media, 'ended', function () {
+ // Show poster on end
+ if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) {
+ // Restart
+ player.restart(); // Call pause otherwise IE11 will start playing the video again
+
+ player.pause();
+ }
+ }); // Check for buffer progress
+
+ on.call(player, player.media, 'progress playing seeking seeked', function (event) {
+ return controls.updateProgress.call(player, event);
+ }); // Handle volume changes
+
+ on.call(player, player.media, 'volumechange', function (event) {
+ return controls.updateVolume.call(player, event);
+ }); // Handle play/pause
+
+ on.call(player, player.media, 'playing play pause ended emptied timeupdate', function (event) {
+ return ui.checkPlaying.call(player, event);
+ }); // Loading state
+
+ on.call(player, player.media, 'waiting canplay seeked playing', function (event) {
+ return ui.checkLoading.call(player, event);
+ }); // Click video
+
+ if (player.supported.ui && player.config.clickToPlay && !player.isAudio) {
+ // Re-fetch the wrapper
+ var wrapper = getElement.call(player, ".".concat(player.config.classNames.video)); // Bail if there's no wrapper (this should never happen)
+
+ if (!is$1.element(wrapper)) {
+ return;
+ } // On click play, pause or restart
+
+
+ on.call(player, elements.container, 'click', function (event) {
+ var targets = [elements.container, wrapper]; // Ignore if click if not container or in video wrapper
+
+ if (!targets.includes(event.target) && !wrapper.contains(event.target)) {
+ return;
+ } // Touch devices will just show controls (if hidden)
+
+
+ if (player.touch && player.config.hideControls) {
+ return;
+ }
+
+ if (player.ended) {
+ _this.proxy(event, player.restart, 'restart');
+
+ _this.proxy(event, function () {
+ silencePromise(player.play());
+ }, 'play');
+ } else {
+ _this.proxy(event, function () {
+ silencePromise(player.togglePlay());
+ }, 'play');
+ }
+ });
+ } // Disable right click
+
+
+ if (player.supported.ui && player.config.disableContextMenu) {
+ on.call(player, elements.wrapper, 'contextmenu', function (event) {
+ event.preventDefault();
+ }, false);
+ } // Volume change
+
+
+ on.call(player, player.media, 'volumechange', function () {
+ // Save to storage
+ player.storage.set({
+ volume: player.volume,
+ muted: player.muted
+ });
+ }); // Speed change
+
+ on.call(player, player.media, 'ratechange', function () {
+ // Update UI
+ controls.updateSetting.call(player, 'speed'); // Save to storage
+
+
+ player.storage.set({
+ speed: player.speed
+ });
+ }); // Quality change
+
+ on.call(player, player.media, 'qualitychange', function (event) {
+ // Update UI
+ controls.updateSetting.call(player, 'quality', null, event.detail.quality);
+ }); // Update download link when ready and if quality changes
+
+ on.call(player, player.media, 'ready qualitychange', function () {
+ controls.setDownloadUrl.call(player);
+ }); // Proxy events to container
+ // Bubble up key events for Edge
+
+ var proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' ');
+ on.call(player, player.media, proxyEvents, function (event) {
+ var _event$detail = event.detail,
+ detail = _event$detail === void 0 ? {} : _event$detail; // Get error details from media
+
+ if (event.type === 'error') {
+ detail = player.media.error;
+ }
+
+ triggerEvent.call(player, elements.container, event.type, true, detail);
+ });
+ } // Run default and custom handlers
+
+ }, {
+ key: "proxy",
+ value: function proxy(event, defaultHandler, customHandlerKey) {
+ var player = this.player;
+ var customHandler = player.config.listeners[customHandlerKey];
+ var hasCustomHandler = is$1.function(customHandler);
+ var returned = true; // Execute custom handler
+
+ if (hasCustomHandler) {
+ returned = customHandler.call(player, event);
+ } // Only call default handler if not prevented in custom handler
+
+
+ if (returned !== false && is$1.function(defaultHandler)) {
+ defaultHandler.call(player, event);
+ }
+ } // Trigger custom and default handlers
+
+ }, {
+ key: "bind",
+ value: function bind(element, type, defaultHandler, customHandlerKey) {
+ var _this2 = this;
+
+ var passive = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : true;
+ var player = this.player;
+ var customHandler = player.config.listeners[customHandlerKey];
+ var hasCustomHandler = is$1.function(customHandler);
+ on.call(player, element, type, function (event) {
+ return _this2.proxy(event, defaultHandler, customHandlerKey);
+ }, passive && !hasCustomHandler);
+ } // Listen for control events
+
+ }, {
+ key: "controls",
+ value: function controls$1() {
+ var _this3 = this;
+
+ var player = this.player;
+ var elements = player.elements; // IE doesn't support input event, so we fallback to change
+
+ var inputEvent = browser.isIE ? 'change' : 'input'; // Play/pause toggle
+
+ if (elements.buttons.play) {
+ Array.from(elements.buttons.play).forEach(function (button) {
+ _this3.bind(button, 'click', function () {
+ silencePromise(player.togglePlay());
+ }, 'play');
+ });
+ } // Pause
+
+
+ this.bind(elements.buttons.restart, 'click', player.restart, 'restart'); // Rewind
+
+ this.bind(elements.buttons.rewind, 'click', player.rewind, 'rewind'); // Rewind
+
+ this.bind(elements.buttons.fastForward, 'click', player.forward, 'fastForward'); // Mute toggle
+
+ this.bind(elements.buttons.mute, 'click', function () {
+ player.muted = !player.muted;
+ }, 'mute'); // Captions toggle
+
+ this.bind(elements.buttons.captions, 'click', function () {
+ return player.toggleCaptions();
+ }); // Download
+
+ this.bind(elements.buttons.download, 'click', function () {
+ triggerEvent.call(player, player.media, 'download');
+ }, 'download'); // Fullscreen toggle
+
+ this.bind(elements.buttons.fullscreen, 'click', function () {
+ player.fullscreen.toggle();
+ }, 'fullscreen'); // Picture-in-Picture
+
+ this.bind(elements.buttons.pip, 'click', function () {
+ player.pip = 'toggle';
+ }, 'pip'); // Airplay
+
+ this.bind(elements.buttons.airplay, 'click', player.airplay, 'airplay'); // Settings menu - click toggle
+
+ this.bind(elements.buttons.settings, 'click', function (event) {
+ // Prevent the document click listener closing the menu
+ event.stopPropagation();
+ event.preventDefault();
+
+ controls.toggleMenu.call(player, event);
+ }, null, false); // Can't be passive as we're preventing default
+ // Settings menu - keyboard toggle
+ // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
+
+ this.bind(elements.buttons.settings, 'keyup', function (event) {
+ var code = event.which; // We only care about space and return
+
+ if (![13, 32].includes(code)) {
+ return;
+ } // Because return triggers a click anyway, all we need to do is set focus
+
+
+ if (code === 13) {
+ controls.focusFirstMenuItem.call(player, null, true);
+
+ return;
+ } // Prevent scroll
+
+
+ event.preventDefault(); // Prevent playing video (Firefox)
+
+ event.stopPropagation(); // Toggle menu
+
+ controls.toggleMenu.call(player, event);
+ }, null, false // Can't be passive as we're preventing default
+ ); // Escape closes menu
+
+ this.bind(elements.settings.menu, 'keydown', function (event) {
+ if (event.which === 27) {
+ controls.toggleMenu.call(player, event);
+ }
+ }); // Set range input alternative "value", which matches the tooltip time (#954)
+
+ this.bind(elements.inputs.seek, 'mousedown mousemove', function (event) {
+ var rect = elements.progress.getBoundingClientRect();
+ var percent = 100 / rect.width * (event.pageX - rect.left);
+ event.currentTarget.setAttribute('seek-value', percent);
+ }); // Pause while seeking
+
+ this.bind(elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', function (event) {
+ var seek = event.currentTarget;
+ var code = event.keyCode ? event.keyCode : event.which;
+ var attribute = 'play-on-seeked';
+
+ if (is$1.keyboardEvent(event) && code !== 39 && code !== 37) {
+ return;
+ } // Record seek time so we can prevent hiding controls for a few seconds after seek
+
+
+ player.lastSeekTime = Date.now(); // Was playing before?
+
+ var play = seek.hasAttribute(attribute); // Done seeking
+
+ var done = ['mouseup', 'touchend', 'keyup'].includes(event.type); // If we're done seeking and it was playing, resume playback
+
+ if (play && done) {
+ seek.removeAttribute(attribute);
+ silencePromise(player.play());
+ } else if (!done && player.playing) {
+ seek.setAttribute(attribute, '');
+ player.pause();
+ }
+ }); // Fix range inputs on iOS
+ // Super weird iOS bug where after you interact with an <input type="range">,
+ // it takes over further interactions on the page. This is a hack
+
+ if (browser.isIos) {
+ var inputs = getElements.call(player, 'input[type="range"]');
+ Array.from(inputs).forEach(function (input) {
+ return _this3.bind(input, inputEvent, function (event) {
+ return repaint(event.target);
+ });
+ });
+ } // Seek
+
+
+ this.bind(elements.inputs.seek, inputEvent, function (event) {
+ var seek = event.currentTarget; // If it exists, use seek-value instead of "value" for consistency with tooltip time (#954)
+
+ var seekTo = seek.getAttribute('seek-value');
+
+ if (is$1.empty(seekTo)) {
+ seekTo = seek.value;
+ }
+
+ seek.removeAttribute('seek-value');
+ player.currentTime = seekTo / seek.max * player.duration;
+ }, 'seek'); // Seek tooltip
+
+ this.bind(elements.progress, 'mouseenter mouseleave mousemove', function (event) {
+ return controls.updateSeekTooltip.call(player, event);
+ }); // Preview thumbnails plugin
+ // TODO: Really need to work on some sort of plug-in wide event bus or pub-sub for this
+
+ this.bind(elements.progress, 'mousemove touchmove', function (event) {
+ var previewThumbnails = player.previewThumbnails;
+
+ if (previewThumbnails && previewThumbnails.loaded) {
+ previewThumbnails.startMove(event);
+ }
+ }); // Hide thumbnail preview - on mouse click, mouse leave, and video play/seek. All four are required, e.g., for buffering
+
+ this.bind(elements.progress, 'mouseleave touchend click', function () {
+ var previewThumbnails = player.previewThumbnails;
+
+ if (previewThumbnails && previewThumbnails.loaded) {
+ previewThumbnails.endMove(false, true);
+ }
+ }); // Show scrubbing preview
+
+ this.bind(elements.progress, 'mousedown touchstart', function (event) {
+ var previewThumbnails = player.previewThumbnails;
+
+ if (previewThumbnails && previewThumbnails.loaded) {
+ previewThumbnails.startScrubbing(event);
+ }
+ });
+ this.bind(elements.progress, 'mouseup touchend', function (event) {
+ var previewThumbnails = player.previewThumbnails;
+
+ if (previewThumbnails && previewThumbnails.loaded) {
+ previewThumbnails.endScrubbing(event);
+ }
+ }); // Polyfill for lower fill in <input type="range"> for webkit
+
+ if (browser.isWebkit) {
+ Array.from(getElements.call(player, 'input[type="range"]')).forEach(function (element) {
+ _this3.bind(element, 'input', function (event) {
+ return controls.updateRangeFill.call(player, event.target);
+ });
+ });
+ } // Current time invert
+ // Only if one time element is used for both currentTime and duration
+
+
+ if (player.config.toggleInvert && !is$1.element(elements.display.duration)) {
+ this.bind(elements.display.currentTime, 'click', function () {
+ // Do nothing if we're at the start
+ if (player.currentTime === 0) {
+ return;
+ }
+
+ player.config.invertTime = !player.config.invertTime;
+
+ controls.timeUpdate.call(player);
+ });
+ } // Volume
+
+
+ this.bind(elements.inputs.volume, inputEvent, function (event) {
+ player.volume = event.target.value;
+ }, 'volume'); // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)
+
+ this.bind(elements.controls, 'mouseenter mouseleave', function (event) {
+ elements.controls.hover = !player.touch && event.type === 'mouseenter';
+ }); // Also update controls.hover state for any non-player children of fullscreen element (as above)
+
+ if (elements.fullscreen) {
+ Array.from(elements.fullscreen.children).filter(function (c) {
+ return !c.contains(elements.container);
+ }).forEach(function (child) {
+ _this3.bind(child, 'mouseenter mouseleave', function (event) {
+ elements.controls.hover = !player.touch && event.type === 'mouseenter';
+ });
+ });
+ } // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)
+
+
+ this.bind(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', function (event) {
+ elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
+ }); // Show controls when they receive focus (e.g., when using keyboard tab key)
+
+ this.bind(elements.controls, 'focusin', function () {
+ var config = player.config,
+ timers = player.timers; // Skip transition to prevent focus from scrolling the parent element
+
+ toggleClass(elements.controls, config.classNames.noTransition, true); // Toggle
+
+ ui.toggleControls.call(player, true); // Restore transition
+
+ setTimeout(function () {
+ toggleClass(elements.controls, config.classNames.noTransition, false);
+ }, 0); // Delay a little more for mouse users
+
+ var delay = _this3.touch ? 3000 : 4000; // Clear timer
+
+ clearTimeout(timers.controls); // Hide again after delay
+
+ timers.controls = setTimeout(function () {
+ return ui.toggleControls.call(player, false);
+ }, delay);
+ }); // Mouse wheel for volume
+
+ this.bind(elements.inputs.volume, 'wheel', function (event) {
+ // Detect "natural" scroll - suppored on OS X Safari only
+ // Other browsers on OS X will be inverted until support improves
+ var inverted = event.webkitDirectionInvertedFromDevice; // Get delta from event. Invert if `inverted` is true
+
+ var _map = [event.deltaX, -event.deltaY].map(function (value) {
+ return inverted ? -value : value;
+ }),
+ _map2 = _slicedToArray(_map, 2),
+ x = _map2[0],
+ y = _map2[1]; // Using the biggest delta, normalize to 1 or -1 (or 0 if no delta)
+
+
+ var direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y); // Change the volume by 2%
+
+ player.increaseVolume(direction / 50); // Don't break page scrolling at max and min
+
+ var volume = player.media.volume;
+
+ if (direction === 1 && volume < 1 || direction === -1 && volume > 0) {
+ event.preventDefault();
+ }
+ }, 'volume', false);
+ }
+ }]);
+
+ return Listeners;
+ }();
+
+ var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};
+
+ function createCommonjsModule(fn, module) {
+ return module = { exports: {} }, fn(module, module.exports), module.exports;
+ }
+
+ var loadjs_umd = createCommonjsModule(function (module, exports) {
+ (function (root, factory) {
+ {
+ module.exports = factory();
+ }
+ })(commonjsGlobal, function () {
+ /**
+ * Global dependencies.
+ * @global {Object} document - DOM
+ */
+ var devnull = function devnull() {},
+ bundleIdCache = {},
+ bundleResultCache = {},
+ bundleCallbackQueue = {};
+ /**
+ * Subscribe to bundle load event.
+ * @param {string[]} bundleIds - Bundle ids
+ * @param {Function} callbackFn - The callback function
+ */
+
+
+ function subscribe(bundleIds, callbackFn) {
+ // listify
+ bundleIds = bundleIds.push ? bundleIds : [bundleIds];
+ var depsNotFound = [],
+ i = bundleIds.length,
+ numWaiting = i,
+ fn,
+ bundleId,
+ r,
+ q; // define callback function
+
+ fn = function fn(bundleId, pathsNotFound) {
+ if (pathsNotFound.length) depsNotFound.push(bundleId);
+ numWaiting--;
+ if (!numWaiting) callbackFn(depsNotFound);
+ }; // register callback
+
+
+ while (i--) {
+ bundleId = bundleIds[i]; // execute callback if in result cache
+
+ r = bundleResultCache[bundleId];
+
+ if (r) {
+ fn(bundleId, r);
+ continue;
+ } // add to callback queue
+
+
+ q = bundleCallbackQueue[bundleId] = bundleCallbackQueue[bundleId] || [];
+ q.push(fn);
+ }
+ }
+ /**
+ * Publish bundle load event.
+ * @param {string} bundleId - Bundle id
+ * @param {string[]} pathsNotFound - List of files not found
+ */
+
+
+ function publish(bundleId, pathsNotFound) {
+ // exit if id isn't defined
+ if (!bundleId) return;
+ var q = bundleCallbackQueue[bundleId]; // cache result
+
+ bundleResultCache[bundleId] = pathsNotFound; // exit if queue is empty
+
+ if (!q) return; // empty callback queue
+
+ while (q.length) {
+ q[0](bundleId, pathsNotFound);
+ q.splice(0, 1);
+ }
+ }
+ /**
+ * Execute callbacks.
+ * @param {Object or Function} args - The callback args
+ * @param {string[]} depsNotFound - List of dependencies not found
+ */
+
+
+ function executeCallbacks(args, depsNotFound) {
+ // accept function as argument
+ if (args.call) args = {
+ success: args
+ }; // success and error callbacks
+
+ if (depsNotFound.length) (args.error || devnull)(depsNotFound);else (args.success || devnull)(args);
+ }
+ /**
+ * Load individual file.
+ * @param {string} path - The file path
+ * @param {Function} callbackFn - The callback function
+ */
+
+
+ function loadFile(path, callbackFn, args, numTries) {
+ var doc = document,
+ async = args.async,
+ maxTries = (args.numRetries || 0) + 1,
+ beforeCallbackFn = args.before || devnull,
+ pathname = path.replace(/[\?|#].*$/, ''),
+ pathStripped = path.replace(/^(css|img)!/, ''),
+ isLegacyIECss,
+ e;
+ numTries = numTries || 0;
+
+ if (/(^css!|\.css$)/.test(pathname)) {
+ // css
+ e = doc.createElement('link');
+ e.rel = 'stylesheet';
+ e.href = pathStripped; // tag IE9+
+
+ isLegacyIECss = 'hideFocus' in e; // use preload in IE Edge (to detect load errors)
+
+ if (isLegacyIECss && e.relList) {
+ isLegacyIECss = 0;
+ e.rel = 'preload';
+ e.as = 'style';
+ }
+ } else if (/(^img!|\.(png|gif|jpg|svg|webp)$)/.test(pathname)) {
+ // image
+ e = doc.createElement('img');
+ e.src = pathStripped;
+ } else {
+ // javascript
+ e = doc.createElement('script');
+ e.src = path;
+ e.async = async === undefined ? true : async;
+ }
+
+ e.onload = e.onerror = e.onbeforeload = function (ev) {
+ var result = ev.type[0]; // treat empty stylesheets as failures to get around lack of onerror
+ // support in IE9-11
+
+ if (isLegacyIECss) {
+ try {
+ if (!e.sheet.cssText.length) result = 'e';
+ } catch (x) {
+ // sheets objects created from load errors don't allow access to
+ // `cssText` (unless error is Code:18 SecurityError)
+ if (x.code != 18) result = 'e';
+ }
+ } // handle retries in case of load failure
+
+
+ if (result == 'e') {
+ // increment counter
+ numTries += 1; // exit function and try again
+
+ if (numTries < maxTries) {
+ return loadFile(path, callbackFn, args, numTries);
+ }
+ } else if (e.rel == 'preload' && e.as == 'style') {
+ // activate preloaded stylesheets
+ return e.rel = 'stylesheet'; // jshint ignore:line
+ } // execute callback
+
+
+ callbackFn(path, result, ev.defaultPrevented);
+ }; // add to document (unless callback returns `false`)
+
+
+ if (beforeCallbackFn(path, e) !== false) doc.head.appendChild(e);
+ }
+ /**
+ * Load multiple files.
+ * @param {string[]} paths - The file paths
+ * @param {Function} callbackFn - The callback function
+ */
+
+
+ function loadFiles(paths, callbackFn, args) {
+ // listify paths
+ paths = paths.push ? paths : [paths];
+ var numWaiting = paths.length,
+ x = numWaiting,
+ pathsNotFound = [],
+ fn,
+ i; // define callback function
+
+ fn = function fn(path, result, defaultPrevented) {
+ // handle error
+ if (result == 'e') pathsNotFound.push(path); // handle beforeload event. If defaultPrevented then that means the load
+ // will be blocked (ex. Ghostery/ABP on Safari)
+
+ if (result == 'b') {
+ if (defaultPrevented) pathsNotFound.push(path);else return;
+ }
+
+ numWaiting--;
+ if (!numWaiting) callbackFn(pathsNotFound);
+ }; // load scripts
+
+
+ for (i = 0; i < x; i++) {
+ loadFile(paths[i], fn, args);
+ }
+ }
+ /**
+ * Initiate script load and register bundle.
+ * @param {(string|string[])} paths - The file paths
+ * @param {(string|Function|Object)} [arg1] - The (1) bundleId or (2) success
+ * callback or (3) object literal with success/error arguments, numRetries,
+ * etc.
+ * @param {(Function|Object)} [arg2] - The (1) success callback or (2) object
+ * literal with success/error arguments, numRetries, etc.
+ */
+
+
+ function loadjs(paths, arg1, arg2) {
+ var bundleId, args; // bundleId (if string)
+
+ if (arg1 && arg1.trim) bundleId = arg1; // args (default is {})
+
+ args = (bundleId ? arg2 : arg1) || {}; // throw error if bundle is already defined
+
+ if (bundleId) {
+ if (bundleId in bundleIdCache) {
+ throw "LoadJS";
+ } else {
+ bundleIdCache[bundleId] = true;
+ }
+ }
+
+ function loadFn(resolve, reject) {
+ loadFiles(paths, function (pathsNotFound) {
+ // execute callbacks
+ executeCallbacks(args, pathsNotFound); // resolve Promise
+
+ if (resolve) {
+ executeCallbacks({
+ success: resolve,
+ error: reject
+ }, pathsNotFound);
+ } // publish bundle load event
+
+
+ publish(bundleId, pathsNotFound);
+ }, args);
+ }
+
+ if (args.returnPromise) return new Promise(loadFn);else loadFn();
+ }
+ /**
+ * Execute callbacks when dependencies have been satisfied.
+ * @param {(string|string[])} deps - List of bundle ids
+ * @param {Object} args - success/error arguments
+ */
+
+
+ loadjs.ready = function ready(deps, args) {
+ // subscribe to bundle load event
+ subscribe(deps, function (depsNotFound) {
+ // execute callbacks
+ executeCallbacks(args, depsNotFound);
+ });
+ return loadjs;
+ };
+ /**
+ * Manually satisfy bundle dependencies.
+ * @param {string} bundleId - The bundle id
+ */
+
+
+ loadjs.done = function done(bundleId) {
+ publish(bundleId, []);
+ };
+ /**
+ * Reset loadjs dependencies statuses
+ */
+
+
+ loadjs.reset = function reset() {
+ bundleIdCache = {};
+ bundleResultCache = {};
+ bundleCallbackQueue = {};
+ };
+ /**
+ * Determine if bundle has already been defined
+ * @param String} bundleId - The bundle id
+ */
+
+
+ loadjs.isDefined = function isDefined(bundleId) {
+ return bundleId in bundleIdCache;
+ }; // export
+
+
+ return loadjs;
+ });
+ });
+
+ // ==========================================================================
+ function loadScript(url) {
+ return new Promise(function (resolve, reject) {
+ loadjs_umd(url, {
+ success: resolve,
+ error: reject
+ });
+ });
+ }
+
+ function parseId(url) {
+ if (is$1.empty(url)) {
+ return null;
+ }
+
+ if (is$1.number(Number(url))) {
+ return url;
+ }
+
+ var regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
+ return url.match(regex) ? RegExp.$2 : url;
+ } // Set playback state and trigger change (only on actual change)
+
+
+ function assurePlaybackState(play) {
+ if (play && !this.embed.hasPlayed) {
+ this.embed.hasPlayed = true;
+ }
+
+ if (this.media.paused === play) {
+ this.media.paused = !play;
+ triggerEvent.call(this, this.media, play ? 'play' : 'pause');
+ }
+ }
+
+ var vimeo = {
+ setup: function setup() {
+ var player = this; // Add embed class for responsive
+
+ toggleClass(player.elements.wrapper, player.config.classNames.embed, true); // Set speed options from config
+
+ player.options.speed = player.config.speed.options; // Set intial ratio
+
+ setAspectRatio.call(player); // Load the SDK if not already
+
+ if (!is$1.object(window.Vimeo)) {
+ loadScript(player.config.urls.vimeo.sdk).then(function () {
+ vimeo.ready.call(player);
+ }).catch(function (error) {
+ player.debug.warn('Vimeo SDK (player.js) failed to load', error);
+ });
+ } else {
+ vimeo.ready.call(player);
+ }
+ },
+ // API Ready
+ ready: function ready() {
+ var _this = this;
+
+ var player = this;
+ var config = player.config.vimeo;
+
+ var premium = config.premium,
+ referrerPolicy = config.referrerPolicy,
+ frameParams = _objectWithoutProperties(config, ["premium", "referrerPolicy"]); // If the owner has a pro or premium account then we can hide controls etc
+
+
+ if (premium) {
+ Object.assign(frameParams, {
+ controls: false,
+ sidedock: false
+ });
+ } // Get Vimeo params for the iframe
+
+
+ var params = buildUrlParams(_objectSpread2({
+ loop: player.config.loop.active,
+ autoplay: player.autoplay,
+ muted: player.muted,
+ gesture: 'media',
+ playsinline: !this.config.fullscreen.iosNative
+ }, frameParams)); // Get the source URL or ID
+
+ var source = player.media.getAttribute('src'); // Get from <div> if needed
+
+ if (is$1.empty(source)) {
+ source = player.media.getAttribute(player.config.attributes.embed.id);
+ }
+
+ var id = parseId(source); // Build an iframe
+
+ var iframe = createElement('iframe');
+ var src = format(player.config.urls.vimeo.iframe, id, params);
+ iframe.setAttribute('src', src);
+ iframe.setAttribute('allowfullscreen', '');
+ iframe.setAttribute('allow', 'autoplay,fullscreen,picture-in-picture'); // Set the referrer policy if required
+
+ if (!is$1.empty(referrerPolicy)) {
+ iframe.setAttribute('referrerPolicy', referrerPolicy);
+ } // Inject the package
+
+
+ var poster = player.poster;
+
+ if (premium) {
+ iframe.setAttribute('data-poster', poster);
+ player.media = replaceElement(iframe, player.media);
+ } else {
+ var wrapper = createElement('div', {
+ class: player.config.classNames.embedContainer,
+ 'data-poster': poster
+ });
+ wrapper.appendChild(iframe);
+ player.media = replaceElement(wrapper, player.media);
+ } // Get poster image
+
+
+ fetch(format(player.config.urls.vimeo.api, id), 'json').then(function (response) {
+ if (is$1.empty(response)) {
+ return;
+ } // Get the URL for thumbnail
+
+
+ var url = new URL(response[0].thumbnail_large); // Get original image
+
+ url.pathname = "".concat(url.pathname.split('_')[0], ".jpg"); // Set and show poster
+
+ ui.setPoster.call(player, url.href).catch(function () {});
+ }); // Setup instance
+ // https://github.com/vimeo/player.js
+
+ player.embed = new window.Vimeo.Player(iframe, {
+ autopause: player.config.autopause,
+ muted: player.muted
+ });
+ player.media.paused = true;
+ player.media.currentTime = 0; // Disable native text track rendering
+
+ if (player.supported.ui) {
+ player.embed.disableTextTrack();
+ } // Create a faux HTML5 API using the Vimeo API
+
+
+ player.media.play = function () {
+ assurePlaybackState.call(player, true);
+ return player.embed.play();
+ };
+
+ player.media.pause = function () {
+ assurePlaybackState.call(player, false);
+ return player.embed.pause();
+ };
+
+ player.media.stop = function () {
+ player.pause();
+ player.currentTime = 0;
+ }; // Seeking
+
+
+ var currentTime = player.media.currentTime;
+ Object.defineProperty(player.media, 'currentTime', {
+ get: function get() {
+ return currentTime;
+ },
+ set: function set(time) {
+ // Vimeo will automatically play on seek if the video hasn't been played before
+ // Get current paused state and volume etc
+ var embed = player.embed,
+ media = player.media,
+ paused = player.paused,
+ volume = player.volume;
+ var restorePause = paused && !embed.hasPlayed; // Set seeking state and trigger event
+
+ media.seeking = true;
+ triggerEvent.call(player, media, 'seeking'); // If paused, mute until seek is complete
+
+ Promise.resolve(restorePause && embed.setVolume(0)) // Seek
+ .then(function () {
+ return embed.setCurrentTime(time);
+ }) // Restore paused
+ .then(function () {
+ return restorePause && embed.pause();
+ }) // Restore volume
+ .then(function () {
+ return restorePause && embed.setVolume(volume);
+ }).catch(function () {// Do nothing
+ });
+ }
+ }); // Playback speed
+
+ var speed = player.config.speed.selected;
+ Object.defineProperty(player.media, 'playbackRate', {
+ get: function get() {
+ return speed;
+ },
+ set: function set(input) {
+ player.embed.setPlaybackRate(input).then(function () {
+ speed = input;
+ triggerEvent.call(player, player.media, 'ratechange');
+ }).catch(function () {
+ // Cannot set Playback Rate, Video is probably not on Pro account
+ player.options.speed = [1];
+ });
+ }
+ }); // Volume
+
+ var volume = player.config.volume;
+ Object.defineProperty(player.media, 'volume', {
+ get: function get() {
+ return volume;
+ },
+ set: function set(input) {
+ player.embed.setVolume(input).then(function () {
+ volume = input;
+ triggerEvent.call(player, player.media, 'volumechange');
+ });
+ }
+ }); // Muted
+
+ var muted = player.config.muted;
+ Object.defineProperty(player.media, 'muted', {
+ get: function get() {
+ return muted;
+ },
+ set: function set(input) {
+ var toggle = is$1.boolean(input) ? input : false;
+ player.embed.setVolume(toggle ? 0 : player.config.volume).then(function () {
+ muted = toggle;
+ triggerEvent.call(player, player.media, 'volumechange');
+ });
+ }
+ }); // Loop
+
+ var loop = player.config.loop;
+ Object.defineProperty(player.media, 'loop', {
+ get: function get() {
+ return loop;
+ },
+ set: function set(input) {
+ var toggle = is$1.boolean(input) ? input : player.config.loop.active;
+ player.embed.setLoop(toggle).then(function () {
+ loop = toggle;
+ });
+ }
+ }); // Source
+
+ var currentSrc;
+ player.embed.getVideoUrl().then(function (value) {
+ currentSrc = value;
+ controls.setDownloadUrl.call(player);
+ }).catch(function (error) {
+ _this.debug.warn(error);
+ });
+ Object.defineProperty(player.media, 'currentSrc', {
+ get: function get() {
+ return currentSrc;
+ }
+ }); // Ended
+
+ Object.defineProperty(player.media, 'ended', {
+ get: function get() {
+ return player.currentTime === player.duration;
+ }
+ }); // Set aspect ratio based on video size
+
+ Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(function (dimensions) {
+ var _dimensions = _slicedToArray(dimensions, 2),
+ width = _dimensions[0],
+ height = _dimensions[1];
+
+ player.embed.ratio = [width, height];
+ setAspectRatio.call(_this);
+ }); // Set autopause
+
+ player.embed.setAutopause(player.config.autopause).then(function (state) {
+ player.config.autopause = state;
+ }); // Get title
+
+ player.embed.getVideoTitle().then(function (title) {
+ player.config.title = title;
+ ui.setTitle.call(_this);
+ }); // Get current time
+
+ player.embed.getCurrentTime().then(function (value) {
+ currentTime = value;
+ triggerEvent.call(player, player.media, 'timeupdate');
+ }); // Get duration
+
+ player.embed.getDuration().then(function (value) {
+ player.media.duration = value;
+ triggerEvent.call(player, player.media, 'durationchange');
+ }); // Get captions
+
+ player.embed.getTextTracks().then(function (tracks) {
+ player.media.textTracks = tracks;
+ captions.setup.call(player);
+ });
+ player.embed.on('cuechange', function (_ref) {
+ var _ref$cues = _ref.cues,
+ cues = _ref$cues === void 0 ? [] : _ref$cues;
+ var strippedCues = cues.map(function (cue) {
+ return stripHTML(cue.text);
+ });
+ captions.updateCues.call(player, strippedCues);
+ });
+ player.embed.on('loaded', function () {
+ // Assure state and events are updated on autoplay
+ player.embed.getPaused().then(function (paused) {
+ assurePlaybackState.call(player, !paused);
+
+ if (!paused) {
+ triggerEvent.call(player, player.media, 'playing');
+ }
+ });
+
+ if (is$1.element(player.embed.element) && player.supported.ui) {
+ var frame = player.embed.element; // Fix keyboard focus issues
+ // https://github.com/sampotts/plyr/issues/317
+
+ frame.setAttribute('tabindex', -1);
+ }
+ });
+ player.embed.on('bufferstart', function () {
+ triggerEvent.call(player, player.media, 'waiting');
+ });
+ player.embed.on('bufferend', function () {
+ triggerEvent.call(player, player.media, 'playing');
+ });
+ player.embed.on('play', function () {
+ assurePlaybackState.call(player, true);
+ triggerEvent.call(player, player.media, 'playing');
+ });
+ player.embed.on('pause', function () {
+ assurePlaybackState.call(player, false);
+ });
+ player.embed.on('timeupdate', function (data) {
+ player.media.seeking = false;
+ currentTime = data.seconds;
+ triggerEvent.call(player, player.media, 'timeupdate');
+ });
+ player.embed.on('progress', function (data) {
+ player.media.buffered = data.percent;
+ triggerEvent.call(player, player.media, 'progress'); // Check all loaded
+
+ if (parseInt(data.percent, 10) === 1) {
+ triggerEvent.call(player, player.media, 'canplaythrough');
+ } // Get duration as if we do it before load, it gives an incorrect value
+ // https://github.com/sampotts/plyr/issues/891
+
+
+ player.embed.getDuration().then(function (value) {
+ if (value !== player.media.duration) {
+ player.media.duration = value;
+ triggerEvent.call(player, player.media, 'durationchange');
+ }
+ });
+ });
+ player.embed.on('seeked', function () {
+ player.media.seeking = false;
+ triggerEvent.call(player, player.media, 'seeked');
+ });
+ player.embed.on('ended', function () {
+ player.media.paused = true;
+ triggerEvent.call(player, player.media, 'ended');
+ });
+ player.embed.on('error', function (detail) {
+ player.media.error = detail;
+ triggerEvent.call(player, player.media, 'error');
+ }); // Rebuild UI
+
+ setTimeout(function () {
+ return ui.build.call(player);
+ }, 0);
+ }
+ };
+
+ // ==========================================================================
+
+ function parseId$1(url) {
+ if (is$1.empty(url)) {
+ return null;
+ }
+
+ var regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
+ return url.match(regex) ? RegExp.$2 : url;
+ } // Set playback state and trigger change (only on actual change)
+
+
+ function assurePlaybackState$1(play) {
+ if (play && !this.embed.hasPlayed) {
+ this.embed.hasPlayed = true;
+ }
+
+ if (this.media.paused === play) {
+ this.media.paused = !play;
+ triggerEvent.call(this, this.media, play ? 'play' : 'pause');
+ }
+ }
+
+ function getHost(config) {
+ if (config.noCookie) {
+ return 'https://www.youtube-nocookie.com';
+ }
+
+ if (window.location.protocol === 'http:') {
+ return 'http://www.youtube.com';
+ } // Use YouTube's default
+
+
+ return undefined;
+ }
+
+ var youtube = {
+ setup: function setup() {
+ var _this = this;
+
+ // Add embed class for responsive
+ toggleClass(this.elements.wrapper, this.config.classNames.embed, true); // Setup API
+
+ if (is$1.object(window.YT) && is$1.function(window.YT.Player)) {
+ youtube.ready.call(this);
+ } else {
+ // Reference current global callback
+ var callback = window.onYouTubeIframeAPIReady; // Set callback to process queue
+
+ window.onYouTubeIframeAPIReady = function () {
+ // Call global callback if set
+ if (is$1.function(callback)) {
+ callback();
+ }
+
+ youtube.ready.call(_this);
+ }; // Load the SDK
+
+
+ loadScript(this.config.urls.youtube.sdk).catch(function (error) {
+ _this.debug.warn('YouTube API failed to load', error);
+ });
+ }
+ },
+ // Get the media title
+ getTitle: function getTitle(videoId) {
+ var _this2 = this;
+
+ var url = format(this.config.urls.youtube.api, videoId);
+ fetch(url).then(function (data) {
+ if (is$1.object(data)) {
+ var title = data.title,
+ height = data.height,
+ width = data.width; // Set title
+
+ _this2.config.title = title;
+ ui.setTitle.call(_this2); // Set aspect ratio
+
+ _this2.embed.ratio = [width, height];
+ }
+
+ setAspectRatio.call(_this2);
+ }).catch(function () {
+ // Set aspect ratio
+ setAspectRatio.call(_this2);
+ });
+ },
+ // API ready
+ ready: function ready() {
+ var player = this; // Ignore already setup (race condition)
+
+ var currentId = player.media && player.media.getAttribute('id');
+
+ if (!is$1.empty(currentId) && currentId.startsWith('youtube-')) {
+ return;
+ } // Get the source URL or ID
+
+
+ var source = player.media.getAttribute('src'); // Get from <div> if needed
+
+ if (is$1.empty(source)) {
+ source = player.media.getAttribute(this.config.attributes.embed.id);
+ } // Replace the <iframe> with a <div> due to YouTube API issues
+
+
+ var videoId = parseId$1(source);
+ var id = generateId(player.provider); // Get poster, if already set
+
+ var poster = player.poster; // Replace media element
+
+ var container = createElement('div', {
+ id: id,
+ 'data-poster': poster
+ });
+ player.media = replaceElement(container, player.media); // Id to poster wrapper
+
+ var posterSrc = function posterSrc(s) {
+ return "https://i.ytimg.com/vi/".concat(videoId, "/").concat(s, "default.jpg");
+ }; // Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide)
+
+
+ loadImage(posterSrc('maxres'), 121) // Higest quality and unpadded
+ .catch(function () {
+ return loadImage(posterSrc('sd'), 121);
+ }) // 480p padded 4:3
+ .catch(function () {
+ return loadImage(posterSrc('hq'));
+ }) // 360p padded 4:3. Always exists
+ .then(function (image) {
+ return ui.setPoster.call(player, image.src);
+ }).then(function (src) {
+ // If the image is padded, use background-size "cover" instead (like youtube does too with their posters)
+ if (!src.includes('maxres')) {
+ player.elements.poster.style.backgroundSize = 'cover';
+ }
+ }).catch(function () {});
+ var config = player.config.youtube; // Setup instance
+ // https://developers.google.com/youtube/iframe_api_reference
+
+ player.embed = new window.YT.Player(id, {
+ videoId: videoId,
+ host: getHost(config),
+ playerVars: extend({}, {
+ autoplay: player.config.autoplay ? 1 : 0,
+ // Autoplay
+ hl: player.config.hl,
+ // iframe interface language
+ controls: player.supported.ui ? 0 : 1,
+ // Only show controls if not fully supported
+ disablekb: 1,
+ // Disable keyboard as we handle it
+ playsinline: !player.config.fullscreen.iosNative ? 1 : 0,
+ // Allow iOS inline playback
+ // Captions are flaky on YouTube
+ cc_load_policy: player.captions.active ? 1 : 0,
+ cc_lang_pref: player.config.captions.language,
+ // Tracking for stats
+ widget_referrer: window ? window.location.href : null
+ }, config),
+ events: {
+ onError: function onError(event) {
+ // YouTube may fire onError twice, so only handle it once
+ if (!player.media.error) {
+ var code = event.data; // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError
+
+ var message = {
+ 2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.',
+ 5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.',
+ 100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.',
+ 101: 'The owner of the requested video does not allow it to be played in embedded players.',
+ 150: 'The owner of the requested video does not allow it to be played in embedded players.'
+ }[code] || 'An unknown error occured';
+ player.media.error = {
+ code: code,
+ message: message
+ };
+ triggerEvent.call(player, player.media, 'error');
+ }
+ },
+ onPlaybackRateChange: function onPlaybackRateChange(event) {
+ // Get the instance
+ var instance = event.target; // Get current speed
+
+ player.media.playbackRate = instance.getPlaybackRate();
+ triggerEvent.call(player, player.media, 'ratechange');
+ },
+ onReady: function onReady(event) {
+ // Bail if onReady has already been called. See issue #1108
+ if (is$1.function(player.media.play)) {
+ return;
+ } // Get the instance
+
+
+ var instance = event.target; // Get the title
+
+ youtube.getTitle.call(player, videoId); // Create a faux HTML5 API using the YouTube API
+
+ player.media.play = function () {
+ assurePlaybackState$1.call(player, true);
+ instance.playVideo();
+ };
+
+ player.media.pause = function () {
+ assurePlaybackState$1.call(player, false);
+ instance.pauseVideo();
+ };
+
+ player.media.stop = function () {
+ instance.stopVideo();
+ };
+
+ player.media.duration = instance.getDuration();
+ player.media.paused = true; // Seeking
+
+ player.media.currentTime = 0;
+ Object.defineProperty(player.media, 'currentTime', {
+ get: function get() {
+ return Number(instance.getCurrentTime());
+ },
+ set: function set(time) {
+ // If paused and never played, mute audio preventively (YouTube starts playing on seek if the video hasn't been played yet).
+ if (player.paused && !player.embed.hasPlayed) {
+ player.embed.mute();
+ } // Set seeking state and trigger event
+
+
+ player.media.seeking = true;
+ triggerEvent.call(player, player.media, 'seeking'); // Seek after events sent
+
+ instance.seekTo(time);
+ }
+ }); // Playback speed
+
+ Object.defineProperty(player.media, 'playbackRate', {
+ get: function get() {
+ return instance.getPlaybackRate();
+ },
+ set: function set(input) {
+ instance.setPlaybackRate(input);
+ }
+ }); // Volume
+
+ var volume = player.config.volume;
+ Object.defineProperty(player.media, 'volume', {
+ get: function get() {
+ return volume;
+ },
+ set: function set(input) {
+ volume = input;
+ instance.setVolume(volume * 100);
+ triggerEvent.call(player, player.media, 'volumechange');
+ }
+ }); // Muted
+
+ var muted = player.config.muted;
+ Object.defineProperty(player.media, 'muted', {
+ get: function get() {
+ return muted;
+ },
+ set: function set(input) {
+ var toggle = is$1.boolean(input) ? input : muted;
+ muted = toggle;
+ instance[toggle ? 'mute' : 'unMute']();
+ triggerEvent.call(player, player.media, 'volumechange');
+ }
+ }); // Source
+
+ Object.defineProperty(player.media, 'currentSrc', {
+ get: function get() {
+ return instance.getVideoUrl();
+ }
+ }); // Ended
+
+ Object.defineProperty(player.media, 'ended', {
+ get: function get() {
+ return player.currentTime === player.duration;
+ }
+ }); // Get available speeds
+
+ var speeds = instance.getAvailablePlaybackRates(); // Filter based on config
+
+ player.options.speed = speeds.filter(function (s) {
+ return player.config.speed.options.includes(s);
+ }); // Set the tabindex to avoid focus entering iframe
+
+ if (player.supported.ui) {
+ player.media.setAttribute('tabindex', -1);
+ }
+
+ triggerEvent.call(player, player.media, 'timeupdate');
+ triggerEvent.call(player, player.media, 'durationchange'); // Reset timer
+
+ clearInterval(player.timers.buffering); // Setup buffering
+
+ player.timers.buffering = setInterval(function () {
+ // Get loaded % from YouTube
+ player.media.buffered = instance.getVideoLoadedFraction(); // Trigger progress only when we actually buffer something
+
+ if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) {
+ triggerEvent.call(player, player.media, 'progress');
+ } // Set last buffer point
+
+
+ player.media.lastBuffered = player.media.buffered; // Bail if we're at 100%
+
+ if (player.media.buffered === 1) {
+ clearInterval(player.timers.buffering); // Trigger event
+
+ triggerEvent.call(player, player.media, 'canplaythrough');
+ }
+ }, 200); // Rebuild UI
+
+ setTimeout(function () {
+ return ui.build.call(player);
+ }, 50);
+ },
+ onStateChange: function onStateChange(event) {
+ // Get the instance
+ var instance = event.target; // Reset timer
+
+ clearInterval(player.timers.playing);
+ var seeked = player.media.seeking && [1, 2].includes(event.data);
+
+ if (seeked) {
+ // Unset seeking and fire seeked event
+ player.media.seeking = false;
+ triggerEvent.call(player, player.media, 'seeked');
+ } // Handle events
+ // -1 Unstarted
+ // 0 Ended
+ // 1 Playing
+ // 2 Paused
+ // 3 Buffering
+ // 5 Video cued
+
+
+ switch (event.data) {
+ case -1:
+ // Update scrubber
+ triggerEvent.call(player, player.media, 'timeupdate'); // Get loaded % from YouTube
+
+ player.media.buffered = instance.getVideoLoadedFraction();
+ triggerEvent.call(player, player.media, 'progress');
+ break;
+
+ case 0:
+ assurePlaybackState$1.call(player, false); // YouTube doesn't support loop for a single video, so mimick it.
+
+ if (player.media.loop) {
+ // YouTube needs a call to `stopVideo` before playing again
+ instance.stopVideo();
+ instance.playVideo();
+ } else {
+ triggerEvent.call(player, player.media, 'ended');
+ }
+
+ break;
+
+ case 1:
+ // Restore paused state (YouTube starts playing on seek if the video hasn't been played yet)
+ if (!player.config.autoplay && player.media.paused && !player.embed.hasPlayed) {
+ player.media.pause();
+ } else {
+ assurePlaybackState$1.call(player, true);
+ triggerEvent.call(player, player.media, 'playing'); // Poll to get playback progress
+
+ player.timers.playing = setInterval(function () {
+ triggerEvent.call(player, player.media, 'timeupdate');
+ }, 50); // Check duration again due to YouTube bug
+ // https://github.com/sampotts/plyr/issues/374
+ // https://code.google.com/p/gdata-issues/issues/detail?id=8690
+
+ if (player.media.duration !== instance.getDuration()) {
+ player.media.duration = instance.getDuration();
+ triggerEvent.call(player, player.media, 'durationchange');
+ }
+ }
+
+ break;
+
+ case 2:
+ // Restore audio (YouTube starts playing on seek if the video hasn't been played yet)
+ if (!player.muted) {
+ player.embed.unMute();
+ }
+
+ assurePlaybackState$1.call(player, false);
+ break;
+
+ case 3:
+ // Trigger waiting event to add loading classes to container as the video buffers.
+ triggerEvent.call(player, player.media, 'waiting');
+ break;
+ }
+
+ triggerEvent.call(player, player.elements.container, 'statechange', false, {
+ code: event.data
+ });
+ }
+ }
+ });
+ }
+ };
+
+ // ==========================================================================
+ var media = {
+ // Setup media
+ setup: function setup() {
+ // If there's no media, bail
+ if (!this.media) {
+ this.debug.warn('No media element found!');
+ return;
+ } // Add type class
+
+
+ toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true); // Add provider class
+
+ toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true); // Add video class for embeds
+ // This will require changes if audio embeds are added
+
+ if (this.isEmbed) {
+ toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);
+ } // Inject the player wrapper
+
+
+ if (this.isVideo) {
+ // Create the wrapper div
+ this.elements.wrapper = createElement('div', {
+ class: this.config.classNames.video
+ }); // Wrap the video in a container
+
+ wrap(this.media, this.elements.wrapper); // Poster image container
+
+ this.elements.poster = createElement('div', {
+ class: this.config.classNames.poster
+ });
+ this.elements.wrapper.appendChild(this.elements.poster);
+ }
+
+ if (this.isHTML5) {
+ html5.setup.call(this);
+ } else if (this.isYouTube) {
+ youtube.setup.call(this);
+ } else if (this.isVimeo) {
+ vimeo.setup.call(this);
+ }
+ }
+ };
+
+ var destroy = function destroy(instance) {
+ // Destroy our adsManager
+ if (instance.manager) {
+ instance.manager.destroy();
+ } // Destroy our adsManager
+
+
+ if (instance.elements.displayContainer) {
+ instance.elements.displayContainer.destroy();
+ }
+
+ instance.elements.container.remove();
+ };
+
+ var Ads = /*#__PURE__*/function () {
+ /**
+ * Ads constructor.
+ * @param {Object} player
+ * @return {Ads}
+ */
+ function Ads(player) {
+ var _this = this;
+
+ _classCallCheck(this, Ads);
+
+ this.player = player;
+ this.config = player.config.ads;
+ this.playing = false;
+ this.initialized = false;
+ this.elements = {
+ container: null,
+ displayContainer: null
+ };
+ this.manager = null;
+ this.loader = null;
+ this.cuePoints = null;
+ this.events = {};
+ this.safetyTimer = null;
+ this.countdownTimer = null; // Setup a promise to resolve when the IMA manager is ready
+
+ this.managerPromise = new Promise(function (resolve, reject) {
+ // The ad is loaded and ready
+ _this.on('loaded', resolve); // Ads failed
+
+
+ _this.on('error', reject);
+ });
+ this.load();
+ }
+
+ _createClass(Ads, [{
+ key: "load",
+
+ /**
+ * Load the IMA SDK
+ */
+ value: function load() {
+ var _this2 = this;
+
+ if (!this.enabled) {
+ return;
+ } // Check if the Google IMA3 SDK is loaded or load it ourselves
+
+
+ if (!is$1.object(window.google) || !is$1.object(window.google.ima)) {
+ loadScript(this.player.config.urls.googleIMA.sdk).then(function () {
+ _this2.ready();
+ }).catch(function () {
+ // Script failed to load or is blocked
+ _this2.trigger('error', new Error('Google IMA SDK failed to load'));
+ });
+ } else {
+ this.ready();
+ }
+ }
+ /**
+ * Get the ads instance ready
+ */
+
+ }, {
+ key: "ready",
+ value: function ready() {
+ var _this3 = this;
+
+ // Double check we're enabled
+ if (!this.enabled) {
+ destroy(this);
+ } // Start ticking our safety timer. If the whole advertisement
+ // thing doesn't resolve within our set time; we bail
+
+
+ this.startSafetyTimer(12000, 'ready()'); // Clear the safety timer
+
+ this.managerPromise.then(function () {
+ _this3.clearSafetyTimer('onAdsManagerLoaded()');
+ }); // Set listeners on the Plyr instance
+
+ this.listeners(); // Setup the IMA SDK
+
+ this.setupIMA();
+ } // Build the tag URL
+
+ }, {
+ key: "setupIMA",
+
+ /**
+ * In order for the SDK to display ads for our video, we need to tell it where to put them,
+ * so here we define our ad container. This div is set up to render on top of the video player.
+ * Using the code below, we tell the SDK to render ads within that div. We also provide a
+ * handle to the content video player - the SDK will poll the current time of our player to
+ * properly place mid-rolls. After we create the ad display container, we initialize it. On
+ * mobile devices, this initialization is done as the result of a user action.
+ */
+ value: function setupIMA() {
+ var _this4 = this;
+
+ // Create the container for our advertisements
+ this.elements.container = createElement('div', {
+ class: this.player.config.classNames.ads
+ });
+ this.player.elements.container.appendChild(this.elements.container); // So we can run VPAID2
+
+ google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED); // Set language
+
+ google.ima.settings.setLocale(this.player.config.ads.language); // Set playback for iOS10+
+
+ google.ima.settings.setDisableCustomPlaybackForIOS10Plus(this.player.config.playsinline); // We assume the adContainer is the video container of the plyr element that will house the ads
+
+ this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container, this.player.media); // Create ads loader
+
+ this.loader = new google.ima.AdsLoader(this.elements.displayContainer); // Listen and respond to ads loaded and error events
+
+ this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, function (event) {
+ return _this4.onAdsManagerLoaded(event);
+ }, false);
+ this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, function (error) {
+ return _this4.onAdError(error);
+ }, false); // Request video ads to be pre-loaded
+
+ this.requestAds();
+ }
+ /**
+ * Request advertisements
+ */
+
+ }, {
+ key: "requestAds",
+ value: function requestAds() {
+ var container = this.player.elements.container;
+
+ try {
+ // Request video ads
+ var request = new google.ima.AdsRequest();
+ request.adTagUrl = this.tagUrl; // Specify the linear and nonlinear slot sizes. This helps the SDK
+ // to select the correct creative if multiple are returned
+
+ request.linearAdSlotWidth = container.offsetWidth;
+ request.linearAdSlotHeight = container.offsetHeight;
+ request.nonLinearAdSlotWidth = container.offsetWidth;
+ request.nonLinearAdSlotHeight = container.offsetHeight; // We only overlay ads as we only support video.
+
+ request.forceNonLinearFullSlot = false; // Mute based on current state
+
+ request.setAdWillPlayMuted(!this.player.muted);
+ this.loader.requestAds(request);
+ } catch (e) {
+ this.onAdError(e);
+ }
+ }
+ /**
+ * Update the ad countdown
+ * @param {Boolean} start
+ */
+
+ }, {
+ key: "pollCountdown",
+ value: function pollCountdown() {
+ var _this5 = this;
+
+ var start = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+
+ if (!start) {
+ clearInterval(this.countdownTimer);
+ this.elements.container.removeAttribute('data-badge-text');
+ return;
+ }
+
+ var update = function update() {
+ var time = formatTime(Math.max(_this5.manager.getRemainingTime(), 0));
+ var label = "".concat(i18n.get('advertisement', _this5.player.config), " - ").concat(time);
+
+ _this5.elements.container.setAttribute('data-badge-text', label);
+ };
+
+ this.countdownTimer = setInterval(update, 100);
+ }
+ /**
+ * This method is called whenever the ads are ready inside the AdDisplayContainer
+ * @param {Event} adsManagerLoadedEvent
+ */
+
+ }, {
+ key: "onAdsManagerLoaded",
+ value: function onAdsManagerLoaded(event) {
+ var _this6 = this;
+
+ // Load could occur after a source change (race condition)
+ if (!this.enabled) {
+ return;
+ } // Get the ads manager
+
+
+ var settings = new google.ima.AdsRenderingSettings(); // Tell the SDK to save and restore content video state on our behalf
+
+ settings.restoreCustomPlaybackStateOnAdBreakComplete = true;
+ settings.enablePreloading = true; // The SDK is polling currentTime on the contentPlayback. And needs a duration
+ // so it can determine when to start the mid- and post-roll
+
+ this.manager = event.getAdsManager(this.player, settings); // Get the cue points for any mid-rolls by filtering out the pre- and post-roll
+
+ this.cuePoints = this.manager.getCuePoints(); // Add listeners to the required events
+ // Advertisement error events
+
+ this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, function (error) {
+ return _this6.onAdError(error);
+ }); // Advertisement regular events
+
+ Object.keys(google.ima.AdEvent.Type).forEach(function (type) {
+ _this6.manager.addEventListener(google.ima.AdEvent.Type[type], function (e) {
+ return _this6.onAdEvent(e);
+ });
+ }); // Resolve our adsManager
+
+ this.trigger('loaded');
+ }
+ }, {
+ key: "addCuePoints",
+ value: function addCuePoints() {
+ var _this7 = this;
+
+ // Add advertisement cue's within the time line if available
+ if (!is$1.empty(this.cuePoints)) {
+ this.cuePoints.forEach(function (cuePoint) {
+ if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < _this7.player.duration) {
+ var seekElement = _this7.player.elements.progress;
+
+ if (is$1.element(seekElement)) {
+ var cuePercentage = 100 / _this7.player.duration * cuePoint;
+ var cue = createElement('span', {
+ class: _this7.player.config.classNames.cues
+ });
+ cue.style.left = "".concat(cuePercentage.toString(), "%");
+ seekElement.appendChild(cue);
+ }
+ }
+ });
+ }
+ }
+ /**
+ * This is where all the event handling takes place. Retrieve the ad from the event. Some
+ * events (e.g. ALL_ADS_COMPLETED) don't have the ad object associated
+ * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type
+ * @param {Event} event
+ */
+
+ }, {
+ key: "onAdEvent",
+ value: function onAdEvent(event) {
+ var _this8 = this;
+
+ var container = this.player.elements.container; // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)
+ // don't have ad object associated
+
+ var ad = event.getAd();
+ var adData = event.getAdData(); // Proxy event
+
+ var dispatchEvent = function dispatchEvent(type) {
+ triggerEvent.call(_this8.player, _this8.player.media, "ads".concat(type.replace(/_/g, '').toLowerCase()));
+ }; // Bubble the event
+
+
+ dispatchEvent(event.type);
+
+ switch (event.type) {
+ case google.ima.AdEvent.Type.LOADED:
+ // This is the first event sent for an ad - it is possible to determine whether the
+ // ad is a video ad or an overlay
+ this.trigger('loaded'); // Start countdown
+
+ this.pollCountdown(true);
+
+ if (!ad.isLinear()) {
+ // Position AdDisplayContainer correctly for overlay
+ ad.width = container.offsetWidth;
+ ad.height = container.offsetHeight;
+ } // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());
+ // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());
+
+
+ break;
+
+ case google.ima.AdEvent.Type.STARTED:
+ // Set volume to match player
+ this.manager.setVolume(this.player.volume);
+ break;
+
+ case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:
+ // All ads for the current videos are done. We can now request new advertisements
+ // in case the video is re-played
+ // TODO: Example for what happens when a next video in a playlist would be loaded.
+ // So here we load a new video when all ads are done.
+ // Then we load new ads within a new adsManager. When the video
+ // Is started - after - the ads are loaded, then we get ads.
+ // You can also easily test cancelling and reloading by running
+ // player.ads.cancel() and player.ads.play from the console I guess.
+ // this.player.source = {
+ // type: 'video',
+ // title: 'View From A Blue Moon',
+ // sources: [{
+ // src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', type:
+ // 'video/mp4', }], poster:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', tracks:
+ // [ { kind: 'captions', label: 'English', srclang: 'en', src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt',
+ // default: true, }, { kind: 'captions', label: 'French', srclang: 'fr', src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', }, ],
+ // };
+ // TODO: So there is still this thing where a video should only be allowed to start
+ // playing when the IMA SDK is ready or has failed
+ if (this.player.ended) {
+ this.loadAds();
+ } else {
+ // The SDK won't allow new ads to be called without receiving a contentComplete()
+ this.loader.contentComplete();
+ }
+
+ break;
+
+ case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:
+ // This event indicates the ad has started - the video player can adjust the UI,
+ // for example display a pause button and remaining time. Fired when content should
+ // be paused. This usually happens right before an ad is about to cover the content
+ this.pauseContent();
+ break;
+
+ case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:
+ // This event indicates the ad has finished - the video player can perform
+ // appropriate UI actions, such as removing the timer for remaining time detection.
+ // Fired when content should be resumed. This usually happens when an ad finishes
+ // or collapses
+ this.pollCountdown();
+ this.resumeContent();
+ break;
+
+ case google.ima.AdEvent.Type.LOG:
+ if (adData.adError) {
+ this.player.debug.warn("Non-fatal ad error: ".concat(adData.adError.getMessage()));
+ }
+
+ break;
+ }
+ }
+ /**
+ * Any ad error handling comes through here
+ * @param {Event} event
+ */
+
+ }, {
+ key: "onAdError",
+ value: function onAdError(event) {
+ this.cancel();
+ this.player.debug.warn('Ads error', event);
+ }
+ /**
+ * Setup hooks for Plyr and window events. This ensures
+ * the mid- and post-roll launch at the correct time. And
+ * resize the advertisement when the player resizes
+ */
+
+ }, {
+ key: "listeners",
+ value: function listeners() {
+ var _this9 = this;
+
+ var container = this.player.elements.container;
+ var time;
+ this.player.on('canplay', function () {
+ _this9.addCuePoints();
+ });
+ this.player.on('ended', function () {
+ _this9.loader.contentComplete();
+ });
+ this.player.on('timeupdate', function () {
+ time = _this9.player.currentTime;
+ });
+ this.player.on('seeked', function () {
+ var seekedTime = _this9.player.currentTime;
+
+ if (is$1.empty(_this9.cuePoints)) {
+ return;
+ }
+
+ _this9.cuePoints.forEach(function (cuePoint, index) {
+ if (time < cuePoint && cuePoint < seekedTime) {
+ _this9.manager.discardAdBreak();
+
+ _this9.cuePoints.splice(index, 1);
+ }
+ });
+ }); // Listen to the resizing of the window. And resize ad accordingly
+ // TODO: eventually implement ResizeObserver
+
+ window.addEventListener('resize', function () {
+ if (_this9.manager) {
+ _this9.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
+ }
+ });
+ }
+ /**
+ * Initialize the adsManager and start playing advertisements
+ */
+
+ }, {
+ key: "play",
+ value: function play() {
+ var _this10 = this;
+
+ var container = this.player.elements.container;
+
+ if (!this.managerPromise) {
+ this.resumeContent();
+ } // Play the requested advertisement whenever the adsManager is ready
+
+
+ this.managerPromise.then(function () {
+ // Set volume to match player
+ _this10.manager.setVolume(_this10.player.volume); // Initialize the container. Must be done via a user action on mobile devices
+
+
+ _this10.elements.displayContainer.initialize();
+
+ try {
+ if (!_this10.initialized) {
+ // Initialize the ads manager. Ad rules playlist will start at this time
+ _this10.manager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL); // Call play to start showing the ad. Single video and overlay ads will
+ // start at this time; the call will be ignored for ad rules
+
+
+ _this10.manager.start();
+ }
+
+ _this10.initialized = true;
+ } catch (adError) {
+ // An error may be thrown if there was a problem with the
+ // VAST response
+ _this10.onAdError(adError);
+ }
+ }).catch(function () {});
+ }
+ /**
+ * Resume our video
+ */
+
+ }, {
+ key: "resumeContent",
+ value: function resumeContent() {
+ // Hide the advertisement container
+ this.elements.container.style.zIndex = ''; // Ad is stopped
+
+ this.playing = false; // Play video
+
+ silencePromise(this.player.media.play());
+ }
+ /**
+ * Pause our video
+ */
+
+ }, {
+ key: "pauseContent",
+ value: function pauseContent() {
+ // Show the advertisement container
+ this.elements.container.style.zIndex = 3; // Ad is playing
+
+ this.playing = true; // Pause our video.
+
+ this.player.media.pause();
+ }
+ /**
+ * Destroy the adsManager so we can grab new ads after this. If we don't then we're not
+ * allowed to call new ads based on google policies, as they interpret this as an accidental
+ * video requests. https://developers.google.com/interactive-
+ * media-ads/docs/sdks/android/faq#8
+ */
+
+ }, {
+ key: "cancel",
+ value: function cancel() {
+ // Pause our video
+ if (this.initialized) {
+ this.resumeContent();
+ } // Tell our instance that we're done for now
+
+
+ this.trigger('error'); // Re-create our adsManager
+
+ this.loadAds();
+ }
+ /**
+ * Re-create our adsManager
+ */
+
+ }, {
+ key: "loadAds",
+ value: function loadAds() {
+ var _this11 = this;
+
+ // Tell our adsManager to go bye bye
+ this.managerPromise.then(function () {
+ // Destroy our adsManager
+ if (_this11.manager) {
+ _this11.manager.destroy();
+ } // Re-set our adsManager promises
+
+
+ _this11.managerPromise = new Promise(function (resolve) {
+ _this11.on('loaded', resolve);
+
+ _this11.player.debug.log(_this11.manager);
+ }); // Now that the manager has been destroyed set it to also be un-initialized
+
+ _this11.initialized = false; // Now request some new advertisements
+
+ _this11.requestAds();
+ }).catch(function () {});
+ }
+ /**
+ * Handles callbacks after an ad event was invoked
+ * @param {String} event - Event type
+ */
+
+ }, {
+ key: "trigger",
+ value: function trigger(event) {
+ var _this12 = this;
+
+ for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+ args[_key - 1] = arguments[_key];
+ }
+
+ var handlers = this.events[event];
+
+ if (is$1.array(handlers)) {
+ handlers.forEach(function (handler) {
+ if (is$1.function(handler)) {
+ handler.apply(_this12, args);
+ }
+ });
+ }
+ }
+ /**
+ * Add event listeners
+ * @param {String} event - Event type
+ * @param {Function} callback - Callback for when event occurs
+ * @return {Ads}
+ */
+
+ }, {
+ key: "on",
+ value: function on(event, callback) {
+ if (!is$1.array(this.events[event])) {
+ this.events[event] = [];
+ }
+
+ this.events[event].push(callback);
+ return this;
+ }
+ /**
+ * Setup a safety timer for when the ad network doesn't respond for whatever reason.
+ * The advertisement has 12 seconds to get its things together. We stop this timer when the
+ * advertisement is playing, or when a user action is required to start, then we clear the
+ * timer on ad ready
+ * @param {Number} time
+ * @param {String} from
+ */
+
+ }, {
+ key: "startSafetyTimer",
+ value: function startSafetyTimer(time, from) {
+ var _this13 = this;
+
+ this.player.debug.log("Safety timer invoked from: ".concat(from));
+ this.safetyTimer = setTimeout(function () {
+ _this13.cancel();
+
+ _this13.clearSafetyTimer('startSafetyTimer()');
+ }, time);
+ }
+ /**
+ * Clear our safety timer(s)
+ * @param {String} from
+ */
+
+ }, {
+ key: "clearSafetyTimer",
+ value: function clearSafetyTimer(from) {
+ if (!is$1.nullOrUndefined(this.safetyTimer)) {
+ this.player.debug.log("Safety timer cleared from: ".concat(from));
+ clearTimeout(this.safetyTimer);
+ this.safetyTimer = null;
+ }
+ }
+ }, {
+ key: "enabled",
+ get: function get() {
+ var config = this.config;
+ return this.player.isHTML5 && this.player.isVideo && config.enabled && (!is$1.empty(config.publisherId) || is$1.url(config.tagUrl));
+ }
+ }, {
+ key: "tagUrl",
+ get: function get() {
+ var config = this.config;
+
+ if (is$1.url(config.tagUrl)) {
+ return config.tagUrl;
+ }
+
+ var params = {
+ AV_PUBLISHERID: '58c25bb0073ef448b1087ad6',
+ AV_CHANNELID: '5a0458dc28a06145e4519d21',
+ AV_URL: window.location.hostname,
+ cb: Date.now(),
+ AV_WIDTH: 640,
+ AV_HEIGHT: 480,
+ AV_CDIM2: config.publisherId
+ };
+ var base = 'https://go.aniview.com/api/adserver6/vast/';
+ return "".concat(base, "?").concat(buildUrlParams(params));
+ }
+ }]);
+
+ return Ads;
+ }();
+
+ var parseVtt = function parseVtt(vttDataString) {
+ var processedList = [];
+ var frames = vttDataString.split(/\r\n\r\n|\n\n|\r\r/);
+ frames.forEach(function (frame) {
+ var result = {};
+ var lines = frame.split(/\r\n|\n|\r/);
+ lines.forEach(function (line) {
+ if (!is$1.number(result.startTime)) {
+ // The line with start and end times on it is the first line of interest
+ var matchTimes = line.match(/([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})( ?--> ?)([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})/); // Note that this currently ignores caption formatting directives that are optionally on the end of this line - fine for non-captions VTT
+
+ if (matchTimes) {
+ result.startTime = Number(matchTimes[1] || 0) * 60 * 60 + Number(matchTimes[2]) * 60 + Number(matchTimes[3]) + Number("0.".concat(matchTimes[4]));
+ result.endTime = Number(matchTimes[6] || 0) * 60 * 60 + Number(matchTimes[7]) * 60 + Number(matchTimes[8]) + Number("0.".concat(matchTimes[9]));
+ }
+ } else if (!is$1.empty(line.trim()) && is$1.empty(result.text)) {
+ // If we already have the startTime, then we're definitely up to the text line(s)
+ var lineSplit = line.trim().split('#xywh=');
+
+ var _lineSplit = _slicedToArray(lineSplit, 1);
+
+ result.text = _lineSplit[0];
+
+ // If there's content in lineSplit[1], then we have sprites. If not, then it's just one frame per image
+ if (lineSplit[1]) {
+ var _lineSplit$1$split = lineSplit[1].split(',');
+
+ var _lineSplit$1$split2 = _slicedToArray(_lineSplit$1$split, 4);
+
+ result.x = _lineSplit$1$split2[0];
+ result.y = _lineSplit$1$split2[1];
+ result.w = _lineSplit$1$split2[2];
+ result.h = _lineSplit$1$split2[3];
+ }
+ }
+ });
+
+ if (result.text) {
+ processedList.push(result);
+ }
+ });
+ return processedList;
+ };
+ /**
+ * Preview thumbnails for seek hover and scrubbing
+ * Seeking: Hover over the seek bar (desktop only): shows a small preview container above the seek bar
+ * Scrubbing: Click and drag the seek bar (desktop and mobile): shows the preview image over the entire video, as if the video is scrubbing at very high speed
+ *
+ * Notes:
+ * - Thumbs are set via JS settings on Plyr init, not HTML5 'track' property. Using the track property would be a bit gross, because it doesn't support custom 'kinds'. kind=metadata might be used for something else, and we want to allow multiple thumbnails tracks. Tracks must have a unique combination of 'kind' and 'label'. We would have to do something like kind=metadata,label=thumbnails1 / kind=metadata,label=thumbnails2. Square peg, round hole
+ * - VTT info: the image URL is relative to the VTT, not the current document. But if the url starts with a slash, it will naturally be relative to the current domain. https://support.jwplayer.com/articles/how-to-add-preview-thumbnails
+ * - This implementation uses multiple separate img elements. Other implementations use background-image on one element. This would be nice and simple, but Firefox and Safari have flickering issues with replacing backgrounds of larger images. It seems that YouTube perhaps only avoids this because they don't have the option for high-res previews (even the fullscreen ones, when mousedown/seeking). Images appear over the top of each other, and previous ones are discarded once the new ones have been rendered
+ */
+
+
+ var fitRatio = function fitRatio(ratio, outer) {
+ var targetRatio = outer.width / outer.height;
+ var result = {};
+
+ if (ratio > targetRatio) {
+ result.width = outer.width;
+ result.height = 1 / ratio * outer.width;
+ } else {
+ result.height = outer.height;
+ result.width = ratio * outer.height;
+ }
+
+ return result;
+ };
+
+ var PreviewThumbnails = /*#__PURE__*/function () {
+ /**
+ * PreviewThumbnails constructor.
+ * @param {Plyr} player
+ * @return {PreviewThumbnails}
+ */
+ function PreviewThumbnails(player) {
+ _classCallCheck(this, PreviewThumbnails);
+
+ this.player = player;
+ this.thumbnails = [];
+ this.loaded = false;
+ this.lastMouseMoveTime = Date.now();
+ this.mouseDown = false;
+ this.loadedImages = [];
+ this.elements = {
+ thumb: {},
+ scrubbing: {}
+ };
+ this.load();
+ }
+
+ _createClass(PreviewThumbnails, [{
+ key: "load",
+ value: function load() {
+ var _this = this;
+
+ // Toggle the regular seek tooltip
+ if (this.player.elements.display.seekTooltip) {
+ this.player.elements.display.seekTooltip.hidden = this.enabled;
+ }
+
+ if (!this.enabled) {
+ return;
+ }
+
+ this.getThumbnails().then(function () {
+ if (!_this.enabled) {
+ return;
+ } // Render DOM elements
+
+
+ _this.render(); // Check to see if thumb container size was specified manually in CSS
+
+
+ _this.determineContainerAutoSizing();
+
+ _this.loaded = true;
+ });
+ } // Download VTT files and parse them
+
+ }, {
+ key: "getThumbnails",
+ value: function getThumbnails() {
+ var _this2 = this;
+
+ return new Promise(function (resolve) {
+ var src = _this2.player.config.previewThumbnails.src;
+
+ if (is$1.empty(src)) {
+ throw new Error('Missing previewThumbnails.src config attribute');
+ } // Resolve promise
+
+
+ var sortAndResolve = function sortAndResolve() {
+ // Sort smallest to biggest (e.g., [120p, 480p, 1080p])
+ _this2.thumbnails.sort(function (x, y) {
+ return x.height - y.height;
+ });
+
+ _this2.player.debug.log('Preview thumbnails', _this2.thumbnails);
+
+ resolve();
+ }; // Via callback()
+
+
+ if (is$1.function(src)) {
+ src(function (thumbnails) {
+ _this2.thumbnails = thumbnails;
+ sortAndResolve();
+ });
+ } // VTT urls
+ else {
+ // If string, convert into single-element list
+ var urls = is$1.string(src) ? [src] : src; // Loop through each src URL. Download and process the VTT file, storing the resulting data in this.thumbnails
+
+ var promises = urls.map(function (u) {
+ return _this2.getThumbnail(u);
+ }); // Resolve
+
+ Promise.all(promises).then(sortAndResolve);
+ }
+ });
+ } // Process individual VTT file
+
+ }, {
+ key: "getThumbnail",
+ value: function getThumbnail(url) {
+ var _this3 = this;
+
+ return new Promise(function (resolve) {
+ fetch(url).then(function (response) {
+ var thumbnail = {
+ frames: parseVtt(response),
+ height: null,
+ urlPrefix: ''
+ }; // If the URLs don't start with '/', then we need to set their relative path to be the location of the VTT file
+ // If the URLs do start with '/', then they obviously don't need a prefix, so it will remain blank
+ // If the thumbnail URLs start with with none of '/', 'http://' or 'https://', then we need to set their relative path to be the location of the VTT file
+
+ if (!thumbnail.frames[0].text.startsWith('/') && !thumbnail.frames[0].text.startsWith('http://') && !thumbnail.frames[0].text.startsWith('https://')) {
+ thumbnail.urlPrefix = url.substring(0, url.lastIndexOf('/') + 1);
+ } // Download the first frame, so that we can determine/set the height of this thumbnailsDef
+
+
+ var tempImage = new Image();
+
+ tempImage.onload = function () {
+ thumbnail.height = tempImage.naturalHeight;
+ thumbnail.width = tempImage.naturalWidth;
+
+ _this3.thumbnails.push(thumbnail);
+
+ resolve();
+ };
+
+ tempImage.src = thumbnail.urlPrefix + thumbnail.frames[0].text;
+ });
+ });
+ }
+ }, {
+ key: "startMove",
+ value: function startMove(event) {
+ if (!this.loaded) {
+ return;
+ }
+
+ if (!is$1.event(event) || !['touchmove', 'mousemove'].includes(event.type)) {
+ return;
+ } // Wait until media has a duration
+
+
+ if (!this.player.media.duration) {
+ return;
+ }
+
+ if (event.type === 'touchmove') {
+ // Calculate seek hover position as approx video seconds
+ this.seekTime = this.player.media.duration * (this.player.elements.inputs.seek.value / 100);
+ } else {
+ // Calculate seek hover position as approx video seconds
+ var clientRect = this.player.elements.progress.getBoundingClientRect();
+ var percentage = 100 / clientRect.width * (event.pageX - clientRect.left);
+ this.seekTime = this.player.media.duration * (percentage / 100);
+
+ if (this.seekTime < 0) {
+ // The mousemove fires for 10+px out to the left
+ this.seekTime = 0;
+ }
+
+ if (this.seekTime > this.player.media.duration - 1) {
+ // Took 1 second off the duration for safety, because different players can disagree on the real duration of a video
+ this.seekTime = this.player.media.duration - 1;
+ }
+
+ this.mousePosX = event.pageX; // Set time text inside image container
+
+ this.elements.thumb.time.innerText = formatTime(this.seekTime);
+ } // Download and show image
+
+
+ this.showImageAtCurrentTime();
+ }
+ }, {
+ key: "endMove",
+ value: function endMove() {
+ this.toggleThumbContainer(false, true);
+ }
+ }, {
+ key: "startScrubbing",
+ value: function startScrubbing(event) {
+ // Only act on left mouse button (0), or touch device (event.button does not exist or is false)
+ if (is$1.nullOrUndefined(event.button) || event.button === false || event.button === 0) {
+ this.mouseDown = true; // Wait until media has a duration
+
+ if (this.player.media.duration) {
+ this.toggleScrubbingContainer(true);
+ this.toggleThumbContainer(false, true); // Download and show image
+
+ this.showImageAtCurrentTime();
+ }
+ }
+ }
+ }, {
+ key: "endScrubbing",
+ value: function endScrubbing() {
+ var _this4 = this;
+
+ this.mouseDown = false; // Hide scrubbing preview. But wait until the video has successfully seeked before hiding the scrubbing preview
+
+ if (Math.ceil(this.lastTime) === Math.ceil(this.player.media.currentTime)) {
+ // The video was already seeked/loaded at the chosen time - hide immediately
+ this.toggleScrubbingContainer(false);
+ } else {
+ // The video hasn't seeked yet. Wait for that
+ once.call(this.player, this.player.media, 'timeupdate', function () {
+ // Re-check mousedown - we might have already started scrubbing again
+ if (!_this4.mouseDown) {
+ _this4.toggleScrubbingContainer(false);
+ }
+ });
+ }
+ }
+ /**
+ * Setup hooks for Plyr and window events
+ */
+
+ }, {
+ key: "listeners",
+ value: function listeners() {
+ var _this5 = this;
+
+ // Hide thumbnail preview - on mouse click, mouse leave (in listeners.js for now), and video play/seek. All four are required, e.g., for buffering
+ this.player.on('play', function () {
+ _this5.toggleThumbContainer(false, true);
+ });
+ this.player.on('seeked', function () {
+ _this5.toggleThumbContainer(false);
+ });
+ this.player.on('timeupdate', function () {
+ _this5.lastTime = _this5.player.media.currentTime;
+ });
+ }
+ /**
+ * Create HTML elements for image containers
+ */
+
+ }, {
+ key: "render",
+ value: function render() {
+ // Create HTML element: plyr__preview-thumbnail-container
+ this.elements.thumb.container = createElement('div', {
+ class: this.player.config.classNames.previewThumbnails.thumbContainer
+ }); // Wrapper for the image for styling
+
+ this.elements.thumb.imageContainer = createElement('div', {
+ class: this.player.config.classNames.previewThumbnails.imageContainer
+ });
+ this.elements.thumb.container.appendChild(this.elements.thumb.imageContainer); // Create HTML element, parent+span: time text (e.g., 01:32:00)
+
+ var timeContainer = createElement('div', {
+ class: this.player.config.classNames.previewThumbnails.timeContainer
+ });
+ this.elements.thumb.time = createElement('span', {}, '00:00');
+ timeContainer.appendChild(this.elements.thumb.time);
+ this.elements.thumb.container.appendChild(timeContainer); // Inject the whole thumb
+
+ if (is$1.element(this.player.elements.progress)) {
+ this.player.elements.progress.appendChild(this.elements.thumb.container);
+ } // Create HTML element: plyr__preview-scrubbing-container
+
+
+ this.elements.scrubbing.container = createElement('div', {
+ class: this.player.config.classNames.previewThumbnails.scrubbingContainer
+ });
+ this.player.elements.wrapper.appendChild(this.elements.scrubbing.container);
+ }
+ }, {
+ key: "destroy",
+ value: function destroy() {
+ if (this.elements.thumb.container) {
+ this.elements.thumb.container.remove();
+ }
+
+ if (this.elements.scrubbing.container) {
+ this.elements.scrubbing.container.remove();
+ }
+ }
+ }, {
+ key: "showImageAtCurrentTime",
+ value: function showImageAtCurrentTime() {
+ var _this6 = this;
+
+ if (this.mouseDown) {
+ this.setScrubbingContainerSize();
+ } else {
+ this.setThumbContainerSizeAndPos();
+ } // Find the desired thumbnail index
+ // TODO: Handle a video longer than the thumbs where thumbNum is null
+
+
+ var thumbNum = this.thumbnails[0].frames.findIndex(function (frame) {
+ return _this6.seekTime >= frame.startTime && _this6.seekTime <= frame.endTime;
+ });
+ var hasThumb = thumbNum >= 0;
+ var qualityIndex = 0; // Show the thumb container if we're not scrubbing
+
+ if (!this.mouseDown) {
+ this.toggleThumbContainer(hasThumb);
+ } // No matching thumb found
+
+
+ if (!hasThumb) {
+ return;
+ } // Check to see if we've already downloaded higher quality versions of this image
+
+
+ this.thumbnails.forEach(function (thumbnail, index) {
+ if (_this6.loadedImages.includes(thumbnail.frames[thumbNum].text)) {
+ qualityIndex = index;
+ }
+ }); // Only proceed if either thumbnum or thumbfilename has changed
+
+ if (thumbNum !== this.showingThumb) {
+ this.showingThumb = thumbNum;
+ this.loadImage(qualityIndex);
+ }
+ } // Show the image that's currently specified in this.showingThumb
+
+ }, {
+ key: "loadImage",
+ value: function loadImage() {
+ var _this7 = this;
+
+ var qualityIndex = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
+ var thumbNum = this.showingThumb;
+ var thumbnail = this.thumbnails[qualityIndex];
+ var urlPrefix = thumbnail.urlPrefix;
+ var frame = thumbnail.frames[thumbNum];
+ var thumbFilename = thumbnail.frames[thumbNum].text;
+ var thumbUrl = urlPrefix + thumbFilename;
+
+ if (!this.currentImageElement || this.currentImageElement.dataset.filename !== thumbFilename) {
+ // If we're already loading a previous image, remove its onload handler - we don't want it to load after this one
+ // Only do this if not using sprites. Without sprites we really want to show as many images as possible, as a best-effort
+ if (this.loadingImage && this.usingSprites) {
+ this.loadingImage.onload = null;
+ } // We're building and adding a new image. In other implementations of similar functionality (YouTube), background image
+ // is instead used. But this causes issues with larger images in Firefox and Safari - switching between background
+ // images causes a flicker. Putting a new image over the top does not
+
+
+ var previewImage = new Image();
+ previewImage.src = thumbUrl;
+ previewImage.dataset.index = thumbNum;
+ previewImage.dataset.filename = thumbFilename;
+ this.showingThumbFilename = thumbFilename;
+ this.player.debug.log("Loading image: ".concat(thumbUrl)); // For some reason, passing the named function directly causes it to execute immediately. So I've wrapped it in an anonymous function...
+
+ previewImage.onload = function () {
+ return _this7.showImage(previewImage, frame, qualityIndex, thumbNum, thumbFilename, true);
+ };
+
+ this.loadingImage = previewImage;
+ this.removeOldImages(previewImage);
+ } else {
+ // Update the existing image
+ this.showImage(this.currentImageElement, frame, qualityIndex, thumbNum, thumbFilename, false);
+ this.currentImageElement.dataset.index = thumbNum;
+ this.removeOldImages(this.currentImageElement);
+ }
+ }
+ }, {
+ key: "showImage",
+ value: function showImage(previewImage, frame, qualityIndex, thumbNum, thumbFilename) {
+ var newImage = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : true;
+ this.player.debug.log("Showing thumb: ".concat(thumbFilename, ". num: ").concat(thumbNum, ". qual: ").concat(qualityIndex, ". newimg: ").concat(newImage));
+ this.setImageSizeAndOffset(previewImage, frame);
+
+ if (newImage) {
+ this.currentImageContainer.appendChild(previewImage);
+ this.currentImageElement = previewImage;
+
+ if (!this.loadedImages.includes(thumbFilename)) {
+ this.loadedImages.push(thumbFilename);
+ }
+ } // Preload images before and after the current one
+ // Show higher quality of the same frame
+ // Each step here has a short time delay, and only continues if still hovering/seeking the same spot. This is to protect slow connections from overloading
+
+
+ this.preloadNearby(thumbNum, true).then(this.preloadNearby(thumbNum, false)).then(this.getHigherQuality(qualityIndex, previewImage, frame, thumbFilename));
+ } // Remove all preview images that aren't the designated current image
+
+ }, {
+ key: "removeOldImages",
+ value: function removeOldImages(currentImage) {
+ var _this8 = this;
+
+ // Get a list of all images, convert it from a DOM list to an array
+ Array.from(this.currentImageContainer.children).forEach(function (image) {
+ if (image.tagName.toLowerCase() !== 'img') {
+ return;
+ }
+
+ var removeDelay = _this8.usingSprites ? 500 : 1000;
+
+ if (image.dataset.index !== currentImage.dataset.index && !image.dataset.deleting) {
+ // Wait 200ms, as the new image can take some time to show on certain browsers (even though it was downloaded before showing). This will prevent flicker, and show some generosity towards slower clients
+ // First set attribute 'deleting' to prevent multi-handling of this on repeat firing of this function
+ // eslint-disable-next-line no-param-reassign
+ image.dataset.deleting = true; // This has to be set before the timeout - to prevent issues switching between hover and scrub
+
+ var currentImageContainer = _this8.currentImageContainer;
+ setTimeout(function () {
+ currentImageContainer.removeChild(image);
+
+ _this8.player.debug.log("Removing thumb: ".concat(image.dataset.filename));
+ }, removeDelay);
+ }
+ });
+ } // Preload images before and after the current one. Only if the user is still hovering/seeking the same frame
+ // This will only preload the lowest quality
+
+ }, {
+ key: "preloadNearby",
+ value: function preloadNearby(thumbNum) {
+ var _this9 = this;
+
+ var forward = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
+ return new Promise(function (resolve) {
+ setTimeout(function () {
+ var oldThumbFilename = _this9.thumbnails[0].frames[thumbNum].text;
+
+ if (_this9.showingThumbFilename === oldThumbFilename) {
+ // Find the nearest thumbs with different filenames. Sometimes it'll be the next index, but in the case of sprites, it might be 100+ away
+ var thumbnailsClone;
+
+ if (forward) {
+ thumbnailsClone = _this9.thumbnails[0].frames.slice(thumbNum);
+ } else {
+ thumbnailsClone = _this9.thumbnails[0].frames.slice(0, thumbNum).reverse();
+ }
+
+ var foundOne = false;
+ thumbnailsClone.forEach(function (frame) {
+ var newThumbFilename = frame.text;
+
+ if (newThumbFilename !== oldThumbFilename) {
+ // Found one with a different filename. Make sure it hasn't already been loaded on this page visit
+ if (!_this9.loadedImages.includes(newThumbFilename)) {
+ foundOne = true;
+
+ _this9.player.debug.log("Preloading thumb filename: ".concat(newThumbFilename));
+
+ var urlPrefix = _this9.thumbnails[0].urlPrefix;
+ var thumbURL = urlPrefix + newThumbFilename;
+ var previewImage = new Image();
+ previewImage.src = thumbURL;
+
+ previewImage.onload = function () {
+ _this9.player.debug.log("Preloaded thumb filename: ".concat(newThumbFilename));
+
+ if (!_this9.loadedImages.includes(newThumbFilename)) _this9.loadedImages.push(newThumbFilename); // We don't resolve until the thumb is loaded
+
+ resolve();
+ };
+ }
+ }
+ }); // If there are none to preload then we want to resolve immediately
+
+ if (!foundOne) {
+ resolve();
+ }
+ }
+ }, 300);
+ });
+ } // If user has been hovering current image for half a second, look for a higher quality one
+
+ }, {
+ key: "getHigherQuality",
+ value: function getHigherQuality(currentQualityIndex, previewImage, frame, thumbFilename) {
+ var _this10 = this;
+
+ if (currentQualityIndex < this.thumbnails.length - 1) {
+ // Only use the higher quality version if it's going to look any better - if the current thumb is of a lower pixel density than the thumbnail container
+ var previewImageHeight = previewImage.naturalHeight;
+
+ if (this.usingSprites) {
+ previewImageHeight = frame.h;
+ }
+
+ if (previewImageHeight < this.thumbContainerHeight) {
+ // Recurse back to the loadImage function - show a higher quality one, but only if the viewer is on this frame for a while
+ setTimeout(function () {
+ // Make sure the mouse hasn't already moved on and started hovering at another image
+ if (_this10.showingThumbFilename === thumbFilename) {
+ _this10.player.debug.log("Showing higher quality thumb for: ".concat(thumbFilename));
+
+ _this10.loadImage(currentQualityIndex + 1);
+ }
+ }, 300);
+ }
+ }
+ }
+ }, {
+ key: "toggleThumbContainer",
+ value: function toggleThumbContainer() {
+ var toggle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+ var clearShowing = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+ var className = this.player.config.classNames.previewThumbnails.thumbContainerShown;
+ this.elements.thumb.container.classList.toggle(className, toggle);
+
+ if (!toggle && clearShowing) {
+ this.showingThumb = null;
+ this.showingThumbFilename = null;
+ }
+ }
+ }, {
+ key: "toggleScrubbingContainer",
+ value: function toggleScrubbingContainer() {
+ var toggle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+ var className = this.player.config.classNames.previewThumbnails.scrubbingContainerShown;
+ this.elements.scrubbing.container.classList.toggle(className, toggle);
+
+ if (!toggle) {
+ this.showingThumb = null;
+ this.showingThumbFilename = null;
+ }
+ }
+ }, {
+ key: "determineContainerAutoSizing",
+ value: function determineContainerAutoSizing() {
+ if (this.elements.thumb.imageContainer.clientHeight > 20 || this.elements.thumb.imageContainer.clientWidth > 20) {
+ // This will prevent auto sizing in this.setThumbContainerSizeAndPos()
+ this.sizeSpecifiedInCSS = true;
+ }
+ } // Set the size to be about a quarter of the size of video. Unless option dynamicSize === false, in which case it needs to be set in CSS
+
+ }, {
+ key: "setThumbContainerSizeAndPos",
+ value: function setThumbContainerSizeAndPos() {
+ if (!this.sizeSpecifiedInCSS) {
+ var thumbWidth = Math.floor(this.thumbContainerHeight * this.thumbAspectRatio);
+ this.elements.thumb.imageContainer.style.height = "".concat(this.thumbContainerHeight, "px");
+ this.elements.thumb.imageContainer.style.width = "".concat(thumbWidth, "px");
+ } else if (this.elements.thumb.imageContainer.clientHeight > 20 && this.elements.thumb.imageContainer.clientWidth < 20) {
+ var _thumbWidth = Math.floor(this.elements.thumb.imageContainer.clientHeight * this.thumbAspectRatio);
+
+ this.elements.thumb.imageContainer.style.width = "".concat(_thumbWidth, "px");
+ } else if (this.elements.thumb.imageContainer.clientHeight < 20 && this.elements.thumb.imageContainer.clientWidth > 20) {
+ var thumbHeight = Math.floor(this.elements.thumb.imageContainer.clientWidth / this.thumbAspectRatio);
+ this.elements.thumb.imageContainer.style.height = "".concat(thumbHeight, "px");
+ }
+
+ this.setThumbContainerPos();
+ }
+ }, {
+ key: "setThumbContainerPos",
+ value: function setThumbContainerPos() {
+ var seekbarRect = this.player.elements.progress.getBoundingClientRect();
+ var plyrRect = this.player.elements.container.getBoundingClientRect();
+ var container = this.elements.thumb.container; // Find the lowest and highest desired left-position, so we don't slide out the side of the video container
+
+ var minVal = plyrRect.left - seekbarRect.left + 10;
+ var maxVal = plyrRect.right - seekbarRect.left - container.clientWidth - 10; // Set preview container position to: mousepos, minus seekbar.left, minus half of previewContainer.clientWidth
+
+ var previewPos = this.mousePosX - seekbarRect.left - container.clientWidth / 2;
+
+ if (previewPos < minVal) {
+ previewPos = minVal;
+ }
+
+ if (previewPos > maxVal) {
+ previewPos = maxVal;
+ }
+
+ container.style.left = "".concat(previewPos, "px");
+ } // Can't use 100% width, in case the video is a different aspect ratio to the video container
+
+ }, {
+ key: "setScrubbingContainerSize",
+ value: function setScrubbingContainerSize() {
+ var _fitRatio = fitRatio(this.thumbAspectRatio, {
+ width: this.player.media.clientWidth,
+ height: this.player.media.clientHeight
+ }),
+ width = _fitRatio.width,
+ height = _fitRatio.height;
+
+ this.elements.scrubbing.container.style.width = "".concat(width, "px");
+ this.elements.scrubbing.container.style.height = "".concat(height, "px");
+ } // Sprites need to be offset to the correct location
+
+ }, {
+ key: "setImageSizeAndOffset",
+ value: function setImageSizeAndOffset(previewImage, frame) {
+ if (!this.usingSprites) {
+ return;
+ } // Find difference between height and preview container height
+
+
+ var multiplier = this.thumbContainerHeight / frame.h; // eslint-disable-next-line no-param-reassign
+
+ previewImage.style.height = "".concat(previewImage.naturalHeight * multiplier, "px"); // eslint-disable-next-line no-param-reassign
+
+ previewImage.style.width = "".concat(previewImage.naturalWidth * multiplier, "px"); // eslint-disable-next-line no-param-reassign
+
+ previewImage.style.left = "-".concat(frame.x * multiplier, "px"); // eslint-disable-next-line no-param-reassign
+
+ previewImage.style.top = "-".concat(frame.y * multiplier, "px");
+ }
+ }, {
+ key: "enabled",
+ get: function get() {
+ return this.player.isHTML5 && this.player.isVideo && this.player.config.previewThumbnails.enabled;
+ }
+ }, {
+ key: "currentImageContainer",
+ get: function get() {
+ if (this.mouseDown) {
+ return this.elements.scrubbing.container;
+ }
+
+ return this.elements.thumb.imageContainer;
+ }
+ }, {
+ key: "usingSprites",
+ get: function get() {
+ return Object.keys(this.thumbnails[0].frames[0]).includes('w');
+ }
+ }, {
+ key: "thumbAspectRatio",
+ get: function get() {
+ if (this.usingSprites) {
+ return this.thumbnails[0].frames[0].w / this.thumbnails[0].frames[0].h;
+ }
+
+ return this.thumbnails[0].width / this.thumbnails[0].height;
+ }
+ }, {
+ key: "thumbContainerHeight",
+ get: function get() {
+ if (this.mouseDown) {
+ var _fitRatio2 = fitRatio(this.thumbAspectRatio, {
+ width: this.player.media.clientWidth,
+ height: this.player.media.clientHeight
+ }),
+ height = _fitRatio2.height;
+
+ return height;
+ } // If css is used this needs to return the css height for sprites to work (see setImageSizeAndOffset)
+
+
+ if (this.sizeSpecifiedInCSS) {
+ return this.elements.thumb.imageContainer.clientHeight;
+ }
+
+ return Math.floor(this.player.media.clientWidth / this.thumbAspectRatio / 4);
+ }
+ }, {
+ key: "currentImageElement",
+ get: function get() {
+ if (this.mouseDown) {
+ return this.currentScrubbingImageElement;
+ }
+
+ return this.currentThumbnailImageElement;
+ },
+ set: function set(element) {
+ if (this.mouseDown) {
+ this.currentScrubbingImageElement = element;
+ } else {
+ this.currentThumbnailImageElement = element;
+ }
+ }
+ }]);
+
+ return PreviewThumbnails;
+ }();
+
+ var source = {
+ // Add elements to HTML5 media (source, tracks, etc)
+ insertElements: function insertElements(type, attributes) {
+ var _this = this;
+
+ if (is$1.string(attributes)) {
+ insertElement(type, this.media, {
+ src: attributes
+ });
+ } else if (is$1.array(attributes)) {
+ attributes.forEach(function (attribute) {
+ insertElement(type, _this.media, attribute);
+ });
+ }
+ },
+ // Update source
+ // Sources are not checked for support so be careful
+ change: function change(input) {
+ var _this2 = this;
+
+ if (!getDeep(input, 'sources.length')) {
+ this.debug.warn('Invalid source format');
+ return;
+ } // Cancel current network requests
+
+
+ html5.cancelRequests.call(this); // Destroy instance and re-setup
+
+ this.destroy.call(this, function () {
+ // Reset quality options
+ _this2.options.quality = []; // Remove elements
+
+ removeElement(_this2.media);
+ _this2.media = null; // Reset class name
+
+ if (is$1.element(_this2.elements.container)) {
+ _this2.elements.container.removeAttribute('class');
+ } // Set the type and provider
+
+
+ var sources = input.sources,
+ type = input.type;
+
+ var _sources = _slicedToArray(sources, 1),
+ _sources$ = _sources[0],
+ _sources$$provider = _sources$.provider,
+ provider = _sources$$provider === void 0 ? providers.html5 : _sources$$provider,
+ src = _sources$.src;
+
+ var tagName = provider === 'html5' ? type : 'div';
+ var attributes = provider === 'html5' ? {} : {
+ src: src
+ };
+ Object.assign(_this2, {
+ provider: provider,
+ type: type,
+ // Check for support
+ supported: support.check(type, provider, _this2.config.playsinline),
+ // Create new element
+ media: createElement(tagName, attributes)
+ }); // Inject the new element
+
+ _this2.elements.container.appendChild(_this2.media); // Autoplay the new source?
+
+
+ if (is$1.boolean(input.autoplay)) {
+ _this2.config.autoplay = input.autoplay;
+ } // Set attributes for audio and video
+
+
+ if (_this2.isHTML5) {
+ if (_this2.config.crossorigin) {
+ _this2.media.setAttribute('crossorigin', '');
+ }
+
+ if (_this2.config.autoplay) {
+ _this2.media.setAttribute('autoplay', '');
+ }
+
+ if (!is$1.empty(input.poster)) {
+ _this2.poster = input.poster;
+ }
+
+ if (_this2.config.loop.active) {
+ _this2.media.setAttribute('loop', '');
+ }
+
+ if (_this2.config.muted) {
+ _this2.media.setAttribute('muted', '');
+ }
+
+ if (_this2.config.playsinline) {
+ _this2.media.setAttribute('playsinline', '');
+ }
+ } // Restore class hook
+
+
+ ui.addStyleHook.call(_this2); // Set new sources for html5
+
+ if (_this2.isHTML5) {
+ source.insertElements.call(_this2, 'source', sources);
+ } // Set video title
+
+
+ _this2.config.title = input.title; // Set up from scratch
+
+ media.setup.call(_this2); // HTML5 stuff
+
+ if (_this2.isHTML5) {
+ // Setup captions
+ if (Object.keys(input).includes('tracks')) {
+ source.insertElements.call(_this2, 'track', input.tracks);
+ }
+ } // If HTML5 or embed but not fully supported, setupInterface and call ready now
+
+
+ if (_this2.isHTML5 || _this2.isEmbed && !_this2.supported.ui) {
+ // Setup interface
+ ui.build.call(_this2);
+ } // Load HTML5 sources
+
+
+ if (_this2.isHTML5) {
+ _this2.media.load();
+ } // Update previewThumbnails config & reload plugin
+
+
+ if (!is$1.empty(input.previewThumbnails)) {
+ Object.assign(_this2.config.previewThumbnails, input.previewThumbnails); // Cleanup previewThumbnails plugin if it was loaded
+
+ if (_this2.previewThumbnails && _this2.previewThumbnails.loaded) {
+ _this2.previewThumbnails.destroy();
+
+ _this2.previewThumbnails = null;
+ } // Create new instance if it is still enabled
+
+
+ if (_this2.config.previewThumbnails.enabled) {
+ _this2.previewThumbnails = new PreviewThumbnails(_this2);
+ }
+ } // Update the fullscreen support
+
+
+ _this2.fullscreen.update();
+ }, true);
+ }
+ };
+
+ /**
+ * Returns a number whose value is limited to the given range.
+ *
+ * Example: limit the output of this computation to between 0 and 255
+ * (x * 255).clamp(0, 255)
+ *
+ * @param {Number} input
+ * @param {Number} min The lower boundary of the output range
+ * @param {Number} max The upper boundary of the output range
+ * @returns A number in the range [min, max]
+ * @type Number
+ */
+ function clamp() {
+ var input = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
+ var min = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
+ var max = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 255;
+ return Math.min(Math.max(input, min), max);
+ }
+
+ // TODO: Use a WeakMap for private globals
+ // const globals = new WeakMap();
+ // Plyr instance
+
+ var Plyr = /*#__PURE__*/function () {
+ function Plyr(target, options) {
+ var _this = this;
+
+ _classCallCheck(this, Plyr);
+
+ this.timers = {}; // State
+
+ this.ready = false;
+ this.loading = false;
+ this.failed = false; // Touch device
+
+ this.touch = support.touch; // Set the media element
+
+ this.media = target; // String selector passed
+
+ if (is$1.string(this.media)) {
+ this.media = document.querySelectorAll(this.media);
+ } // jQuery, NodeList or Array passed, use first element
+
+
+ if (window.jQuery && this.media instanceof jQuery || is$1.nodeList(this.media) || is$1.array(this.media)) {
+ // eslint-disable-next-line
+ this.media = this.media[0];
+ } // Set config
+
+
+ this.config = extend({}, defaults$1, Plyr.defaults, options || {}, function () {
+ try {
+ return JSON.parse(_this.media.getAttribute('data-plyr-config'));
+ } catch (e) {
+ return {};
+ }
+ }()); // Elements cache
+
+ this.elements = {
+ container: null,
+ fullscreen: null,
+ captions: null,
+ buttons: {},
+ display: {},
+ progress: {},
+ inputs: {},
+ settings: {
+ popup: null,
+ menu: null,
+ panels: {},
+ buttons: {}
+ }
+ }; // Captions
+
+ this.captions = {
+ active: null,
+ currentTrack: -1,
+ meta: new WeakMap()
+ }; // Fullscreen
+
+ this.fullscreen = {
+ active: false
+ }; // Options
+
+ this.options = {
+ speed: [],
+ quality: []
+ }; // Debugging
+ // TODO: move to globals
+
+ this.debug = new Console(this.config.debug); // Log config options and support
+
+ this.debug.log('Config', this.config);
+ this.debug.log('Support', support); // We need an element to setup
+
+ if (is$1.nullOrUndefined(this.media) || !is$1.element(this.media)) {
+ this.debug.error('Setup failed: no suitable element passed');
+ return;
+ } // Bail if the element is initialized
+
+
+ if (this.media.plyr) {
+ this.debug.warn('Target already setup');
+ return;
+ } // Bail if not enabled
+
+
+ if (!this.config.enabled) {
+ this.debug.error('Setup failed: disabled by config');
+ return;
+ } // Bail if disabled or no basic support
+ // You may want to disable certain UAs etc
+
+
+ if (!support.check().api) {
+ this.debug.error('Setup failed: no support');
+ return;
+ } // Cache original element state for .destroy()
+
+
+ var clone = this.media.cloneNode(true);
+ clone.autoplay = false;
+ this.elements.original = clone; // Set media type based on tag or data attribute
+ // Supported: video, audio, vimeo, youtube
+
+ var type = this.media.tagName.toLowerCase(); // Embed properties
+
+ var iframe = null;
+ var url = null; // Different setup based on type
+
+ switch (type) {
+ case 'div':
+ // Find the frame
+ iframe = this.media.querySelector('iframe'); // <iframe> type
+
+ if (is$1.element(iframe)) {
+ // Detect provider
+ url = parseUrl(iframe.getAttribute('src'));
+ this.provider = getProviderByUrl(url.toString()); // Rework elements
+
+ this.elements.container = this.media;
+ this.media = iframe; // Reset classname
+
+ this.elements.container.className = ''; // Get attributes from URL and set config
+
+ if (url.search.length) {
+ var truthy = ['1', 'true'];
+
+ if (truthy.includes(url.searchParams.get('autoplay'))) {
+ this.config.autoplay = true;
+ }
+
+ if (truthy.includes(url.searchParams.get('loop'))) {
+ this.config.loop.active = true;
+ } // TODO: replace fullscreen.iosNative with this playsinline config option
+ // YouTube requires the playsinline in the URL
+
+
+ if (this.isYouTube) {
+ this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));
+ this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?
+ } else {
+ this.config.playsinline = true;
+ }
+ }
+ } else {
+ // <div> with attributes
+ this.provider = this.media.getAttribute(this.config.attributes.embed.provider); // Remove attribute
+
+ this.media.removeAttribute(this.config.attributes.embed.provider);
+ } // Unsupported or missing provider
+
+
+ if (is$1.empty(this.provider) || !Object.keys(providers).includes(this.provider)) {
+ this.debug.error('Setup failed: Invalid provider');
+ return;
+ } // Audio will come later for external providers
+
+
+ this.type = types.video;
+ break;
+
+ case 'video':
+ case 'audio':
+ this.type = type;
+ this.provider = providers.html5; // Get config from attributes
+
+ if (this.media.hasAttribute('crossorigin')) {
+ this.config.crossorigin = true;
+ }
+
+ if (this.media.hasAttribute('autoplay')) {
+ this.config.autoplay = true;
+ }
+
+ if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {
+ this.config.playsinline = true;
+ }
+
+ if (this.media.hasAttribute('muted')) {
+ this.config.muted = true;
+ }
+
+ if (this.media.hasAttribute('loop')) {
+ this.config.loop.active = true;
+ }
+
+ break;
+
+ default:
+ this.debug.error('Setup failed: unsupported type');
+ return;
+ } // Check for support again but with type
+
+
+ this.supported = support.check(this.type, this.provider, this.config.playsinline); // If no support for even API, bail
+
+ if (!this.supported.api) {
+ this.debug.error('Setup failed: no support');
+ return;
+ }
+
+ this.eventListeners = []; // Create listeners
+
+ this.listeners = new Listeners(this); // Setup local storage for user settings
+
+ this.storage = new Storage(this); // Store reference
+
+ this.media.plyr = this; // Wrap media
+
+ if (!is$1.element(this.elements.container)) {
+ this.elements.container = createElement('div', {
+ tabindex: 0
+ });
+ wrap(this.media, this.elements.container);
+ } // Migrate custom properties from media to container (so they work 😉)
+
+
+ ui.migrateStyles.call(this); // Add style hook
+
+ ui.addStyleHook.call(this); // Setup media
+
+ media.setup.call(this); // Listen for events if debugging
+
+ if (this.config.debug) {
+ on.call(this, this.elements.container, this.config.events.join(' '), function (event) {
+ _this.debug.log("event: ".concat(event.type));
+ });
+ } // Setup fullscreen
+
+
+ this.fullscreen = new Fullscreen(this); // Setup interface
+ // If embed but not fully supported, build interface now to avoid flash of controls
+
+ if (this.isHTML5 || this.isEmbed && !this.supported.ui) {
+ ui.build.call(this);
+ } // Container listeners
+
+
+ this.listeners.container(); // Global listeners
+
+ this.listeners.global(); // Setup ads if provided
+
+ if (this.config.ads.enabled) {
+ this.ads = new Ads(this);
+ } // Autoplay if required
+
+
+ if (this.isHTML5 && this.config.autoplay) {
+ setTimeout(function () {
+ return silencePromise(_this.play());
+ }, 10);
+ } // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek
+
+
+ this.lastSeekTime = 0; // Setup preview thumbnails if enabled
+
+ if (this.config.previewThumbnails.enabled) {
+ this.previewThumbnails = new PreviewThumbnails(this);
+ }
+ } // ---------------------------------------
+ // API
+ // ---------------------------------------
+
+ /**
+ * Types and provider helpers
+ */
+
+
+ _createClass(Plyr, [{
+ key: "play",
+
+ /**
+ * Play the media, or play the advertisement (if they are not blocked)
+ */
+ value: function play() {
+ var _this2 = this;
+
+ if (!is$1.function(this.media.play)) {
+ return null;
+ } // Intecept play with ads
+
+
+ if (this.ads && this.ads.enabled) {
+ this.ads.managerPromise.then(function () {
+ return _this2.ads.play();
+ }).catch(function () {
+ return silencePromise(_this2.media.play());
+ });
+ } // Return the promise (for HTML5)
+
+
+ return this.media.play();
+ }
+ /**
+ * Pause the media
+ */
+
+ }, {
+ key: "pause",
+ value: function pause() {
+ if (!this.playing || !is$1.function(this.media.pause)) {
+ return null;
+ }
+
+ return this.media.pause();
+ }
+ /**
+ * Get playing state
+ */
+
+ }, {
+ key: "togglePlay",
+
+ /**
+ * Toggle playback based on current status
+ * @param {Boolean} input
+ */
+ value: function togglePlay(input) {
+ // Toggle based on current state if nothing passed
+ var toggle = is$1.boolean(input) ? input : !this.playing;
+
+ if (toggle) {
+ return this.play();
+ }
+
+ return this.pause();
+ }
+ /**
+ * Stop playback
+ */
+
+ }, {
+ key: "stop",
+ value: function stop() {
+ if (this.isHTML5) {
+ this.pause();
+ this.restart();
+ } else if (is$1.function(this.media.stop)) {
+ this.media.stop();
+ }
+ }
+ /**
+ * Restart playback
+ */
+
+ }, {
+ key: "restart",
+ value: function restart() {
+ this.currentTime = 0;
+ }
+ /**
+ * Rewind
+ * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
+ */
+
+ }, {
+ key: "rewind",
+ value: function rewind(seekTime) {
+ this.currentTime -= is$1.number(seekTime) ? seekTime : this.config.seekTime;
+ }
+ /**
+ * Fast forward
+ * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
+ */
+
+ }, {
+ key: "forward",
+ value: function forward(seekTime) {
+ this.currentTime += is$1.number(seekTime) ? seekTime : this.config.seekTime;
+ }
+ /**
+ * Seek to a time
+ * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start)
+ */
+
+ }, {
+ key: "increaseVolume",
+
+ /**
+ * Increase volume
+ * @param {Boolean} step - How much to decrease by (between 0 and 1)
+ */
+ value: function increaseVolume(step) {
+ var volume = this.media.muted ? 0 : this.volume;
+ this.volume = volume + (is$1.number(step) ? step : 0);
+ }
+ /**
+ * Decrease volume
+ * @param {Boolean} step - How much to decrease by (between 0 and 1)
+ */
+
+ }, {
+ key: "decreaseVolume",
+ value: function decreaseVolume(step) {
+ this.increaseVolume(-step);
+ }
+ /**
+ * Set muted state
+ * @param {Boolean} mute
+ */
+
+ }, {
+ key: "toggleCaptions",
+
+ /**
+ * Toggle captions
+ * @param {Boolean} input - Whether to enable captions
+ */
+ value: function toggleCaptions(input) {
+ captions.toggle.call(this, input, false);
+ }
+ /**
+ * Set the caption track by index
+ * @param {Number} - Caption index
+ */
+
+ }, {
+ key: "airplay",
+
+ /**
+ * Trigger the airplay dialog
+ * TODO: update player with state, support, enabled
+ */
+ value: function airplay() {
+ // Show dialog if supported
+ if (support.airplay) {
+ this.media.webkitShowPlaybackTargetPicker();
+ }
+ }
+ /**
+ * Toggle the player controls
+ * @param {Boolean} [toggle] - Whether to show the controls
+ */
+
+ }, {
+ key: "toggleControls",
+ value: function toggleControls(toggle) {
+ // Don't toggle if missing UI support or if it's audio
+ if (this.supported.ui && !this.isAudio) {
+ // Get state before change
+ var isHidden = hasClass(this.elements.container, this.config.classNames.hideControls); // Negate the argument if not undefined since adding the class to hides the controls
+
+ var force = typeof toggle === 'undefined' ? undefined : !toggle; // Apply and get updated state
+
+ var hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force); // Close menu
+
+ if (hiding && is$1.array(this.config.controls) && this.config.controls.includes('settings') && !is$1.empty(this.config.settings)) {
+ controls.toggleMenu.call(this, false);
+ } // Trigger event on change
+
+
+ if (hiding !== isHidden) {
+ var eventName = hiding ? 'controlshidden' : 'controlsshown';
+ triggerEvent.call(this, this.media, eventName);
+ }
+
+ return !hiding;
+ }
+
+ return false;
+ }
+ /**
+ * Add event listeners
+ * @param {String} event - Event type
+ * @param {Function} callback - Callback for when event occurs
+ */
+
+ }, {
+ key: "on",
+ value: function on$1(event, callback) {
+ on.call(this, this.elements.container, event, callback);
+ }
+ /**
+ * Add event listeners once
+ * @param {String} event - Event type
+ * @param {Function} callback - Callback for when event occurs
+ */
+
+ }, {
+ key: "once",
+ value: function once$1(event, callback) {
+ once.call(this, this.elements.container, event, callback);
+ }
+ /**
+ * Remove event listeners
+ * @param {String} event - Event type
+ * @param {Function} callback - Callback for when event occurs
+ */
+
+ }, {
+ key: "off",
+ value: function off$1(event, callback) {
+ off(this.elements.container, event, callback);
+ }
+ /**
+ * Destroy an instance
+ * Event listeners are removed when elements are removed
+ * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory
+ * @param {Function} callback - Callback for when destroy is complete
+ * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc)
+ */
+
+ }, {
+ key: "destroy",
+ value: function destroy(callback) {
+ var _this3 = this;
+
+ var soft = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+
+ if (!this.ready) {
+ return;
+ }
+
+ var done = function done() {
+ // Reset overflow (incase destroyed while in fullscreen)
+ document.body.style.overflow = ''; // GC for embed
+
+ _this3.embed = null; // If it's a soft destroy, make minimal changes
+
+ if (soft) {
+ if (Object.keys(_this3.elements).length) {
+ // Remove elements
+ removeElement(_this3.elements.buttons.play);
+ removeElement(_this3.elements.captions);
+ removeElement(_this3.elements.controls);
+ removeElement(_this3.elements.wrapper); // Clear for GC
+
+ _this3.elements.buttons.play = null;
+ _this3.elements.captions = null;
+ _this3.elements.controls = null;
+ _this3.elements.wrapper = null;
+ } // Callback
+
+
+ if (is$1.function(callback)) {
+ callback();
+ }
+ } else {
+ // Unbind listeners
+ unbindListeners.call(_this3); // Replace the container with the original element provided
+
+ replaceElement(_this3.elements.original, _this3.elements.container); // Event
+
+ triggerEvent.call(_this3, _this3.elements.original, 'destroyed', true); // Callback
+
+ if (is$1.function(callback)) {
+ callback.call(_this3.elements.original);
+ } // Reset state
+
+
+ _this3.ready = false; // Clear for garbage collection
+
+ setTimeout(function () {
+ _this3.elements = null;
+ _this3.media = null;
+ }, 200);
+ }
+ }; // Stop playback
+
+
+ this.stop(); // Clear timeouts
+
+ clearTimeout(this.timers.loading);
+ clearTimeout(this.timers.controls);
+ clearTimeout(this.timers.resized); // Provider specific stuff
+
+ if (this.isHTML5) {
+ // Restore native video controls
+ ui.toggleNativeControls.call(this, true); // Clean up
+
+ done();
+ } else if (this.isYouTube) {
+ // Clear timers
+ clearInterval(this.timers.buffering);
+ clearInterval(this.timers.playing); // Destroy YouTube API
+
+ if (this.embed !== null && is$1.function(this.embed.destroy)) {
+ this.embed.destroy();
+ } // Clean up
+
+
+ done();
+ } else if (this.isVimeo) {
+ // Destroy Vimeo API
+ // then clean up (wait, to prevent postmessage errors)
+ if (this.embed !== null) {
+ this.embed.unload().then(done);
+ } // Vimeo does not always return
+
+
+ setTimeout(done, 200);
+ }
+ }
+ /**
+ * Check for support for a mime type (HTML5 only)
+ * @param {String} type - Mime type
+ */
+
+ }, {
+ key: "supports",
+ value: function supports(type) {
+ return support.mime.call(this, type);
+ }
+ /**
+ * Check for support
+ * @param {String} type - Player type (audio/video)
+ * @param {String} provider - Provider (html5/youtube/vimeo)
+ * @param {Boolean} inline - Where player has `playsinline` sttribute
+ */
+
+ }, {
+ key: "isHTML5",
+ get: function get() {
+ return this.provider === providers.html5;
+ }
+ }, {
+ key: "isEmbed",
+ get: function get() {
+ return this.isYouTube || this.isVimeo;
+ }
+ }, {
+ key: "isYouTube",
+ get: function get() {
+ return this.provider === providers.youtube;
+ }
+ }, {
+ key: "isVimeo",
+ get: function get() {
+ return this.provider === providers.vimeo;
+ }
+ }, {
+ key: "isVideo",
+ get: function get() {
+ return this.type === types.video;
+ }
+ }, {
+ key: "isAudio",
+ get: function get() {
+ return this.type === types.audio;
+ }
+ }, {
+ key: "playing",
+ get: function get() {
+ return Boolean(this.ready && !this.paused && !this.ended);
+ }
+ /**
+ * Get paused state
+ */
+
+ }, {
+ key: "paused",
+ get: function get() {
+ return Boolean(this.media.paused);
+ }
+ /**
+ * Get stopped state
+ */
+
+ }, {
+ key: "stopped",
+ get: function get() {
+ return Boolean(this.paused && this.currentTime === 0);
+ }
+ /**
+ * Get ended state
+ */
+
+ }, {
+ key: "ended",
+ get: function get() {
+ return Boolean(this.media.ended);
+ }
+ }, {
+ key: "currentTime",
+ set: function set(input) {
+ // Bail if media duration isn't available yet
+ if (!this.duration) {
+ return;
+ } // Validate input
+
+
+ var inputIsValid = is$1.number(input) && input > 0; // Set
+
+ this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0; // Logging
+
+ this.debug.log("Seeking to ".concat(this.currentTime, " seconds"));
+ }
+ /**
+ * Get current time
+ */
+ ,
+ get: function get() {
+ return Number(this.media.currentTime);
+ }
+ /**
+ * Get buffered
+ */
+
+ }, {
+ key: "buffered",
+ get: function get() {
+ var buffered = this.media.buffered; // YouTube / Vimeo return a float between 0-1
+
+ if (is$1.number(buffered)) {
+ return buffered;
+ } // HTML5
+ // TODO: Handle buffered chunks of the media
+ // (i.e. seek to another section buffers only that section)
+
+
+ if (buffered && buffered.length && this.duration > 0) {
+ return buffered.end(0) / this.duration;
+ }
+
+ return 0;
+ }
+ /**
+ * Get seeking status
+ */
+
+ }, {
+ key: "seeking",
+ get: function get() {
+ return Boolean(this.media.seeking);
+ }
+ /**
+ * Get the duration of the current media
+ */
+
+ }, {
+ key: "duration",
+ get: function get() {
+ // Faux duration set via config
+ var fauxDuration = parseFloat(this.config.duration); // Media duration can be NaN or Infinity before the media has loaded
+
+ var realDuration = (this.media || {}).duration;
+ var duration = !is$1.number(realDuration) || realDuration === Infinity ? 0 : realDuration; // If config duration is funky, use regular duration
+
+ return fauxDuration || duration;
+ }
+ /**
+ * Set the player volume
+ * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage
+ */
+
+ }, {
+ key: "volume",
+ set: function set(value) {
+ var volume = value;
+ var max = 1;
+ var min = 0;
+
+ if (is$1.string(volume)) {
+ volume = Number(volume);
+ } // Load volume from storage if no value specified
+
+
+ if (!is$1.number(volume)) {
+ volume = this.storage.get('volume');
+ } // Use config if all else fails
+
+
+ if (!is$1.number(volume)) {
+ volume = this.config.volume;
+ } // Maximum is volumeMax
+
+
+ if (volume > max) {
+ volume = max;
+ } // Minimum is volumeMin
+
+
+ if (volume < min) {
+ volume = min;
+ } // Update config
+
+
+ this.config.volume = volume; // Set the player volume
+
+ this.media.volume = volume; // If muted, and we're increasing volume manually, reset muted state
+
+ if (!is$1.empty(value) && this.muted && volume > 0) {
+ this.muted = false;
+ }
+ }
+ /**
+ * Get the current player volume
+ */
+ ,
+ get: function get() {
+ return Number(this.media.volume);
+ }
+ }, {
+ key: "muted",
+ set: function set(mute) {
+ var toggle = mute; // Load muted state from storage
+
+ if (!is$1.boolean(toggle)) {
+ toggle = this.storage.get('muted');
+ } // Use config if all else fails
+
+
+ if (!is$1.boolean(toggle)) {
+ toggle = this.config.muted;
+ } // Update config
+
+
+ this.config.muted = toggle; // Set mute on the player
+
+ this.media.muted = toggle;
+ }
+ /**
+ * Get current muted state
+ */
+ ,
+ get: function get() {
+ return Boolean(this.media.muted);
+ }
+ /**
+ * Check if the media has audio
+ */
+
+ }, {
+ key: "hasAudio",
+ get: function get() {
+ // Assume yes for all non HTML5 (as we can't tell...)
+ if (!this.isHTML5) {
+ return true;
+ }
+
+ if (this.isAudio) {
+ return true;
+ } // Get audio tracks
+
+
+ return Boolean(this.media.mozHasAudio) || Boolean(this.media.webkitAudioDecodedByteCount) || Boolean(this.media.audioTracks && this.media.audioTracks.length);
+ }
+ /**
+ * Set playback speed
+ * @param {Number} speed - the speed of playback (0.5-2.0)
+ */
+
+ }, {
+ key: "speed",
+ set: function set(input) {
+ var _this4 = this;
+
+ var speed = null;
+
+ if (is$1.number(input)) {
+ speed = input;
+ }
+
+ if (!is$1.number(speed)) {
+ speed = this.storage.get('speed');
+ }
+
+ if (!is$1.number(speed)) {
+ speed = this.config.speed.selected;
+ } // Clamp to min/max
+
+
+ var min = this.minimumSpeed,
+ max = this.maximumSpeed;
+ speed = clamp(speed, min, max); // Update config
+
+ this.config.speed.selected = speed; // Set media speed
+
+ setTimeout(function () {
+ _this4.media.playbackRate = speed;
+ }, 0);
+ }
+ /**
+ * Get current playback speed
+ */
+ ,
+ get: function get() {
+ return Number(this.media.playbackRate);
+ }
+ /**
+ * Get the minimum allowed speed
+ */
+
+ }, {
+ key: "minimumSpeed",
+ get: function get() {
+ if (this.isYouTube) {
+ // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
+ return Math.min.apply(Math, _toConsumableArray(this.options.speed));
+ }
+
+ if (this.isVimeo) {
+ // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
+ return 0.5;
+ } // https://stackoverflow.com/a/32320020/1191319
+
+
+ return 0.0625;
+ }
+ /**
+ * Get the maximum allowed speed
+ */
+
+ }, {
+ key: "maximumSpeed",
+ get: function get() {
+ if (this.isYouTube) {
+ // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
+ return Math.max.apply(Math, _toConsumableArray(this.options.speed));
+ }
+
+ if (this.isVimeo) {
+ // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
+ return 2;
+ } // https://stackoverflow.com/a/32320020/1191319
+
+
+ return 16;
+ }
+ /**
+ * Set playback quality
+ * Currently HTML5 & YouTube only
+ * @param {Number} input - Quality level
+ */
+
+ }, {
+ key: "quality",
+ set: function set(input) {
+ var config = this.config.quality;
+ var options = this.options.quality;
+
+ if (!options.length) {
+ return;
+ }
+
+ var quality = [!is$1.empty(input) && Number(input), this.storage.get('quality'), config.selected, config.default].find(is$1.number);
+ var updateStorage = true;
+
+ if (!options.includes(quality)) {
+ var value = closest$1(options, quality);
+ this.debug.warn("Unsupported quality option: ".concat(quality, ", using ").concat(value, " instead"));
+ quality = value; // Don't update storage if quality is not supported
+
+ updateStorage = false;
+ } // Update config
+
+
+ config.selected = quality; // Set quality
+
+ this.media.quality = quality; // Save to storage
+
+ if (updateStorage) {
+ this.storage.set({
+ quality: quality
+ });
+ }
+ }
+ /**
+ * Get current quality level
+ */
+ ,
+ get: function get() {
+ return this.media.quality;
+ }
+ /**
+ * Toggle loop
+ * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config
+ * @param {Boolean} input - Whether to loop or not
+ */
+
+ }, {
+ key: "loop",
+ set: function set(input) {
+ var toggle = is$1.boolean(input) ? input : this.config.loop.active;
+ this.config.loop.active = toggle;
+ this.media.loop = toggle; // Set default to be a true toggle
+
+ /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';
+ switch (type) {
+ case 'start':
+ if (this.config.loop.end && this.config.loop.end <= this.currentTime) {
+ this.config.loop.end = null;
+ }
+ this.config.loop.start = this.currentTime;
+ // this.config.loop.indicator.start = this.elements.display.played.value;
+ break;
+ case 'end':
+ if (this.config.loop.start >= this.currentTime) {
+ return this;
+ }
+ this.config.loop.end = this.currentTime;
+ // this.config.loop.indicator.end = this.elements.display.played.value;
+ break;
+ case 'all':
+ this.config.loop.start = 0;
+ this.config.loop.end = this.duration - 2;
+ this.config.loop.indicator.start = 0;
+ this.config.loop.indicator.end = 100;
+ break;
+ case 'toggle':
+ if (this.config.loop.active) {
+ this.config.loop.start = 0;
+ this.config.loop.end = null;
+ } else {
+ this.config.loop.start = 0;
+ this.config.loop.end = this.duration - 2;
+ }
+ break;
+ default:
+ this.config.loop.start = 0;
+ this.config.loop.end = null;
+ break;
+ } */
+ }
+ /**
+ * Get current loop state
+ */
+ ,
+ get: function get() {
+ return Boolean(this.media.loop);
+ }
+ /**
+ * Set new media source
+ * @param {Object} input - The new source object (see docs)
+ */
+
+ }, {
+ key: "source",
+ set: function set(input) {
+ source.change.call(this, input);
+ }
+ /**
+ * Get current source
+ */
+ ,
+ get: function get() {
+ return this.media.currentSrc;
+ }
+ /**
+ * Get a download URL (either source or custom)
+ */
+
+ }, {
+ key: "download",
+ get: function get() {
+ var download = this.config.urls.download;
+ return is$1.url(download) ? download : this.source;
+ }
+ /**
+ * Set the download URL
+ */
+ ,
+ set: function set(input) {
+ if (!is$1.url(input)) {
+ return;
+ }
+
+ this.config.urls.download = input;
+ controls.setDownloadUrl.call(this);
+ }
+ /**
+ * Set the poster image for a video
+ * @param {String} input - the URL for the new poster image
+ */
+
+ }, {
+ key: "poster",
+ set: function set(input) {
+ if (!this.isVideo) {
+ this.debug.warn('Poster can only be set for video');
+ return;
+ }
+
+ ui.setPoster.call(this, input, false).catch(function () {});
+ }
+ /**
+ * Get the current poster image
+ */
+ ,
+ get: function get() {
+ if (!this.isVideo) {
+ return null;
+ }
+
+ return this.media.getAttribute('poster') || this.media.getAttribute('data-poster');
+ }
+ /**
+ * Get the current aspect ratio in use
+ */
+
+ }, {
+ key: "ratio",
+ get: function get() {
+ if (!this.isVideo) {
+ return null;
+ }
+
+ var ratio = reduceAspectRatio(getAspectRatio.call(this));
+ return is$1.array(ratio) ? ratio.join(':') : ratio;
+ }
+ /**
+ * Set video aspect ratio
+ */
+ ,
+ set: function set(input) {
+ if (!this.isVideo) {
+ this.debug.warn('Aspect ratio can only be set for video');
+ return;
+ }
+
+ if (!is$1.string(input) || !validateRatio(input)) {
+ this.debug.error("Invalid aspect ratio specified (".concat(input, ")"));
+ return;
+ }
+
+ this.config.ratio = input;
+ setAspectRatio.call(this);
+ }
+ /**
+ * Set the autoplay state
+ * @param {Boolean} input - Whether to autoplay or not
+ */
+
+ }, {
+ key: "autoplay",
+ set: function set(input) {
+ var toggle = is$1.boolean(input) ? input : this.config.autoplay;
+ this.config.autoplay = toggle;
+ }
+ /**
+ * Get the current autoplay state
+ */
+ ,
+ get: function get() {
+ return Boolean(this.config.autoplay);
+ }
+ }, {
+ key: "currentTrack",
+ set: function set(input) {
+ captions.set.call(this, input, false);
+ }
+ /**
+ * Get the current caption track index (-1 if disabled)
+ */
+ ,
+ get: function get() {
+ var _this$captions = this.captions,
+ toggled = _this$captions.toggled,
+ currentTrack = _this$captions.currentTrack;
+ return toggled ? currentTrack : -1;
+ }
+ /**
+ * Set the wanted language for captions
+ * Since tracks can be added later it won't update the actual caption track until there is a matching track
+ * @param {String} - Two character ISO language code (e.g. EN, FR, PT, etc)
+ */
+
+ }, {
+ key: "language",
+ set: function set(input) {
+ captions.setLanguage.call(this, input, false);
+ }
+ /**
+ * Get the current track's language
+ */
+ ,
+ get: function get() {
+ return (captions.getCurrentTrack.call(this) || {}).language;
+ }
+ /**
+ * Toggle picture-in-picture playback on WebKit/MacOS
+ * TODO: update player with state, support, enabled
+ * TODO: detect outside changes
+ */
+
+ }, {
+ key: "pip",
+ set: function set(input) {
+ // Bail if no support
+ if (!support.pip) {
+ return;
+ } // Toggle based on current state if not passed
+
+
+ var toggle = is$1.boolean(input) ? input : !this.pip; // Toggle based on current state
+ // Safari
+
+ if (is$1.function(this.media.webkitSetPresentationMode)) {
+ this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive);
+ } // Chrome
+
+
+ if (is$1.function(this.media.requestPictureInPicture)) {
+ if (!this.pip && toggle) {
+ this.media.requestPictureInPicture();
+ } else if (this.pip && !toggle) {
+ document.exitPictureInPicture();
+ }
+ }
+ }
+ /**
+ * Get the current picture-in-picture state
+ */
+ ,
+ get: function get() {
+ if (!support.pip) {
+ return null;
+ } // Safari
+
+
+ if (!is$1.empty(this.media.webkitPresentationMode)) {
+ return this.media.webkitPresentationMode === pip.active;
+ } // Chrome
+
+
+ return this.media === document.pictureInPictureElement;
+ }
+ }], [{
+ key: "supported",
+ value: function supported(type, provider, inline) {
+ return support.check(type, provider, inline);
+ }
+ /**
+ * Load an SVG sprite into the page
+ * @param {String} url - URL for the SVG sprite
+ * @param {String} [id] - Unique ID
+ */
+
+ }, {
+ key: "loadSprite",
+ value: function loadSprite$1(url, id) {
+ return loadSprite(url, id);
+ }
+ /**
+ * Setup multiple instances
+ * @param {*} selector
+ * @param {Object} options
+ */
+
+ }, {
+ key: "setup",
+ value: function setup(selector) {
+ var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+ var targets = null;
+
+ if (is$1.string(selector)) {
+ targets = Array.from(document.querySelectorAll(selector));
+ } else if (is$1.nodeList(selector)) {
+ targets = Array.from(selector);
+ } else if (is$1.array(selector)) {
+ targets = selector.filter(is$1.element);
+ }
+
+ if (is$1.empty(targets)) {
+ return null;
+ }
+
+ return targets.map(function (t) {
+ return new Plyr(t, options);
+ });
+ }
+ }]);
+
+ return Plyr;
+ }();
+
+ Plyr.defaults = cloneDeep(defaults$1);
+
+ return Plyr;
+
+})));
diff --git a/youtube/static/modules/plyr/plyr.min.js b/youtube/static/modules/plyr/plyr.min.js
new file mode 100644
index 0000000..213c413
--- /dev/null
+++ b/youtube/static/modules/plyr/plyr.min.js
@@ -0,0 +1,4 @@
+"object"==typeof navigator&&function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define("Plyr",t):(e=e||self).Plyr=t()}(this,(function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var i=0;i<t.length;i++){var n=t[i];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function i(e,i,n){return i&&t(e.prototype,i),n&&t(e,n),e}function n(e,t,i){return t in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i,e}function a(e,t){var i=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),i.push.apply(i,n)}return i}function s(e){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?a(Object(i),!0).forEach((function(t){n(e,t,i[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(i)):a(Object(i)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(i,t))}))}return e}function r(e,t){if(null==e)return{};var i,n,a=function(e,t){if(null==e)return{};var i,n,a={},s=Object.keys(e);for(n=0;n<s.length;n++)i=s[n],t.indexOf(i)>=0||(a[i]=e[i]);return a}(e,t);if(Object.getOwnPropertySymbols){var s=Object.getOwnPropertySymbols(e);for(n=0;n<s.length;n++)i=s[n],t.indexOf(i)>=0||Object.prototype.propertyIsEnumerable.call(e,i)&&(a[i]=e[i])}return a}function o(e,t){return function(e){if(Array.isArray(e))return e}(e)||function(e,t){if("undefined"==typeof Symbol||!(Symbol.iterator in Object(e)))return;var i=[],n=!0,a=!1,s=void 0;try{for(var r,o=e[Symbol.iterator]();!(n=(r=o.next()).done)&&(i.push(r.value),!t||i.length!==t);n=!0);}catch(e){a=!0,s=e}finally{try{n||null==o.return||o.return()}finally{if(a)throw s}}return i}(e,t)||c(e,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(e){return function(e){if(Array.isArray(e))return u(e)}(e)||function(e){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||c(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function c(e,t){if(e){if("string"==typeof e)return u(e,t);var i=Object.prototype.toString.call(e).slice(8,-1);return"Object"===i&&e.constructor&&(i=e.constructor.name),"Map"===i||"Set"===i?Array.from(e):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?u(e,t):void 0}}function u(e,t){(null==t||t>e.length)&&(t=e.length);for(var i=0,n=new Array(t);i<t;i++)n[i]=e[i];return n}function d(e,t){for(var i=0;i<t.length;i++){var n=t[i];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function h(e,t,i){return t in e?Object.defineProperty(e,t,{value:i,enumerable:!0,configurable:!0,writable:!0}):e[t]=i,e}function p(e,t){var i=Object.keys(e);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(e);t&&(n=n.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),i.push.apply(i,n)}return i}function m(e){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?p(Object(i),!0).forEach((function(t){h(e,t,i[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(i)):p(Object(i)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(i,t))}))}return e}var f={addCSS:!0,thumbWidth:15,watch:!0};function g(e,t){return function(){return Array.from(document.querySelectorAll(t)).includes(this)}.call(e,t)}var y=function(e){return null!=e?e.constructor:null},v=function(e,t){return!!(e&&t&&e instanceof t)},b=function(e){return null==e},w=function(e){return y(e)===Object},k=function(e){return y(e)===String},T=function(e){return Array.isArray(e)},C=function(e){return v(e,NodeList)},A=k,S=T,P=C,E=function(e){return v(e,Element)},N=function(e){return v(e,Event)},M=function(e){return b(e)||(k(e)||T(e)||C(e))&&!e.length||w(e)&&!Object.keys(e).length};function x(e,t){if(1>t){var i=function(e){var t="".concat(e).match(/(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/);return t?Math.max(0,(t[1]?t[1].length:0)-(t[2]?+t[2]:0)):0}(t);return parseFloat(e.toFixed(i))}return Math.round(e/t)*t}var I,L,O,_=function(){function e(t,i){(function(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")})(this,e),E(t)?this.element=t:A(t)&&(this.element=document.querySelector(t)),E(this.element)&&M(this.element.rangeTouch)&&(this.config=m({},f,{},i),this.init())}return function(e,t,i){t&&d(e.prototype,t),i&&d(e,i)}(e,[{key:"init",value:function(){e.enabled&&(this.config.addCSS&&(this.element.style.userSelect="none",this.element.style.webKitUserSelect="none",this.element.style.touchAction="manipulation"),this.listeners(!0),this.element.rangeTouch=this)}},{key:"destroy",value:function(){e.enabled&&(this.config.addCSS&&(this.element.style.userSelect="",this.element.style.webKitUserSelect="",this.element.style.touchAction=""),this.listeners(!1),this.element.rangeTouch=null)}},{key:"listeners",value:function(e){var t=this,i=e?"addEventListener":"removeEventListener";["touchstart","touchmove","touchend"].forEach((function(e){t.element[i](e,(function(e){return t.set(e)}),!1)}))}},{key:"get",value:function(t){if(!e.enabled||!N(t))return null;var i,n=t.target,a=t.changedTouches[0],s=parseFloat(n.getAttribute("min"))||0,r=parseFloat(n.getAttribute("max"))||100,o=parseFloat(n.getAttribute("step"))||1,l=n.getBoundingClientRect(),c=100/l.width*(this.config.thumbWidth/2)/100;return 0>(i=100/l.width*(a.clientX-l.left))?i=0:100<i&&(i=100),50>i?i-=(100-2*i)*c:50<i&&(i+=2*(i-50)*c),s+x(i/100*(r-s),o)}},{key:"set",value:function(t){e.enabled&&N(t)&&!t.target.disabled&&(t.preventDefault(),t.target.value=this.get(t),function(e,t){if(e&&t){var i=new Event(t,{bubbles:!0});e.dispatchEvent(i)}}(t.target,"touchend"===t.type?"change":"input"))}}],[{key:"setup",value:function(t){var i=1<arguments.length&&void 0!==arguments[1]?arguments[1]:{},n=null;if(M(t)||A(t)?n=Array.from(document.querySelectorAll(A(t)?t:'input[type="range"]')):E(t)?n=[t]:P(t)?n=Array.from(t):S(t)&&(n=t.filter(E)),M(n))return null;var a=m({},f,{},i);if(A(t)&&a.watch){var s=new MutationObserver((function(i){Array.from(i).forEach((function(i){Array.from(i.addedNodes).forEach((function(i){E(i)&&g(i,t)&&new e(i,a)}))}))}));s.observe(document.body,{childList:!0,subtree:!0})}return n.map((function(t){return new e(t,i)}))}},{key:"enabled",get:function(){return"ontouchstart"in document.documentElement}}]),e}(),j=function(e){return null!=e?e.constructor:null},q=function(e,t){return Boolean(e&&t&&e instanceof t)},D=function(e){return null==e},H=function(e){return j(e)===Object},F=function(e){return j(e)===String},R=function(e){return j(e)===Function},V=function(e){return Array.isArray(e)},B=function(e){return q(e,NodeList)},U=function(e){return D(e)||(F(e)||V(e)||B(e))&&!e.length||H(e)&&!Object.keys(e).length},W=D,z=H,K=function(e){return j(e)===Number&&!Number.isNaN(e)},Y=F,Q=function(e){return j(e)===Boolean},X=R,$=V,J=B,G=function(e){return q(e,Element)},Z=function(e){return q(e,Event)},ee=function(e){return q(e,KeyboardEvent)},te=function(e){return q(e,TextTrack)||!D(e)&&F(e.kind)},ie=function(e){return q(e,Promise)&&R(e.then)},ne=function(e){if(q(e,window.URL))return!0;if(!F(e))return!1;var t=e;e.startsWith("http://")&&e.startsWith("https://")||(t="http://".concat(e));try{return!U(new URL(t).hostname)}catch(e){return!1}},ae=U,se=(I=document.createElement("span"),L={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"},O=Object.keys(L).find((function(e){return void 0!==I.style[e]})),!!Y(O)&&L[O]);function re(e,t){setTimeout((function(){try{e.hidden=!0,e.offsetHeight,e.hidden=!1}catch(e){}}),t)}var oe={isIE:
+/* @cc_on!@ */
+!!document.documentMode,isEdge:window.navigator.userAgent.includes("Edge"),isWebkit:"WebkitAppearance"in document.documentElement.style&&!/Edge/.test(navigator.userAgent),isIPhone:/(iPhone|iPod)/gi.test(navigator.platform),isIos:/(iPad|iPhone|iPod)/gi.test(navigator.platform)};function le(e,t){return t.split(".").reduce((function(e,t){return e&&e[t]}),e)}function ce(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=arguments.length,i=new Array(t>1?t-1:0),a=1;a<t;a++)i[a-1]=arguments[a];if(!i.length)return e;var s=i.shift();return z(s)?(Object.keys(s).forEach((function(t){z(s[t])?(Object.keys(e).includes(t)||Object.assign(e,n({},t,{})),ce(e[t],s[t])):Object.assign(e,n({},t,s[t]))})),ce.apply(void 0,[e].concat(i))):e}function ue(e,t){var i=e.length?e:[e];Array.from(i).reverse().forEach((function(e,i){var n=i>0?t.cloneNode(!0):t,a=e.parentNode,s=e.nextSibling;n.appendChild(e),s?a.insertBefore(n,s):a.appendChild(n)}))}function de(e,t){G(e)&&!ae(t)&&Object.entries(t).filter((function(e){var t=o(e,2)[1];return!W(t)})).forEach((function(t){var i=o(t,2),n=i[0],a=i[1];return e.setAttribute(n,a)}))}function he(e,t,i){var n=document.createElement(e);return z(t)&&de(n,t),Y(i)&&(n.innerText=i),n}function pe(e,t,i,n){G(t)&&t.appendChild(he(e,i,n))}function me(e){J(e)||$(e)?Array.from(e).forEach(me):G(e)&&G(e.parentNode)&&e.parentNode.removeChild(e)}function fe(e){if(G(e))for(var t=e.childNodes.length;t>0;)e.removeChild(e.lastChild),t-=1}function ge(e,t){return G(t)&&G(t.parentNode)&&G(e)?(t.parentNode.replaceChild(e,t),e):null}function ye(e,t){if(!Y(e)||ae(e))return{};var i={},n=ce({},t);return e.split(",").forEach((function(e){var t=e.trim(),a=t.replace(".",""),s=t.replace(/[[\]]/g,"").split("="),r=o(s,1)[0],l=s.length>1?s[1].replace(/["']/g,""):"";switch(t.charAt(0)){case".":Y(n.class)?i.class="".concat(n.class," ").concat(a):i.class=a;break;case"#":i.id=t.replace("#","");break;case"[":i[r]=l}})),ce(n,i)}function ve(e,t){if(G(e)){var i=t;Q(i)||(i=!e.hidden),e.hidden=i}}function be(e,t,i){if(J(e))return Array.from(e).map((function(e){return be(e,t,i)}));if(G(e)){var n="toggle";return void 0!==i&&(n=i?"add":"remove"),e.classList[n](t),e.classList.contains(t)}return!1}function we(e,t){return G(e)&&e.classList.contains(t)}function ke(e,t){var i=Element.prototype;return(i.matches||i.webkitMatchesSelector||i.mozMatchesSelector||i.msMatchesSelector||function(){return Array.from(document.querySelectorAll(t)).includes(this)}).call(e,t)}function Te(e){return this.elements.container.querySelectorAll(e)}function Ce(e){return this.elements.container.querySelector(e)}function Ae(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];G(e)&&(e.focus({preventScroll:!0}),t&&be(e,this.config.classNames.tabFocus))}var Se,Pe={"audio/ogg":"vorbis","audio/wav":"1","video/webm":"vp8, vorbis","video/mp4":"avc1.42E01E, mp4a.40.2","video/ogg":"theora"},Ee={audio:"canPlayType"in document.createElement("audio"),video:"canPlayType"in document.createElement("video"),check:function(e,t,i){var n=oe.isIPhone&&i&&Ee.playsinline,a=Ee[e]||"html5"!==t;return{api:a,ui:a&&Ee.rangeInput&&("video"!==e||!oe.isIPhone||n)}},pip:!(oe.isIPhone||!X(he("video").webkitSetPresentationMode)&&(!document.pictureInPictureEnabled||he("video").disablePictureInPicture)),airplay:X(window.WebKitPlaybackTargetAvailabilityEvent),playsinline:"playsInline"in document.createElement("video"),mime:function(e){if(ae(e))return!1;var t=o(e.split("/"),1)[0],i=e;if(!this.isHTML5||t!==this.type)return!1;Object.keys(Pe).includes(i)&&(i+='; codecs="'.concat(Pe[e],'"'));try{return Boolean(i&&this.media.canPlayType(i).replace(/no/,""))}catch(e){return!1}},textTracks:"textTracks"in document.createElement("video"),rangeInput:(Se=document.createElement("input"),Se.type="range","range"===Se.type),touch:"ontouchstart"in document.documentElement,transitions:!1!==se,reducedMotion:"matchMedia"in window&&window.matchMedia("(prefers-reduced-motion)").matches},Ne=function(){var e=!1;try{var t=Object.defineProperty({},"passive",{get:function(){return e=!0,null}});window.addEventListener("test",null,t),window.removeEventListener("test",null,t)}catch(e){}return e}();function Me(e,t,i){var n=this,a=arguments.length>3&&void 0!==arguments[3]&&arguments[3],s=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],r=arguments.length>5&&void 0!==arguments[5]&&arguments[5];if(e&&"addEventListener"in e&&!ae(t)&&X(i)){var o=t.split(" "),l=r;Ne&&(l={passive:s,capture:r}),o.forEach((function(t){n&&n.eventListeners&&a&&n.eventListeners.push({element:e,type:t,callback:i,options:l}),e[a?"addEventListener":"removeEventListener"](t,i,l)}))}}function xe(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,n=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],a=arguments.length>4&&void 0!==arguments[4]&&arguments[4];Me.call(this,e,t,i,!0,n,a)}function Ie(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,n=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],a=arguments.length>4&&void 0!==arguments[4]&&arguments[4];Me.call(this,e,t,i,!1,n,a)}function Le(e){var t=this,i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",n=arguments.length>2?arguments[2]:void 0,a=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],s=arguments.length>4&&void 0!==arguments[4]&&arguments[4],r=function r(){Ie(e,i,r,a,s);for(var o=arguments.length,l=new Array(o),c=0;c<o;c++)l[c]=arguments[c];n.apply(t,l)};Me.call(this,e,i,r,!0,a,s)}function Oe(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2&&void 0!==arguments[2]&&arguments[2],n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{};if(G(e)&&!ae(t)){var a=new CustomEvent(t,{bubbles:i,detail:s(s({},n),{},{plyr:this})});e.dispatchEvent(a)}}function _e(){this&&this.eventListeners&&(this.eventListeners.forEach((function(e){var t=e.element,i=e.type,n=e.callback,a=e.options;t.removeEventListener(i,n,a)})),this.eventListeners=[])}function je(){var e=this;return new Promise((function(t){return e.ready?setTimeout(t,0):xe.call(e,e.elements.container,"ready",t)})).then((function(){}))}function qe(e){ie(e)&&e.then(null,(function(){}))}function De(e){return!!($(e)||Y(e)&&e.includes(":"))&&($(e)?e:e.split(":")).map(Number).every(K)}function He(e){if(!$(e)||!e.every(K))return null;var t=o(e,2),i=t[0],n=t[1],a=function e(t,i){return 0===i?t:e(i,t%i)}(i,n);return[i/a,n/a]}function Fe(e){var t=function(e){return De(e)?e.split(":").map(Number):null},i=t(e);if(null===i&&(i=t(this.config.ratio)),null===i&&!ae(this.embed)&&$(this.embed.ratio)&&(i=this.embed.ratio),null===i&&this.isHTML5){var n=this.media;i=He([n.videoWidth,n.videoHeight])}return i}function Re(e){if(!this.isVideo)return{};var t=this.elements.wrapper,i=Fe.call(this,e),n=o($(i)?i:[0,0],2),a=100/n[0]*n[1];if(t.style.paddingBottom="".concat(a,"%"),this.isVimeo&&!this.config.vimeo.premium&&this.supported.ui){var s=100/this.media.offsetWidth*parseInt(window.getComputedStyle(this.media).paddingBottom,10),r=(s-a)/(s/50);this.media.style.transform="translateY(-".concat(r,"%)")}else this.isHTML5&&t.classList.toggle(this.config.classNames.videoFixedRatio,null!==i);return{padding:a,ratio:i}}var Ve={getSources:function(){var e=this;return this.isHTML5?Array.from(this.media.querySelectorAll("source")).filter((function(t){var i=t.getAttribute("type");return!!ae(i)||Ee.mime.call(e,i)})):[]},getQualityOptions:function(){return this.config.quality.forced?this.config.quality.options:Ve.getSources.call(this).map((function(e){return Number(e.getAttribute("data-res"))})).filter(Boolean)},setup:function(){if(this.isHTML5){var e=this;e.options.speed=e.config.speed.options,ae(this.config.ratio)||Re.call(e),Object.defineProperty(e.media,"quality",{get:function(){var t=Ve.getSources.call(e).find((function(t){return t.getAttribute("src")===e.source}));return t&&Number(t.getAttribute("data-res"))},set:function(t){if(e.quality!==t){if(e.config.quality.forced&&X(e.config.quality.onChange))e.config.quality.onChange(t);else{var i=Ve.getSources.call(e).find((function(e){return Number(e.getAttribute("data-res"))===t}));if(!i)return;var n=e.media,a=n.currentTime,s=n.paused,r=n.preload,o=n.readyState,l=n.playbackRate;e.media.src=i.getAttribute("src"),("none"!==r||o)&&(e.once("loadedmetadata",(function(){e.speed=l,e.currentTime=a,s||qe(e.play())})),e.media.load())}Oe.call(e,e.media,"qualitychange",!1,{quality:t})}}})}},cancelRequests:function(){this.isHTML5&&(me(Ve.getSources.call(this)),this.media.setAttribute("src",this.config.blankVideo),this.media.load(),this.debug.log("Cancelled network requests"))}};function Be(e){return $(e)?e.filter((function(t,i){return e.indexOf(t)===i})):e}function Ue(e){for(var t=arguments.length,i=new Array(t>1?t-1:0),n=1;n<t;n++)i[n-1]=arguments[n];return ae(e)?e:e.toString().replace(/{(\d+)}/g,(function(e,t){return i[t].toString()}))}var We=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";return e.replace(new RegExp(t.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g,"\\$1"),"g"),i.toString())},ze=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"";return e.toString().replace(/\w\S*/g,(function(e){return e.charAt(0).toUpperCase()+e.substr(1).toLowerCase()}))};function Ke(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=e.toString();return t=We(t,"-"," "),t=We(t,"_"," "),t=ze(t),We(t," ","")}function Ye(e){var t=document.createElement("div");return t.appendChild(e),t.innerHTML}var Qe={pip:"PIP",airplay:"AirPlay",html5:"HTML5",vimeo:"Vimeo",youtube:"YouTube"},Xe=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(ae(e)||ae(t))return"";var i=le(t.i18n,e);if(ae(i))return Object.keys(Qe).includes(e)?Qe[e]:"";var n={"{seektime}":t.seekTime,"{title}":t.title};return Object.entries(n).forEach((function(e){var t=o(e,2),n=t[0],a=t[1];i=We(i,n,a)})),i},$e=function(){function t(i){e(this,t),this.enabled=i.config.storage.enabled,this.key=i.config.storage.key}return i(t,[{key:"get",value:function(e){if(!t.supported||!this.enabled)return null;var i=window.localStorage.getItem(this.key);if(ae(i))return null;var n=JSON.parse(i);return Y(e)&&e.length?n[e]:n}},{key:"set",value:function(e){if(t.supported&&this.enabled&&z(e)){var i=this.get();ae(i)&&(i={}),ce(i,e),window.localStorage.setItem(this.key,JSON.stringify(i))}}}],[{key:"supported",get:function(){try{if(!("localStorage"in window))return!1;return window.localStorage.setItem("___test","___test"),window.localStorage.removeItem("___test"),!0}catch(e){return!1}}}]),t}();function Je(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"text";return new Promise((function(i,n){try{var a=new XMLHttpRequest;if(!("withCredentials"in a))return;a.addEventListener("load",(function(){if("text"===t)try{i(JSON.parse(a.responseText))}catch(e){i(a.responseText)}else i(a.response)})),a.addEventListener("error",(function(){throw new Error(a.status)})),a.open("GET",e,!0),a.responseType=t,a.send()}catch(e){n(e)}}))}function Ge(e,t){if(Y(e)){var i=Y(t),n=function(){return null!==document.getElementById(t)},a=function(e,t){e.innerHTML=t,i&&n()||document.body.insertAdjacentElement("afterbegin",e)};if(!i||!n()){var s=$e.supported,r=document.createElement("div");if(r.setAttribute("hidden",""),i&&r.setAttribute("id",t),s){var o=window.localStorage.getItem("".concat("cache","-").concat(t));if(null!==o){var l=JSON.parse(o);a(r,l.content)}}Je(e).then((function(e){ae(e)||(s&&window.localStorage.setItem("".concat("cache","-").concat(t),JSON.stringify({content:e})),a(r,e))})).catch((function(){}))}}}var Ze=function(e){return Math.trunc(e/60/60%60,10)},et=function(e){return Math.trunc(e/60%60,10)},tt=function(e){return Math.trunc(e%60,10)};function it(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=arguments.length>2&&void 0!==arguments[2]&&arguments[2];if(!K(e))return it(void 0,t,i);var n=function(e){return"0".concat(e).slice(-2)},a=Ze(e),s=et(e),r=tt(e);return a=t||a>0?"".concat(a,":"):"","".concat(i&&e>0?"-":"").concat(a).concat(n(s),":").concat(n(r))}var nt={getIconUrl:function(){var e=new URL(this.config.iconUrl,window.location).host!==window.location.host||oe.isIE&&!window.svg4everybody;return{url:this.config.iconUrl,cors:e}},findElements:function(){try{return this.elements.controls=Ce.call(this,this.config.selectors.controls.wrapper),this.elements.buttons={play:Te.call(this,this.config.selectors.buttons.play),pause:Ce.call(this,this.config.selectors.buttons.pause),restart:Ce.call(this,this.config.selectors.buttons.restart),rewind:Ce.call(this,this.config.selectors.buttons.rewind),fastForward:Ce.call(this,this.config.selectors.buttons.fastForward),mute:Ce.call(this,this.config.selectors.buttons.mute),pip:Ce.call(this,this.config.selectors.buttons.pip),airplay:Ce.call(this,this.config.selectors.buttons.airplay),settings:Ce.call(this,this.config.selectors.buttons.settings),captions:Ce.call(this,this.config.selectors.buttons.captions),fullscreen:Ce.call(this,this.config.selectors.buttons.fullscreen)},this.elements.progress=Ce.call(this,this.config.selectors.progress),this.elements.inputs={seek:Ce.call(this,this.config.selectors.inputs.seek),volume:Ce.call(this,this.config.selectors.inputs.volume)},this.elements.display={buffer:Ce.call(this,this.config.selectors.display.buffer),currentTime:Ce.call(this,this.config.selectors.display.currentTime),duration:Ce.call(this,this.config.selectors.display.duration)},G(this.elements.progress)&&(this.elements.display.seekTooltip=this.elements.progress.querySelector(".".concat(this.config.classNames.tooltip))),!0}catch(e){return this.debug.warn("It looks like there is a problem with your custom controls HTML",e),this.toggleNativeControls(!0),!1}},createIcon:function(e,t){var i=nt.getIconUrl.call(this),n="".concat(i.cors?"":i.url,"#").concat(this.config.iconPrefix),a=document.createElementNS("http://www.w3.org/2000/svg","svg");de(a,ce(t,{"aria-hidden":"true",focusable:"false"}));var s=document.createElementNS("http://www.w3.org/2000/svg","use"),r="".concat(n,"-").concat(e);return"href"in s&&s.setAttributeNS("http://www.w3.org/1999/xlink","href",r),s.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",r),a.appendChild(s),a},createLabel:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=Xe(e,this.config),n=s(s({},t),{},{class:[t.class,this.config.classNames.hidden].filter(Boolean).join(" ")});return he("span",n,i)},createBadge:function(e){if(ae(e))return null;var t=he("span",{class:this.config.classNames.menu.value});return t.appendChild(he("span",{class:this.config.classNames.menu.badge},e)),t},createButton:function(e,t){var i=this,n=ce({},t),a=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=e.toString();return(t=Ke(t)).charAt(0).toLowerCase()+t.slice(1)}(e),s={element:"button",toggle:!1,label:null,icon:null,labelPressed:null,iconPressed:null};switch(["element","icon","label"].forEach((function(e){Object.keys(n).includes(e)&&(s[e]=n[e],delete n[e])})),"button"!==s.element||Object.keys(n).includes("type")||(n.type="button"),Object.keys(n).includes("class")?n.class.split(" ").some((function(e){return e===i.config.classNames.control}))||ce(n,{class:"".concat(n.class," ").concat(this.config.classNames.control)}):n.class=this.config.classNames.control,e){case"play":s.toggle=!0,s.label="play",s.labelPressed="pause",s.icon="play",s.iconPressed="pause";break;case"mute":s.toggle=!0,s.label="mute",s.labelPressed="unmute",s.icon="volume",s.iconPressed="muted";break;case"captions":s.toggle=!0,s.label="enableCaptions",s.labelPressed="disableCaptions",s.icon="captions-off",s.iconPressed="captions-on";break;case"fullscreen":s.toggle=!0,s.label="enterFullscreen",s.labelPressed="exitFullscreen",s.icon="enter-fullscreen",s.iconPressed="exit-fullscreen";break;case"play-large":n.class+=" ".concat(this.config.classNames.control,"--overlaid"),a="play",s.label="play",s.icon="play";break;default:ae(s.label)&&(s.label=a),ae(s.icon)&&(s.icon=e)}var r=he(s.element);return s.toggle?(r.appendChild(nt.createIcon.call(this,s.iconPressed,{class:"icon--pressed"})),r.appendChild(nt.createIcon.call(this,s.icon,{class:"icon--not-pressed"})),r.appendChild(nt.createLabel.call(this,s.labelPressed,{class:"label--pressed"})),r.appendChild(nt.createLabel.call(this,s.label,{class:"label--not-pressed"}))):(r.appendChild(nt.createIcon.call(this,s.icon)),r.appendChild(nt.createLabel.call(this,s.label))),ce(n,ye(this.config.selectors.buttons[a],n)),de(r,n),"play"===a?($(this.elements.buttons[a])||(this.elements.buttons[a]=[]),this.elements.buttons[a].push(r)):this.elements.buttons[a]=r,r},createRange:function(e,t){var i=he("input",ce(ye(this.config.selectors.inputs[e]),{type:"range",min:0,max:100,step:.01,value:0,autocomplete:"off",role:"slider","aria-label":Xe(e,this.config),"aria-valuemin":0,"aria-valuemax":100,"aria-valuenow":0},t));return this.elements.inputs[e]=i,nt.updateRangeFill.call(this,i),_.setup(i),i},createProgress:function(e,t){var i=he("progress",ce(ye(this.config.selectors.display[e]),{min:0,max:100,value:0,role:"progressbar","aria-hidden":!0},t));if("volume"!==e){i.appendChild(he("span",null,"0"));var n={played:"played",buffer:"buffered"}[e],a=n?Xe(n,this.config):"";i.innerText="% ".concat(a.toLowerCase())}return this.elements.display[e]=i,i},createTime:function(e,t){var i=ye(this.config.selectors.display[e],t),n=he("div",ce(i,{class:"".concat(i.class?i.class:""," ").concat(this.config.classNames.display.time," ").trim(),"aria-label":Xe(e,this.config)}),"00:00");return this.elements.display[e]=n,n},bindMenuItemShortcuts:function(e,t){var i=this;xe.call(this,e,"keydown keyup",(function(n){if([32,38,39,40].includes(n.which)&&(n.preventDefault(),n.stopPropagation(),"keydown"!==n.type)){var a,s=ke(e,'[role="menuitemradio"]');if(!s&&[32,39].includes(n.which))nt.showMenuPanel.call(i,t,!0);else 32!==n.which&&(40===n.which||s&&39===n.which?(a=e.nextElementSibling,G(a)||(a=e.parentNode.firstElementChild)):(a=e.previousElementSibling,G(a)||(a=e.parentNode.lastElementChild)),Ae.call(i,a,!0))}}),!1),xe.call(this,e,"keyup",(function(e){13===e.which&&nt.focusFirstMenuItem.call(i,null,!0)}))},createMenuItem:function(e){var t=this,i=e.value,n=e.list,a=e.type,s=e.title,r=e.badge,o=void 0===r?null:r,l=e.checked,c=void 0!==l&&l,u=ye(this.config.selectors.inputs[a]),d=he("button",ce(u,{type:"button",role:"menuitemradio",class:"".concat(this.config.classNames.control," ").concat(u.class?u.class:"").trim(),"aria-checked":c,value:i})),h=he("span");h.innerHTML=s,G(o)&&h.appendChild(o),d.appendChild(h),Object.defineProperty(d,"checked",{enumerable:!0,get:function(){return"true"===d.getAttribute("aria-checked")},set:function(e){e&&Array.from(d.parentNode.children).filter((function(e){return ke(e,'[role="menuitemradio"]')})).forEach((function(e){return e.setAttribute("aria-checked","false")})),d.setAttribute("aria-checked",e?"true":"false")}}),this.listeners.bind(d,"click keyup",(function(e){if(!ee(e)||32===e.which){switch(e.preventDefault(),e.stopPropagation(),d.checked=!0,a){case"language":t.currentTrack=Number(i);break;case"quality":t.quality=i;break;case"speed":t.speed=parseFloat(i)}nt.showMenuPanel.call(t,"home",ee(e))}}),a,!1),nt.bindMenuItemShortcuts.call(this,d,a),n.appendChild(d)},formatTime:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(!K(e))return e;var i=Ze(this.duration)>0;return it(e,i,t)},updateTimeDisplay:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:null,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,i=arguments.length>2&&void 0!==arguments[2]&&arguments[2];G(e)&&K(t)&&(e.innerText=nt.formatTime(t,i))},updateVolume:function(){this.supported.ui&&(G(this.elements.inputs.volume)&&nt.setRange.call(this,this.elements.inputs.volume,this.muted?0:this.volume),G(this.elements.buttons.mute)&&(this.elements.buttons.mute.pressed=this.muted||0===this.volume))},setRange:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;G(e)&&(e.value=t,nt.updateRangeFill.call(this,e))},updateProgress:function(e){var t=this;if(this.supported.ui&&Z(e)){var i,n,a=0;if(e)switch(e.type){case"timeupdate":case"seeking":case"seeked":i=this.currentTime,n=this.duration,a=0===i||0===n||Number.isNaN(i)||Number.isNaN(n)?0:(i/n*100).toFixed(2),"timeupdate"===e.type&&nt.setRange.call(this,this.elements.inputs.seek,a);break;case"playing":case"progress":!function(e,i){var n=K(i)?i:0,a=G(e)?e:t.elements.display.buffer;if(G(a)){a.value=n;var s=a.getElementsByTagName("span")[0];G(s)&&(s.childNodes[0].nodeValue=n)}}(this.elements.display.buffer,100*this.buffered)}}},updateRangeFill:function(e){var t=Z(e)?e.target:e;if(G(t)&&"range"===t.getAttribute("type")){if(ke(t,this.config.selectors.inputs.seek)){t.setAttribute("aria-valuenow",this.currentTime);var i=nt.formatTime(this.currentTime),n=nt.formatTime(this.duration),a=Xe("seekLabel",this.config);t.setAttribute("aria-valuetext",a.replace("{currentTime}",i).replace("{duration}",n))}else if(ke(t,this.config.selectors.inputs.volume)){var s=100*t.value;t.setAttribute("aria-valuenow",s),t.setAttribute("aria-valuetext","".concat(s.toFixed(1),"%"))}else t.setAttribute("aria-valuenow",t.value);oe.isWebkit&&t.style.setProperty("--value","".concat(t.value/t.max*100,"%"))}},updateSeekTooltip:function(e){var t=this;if(this.config.tooltips.seek&&G(this.elements.inputs.seek)&&G(this.elements.display.seekTooltip)&&0!==this.duration){var i="".concat(this.config.classNames.tooltip,"--visible"),n=function(e){return be(t.elements.display.seekTooltip,i,e)};if(this.touch)n(!1);else{var a=0,s=this.elements.progress.getBoundingClientRect();if(Z(e))a=100/s.width*(e.pageX-s.left);else{if(!we(this.elements.display.seekTooltip,i))return;a=parseFloat(this.elements.display.seekTooltip.style.left,10)}a<0?a=0:a>100&&(a=100),nt.updateTimeDisplay.call(this,this.elements.display.seekTooltip,this.duration/100*a),this.elements.display.seekTooltip.style.left="".concat(a,"%"),Z(e)&&["mouseenter","mouseleave"].includes(e.type)&&n("mouseenter"===e.type)}}},timeUpdate:function(e){var t=!G(this.elements.display.duration)&&this.config.invertTime;nt.updateTimeDisplay.call(this,this.elements.display.currentTime,t?this.duration-this.currentTime:this.currentTime,t),e&&"timeupdate"===e.type&&this.media.seeking||nt.updateProgress.call(this,e)},durationUpdate:function(){if(this.supported.ui&&(this.config.invertTime||!this.currentTime)){if(this.duration>=Math.pow(2,32))return ve(this.elements.display.currentTime,!0),void ve(this.elements.progress,!0);G(this.elements.inputs.seek)&&this.elements.inputs.seek.setAttribute("aria-valuemax",this.duration);var e=G(this.elements.display.duration);!e&&this.config.displayDuration&&this.paused&&nt.updateTimeDisplay.call(this,this.elements.display.currentTime,this.duration),e&&nt.updateTimeDisplay.call(this,this.elements.display.duration,this.duration),nt.updateSeekTooltip.call(this)}},toggleMenuButton:function(e,t){ve(this.elements.settings.buttons[e],!t)},updateSetting:function(e,t,i){var n=this.elements.settings.panels[e],a=null,s=t;if("captions"===e)a=this.currentTrack;else{if(a=ae(i)?this[e]:i,ae(a)&&(a=this.config[e].default),!ae(this.options[e])&&!this.options[e].includes(a))return void this.debug.warn("Unsupported value of '".concat(a,"' for ").concat(e));if(!this.config[e].options.includes(a))return void this.debug.warn("Disabled value of '".concat(a,"' for ").concat(e))}if(G(s)||(s=n&&n.querySelector('[role="menu"]')),G(s)){this.elements.settings.buttons[e].querySelector(".".concat(this.config.classNames.menu.value)).innerHTML=nt.getLabel.call(this,e,a);var r=s&&s.querySelector('[value="'.concat(a,'"]'));G(r)&&(r.checked=!0)}},getLabel:function(e,t){switch(e){case"speed":return 1===t?Xe("normal",this.config):"".concat(t,"&times;");case"quality":if(K(t)){var i=Xe("qualityLabel.".concat(t),this.config);return i.length?i:"".concat(t,"p")}return ze(t);case"captions":return rt.getLabel.call(this);default:return null}},setQualityMenu:function(e){var t=this;if(G(this.elements.settings.panels.quality)){var i=this.elements.settings.panels.quality.querySelector('[role="menu"]');$(e)&&(this.options.quality=Be(e).filter((function(e){return t.config.quality.options.includes(e)})));var n=!ae(this.options.quality)&&this.options.quality.length>1;if(nt.toggleMenuButton.call(this,"quality",n),fe(i),nt.checkMenu.call(this),n){var a=function(e){var i=Xe("qualityBadge.".concat(e),t.config);return i.length?nt.createBadge.call(t,i):null};this.options.quality.sort((function(e,i){var n=t.config.quality.options;return n.indexOf(e)>n.indexOf(i)?1:-1})).forEach((function(e){nt.createMenuItem.call(t,{value:e,list:i,type:"quality",title:nt.getLabel.call(t,"quality",e),badge:a(e)})})),nt.updateSetting.call(this,"quality",i)}}},setCaptionsMenu:function(){var e=this;if(G(this.elements.settings.panels.captions)){var t=this.elements.settings.panels.captions.querySelector('[role="menucaptions"]'),i=rt.getTracks.call(this),n=Boolean(i.length);if(nt.toggleMenuButton.call(this,"captions",n),fe(t),nt.checkMenu.call(this),n){var a=i.map((function(i,n){return{value:n,checked:e.captions.toggled&&e.currentTrack===n,title:rt.getLabel.call(e,i),badge:i.language&&nt.createBadge.call(e,i.language.toUpperCase()),list:t,type:"language"}}));a.unshift({value:-1,checked:!this.captions.toggled,title:Xe("disabled",this.config),list:t,type:"language"}),a.forEach(nt.createMenuItem.bind(this)),nt.updateSetting.call(this,"captions",t)}}},setSpeedMenu:function(){var e=this;if(G(this.elements.settings.panels.speed)){var t=this.elements.settings.panels.speed.querySelector('[role="menu"]');this.options.speed=this.options.speed.filter((function(t){return t>=e.minimumSpeed&&t<=e.maximumSpeed}));var i=!ae(this.options.speed)&&this.options.speed.length>1;nt.toggleMenuButton.call(this,"speed",i),fe(t),nt.checkMenu.call(this),i&&(this.options.speed.forEach((function(i){nt.createMenuItem.call(e,{value:i,list:t,type:"speed",title:nt.getLabel.call(e,"speed",i)})})),nt.updateSetting.call(this,"speed",t))}},checkMenu:function(){var e=this.elements.settings.buttons,t=!ae(e)&&Object.values(e).some((function(e){return!e.hidden}));ve(this.elements.settings.menu,!t)},focusFirstMenuItem:function(e){var t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(!this.elements.settings.popup.hidden){var i=e;G(i)||(i=Object.values(this.elements.settings.panels).find((function(e){return!e.hidden})));var n=i.querySelector('[role^="menuitem"]');Ae.call(this,n,t)}},toggleMenu:function(e){var t=this.elements.settings.popup,i=this.elements.buttons.settings;if(G(t)&&G(i)){var n=t.hidden,a=n;if(Q(e))a=e;else if(ee(e)&&27===e.which)a=!1;else if(Z(e)){var s=X(e.composedPath)?e.composedPath()[0]:e.target,r=t.contains(s);if(r||!r&&e.target!==i&&a)return}i.setAttribute("aria-expanded",a),ve(t,!a),be(this.elements.container,this.config.classNames.menu.open,a),a&&ee(e)?nt.focusFirstMenuItem.call(this,null,!0):a||n||Ae.call(this,i,ee(e))}},getMenuSize:function(e){var t=e.cloneNode(!0);t.style.position="absolute",t.style.opacity=0,t.removeAttribute("hidden"),e.parentNode.appendChild(t);var i=t.scrollWidth,n=t.scrollHeight;return me(t),{width:i,height:n}},showMenuPanel:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",i=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n=this.elements.container.querySelector("#plyr-settings-".concat(this.id,"-").concat(t));if(G(n)){var a=n.parentNode,s=Array.from(a.children).find((function(e){return!e.hidden}));if(Ee.transitions&&!Ee.reducedMotion){a.style.width="".concat(s.scrollWidth,"px"),a.style.height="".concat(s.scrollHeight,"px");var r=nt.getMenuSize.call(this,n),o=function t(i){i.target===a&&["width","height"].includes(i.propertyName)&&(a.style.width="",a.style.height="",Ie.call(e,a,se,t))};xe.call(this,a,se,o),a.style.width="".concat(r.width,"px"),a.style.height="".concat(r.height,"px")}ve(s,!0),ve(n,!1),nt.focusFirstMenuItem.call(this,n,i)}},setDownloadUrl:function(){var e=this.elements.buttons.download;G(e)&&e.setAttribute("href",this.download)},create:function(e){var t=this,i=nt.bindMenuItemShortcuts,n=nt.createButton,a=nt.createProgress,s=nt.createRange,r=nt.createTime,o=nt.setQualityMenu,l=nt.setSpeedMenu,c=nt.showMenuPanel;this.elements.controls=null,$(this.config.controls)&&this.config.controls.includes("play-large")&&this.elements.container.appendChild(n.call(this,"play-large"));var u=he("div",ye(this.config.selectors.controls.wrapper));this.elements.controls=u;var d={class:"plyr__controls__item"};return Be($(this.config.controls)?this.config.controls:[]).forEach((function(o){if("restart"===o&&u.appendChild(n.call(t,"restart",d)),"rewind"===o&&u.appendChild(n.call(t,"rewind",d)),"play"===o&&u.appendChild(n.call(t,"play",d)),"fast-forward"===o&&u.appendChild(n.call(t,"fast-forward",d)),"progress"===o){var l=he("div",{class:"".concat(d.class," plyr__progress__container")}),h=he("div",ye(t.config.selectors.progress));if(h.appendChild(s.call(t,"seek",{id:"plyr-seek-".concat(e.id)})),h.appendChild(a.call(t,"buffer")),t.config.tooltips.seek){var p=he("span",{class:t.config.classNames.tooltip},"00:00");h.appendChild(p),t.elements.display.seekTooltip=p}t.elements.progress=h,l.appendChild(t.elements.progress),u.appendChild(l)}if("current-time"===o&&u.appendChild(r.call(t,"currentTime",d)),"duration"===o&&u.appendChild(r.call(t,"duration",d)),"mute"===o||"volume"===o){var m=t.elements.volume;if(G(m)&&u.contains(m)||(m=he("div",ce({},d,{class:"".concat(d.class," plyr__volume").trim()})),t.elements.volume=m,u.appendChild(m)),"mute"===o&&m.appendChild(n.call(t,"mute")),"volume"===o&&!oe.isIos){var f={max:1,step:.05,value:t.config.volume};m.appendChild(s.call(t,"volume",ce(f,{id:"plyr-volume-".concat(e.id)})))}}if("captions"===o&&u.appendChild(n.call(t,"captions",d)),"settings"===o&&!ae(t.config.settings)){var g=he("div",ce({},d,{class:"".concat(d.class," plyr__menu").trim(),hidden:""}));g.appendChild(n.call(t,"settings",{"aria-haspopup":!0,"aria-controls":"plyr-settings-".concat(e.id),"aria-expanded":!1}));var y=he("div",{class:"plyr__menu__container",id:"plyr-settings-".concat(e.id),hidden:""}),v=he("div"),b=he("div",{id:"plyr-settings-".concat(e.id,"-home")}),w=he("div",{role:"menu"});b.appendChild(w),v.appendChild(b),t.elements.settings.panels.home=b,t.config.settings.forEach((function(n){var a=he("button",ce(ye(t.config.selectors.buttons.settings),{type:"button",class:"".concat(t.config.classNames.control," ").concat(t.config.classNames.control,"--forward"),role:"menuitem","aria-haspopup":!0,hidden:""}));i.call(t,a,n),xe.call(t,a,"click",(function(){c.call(t,n,!1)}));var s=he("span",null,Xe(n,t.config)),r=he("span",{class:t.config.classNames.menu.value});r.innerHTML=e[n],s.appendChild(r),a.appendChild(s),w.appendChild(a);var o=he("div",{id:"plyr-settings-".concat(e.id,"-").concat(n),hidden:""}),l=he("button",{type:"button",class:"".concat(t.config.classNames.control," ").concat(t.config.classNames.control,"--back")});l.appendChild(he("span",{"aria-hidden":!0},Xe(n,t.config))),l.appendChild(he("span",{class:t.config.classNames.hidden},Xe("menuBack",t.config))),xe.call(t,o,"keydown",(function(e){37===e.which&&(e.preventDefault(),e.stopPropagation(),c.call(t,"home",!0))}),!1),xe.call(t,l,"click",(function(){c.call(t,"home",!1)})),o.appendChild(l),o.appendChild(he("div",{role:"menu"})),o.appendChild(he("div",{role:"menucaptions"})),v.appendChild(o),t.elements.settings.buttons[n]=a,t.elements.settings.panels[n]=o})),y.appendChild(v),g.appendChild(y),u.appendChild(g),t.elements.settings.popup=y,t.elements.settings.menu=g}if("pip"===o&&Ee.pip&&u.appendChild(n.call(t,"pip",d)),"airplay"===o&&Ee.airplay&&u.appendChild(n.call(t,"airplay",d)),"download"===o){var k=ce({},d,{element:"a",href:t.download,target:"_blank"});t.isHTML5&&(k.download="");var T=t.config.urls.download;!ne(T)&&t.isEmbed&&ce(k,{icon:"logo-".concat(t.provider),label:t.provider}),u.appendChild(n.call(t,"download",k))}"fullscreen"===o&&u.appendChild(n.call(t,"fullscreen",d))})),this.isHTML5&&o.call(this,Ve.getQualityOptions.call(this)),l.call(this),u},inject:function(){var e=this;if(this.config.loadSprite){var t=nt.getIconUrl.call(this);t.cors&&Ge(t.url,"sprite-plyr")}this.id=Math.floor(1e4*Math.random());var i=null;this.elements.controls=null;var n={id:this.id,seektime:this.config.seekTime,title:this.config.title},a=!0;X(this.config.controls)&&(this.config.controls=this.config.controls.call(this,n)),this.config.controls||(this.config.controls=[]),G(this.config.controls)||Y(this.config.controls)?i=this.config.controls:(i=nt.create.call(this,{id:this.id,seektime:this.config.seekTime,speed:this.speed,quality:this.quality,captions:rt.getLabel.call(this)}),a=!1);var s,r;if(a&&Y(this.config.controls)&&(s=i,Object.entries(n).forEach((function(e){var t=o(e,2),i=t[0],n=t[1];s=We(s,"{".concat(i,"}"),n)})),i=s),Y(this.config.selectors.controls.container)&&(r=document.querySelector(this.config.selectors.controls.container)),G(r)||(r=this.elements.container),r[G(i)?"insertAdjacentElement":"insertAdjacentHTML"]("afterbegin",i),G(this.elements.controls)||nt.findElements.call(this),!ae(this.elements.buttons)){var l=function(t){var i=e.config.classNames.controlPressed;Object.defineProperty(t,"pressed",{enumerable:!0,get:function(){return we(t,i)},set:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0];be(t,i,e)}})};Object.values(this.elements.buttons).filter(Boolean).forEach((function(e){$(e)||J(e)?Array.from(e).filter(Boolean).forEach(l):l(e)}))}if(oe.isEdge&&re(r),this.config.tooltips.controls){var c=this.config,u=c.classNames,d=c.selectors,h="".concat(d.controls.wrapper," ").concat(d.labels," .").concat(u.hidden),p=Te.call(this,h);Array.from(p).forEach((function(t){be(t,e.config.classNames.hidden,!1),be(t,e.config.classNames.tooltip,!0)}))}}};function at(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=e;if(t){var n=document.createElement("a");n.href=i,i=n.href}try{return new URL(i)}catch(e){return null}}function st(e){var t=new URLSearchParams;return z(e)&&Object.entries(e).forEach((function(e){var i=o(e,2),n=i[0],a=i[1];t.set(n,a)})),t}var rt={setup:function(){if(this.supported.ui)if(!this.isVideo||this.isYouTube||this.isHTML5&&!Ee.textTracks)$(this.config.controls)&&this.config.controls.includes("settings")&&this.config.settings.includes("captions")&&nt.setCaptionsMenu.call(this);else{if(G(this.elements.captions)||(this.elements.captions=he("div",ye(this.config.selectors.captions)),function(e,t){G(e)&&G(t)&&t.parentNode.insertBefore(e,t.nextSibling)}(this.elements.captions,this.elements.wrapper)),oe.isIE&&window.URL){var e=this.media.querySelectorAll("track");Array.from(e).forEach((function(e){var t=e.getAttribute("src"),i=at(t);null!==i&&i.hostname!==window.location.href.hostname&&["http:","https:"].includes(i.protocol)&&Je(t,"blob").then((function(t){e.setAttribute("src",window.URL.createObjectURL(t))})).catch((function(){me(e)}))}))}var t=Be((navigator.languages||[navigator.language||navigator.userLanguage||"en"]).map((function(e){return e.split("-")[0]}))),i=(this.storage.get("language")||this.config.captions.language||"auto").toLowerCase();if("auto"===i)i=o(t,1)[0];var n=this.storage.get("captions");if(Q(n)||(n=this.config.captions.active),Object.assign(this.captions,{toggled:!1,active:n,language:i,languages:t}),this.isHTML5){var a=this.config.captions.update?"addtrack removetrack":"removetrack";xe.call(this,this.media.textTracks,a,rt.update.bind(this))}setTimeout(rt.update.bind(this),0)}},update:function(){var e=this,t=rt.getTracks.call(this,!0),i=this.captions,n=i.active,a=i.language,s=i.meta,r=i.currentTrackNode,o=Boolean(t.find((function(e){return e.language===a})));this.isHTML5&&this.isVideo&&t.filter((function(e){return!s.get(e)})).forEach((function(t){e.debug.log("Track added",t),s.set(t,{default:"showing"===t.mode}),"showing"===t.mode&&(t.mode="hidden"),xe.call(e,t,"cuechange",(function(){return rt.updateCues.call(e)}))})),(o&&this.language!==a||!t.includes(r))&&(rt.setLanguage.call(this,a),rt.toggle.call(this,n&&o)),be(this.elements.container,this.config.classNames.captions.enabled,!ae(t)),$(this.config.controls)&&this.config.controls.includes("settings")&&this.config.settings.includes("captions")&&nt.setCaptionsMenu.call(this)},toggle:function(e){var t=this,i=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(this.supported.ui){var n=this.captions.toggled,a=this.config.classNames.captions.active,s=W(e)?!n:e;if(s!==n){if(i||(this.captions.active=s,this.storage.set({captions:s})),!this.language&&s&&!i){var r=rt.getTracks.call(this),o=rt.findTrack.call(this,[this.captions.language].concat(l(this.captions.languages)),!0);return this.captions.language=o.language,void rt.set.call(this,r.indexOf(o))}this.elements.buttons.captions&&(this.elements.buttons.captions.pressed=s),be(this.elements.container,a,s),this.captions.toggled=s,nt.updateSetting.call(this,"captions"),Oe.call(this,this.media,s?"captionsenabled":"captionsdisabled")}setTimeout((function(){s&&t.captions.toggled&&(t.captions.currentTrackNode.mode="hidden")}))}},set:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],i=rt.getTracks.call(this);if(-1!==e)if(K(e))if(e in i){if(this.captions.currentTrack!==e){this.captions.currentTrack=e;var n=i[e],a=n||{},s=a.language;this.captions.currentTrackNode=n,nt.updateSetting.call(this,"captions"),t||(this.captions.language=s,this.storage.set({language:s})),this.isVimeo&&this.embed.enableTextTrack(s),Oe.call(this,this.media,"languagechange")}rt.toggle.call(this,!0,t),this.isHTML5&&this.isVideo&&rt.updateCues.call(this)}else this.debug.warn("Track not found",e);else this.debug.warn("Invalid caption argument",e);else rt.toggle.call(this,!1,t)},setLanguage:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];if(Y(e)){var i=e.toLowerCase();this.captions.language=i;var n=rt.getTracks.call(this),a=rt.findTrack.call(this,[i]);rt.set.call(this,n.indexOf(a),t)}else this.debug.warn("Invalid language argument",e)},getTracks:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]&&arguments[0],i=Array.from((this.media||{}).textTracks||[]);return i.filter((function(i){return!e.isHTML5||t||e.captions.meta.has(i)})).filter((function(e){return["captions","subtitles"].includes(e.kind)}))},findTrack:function(e){var t,i=this,n=arguments.length>1&&void 0!==arguments[1]&&arguments[1],a=rt.getTracks.call(this),s=function(e){return Number((i.captions.meta.get(e)||{}).default)},r=Array.from(a).sort((function(e,t){return s(t)-s(e)}));return e.every((function(e){return!(t=r.find((function(t){return t.language===e})))})),t||(n?r[0]:void 0)},getCurrentTrack:function(){return rt.getTracks.call(this)[this.currentTrack]},getLabel:function(e){var t=e;return!te(t)&&Ee.textTracks&&this.captions.toggled&&(t=rt.getCurrentTrack.call(this)),te(t)?ae(t.label)?ae(t.language)?Xe("enabled",this.config):e.language.toUpperCase():t.label:Xe("disabled",this.config)},updateCues:function(e){if(this.supported.ui)if(G(this.elements.captions))if(W(e)||Array.isArray(e)){var t=e;if(!t){var i=rt.getCurrentTrack.call(this);t=Array.from((i||{}).activeCues||[]).map((function(e){return e.getCueAsHTML()})).map(Ye)}var n=t.map((function(e){return e.trim()})).join("\n");if(n!==this.elements.captions.innerHTML){fe(this.elements.captions);var a=he("span",ye(this.config.selectors.caption));a.innerHTML=n,this.elements.captions.appendChild(a),Oe.call(this,this.media,"cuechange")}}else this.debug.warn("updateCues: Invalid input",e);else this.debug.warn("No captions element to render to")}},ot={enabled:!0,title:"",debug:!1,autoplay:!1,autopause:!0,playsinline:!0,seekTime:10,volume:1,muted:!1,duration:null,displayDuration:!0,invertTime:!0,toggleInvert:!0,ratio:null,clickToPlay:!0,hideControls:!0,resetOnEnd:!1,disableContextMenu:!0,loadSprite:!0,iconPrefix:"plyr",iconUrl:"/theme/modules/plyr/plyr.svg",blankVideo:"/theme/modules/plyr/blank.webm",quality:{default:576,options:[4320,2880,2160,1440,1080,720,576,480,360,240],forced:!1,onChange:null},loop:{active:!1},speed:{selected:1,options:[.5,.75,1,1.25,1.5,1.75,2,4]},keyboard:{focused:!0,global:!1},tooltips:{controls:!1,seek:!0},captions:{active:!1,language:"auto",update:!1},fullscreen:{enabled:!0,fallback:!0,iosNative:!1},storage:{enabled:!0,key:"plyr"},controls:["play-large","play","progress","current-time","mute","volume","captions","settings","pip","airplay","fullscreen"],settings:["captions","quality","speed"],i18n:{restart:"Restart",rewind:"Rewind {seektime}s",play:"Play",pause:"Pause",fastForward:"Forward {seektime}s",seek:"Seek",seekLabel:"{currentTime} of {duration}",played:"Played",buffered:"Buffered",currentTime:"Current time",duration:"Duration",volume:"Volume",mute:"Mute",unmute:"Unmute",enableCaptions:"Enable captions",disableCaptions:"Disable captions",download:"Download",enterFullscreen:"Enter fullscreen",exitFullscreen:"Exit fullscreen",frameTitle:"Player for {title}",captions:"Captions",settings:"Settings",pip:"PIP",menuBack:"Go back to previous menu",speed:"Speed",normal:"Normal",quality:"Quality",loop:"Loop",start:"Start",end:"End",all:"All",reset:"Reset",disabled:"Disabled",enabled:"Enabled",advertisement:"Ad",qualityBadge:{2160:"4K",1440:"HD",1080:"HD",720:"HD",576:"SD",480:"SD"}},urls:{download:null,vimeo:{sdk:"https://player.vimeo.com/api/player.js",iframe:"https://player.vimeo.com/video/{0}?{1}",api:"https://vimeo.com/api/v2/video/{0}.json"},youtube:{sdk:"https://www.youtube.com/iframe_api",api:"https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}"},googleIMA:{sdk:"https://imasdk.googleapis.com/js/sdkloader/ima3.js"}},listeners:{seek:null,play:null,pause:null,restart:null,rewind:null,fastForward:null,mute:null,volume:null,captions:null,download:null,fullscreen:null,pip:null,airplay:null,speed:null,quality:null,loop:null,language:null},events:["ended","progress","stalled","playing","waiting","canplay","canplaythrough","loadstart","loadeddata","loadedmetadata","timeupdate","volumechange","play","pause","error","seeking","seeked","emptied","ratechange","cuechange","download","enterfullscreen","exitfullscreen","captionsenabled","captionsdisabled","languagechange","controlshidden","controlsshown","ready","statechange","qualitychange","adsloaded","adscontentpause","adscontentresume","adstarted","adsmidpoint","adscomplete","adsallcomplete","adsimpression","adsclick"],selectors:{editable:"input, textarea, select, [contenteditable]",container:".plyr",controls:{container:null,wrapper:".plyr__controls"},labels:"[data-plyr]",buttons:{play:'[data-plyr="play"]',pause:'[data-plyr="pause"]',restart:'[data-plyr="restart"]',rewind:'[data-plyr="rewind"]',fastForward:'[data-plyr="fast-forward"]',mute:'[data-plyr="mute"]',captions:'[data-plyr="captions"]',download:'[data-plyr="download"]',fullscreen:'[data-plyr="fullscreen"]',pip:'[data-plyr="pip"]',airplay:'[data-plyr="airplay"]',settings:'[data-plyr="settings"]',loop:'[data-plyr="loop"]'},inputs:{seek:'[data-plyr="seek"]',volume:'[data-plyr="volume"]',speed:'[data-plyr="speed"]',language:'[data-plyr="language"]',quality:'[data-plyr="quality"]'},display:{currentTime:".plyr__time--current",duration:".plyr__time--duration",buffer:".plyr__progress__buffer",loop:".plyr__progress__loop",volume:".plyr__volume--display"},progress:".plyr__progress",captions:".plyr__captions",caption:".plyr__caption"},classNames:{type:"plyr--{0}",provider:"plyr--{0}",video:"plyr__video-wrapper",embed:"plyr__video-embed",videoFixedRatio:"plyr__video-wrapper--fixed-ratio",embedContainer:"plyr__video-embed__container",poster:"plyr__poster",posterEnabled:"plyr__poster-enabled",ads:"plyr__ads",control:"plyr__control",controlPressed:"plyr__control--pressed",playing:"plyr--playing",paused:"plyr--paused",stopped:"plyr--stopped",loading:"plyr--loading",hover:"plyr--hover",tooltip:"plyr__tooltip",cues:"plyr__cues",hidden:"plyr__sr-only",hideControls:"plyr--hide-controls",isIos:"plyr--is-ios",isTouch:"plyr--is-touch",uiSupported:"plyr--full-ui",noTransition:"plyr--no-transition",display:{time:"plyr__time"},menu:{value:"plyr__menu__value",badge:"plyr__badge",open:"plyr--menu-open"},captions:{enabled:"plyr--captions-enabled",active:"plyr--captions-active"},fullscreen:{enabled:"plyr--fullscreen-enabled",fallback:"plyr--fullscreen-fallback"},pip:{supported:"plyr--pip-supported",active:"plyr--pip-active"},airplay:{supported:"plyr--airplay-supported",active:"plyr--airplay-active"},tabFocus:"plyr__tab-focus",previewThumbnails:{thumbContainer:"plyr__preview-thumb",thumbContainerShown:"plyr__preview-thumb--is-shown",imageContainer:"plyr__preview-thumb__image-container",timeContainer:"plyr__preview-thumb__time-container",scrubbingContainer:"plyr__preview-scrubbing",scrubbingContainerShown:"plyr__preview-scrubbing--is-shown"}},attributes:{embed:{provider:"data-plyr-provider",id:"data-plyr-embed-id"}},ads:{enabled:!1,publisherId:"",tagUrl:""},previewThumbnails:{enabled:!1,src:""},vimeo:{byline:!1,portrait:!1,title:!1,speed:!0,transparent:!1,premium:!1,referrerPolicy:null},youtube:{noCookie:!0,rel:0,showinfo:0,iv_load_policy:3,modestbranding:1}},lt="picture-in-picture",ct="inline",ut={html5:"html5",youtube:"youtube",vimeo:"vimeo"},dt="audio",ht="video";var pt=function(){},mt=function(){function t(){var i=arguments.length>0&&void 0!==arguments[0]&&arguments[0];e(this,t),this.enabled=window.console&&i,this.enabled&&this.log("Debugging enabled")}return i(t,[{key:"log",get:function(){return this.enabled?Function.prototype.bind.call(console.log,console):pt}},{key:"warn",get:function(){return this.enabled?Function.prototype.bind.call(console.warn,console):pt}},{key:"error",get:function(){return this.enabled?Function.prototype.bind.call(console.error,console):pt}}]),t}(),ft=function(){function t(i){var n=this;e(this,t),this.player=i,this.prefix=t.prefix,this.property=t.property,this.scrollPosition={x:0,y:0},this.forceFallback="force"===i.config.fullscreen.fallback,this.player.elements.fullscreen=i.config.fullscreen.container&&function(e,t){return(Element.prototype.closest||function(){var e=this;do{if(ke.matches(e,t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null}).call(e,t)}(this.player.elements.container,i.config.fullscreen.container),xe.call(this.player,document,"ms"===this.prefix?"MSFullscreenChange":"".concat(this.prefix,"fullscreenchange"),(function(){n.onChange()})),xe.call(this.player,this.player.elements.container,"dblclick",(function(e){G(n.player.elements.controls)&&n.player.elements.controls.contains(e.target)||n.toggle()})),xe.call(this,this.player.elements.container,"keydown",(function(e){return n.trapFocus(e)})),this.update()}return i(t,[{key:"onChange",value:function(){if(this.enabled){var e=this.player.elements.buttons.fullscreen;G(e)&&(e.pressed=this.active),Oe.call(this.player,this.target,this.active?"enterfullscreen":"exitfullscreen",!0)}}},{key:"toggleFallback",value:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0];if(e?this.scrollPosition={x:window.scrollX||0,y:window.scrollY||0}:window.scrollTo(this.scrollPosition.x,this.scrollPosition.y),document.body.style.overflow=e?"hidden":"",be(this.target,this.player.config.classNames.fullscreen.fallback,e),oe.isIos){var t=document.head.querySelector('meta[name="viewport"]'),i="viewport-fit=cover";t||(t=document.createElement("meta")).setAttribute("name","viewport");var n=Y(t.content)&&t.content.includes(i);e?(this.cleanupViewport=!n,n||(t.content+=",".concat(i))):this.cleanupViewport&&(t.content=t.content.split(",").filter((function(e){return e.trim()!==i})).join(","))}this.onChange()}},{key:"trapFocus",value:function(e){if(!oe.isIos&&this.active&&"Tab"===e.key&&9===e.keyCode){var t=document.activeElement,i=Te.call(this.player,"a[href], button:not(:disabled), input:not(:disabled), [tabindex]"),n=o(i,1)[0],a=i[i.length-1];t!==a||e.shiftKey?t===n&&e.shiftKey&&(a.focus(),e.preventDefault()):(n.focus(),e.preventDefault())}}},{key:"update",value:function(){var e;this.enabled?(e=this.forceFallback?"Fallback (forced)":t.native?"Native":"Fallback",this.player.debug.log("".concat(e," fullscreen enabled"))):this.player.debug.log("Fullscreen not supported and fallback disabled");be(this.player.elements.container,this.player.config.classNames.fullscreen.enabled,this.enabled)}},{key:"enter",value:function(){this.enabled&&(oe.isIos&&this.player.config.fullscreen.iosNative?this.target.webkitEnterFullscreen():!t.native||this.forceFallback?this.toggleFallback(!0):this.prefix?ae(this.prefix)||this.target["".concat(this.prefix,"Request").concat(this.property)]():this.target.requestFullscreen({navigationUI:"hide"}))}},{key:"exit",value:function(){if(this.enabled)if(oe.isIos&&this.player.config.fullscreen.iosNative)this.target.webkitExitFullscreen(),qe(this.player.play());else if(!t.native||this.forceFallback)this.toggleFallback(!1);else if(this.prefix){if(!ae(this.prefix)){var e="moz"===this.prefix?"Cancel":"Exit";document["".concat(this.prefix).concat(e).concat(this.property)]()}}else(document.cancelFullScreen||document.exitFullscreen).call(document)}},{key:"toggle",value:function(){this.active?this.exit():this.enter()}},{key:"usingNative",get:function(){return t.native&&!this.forceFallback}},{key:"enabled",get:function(){return(t.native||this.player.config.fullscreen.fallback)&&this.player.config.fullscreen.enabled&&this.player.supported.ui&&this.player.isVideo}},{key:"active",get:function(){if(!this.enabled)return!1;if(!t.native||this.forceFallback)return we(this.target,this.player.config.classNames.fullscreen.fallback);var e=this.prefix?document["".concat(this.prefix).concat(this.property,"Element")]:document.fullscreenElement;return e&&e.shadowRoot?e===this.target.getRootNode().host:e===this.target}},{key:"target",get:function(){return oe.isIos&&this.player.config.fullscreen.iosNative?this.player.media:this.player.elements.fullscreen||this.player.elements.container}}],[{key:"native",get:function(){return!!(document.fullscreenEnabled||document.webkitFullscreenEnabled||document.mozFullScreenEnabled||document.msFullscreenEnabled)}},{key:"prefix",get:function(){if(X(document.exitFullscreen))return"";var e="";return["webkit","moz","ms"].some((function(t){return!(!X(document["".concat(t,"ExitFullscreen")])&&!X(document["".concat(t,"CancelFullScreen")]))&&(e=t,!0)})),e}},{key:"property",get:function(){return"moz"===this.prefix?"FullScreen":"Fullscreen"}}]),t}();function gt(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1;return new Promise((function(i,n){var a=new Image,s=function(){delete a.onload,delete a.onerror,(a.naturalWidth>=t?i:n)(a)};Object.assign(a,{onload:s,onerror:s,src:e})}))}var yt={addStyleHook:function(){be(this.elements.container,this.config.selectors.container.replace(".",""),!0),be(this.elements.container,this.config.classNames.uiSupported,this.supported.ui)},toggleNativeControls:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0];e&&this.isHTML5?this.media.setAttribute("controls",""):this.media.removeAttribute("controls")},build:function(){var e=this;if(this.listeners.media(),!this.supported.ui)return this.debug.warn("Basic support only for ".concat(this.provider," ").concat(this.type)),void yt.toggleNativeControls.call(this,!0);G(this.elements.controls)||(nt.inject.call(this),this.listeners.controls()),yt.toggleNativeControls.call(this),this.isHTML5&&rt.setup.call(this),this.volume=null,this.muted=null,this.loop=null,this.quality=null,this.speed=null,nt.updateVolume.call(this),nt.timeUpdate.call(this),yt.checkPlaying.call(this),be(this.elements.container,this.config.classNames.pip.supported,Ee.pip&&this.isHTML5&&this.isVideo),be(this.elements.container,this.config.classNames.airplay.supported,Ee.airplay&&this.isHTML5),be(this.elements.container,this.config.classNames.isIos,oe.isIos),be(this.elements.container,this.config.classNames.isTouch,this.touch),this.ready=!0,setTimeout((function(){Oe.call(e,e.media,"ready")}),0),yt.setTitle.call(this),this.poster&&yt.setPoster.call(this,this.poster,!1).catch((function(){})),this.config.duration&&nt.durationUpdate.call(this)},setTitle:function(){var e=Xe("play",this.config);if(Y(this.config.title)&&!ae(this.config.title)&&(e+=", ".concat(this.config.title)),Array.from(this.elements.buttons.play||[]).forEach((function(t){t.setAttribute("aria-label",e)})),this.isEmbed){var t=Ce.call(this,"iframe");if(!G(t))return;var i=ae(this.config.title)?"video":this.config.title,n=Xe("frameTitle",this.config);t.setAttribute("title",n.replace("{title}",i))}},togglePoster:function(e){be(this.elements.container,this.config.classNames.posterEnabled,e)},setPoster:function(e){var t=this,i=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return i&&this.poster?Promise.reject(new Error("Poster already set")):(this.media.setAttribute("data-poster",e),je.call(this).then((function(){return gt(e)})).catch((function(i){throw e===t.poster&&yt.togglePoster.call(t,!1),i})).then((function(){if(e!==t.poster)throw new Error("setPoster cancelled by later call to setPoster")})).then((function(){return Object.assign(t.elements.poster.style,{backgroundImage:"url('".concat(e,"')"),backgroundSize:""}),yt.togglePoster.call(t,!0),e})))},checkPlaying:function(e){var t=this;be(this.elements.container,this.config.classNames.playing,this.playing),be(this.elements.container,this.config.classNames.paused,this.paused),be(this.elements.container,this.config.classNames.stopped,this.stopped),Array.from(this.elements.buttons.play||[]).forEach((function(e){Object.assign(e,{pressed:t.playing}),e.setAttribute("aria-label",Xe(t.playing?"pause":"play",t.config))})),Z(e)&&"timeupdate"===e.type||yt.toggleControls.call(this)},checkLoading:function(e){var t=this;this.loading=["stalled","waiting"].includes(e.type),clearTimeout(this.timers.loading),this.timers.loading=setTimeout((function(){be(t.elements.container,t.config.classNames.loading,t.loading),yt.toggleControls.call(t)}),this.loading?250:0)},toggleControls:function(e){var t=this.elements.controls;if(t&&this.config.hideControls){var i=this.touch&&this.lastSeekTime+2e3>Date.now();this.toggleControls(Boolean(e||this.loading||this.paused||t.pressed||t.hover||i))}},migrateStyles:function(){var e=this;Object.values(s({},this.media.style)).filter((function(e){return!ae(e)&&e.startsWith("--plyr")})).forEach((function(t){e.elements.container.style.setProperty(t,e.media.style.getPropertyValue(t)),e.media.style.removeProperty(t)})),ae(this.media.style)&&this.media.removeAttribute("style")}},vt=function(){function t(i){e(this,t),this.player=i,this.lastKey=null,this.focusTimer=null,this.lastKeyDown=null,this.handleKey=this.handleKey.bind(this),this.toggleMenu=this.toggleMenu.bind(this),this.setTabFocus=this.setTabFocus.bind(this),this.firstTouch=this.firstTouch.bind(this)}return i(t,[{key:"handleKey",value:function(e){var t=this.player,i=t.elements,n=e.keyCode?e.keyCode:e.which,a="keydown"===e.type,s=a&&n===this.lastKey;if(!(e.altKey||e.ctrlKey||e.metaKey||e.shiftKey)&&K(n)){if(a){var r=document.activeElement;if(G(r)){var o=t.config.selectors.editable;if(r!==i.inputs.seek&&ke(r,o))return;if(32===e.which&&ke(r,'button, [role^="menuitem"]'))return}switch([32,37,38,39,40,48,49,50,51,52,53,54,56,57,67,70,73,75,76,77,79].includes(n)&&(e.preventDefault(),e.stopPropagation()),n){case 48:case 49:case 50:case 51:case 52:case 53:case 54:case 55:case 56:case 57:s||(t.currentTime=t.duration/10*(n-48));break;case 32:case 75:s||qe(t.togglePlay());break;case 38:t.increaseVolume(.1);break;case 40:t.decreaseVolume(.1);break;case 77:s||(t.muted=!t.muted);break;case 39:t.forward();break;case 37:t.rewind();break;case 70:t.fullscreen.toggle();break;case 67:s||t.toggleCaptions();break;case 76:t.loop=!t.loop}27===n&&!t.fullscreen.usingNative&&t.fullscreen.active&&t.fullscreen.toggle(),this.lastKey=n}else this.lastKey=null}}},{key:"toggleMenu",value:function(e){nt.toggleMenu.call(this.player,e)}},{key:"firstTouch",value:function(){var e=this.player,t=e.elements;e.touch=!0,be(t.container,e.config.classNames.isTouch,!0)}},{key:"setTabFocus",value:function(e){var t=this.player,i=t.elements;if(clearTimeout(this.focusTimer),"keydown"!==e.type||9===e.which){"keydown"===e.type&&(this.lastKeyDown=e.timeStamp);var n,a=e.timeStamp-this.lastKeyDown<=20;if("focus"!==e.type||a)n=t.config.classNames.tabFocus,be(Te.call(t,".".concat(n)),n,!1),"focusout"!==e.type&&(this.focusTimer=setTimeout((function(){var e=document.activeElement;i.container.contains(e)&&be(document.activeElement,t.config.classNames.tabFocus,!0)}),10))}}},{key:"global",value:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],t=this.player;t.config.keyboard.global&&Me.call(t,window,"keydown keyup",this.handleKey,e,!1),Me.call(t,document.body,"click",this.toggleMenu,e),Le.call(t,document.body,"touchstart",this.firstTouch),Me.call(t,document.body,"keydown focus blur focusout",this.setTabFocus,e,!1,!0)}},{key:"container",value:function(){var e=this.player,t=e.config,i=e.elements,n=e.timers;!t.keyboard.global&&t.keyboard.focused&&xe.call(e,i.container,"keydown keyup",this.handleKey,!1),xe.call(e,i.container,"mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen",(function(t){var a=i.controls;a&&"enterfullscreen"===t.type&&(a.pressed=!1,a.hover=!1);var s=0;["touchstart","touchmove","mousemove"].includes(t.type)&&(yt.toggleControls.call(e,!0),s=e.touch?3e3:2e3),clearTimeout(n.controls),n.controls=setTimeout((function(){return yt.toggleControls.call(e,!1)}),s)}));var a=function(t){if(!t)return Re.call(e);var n=i.container.getBoundingClientRect(),a=n.width,s=n.height;return Re.call(e,"".concat(a,":").concat(s))},s=function(){clearTimeout(n.resized),n.resized=setTimeout(a,50)};xe.call(e,i.container,"enterfullscreen exitfullscreen",(function(t){var n=e.fullscreen,r=n.target,l=n.usingNative;if(r===i.container&&(e.isEmbed||!ae(e.config.ratio))){var c="enterfullscreen"===t.type,u=a(c);u.padding;!function(t,i,n){if(e.isVimeo&&!e.config.vimeo.premium){var a=e.elements.wrapper.firstChild,s=o(t,2)[1],r=o(Fe.call(e),2),l=r[0],c=r[1];a.style.maxWidth=n?"".concat(s/c*l,"px"):null,a.style.margin=n?"0 auto":null}}(u.ratio,0,c),l||(c?xe.call(e,window,"resize",s):Ie.call(e,window,"resize",s))}}))}},{key:"media",value:function(){var e=this,t=this.player,i=t.elements;if(xe.call(t,t.media,"timeupdate seeking seeked",(function(e){return nt.timeUpdate.call(t,e)})),xe.call(t,t.media,"durationchange loadeddata loadedmetadata",(function(e){return nt.durationUpdate.call(t,e)})),xe.call(t,t.media,"ended",(function(){t.isHTML5&&t.isVideo&&t.config.resetOnEnd&&(t.restart(),t.pause())})),xe.call(t,t.media,"progress playing seeking seeked",(function(e){return nt.updateProgress.call(t,e)})),xe.call(t,t.media,"volumechange",(function(e){return nt.updateVolume.call(t,e)})),xe.call(t,t.media,"playing play pause ended emptied timeupdate",(function(e){return yt.checkPlaying.call(t,e)})),xe.call(t,t.media,"waiting canplay seeked playing",(function(e){return yt.checkLoading.call(t,e)})),t.supported.ui&&t.config.clickToPlay&&!t.isAudio){var n=Ce.call(t,".".concat(t.config.classNames.video));if(!G(n))return;xe.call(t,i.container,"click",(function(a){([i.container,n].includes(a.target)||n.contains(a.target))&&(t.touch&&t.config.hideControls||(t.ended?(e.proxy(a,t.restart,"restart"),e.proxy(a,(function(){qe(t.play())}),"play")):e.proxy(a,(function(){qe(t.togglePlay())}),"play")))}))}t.supported.ui&&t.config.disableContextMenu&&xe.call(t,i.wrapper,"contextmenu",(function(e){e.preventDefault()}),!1),xe.call(t,t.media,"volumechange",(function(){t.storage.set({volume:t.volume,muted:t.muted})})),xe.call(t,t.media,"ratechange",(function(){nt.updateSetting.call(t,"speed"),t.storage.set({speed:t.speed})})),xe.call(t,t.media,"qualitychange",(function(e){nt.updateSetting.call(t,"quality",null,e.detail.quality)})),xe.call(t,t.media,"ready qualitychange",(function(){nt.setDownloadUrl.call(t)}));var a=t.config.events.concat(["keyup","keydown"]).join(" ");xe.call(t,t.media,a,(function(e){var n=e.detail,a=void 0===n?{}:n;"error"===e.type&&(a=t.media.error),Oe.call(t,i.container,e.type,!0,a)}))}},{key:"proxy",value:function(e,t,i){var n=this.player,a=n.config.listeners[i],s=!0;X(a)&&(s=a.call(n,e)),!1!==s&&X(t)&&t.call(n,e)}},{key:"bind",value:function(e,t,i,n){var a=this,s=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],r=this.player,o=r.config.listeners[n],l=X(o);xe.call(r,e,t,(function(e){return a.proxy(e,i,n)}),s&&!l)}},{key:"controls",value:function(){var e=this,t=this.player,i=t.elements,n=oe.isIE?"change":"input";if(i.buttons.play&&Array.from(i.buttons.play).forEach((function(i){e.bind(i,"click",(function(){qe(t.togglePlay())}),"play")})),this.bind(i.buttons.restart,"click",t.restart,"restart"),this.bind(i.buttons.rewind,"click",t.rewind,"rewind"),this.bind(i.buttons.fastForward,"click",t.forward,"fastForward"),this.bind(i.buttons.mute,"click",(function(){t.muted=!t.muted}),"mute"),this.bind(i.buttons.captions,"click",(function(){return t.toggleCaptions()})),this.bind(i.buttons.download,"click",(function(){Oe.call(t,t.media,"download")}),"download"),this.bind(i.buttons.fullscreen,"click",(function(){t.fullscreen.toggle()}),"fullscreen"),this.bind(i.buttons.pip,"click",(function(){t.pip="toggle"}),"pip"),this.bind(i.buttons.airplay,"click",t.airplay,"airplay"),this.bind(i.buttons.settings,"click",(function(e){e.stopPropagation(),e.preventDefault(),nt.toggleMenu.call(t,e)}),null,!1),this.bind(i.buttons.settings,"keyup",(function(e){var i=e.which;[13,32].includes(i)&&(13!==i?(e.preventDefault(),e.stopPropagation(),nt.toggleMenu.call(t,e)):nt.focusFirstMenuItem.call(t,null,!0))}),null,!1),this.bind(i.settings.menu,"keydown",(function(e){27===e.which&&nt.toggleMenu.call(t,e)})),this.bind(i.inputs.seek,"mousedown mousemove",(function(e){var t=i.progress.getBoundingClientRect(),n=100/t.width*(e.pageX-t.left);e.currentTarget.setAttribute("seek-value",n)})),this.bind(i.inputs.seek,"mousedown mouseup keydown keyup touchstart touchend",(function(e){var i=e.currentTarget,n=e.keyCode?e.keyCode:e.which;if(!ee(e)||39===n||37===n){t.lastSeekTime=Date.now();var a=i.hasAttribute("play-on-seeked"),s=["mouseup","touchend","keyup"].includes(e.type);a&&s?(i.removeAttribute("play-on-seeked"),qe(t.play())):!s&&t.playing&&(i.setAttribute("play-on-seeked",""),t.pause())}})),oe.isIos){var a=Te.call(t,'input[type="range"]');Array.from(a).forEach((function(t){return e.bind(t,n,(function(e){return re(e.target)}))}))}this.bind(i.inputs.seek,n,(function(e){var i=e.currentTarget,n=i.getAttribute("seek-value");ae(n)&&(n=i.value),i.removeAttribute("seek-value"),t.currentTime=n/i.max*t.duration}),"seek"),this.bind(i.progress,"mouseenter mouseleave mousemove",(function(e){return nt.updateSeekTooltip.call(t,e)})),this.bind(i.progress,"mousemove touchmove",(function(e){var i=t.previewThumbnails;i&&i.loaded&&i.startMove(e)})),this.bind(i.progress,"mouseleave touchend click",(function(){var e=t.previewThumbnails;e&&e.loaded&&e.endMove(!1,!0)})),this.bind(i.progress,"mousedown touchstart",(function(e){var i=t.previewThumbnails;i&&i.loaded&&i.startScrubbing(e)})),this.bind(i.progress,"mouseup touchend",(function(e){var i=t.previewThumbnails;i&&i.loaded&&i.endScrubbing(e)})),oe.isWebkit&&Array.from(Te.call(t,'input[type="range"]')).forEach((function(i){e.bind(i,"input",(function(e){return nt.updateRangeFill.call(t,e.target)}))})),t.config.toggleInvert&&!G(i.display.duration)&&this.bind(i.display.currentTime,"click",(function(){0!==t.currentTime&&(t.config.invertTime=!t.config.invertTime,nt.timeUpdate.call(t))})),this.bind(i.inputs.volume,n,(function(e){t.volume=e.target.value}),"volume"),this.bind(i.controls,"mouseenter mouseleave",(function(e){i.controls.hover=!t.touch&&"mouseenter"===e.type})),i.fullscreen&&Array.from(i.fullscreen.children).filter((function(e){return!e.contains(i.container)})).forEach((function(n){e.bind(n,"mouseenter mouseleave",(function(e){i.controls.hover=!t.touch&&"mouseenter"===e.type}))})),this.bind(i.controls,"mousedown mouseup touchstart touchend touchcancel",(function(e){i.controls.pressed=["mousedown","touchstart"].includes(e.type)})),this.bind(i.controls,"focusin",(function(){var n=t.config,a=t.timers;be(i.controls,n.classNames.noTransition,!0),yt.toggleControls.call(t,!0),setTimeout((function(){be(i.controls,n.classNames.noTransition,!1)}),0);var s=e.touch?3e3:4e3;clearTimeout(a.controls),a.controls=setTimeout((function(){return yt.toggleControls.call(t,!1)}),s)})),this.bind(i.inputs.volume,"wheel",(function(e){var i=e.webkitDirectionInvertedFromDevice,n=o([e.deltaX,-e.deltaY].map((function(e){return i?-e:e})),2),a=n[0],s=n[1],r=Math.sign(Math.abs(a)>Math.abs(s)?a:s);t.increaseVolume(r/50);var l=t.media.volume;(1===r&&l<1||-1===r&&l>0)&&e.preventDefault()}),"volume",!1)}}]),t}();"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self&&self;var bt=function(e,t){return e(t={exports:{}},t.exports),t.exports}((function(e,t){e.exports=function(){var e=function(){},t={},i={},n={};function a(e,t){if(e){var a=n[e];if(i[e]=t,a)for(;a.length;)a[0](e,t),a.splice(0,1)}}function s(t,i){t.call&&(t={success:t}),i.length?(t.error||e)(i):(t.success||e)(t)}function r(t,i,n,a){var s,o,l=document,c=n.async,u=(n.numRetries||0)+1,d=n.before||e,h=t.replace(/[\?|#].*$/,""),p=t.replace(/^(css|img)!/,"");a=a||0,/(^css!|\.css$)/.test(h)?((o=l.createElement("link")).rel="stylesheet",o.href=p,(s="hideFocus"in o)&&o.relList&&(s=0,o.rel="preload",o.as="style")):/(^img!|\.(png|gif|jpg|svg|webp)$)/.test(h)?(o=l.createElement("img")).src=p:((o=l.createElement("script")).src=t,o.async=void 0===c||c),o.onload=o.onerror=o.onbeforeload=function(e){var l=e.type[0];if(s)try{o.sheet.cssText.length||(l="e")}catch(e){18!=e.code&&(l="e")}if("e"==l){if((a+=1)<u)return r(t,i,n,a)}else if("preload"==o.rel&&"style"==o.as)return o.rel="stylesheet";i(t,l,e.defaultPrevented)},!1!==d(t,o)&&l.head.appendChild(o)}function o(e,i,n){var o,l;if(i&&i.trim&&(o=i),l=(o?n:i)||{},o){if(o in t)throw"LoadJS";t[o]=!0}function c(t,i){!function(e,t,i){var n,a,s=(e=e.push?e:[e]).length,o=s,l=[];for(n=function(e,i,n){if("e"==i&&l.push(e),"b"==i){if(!n)return;l.push(e)}--s||t(l)},a=0;a<o;a++)r(e[a],n,i)}(e,(function(e){s(l,e),t&&s({success:t,error:i},e),a(o,e)}),l)}if(l.returnPromise)return new Promise(c);c()}return o.ready=function(e,t){return function(e,t){e=e.push?e:[e];var a,s,r,o=[],l=e.length,c=l;for(a=function(e,i){i.length&&o.push(e),--c||t(o)};l--;)s=e[l],(r=i[s])?a(s,r):(n[s]=n[s]||[]).push(a)}(e,(function(e){s(t,e)})),o},o.done=function(e){a(e,[])},o.reset=function(){t={},i={},n={}},o.isDefined=function(e){return e in t},o}()}));function wt(e){return new Promise((function(t,i){bt(e,{success:t,error:i})}))}function kt(e){e&&!this.embed.hasPlayed&&(this.embed.hasPlayed=!0),this.media.paused===e&&(this.media.paused=!e,Oe.call(this,this.media,e?"play":"pause"))}var Tt={setup:function(){var e=this;be(e.elements.wrapper,e.config.classNames.embed,!0),e.options.speed=e.config.speed.options,Re.call(e),z(window.Vimeo)?Tt.ready.call(e):wt(e.config.urls.vimeo.sdk).then((function(){Tt.ready.call(e)})).catch((function(t){e.debug.warn("Vimeo SDK (player.js) failed to load",t)}))},ready:function(){var e=this,t=this,i=t.config.vimeo,n=i.premium,a=i.referrerPolicy,l=r(i,["premium","referrerPolicy"]);n&&Object.assign(l,{controls:!1,sidedock:!1});var c=st(s({loop:t.config.loop.active,autoplay:t.autoplay,muted:t.muted,gesture:"media",playsinline:!this.config.fullscreen.iosNative},l)),u=t.media.getAttribute("src");ae(u)&&(u=t.media.getAttribute(t.config.attributes.embed.id));var d,h=ae(d=u)?null:K(Number(d))?d:d.match(/^.*(vimeo.com\/|video\/)(\d+).*/)?RegExp.$2:d,p=he("iframe"),m=Ue(t.config.urls.vimeo.iframe,h,c);p.setAttribute("src",m),p.setAttribute("allowfullscreen",""),p.setAttribute("allow","autoplay,fullscreen,picture-in-picture"),ae(a)||p.setAttribute("referrerPolicy",a);var f=t.poster;if(n)p.setAttribute("data-poster",f),t.media=ge(p,t.media);else{var g=he("div",{class:t.config.classNames.embedContainer,"data-poster":f});g.appendChild(p),t.media=ge(g,t.media)}Je(Ue(t.config.urls.vimeo.api,h),"json").then((function(e){if(!ae(e)){var i=new URL(e[0].thumbnail_large);i.pathname="".concat(i.pathname.split("_")[0],".jpg"),yt.setPoster.call(t,i.href).catch((function(){}))}})),t.embed=new window.Vimeo.Player(p,{autopause:t.config.autopause,muted:t.muted}),t.media.paused=!0,t.media.currentTime=0,t.supported.ui&&t.embed.disableTextTrack(),t.media.play=function(){return kt.call(t,!0),t.embed.play()},t.media.pause=function(){return kt.call(t,!1),t.embed.pause()},t.media.stop=function(){t.pause(),t.currentTime=0};var y=t.media.currentTime;Object.defineProperty(t.media,"currentTime",{get:function(){return y},set:function(e){var i=t.embed,n=t.media,a=t.paused,s=t.volume,r=a&&!i.hasPlayed;n.seeking=!0,Oe.call(t,n,"seeking"),Promise.resolve(r&&i.setVolume(0)).then((function(){return i.setCurrentTime(e)})).then((function(){return r&&i.pause()})).then((function(){return r&&i.setVolume(s)})).catch((function(){}))}});var v=t.config.speed.selected;Object.defineProperty(t.media,"playbackRate",{get:function(){return v},set:function(e){t.embed.setPlaybackRate(e).then((function(){v=e,Oe.call(t,t.media,"ratechange")})).catch((function(){t.options.speed=[1]}))}});var b=t.config.volume;Object.defineProperty(t.media,"volume",{get:function(){return b},set:function(e){t.embed.setVolume(e).then((function(){b=e,Oe.call(t,t.media,"volumechange")}))}});var w=t.config.muted;Object.defineProperty(t.media,"muted",{get:function(){return w},set:function(e){var i=!!Q(e)&&e;t.embed.setVolume(i?0:t.config.volume).then((function(){w=i,Oe.call(t,t.media,"volumechange")}))}});var k,T=t.config.loop;Object.defineProperty(t.media,"loop",{get:function(){return T},set:function(e){var i=Q(e)?e:t.config.loop.active;t.embed.setLoop(i).then((function(){T=i}))}}),t.embed.getVideoUrl().then((function(e){k=e,nt.setDownloadUrl.call(t)})).catch((function(t){e.debug.warn(t)})),Object.defineProperty(t.media,"currentSrc",{get:function(){return k}}),Object.defineProperty(t.media,"ended",{get:function(){return t.currentTime===t.duration}}),Promise.all([t.embed.getVideoWidth(),t.embed.getVideoHeight()]).then((function(i){var n=o(i,2),a=n[0],s=n[1];t.embed.ratio=[a,s],Re.call(e)})),t.embed.setAutopause(t.config.autopause).then((function(e){t.config.autopause=e})),t.embed.getVideoTitle().then((function(i){t.config.title=i,yt.setTitle.call(e)})),t.embed.getCurrentTime().then((function(e){y=e,Oe.call(t,t.media,"timeupdate")})),t.embed.getDuration().then((function(e){t.media.duration=e,Oe.call(t,t.media,"durationchange")})),t.embed.getTextTracks().then((function(e){t.media.textTracks=e,rt.setup.call(t)})),t.embed.on("cuechange",(function(e){var i=e.cues,n=(void 0===i?[]:i).map((function(e){return function(e){var t=document.createDocumentFragment(),i=document.createElement("div");return t.appendChild(i),i.innerHTML=e,t.firstChild.innerText}(e.text)}));rt.updateCues.call(t,n)})),t.embed.on("loaded",(function(){(t.embed.getPaused().then((function(e){kt.call(t,!e),e||Oe.call(t,t.media,"playing")})),G(t.embed.element)&&t.supported.ui)&&t.embed.element.setAttribute("tabindex",-1)})),t.embed.on("bufferstart",(function(){Oe.call(t,t.media,"waiting")})),t.embed.on("bufferend",(function(){Oe.call(t,t.media,"playing")})),t.embed.on("play",(function(){kt.call(t,!0),Oe.call(t,t.media,"playing")})),t.embed.on("pause",(function(){kt.call(t,!1)})),t.embed.on("timeupdate",(function(e){t.media.seeking=!1,y=e.seconds,Oe.call(t,t.media,"timeupdate")})),t.embed.on("progress",(function(e){t.media.buffered=e.percent,Oe.call(t,t.media,"progress"),1===parseInt(e.percent,10)&&Oe.call(t,t.media,"canplaythrough"),t.embed.getDuration().then((function(e){e!==t.media.duration&&(t.media.duration=e,Oe.call(t,t.media,"durationchange"))}))})),t.embed.on("seeked",(function(){t.media.seeking=!1,Oe.call(t,t.media,"seeked")})),t.embed.on("ended",(function(){t.media.paused=!0,Oe.call(t,t.media,"ended")})),t.embed.on("error",(function(e){t.media.error=e,Oe.call(t,t.media,"error")})),setTimeout((function(){return yt.build.call(t)}),0)}};function Ct(e){e&&!this.embed.hasPlayed&&(this.embed.hasPlayed=!0),this.media.paused===e&&(this.media.paused=!e,Oe.call(this,this.media,e?"play":"pause"))}function At(e){return e.noCookie?"https://www.youtube-nocookie.com":"http:"===window.location.protocol?"http://www.youtube.com":void 0}var St={setup:function(){var e=this;if(be(this.elements.wrapper,this.config.classNames.embed,!0),z(window.YT)&&X(window.YT.Player))St.ready.call(this);else{var t=window.onYouTubeIframeAPIReady;window.onYouTubeIframeAPIReady=function(){X(t)&&t(),St.ready.call(e)},wt(this.config.urls.youtube.sdk).catch((function(t){e.debug.warn("YouTube API failed to load",t)}))}},getTitle:function(e){var t=this;Je(Ue(this.config.urls.youtube.api,e)).then((function(e){if(z(e)){var i=e.title,n=e.height,a=e.width;t.config.title=i,yt.setTitle.call(t),t.embed.ratio=[a,n]}Re.call(t)})).catch((function(){Re.call(t)}))},ready:function(){var e=this,t=e.media&&e.media.getAttribute("id");if(ae(t)||!t.startsWith("youtube-")){var i=e.media.getAttribute("src");ae(i)&&(i=e.media.getAttribute(this.config.attributes.embed.id));var n,a,s=ae(n=i)?null:n.match(/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/)?RegExp.$2:n,r=(a=e.provider,"".concat(a,"-").concat(Math.floor(1e4*Math.random()))),o=he("div",{id:r,"data-poster":e.poster});e.media=ge(o,e.media);var l=function(e){return"https://i.ytimg.com/vi/".concat(s,"/").concat(e,"default.jpg")};gt(l("maxres"),121).catch((function(){return gt(l("sd"),121)})).catch((function(){return gt(l("hq"))})).then((function(t){return yt.setPoster.call(e,t.src)})).then((function(t){t.includes("maxres")||(e.elements.poster.style.backgroundSize="cover")})).catch((function(){}));var c=e.config.youtube;e.embed=new window.YT.Player(r,{videoId:s,host:At(c),playerVars:ce({},{autoplay:e.config.autoplay?1:0,hl:e.config.hl,controls:e.supported.ui?0:1,disablekb:1,playsinline:e.config.fullscreen.iosNative?0:1,cc_load_policy:e.captions.active?1:0,cc_lang_pref:e.config.captions.language,widget_referrer:window?window.location.href:null},c),events:{onError:function(t){if(!e.media.error){var i=t.data,n={2:"The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.",5:"The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.",100:"The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.",101:"The owner of the requested video does not allow it to be played in embedded players.",150:"The owner of the requested video does not allow it to be played in embedded players."}[i]||"An unknown error occured";e.media.error={code:i,message:n},Oe.call(e,e.media,"error")}},onPlaybackRateChange:function(t){var i=t.target;e.media.playbackRate=i.getPlaybackRate(),Oe.call(e,e.media,"ratechange")},onReady:function(t){if(!X(e.media.play)){var i=t.target;St.getTitle.call(e,s),e.media.play=function(){Ct.call(e,!0),i.playVideo()},e.media.pause=function(){Ct.call(e,!1),i.pauseVideo()},e.media.stop=function(){i.stopVideo()},e.media.duration=i.getDuration(),e.media.paused=!0,e.media.currentTime=0,Object.defineProperty(e.media,"currentTime",{get:function(){return Number(i.getCurrentTime())},set:function(t){e.paused&&!e.embed.hasPlayed&&e.embed.mute(),e.media.seeking=!0,Oe.call(e,e.media,"seeking"),i.seekTo(t)}}),Object.defineProperty(e.media,"playbackRate",{get:function(){return i.getPlaybackRate()},set:function(e){i.setPlaybackRate(e)}});var n=e.config.volume;Object.defineProperty(e.media,"volume",{get:function(){return n},set:function(t){n=t,i.setVolume(100*n),Oe.call(e,e.media,"volumechange")}});var a=e.config.muted;Object.defineProperty(e.media,"muted",{get:function(){return a},set:function(t){var n=Q(t)?t:a;a=n,i[n?"mute":"unMute"](),Oe.call(e,e.media,"volumechange")}}),Object.defineProperty(e.media,"currentSrc",{get:function(){return i.getVideoUrl()}}),Object.defineProperty(e.media,"ended",{get:function(){return e.currentTime===e.duration}});var r=i.getAvailablePlaybackRates();e.options.speed=r.filter((function(t){return e.config.speed.options.includes(t)})),e.supported.ui&&e.media.setAttribute("tabindex",-1),Oe.call(e,e.media,"timeupdate"),Oe.call(e,e.media,"durationchange"),clearInterval(e.timers.buffering),e.timers.buffering=setInterval((function(){e.media.buffered=i.getVideoLoadedFraction(),(null===e.media.lastBuffered||e.media.lastBuffered<e.media.buffered)&&Oe.call(e,e.media,"progress"),e.media.lastBuffered=e.media.buffered,1===e.media.buffered&&(clearInterval(e.timers.buffering),Oe.call(e,e.media,"canplaythrough"))}),200),setTimeout((function(){return yt.build.call(e)}),50)}},onStateChange:function(t){var i=t.target;switch(clearInterval(e.timers.playing),e.media.seeking&&[1,2].includes(t.data)&&(e.media.seeking=!1,Oe.call(e,e.media,"seeked")),t.data){case-1:Oe.call(e,e.media,"timeupdate"),e.media.buffered=i.getVideoLoadedFraction(),Oe.call(e,e.media,"progress");break;case 0:Ct.call(e,!1),e.media.loop?(i.stopVideo(),i.playVideo()):Oe.call(e,e.media,"ended");break;case 1:e.config.autoplay||!e.media.paused||e.embed.hasPlayed?(Ct.call(e,!0),Oe.call(e,e.media,"playing"),e.timers.playing=setInterval((function(){Oe.call(e,e.media,"timeupdate")}),50),e.media.duration!==i.getDuration()&&(e.media.duration=i.getDuration(),Oe.call(e,e.media,"durationchange"))):e.media.pause();break;case 2:e.muted||e.embed.unMute(),Ct.call(e,!1);break;case 3:Oe.call(e,e.media,"waiting")}Oe.call(e,e.elements.container,"statechange",!1,{code:t.data})}}})}}},Pt={setup:function(){this.media?(be(this.elements.container,this.config.classNames.type.replace("{0}",this.type),!0),be(this.elements.container,this.config.classNames.provider.replace("{0}",this.provider),!0),this.isEmbed&&be(this.elements.container,this.config.classNames.type.replace("{0}","video"),!0),this.isVideo&&(this.elements.wrapper=he("div",{class:this.config.classNames.video}),ue(this.media,this.elements.wrapper),this.elements.poster=he("div",{class:this.config.classNames.poster}),this.elements.wrapper.appendChild(this.elements.poster)),this.isHTML5?Ve.setup.call(this):this.isYouTube?St.setup.call(this):this.isVimeo&&Tt.setup.call(this)):this.debug.warn("No media element found!")}},Et=function(){function t(i){var n=this;e(this,t),this.player=i,this.config=i.config.ads,this.playing=!1,this.initialized=!1,this.elements={container:null,displayContainer:null},this.manager=null,this.loader=null,this.cuePoints=null,this.events={},this.safetyTimer=null,this.countdownTimer=null,this.managerPromise=new Promise((function(e,t){n.on("loaded",e),n.on("error",t)})),this.load()}return i(t,[{key:"load",value:function(){var e=this;this.enabled&&(z(window.google)&&z(window.google.ima)?this.ready():wt(this.player.config.urls.googleIMA.sdk).then((function(){e.ready()})).catch((function(){e.trigger("error",new Error("Google IMA SDK failed to load"))})))}},{key:"ready",value:function(){var e,t=this;this.enabled||((e=this).manager&&e.manager.destroy(),e.elements.displayContainer&&e.elements.displayContainer.destroy(),e.elements.container.remove()),this.startSafetyTimer(12e3,"ready()"),this.managerPromise.then((function(){t.clearSafetyTimer("onAdsManagerLoaded()")})),this.listeners(),this.setupIMA()}},{key:"setupIMA",value:function(){var e=this;this.elements.container=he("div",{class:this.player.config.classNames.ads}),this.player.elements.container.appendChild(this.elements.container),google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED),google.ima.settings.setLocale(this.player.config.ads.language),google.ima.settings.setDisableCustomPlaybackForIOS10Plus(this.player.config.playsinline),this.elements.displayContainer=new google.ima.AdDisplayContainer(this.elements.container,this.player.media),this.loader=new google.ima.AdsLoader(this.elements.displayContainer),this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,(function(t){return e.onAdsManagerLoaded(t)}),!1),this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR,(function(t){return e.onAdError(t)}),!1),this.requestAds()}},{key:"requestAds",value:function(){var e=this.player.elements.container;try{var t=new google.ima.AdsRequest;t.adTagUrl=this.tagUrl,t.linearAdSlotWidth=e.offsetWidth,t.linearAdSlotHeight=e.offsetHeight,t.nonLinearAdSlotWidth=e.offsetWidth,t.nonLinearAdSlotHeight=e.offsetHeight,t.forceNonLinearFullSlot=!1,t.setAdWillPlayMuted(!this.player.muted),this.loader.requestAds(t)}catch(e){this.onAdError(e)}}},{key:"pollCountdown",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]&&arguments[0];if(!t)return clearInterval(this.countdownTimer),void this.elements.container.removeAttribute("data-badge-text");var i=function(){var t=it(Math.max(e.manager.getRemainingTime(),0)),i="".concat(Xe("advertisement",e.player.config)," - ").concat(t);e.elements.container.setAttribute("data-badge-text",i)};this.countdownTimer=setInterval(i,100)}},{key:"onAdsManagerLoaded",value:function(e){var t=this;if(this.enabled){var i=new google.ima.AdsRenderingSettings;i.restoreCustomPlaybackStateOnAdBreakComplete=!0,i.enablePreloading=!0,this.manager=e.getAdsManager(this.player,i),this.cuePoints=this.manager.getCuePoints(),this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR,(function(e){return t.onAdError(e)})),Object.keys(google.ima.AdEvent.Type).forEach((function(e){t.manager.addEventListener(google.ima.AdEvent.Type[e],(function(e){return t.onAdEvent(e)}))})),this.trigger("loaded")}}},{key:"addCuePoints",value:function(){var e=this;ae(this.cuePoints)||this.cuePoints.forEach((function(t){if(0!==t&&-1!==t&&t<e.player.duration){var i=e.player.elements.progress;if(G(i)){var n=100/e.player.duration*t,a=he("span",{class:e.player.config.classNames.cues});a.style.left="".concat(n.toString(),"%"),i.appendChild(a)}}}))}},{key:"onAdEvent",value:function(e){var t=this,i=this.player.elements.container,n=e.getAd(),a=e.getAdData();switch(function(e){Oe.call(t.player,t.player.media,"ads".concat(e.replace(/_/g,"").toLowerCase()))}(e.type),e.type){case google.ima.AdEvent.Type.LOADED:this.trigger("loaded"),this.pollCountdown(!0),n.isLinear()||(n.width=i.offsetWidth,n.height=i.offsetHeight);break;case google.ima.AdEvent.Type.STARTED:this.manager.setVolume(this.player.volume);break;case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:this.player.ended?this.loadAds():this.loader.contentComplete();break;case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:this.pauseContent();break;case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:this.pollCountdown(),this.resumeContent();break;case google.ima.AdEvent.Type.LOG:a.adError&&this.player.debug.warn("Non-fatal ad error: ".concat(a.adError.getMessage()))}}},{key:"onAdError",value:function(e){this.cancel(),this.player.debug.warn("Ads error",e)}},{key:"listeners",value:function(){var e,t=this,i=this.player.elements.container;this.player.on("canplay",(function(){t.addCuePoints()})),this.player.on("ended",(function(){t.loader.contentComplete()})),this.player.on("timeupdate",(function(){e=t.player.currentTime})),this.player.on("seeked",(function(){var i=t.player.currentTime;ae(t.cuePoints)||t.cuePoints.forEach((function(n,a){e<n&&n<i&&(t.manager.discardAdBreak(),t.cuePoints.splice(a,1))}))})),window.addEventListener("resize",(function(){t.manager&&t.manager.resize(i.offsetWidth,i.offsetHeight,google.ima.ViewMode.NORMAL)}))}},{key:"play",value:function(){var e=this,t=this.player.elements.container;this.managerPromise||this.resumeContent(),this.managerPromise.then((function(){e.manager.setVolume(e.player.volume),e.elements.displayContainer.initialize();try{e.initialized||(e.manager.init(t.offsetWidth,t.offsetHeight,google.ima.ViewMode.NORMAL),e.manager.start()),e.initialized=!0}catch(t){e.onAdError(t)}})).catch((function(){}))}},{key:"resumeContent",value:function(){this.elements.container.style.zIndex="",this.playing=!1,qe(this.player.media.play())}},{key:"pauseContent",value:function(){this.elements.container.style.zIndex=3,this.playing=!0,this.player.media.pause()}},{key:"cancel",value:function(){this.initialized&&this.resumeContent(),this.trigger("error"),this.loadAds()}},{key:"loadAds",value:function(){var e=this;this.managerPromise.then((function(){e.manager&&e.manager.destroy(),e.managerPromise=new Promise((function(t){e.on("loaded",t),e.player.debug.log(e.manager)})),e.initialized=!1,e.requestAds()})).catch((function(){}))}},{key:"trigger",value:function(e){for(var t=this,i=arguments.length,n=new Array(i>1?i-1:0),a=1;a<i;a++)n[a-1]=arguments[a];var s=this.events[e];$(s)&&s.forEach((function(e){X(e)&&e.apply(t,n)}))}},{key:"on",value:function(e,t){return $(this.events[e])||(this.events[e]=[]),this.events[e].push(t),this}},{key:"startSafetyTimer",value:function(e,t){var i=this;this.player.debug.log("Safety timer invoked from: ".concat(t)),this.safetyTimer=setTimeout((function(){i.cancel(),i.clearSafetyTimer("startSafetyTimer()")}),e)}},{key:"clearSafetyTimer",value:function(e){W(this.safetyTimer)||(this.player.debug.log("Safety timer cleared from: ".concat(e)),clearTimeout(this.safetyTimer),this.safetyTimer=null)}},{key:"enabled",get:function(){var e=this.config;return this.player.isHTML5&&this.player.isVideo&&e.enabled&&(!ae(e.publisherId)||ne(e.tagUrl))}},{key:"tagUrl",get:function(){var e=this.config;if(ne(e.tagUrl))return e.tagUrl;var t={AV_PUBLISHERID:"58c25bb0073ef448b1087ad6",AV_CHANNELID:"5a0458dc28a06145e4519d21",AV_URL:window.location.hostname,cb:Date.now(),AV_WIDTH:640,AV_HEIGHT:480,AV_CDIM2:e.publisherId};return"".concat("https://go.aniview.com/api/adserver6/vast/","?").concat(st(t))}}]),t}(),Nt=function(e,t){var i={};return e>t.width/t.height?(i.width=t.width,i.height=1/e*t.width):(i.height=t.height,i.width=e*t.height),i},Mt=function(){function t(i){e(this,t),this.player=i,this.thumbnails=[],this.loaded=!1,this.lastMouseMoveTime=Date.now(),this.mouseDown=!1,this.loadedImages=[],this.elements={thumb:{},scrubbing:{}},this.load()}return i(t,[{key:"load",value:function(){var e=this;this.player.elements.display.seekTooltip&&(this.player.elements.display.seekTooltip.hidden=this.enabled),this.enabled&&this.getThumbnails().then((function(){e.enabled&&(e.render(),e.determineContainerAutoSizing(),e.loaded=!0)}))}},{key:"getThumbnails",value:function(){var e=this;return new Promise((function(t){var i=e.player.config.previewThumbnails.src;if(ae(i))throw new Error("Missing previewThumbnails.src config attribute");var n=function(){e.thumbnails.sort((function(e,t){return e.height-t.height})),e.player.debug.log("Preview thumbnails",e.thumbnails),t()};if(X(i))i((function(t){e.thumbnails=t,n()}));else{var a=(Y(i)?[i]:i).map((function(t){return e.getThumbnail(t)}));Promise.all(a).then(n)}}))}},{key:"getThumbnail",value:function(e){var t=this;return new Promise((function(i){Je(e).then((function(n){var a,s,r={frames:(a=n,s=[],a.split(/\r\n\r\n|\n\n|\r\r/).forEach((function(e){var t={};e.split(/\r\n|\n|\r/).forEach((function(e){if(K(t.startTime)){if(!ae(e.trim())&&ae(t.text)){var i=e.trim().split("#xywh="),n=o(i,1);if(t.text=n[0],i[1]){var a=o(i[1].split(","),4);t.x=a[0],t.y=a[1],t.w=a[2],t.h=a[3]}}}else{var s=e.match(/([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})( ?--> ?)([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})/);s&&(t.startTime=60*Number(s[1]||0)*60+60*Number(s[2])+Number(s[3])+Number("0.".concat(s[4])),t.endTime=60*Number(s[6]||0)*60+60*Number(s[7])+Number(s[8])+Number("0.".concat(s[9])))}})),t.text&&s.push(t)})),s),height:null,urlPrefix:""};r.frames[0].text.startsWith("/")||r.frames[0].text.startsWith("http://")||r.frames[0].text.startsWith("https://")||(r.urlPrefix=e.substring(0,e.lastIndexOf("/")+1));var l=new Image;l.onload=function(){r.height=l.naturalHeight,r.width=l.naturalWidth,t.thumbnails.push(r),i()},l.src=r.urlPrefix+r.frames[0].text}))}))}},{key:"startMove",value:function(e){if(this.loaded&&Z(e)&&["touchmove","mousemove"].includes(e.type)&&this.player.media.duration){if("touchmove"===e.type)this.seekTime=this.player.media.duration*(this.player.elements.inputs.seek.value/100);else{var t=this.player.elements.progress.getBoundingClientRect(),i=100/t.width*(e.pageX-t.left);this.seekTime=this.player.media.duration*(i/100),this.seekTime<0&&(this.seekTime=0),this.seekTime>this.player.media.duration-1&&(this.seekTime=this.player.media.duration-1),this.mousePosX=e.pageX,this.elements.thumb.time.innerText=it(this.seekTime)}this.showImageAtCurrentTime()}}},{key:"endMove",value:function(){this.toggleThumbContainer(!1,!0)}},{key:"startScrubbing",value:function(e){(W(e.button)||!1===e.button||0===e.button)&&(this.mouseDown=!0,this.player.media.duration&&(this.toggleScrubbingContainer(!0),this.toggleThumbContainer(!1,!0),this.showImageAtCurrentTime()))}},{key:"endScrubbing",value:function(){var e=this;this.mouseDown=!1,Math.ceil(this.lastTime)===Math.ceil(this.player.media.currentTime)?this.toggleScrubbingContainer(!1):Le.call(this.player,this.player.media,"timeupdate",(function(){e.mouseDown||e.toggleScrubbingContainer(!1)}))}},{key:"listeners",value:function(){var e=this;this.player.on("play",(function(){e.toggleThumbContainer(!1,!0)})),this.player.on("seeked",(function(){e.toggleThumbContainer(!1)})),this.player.on("timeupdate",(function(){e.lastTime=e.player.media.currentTime}))}},{key:"render",value:function(){this.elements.thumb.container=he("div",{class:this.player.config.classNames.previewThumbnails.thumbContainer}),this.elements.thumb.imageContainer=he("div",{class:this.player.config.classNames.previewThumbnails.imageContainer}),this.elements.thumb.container.appendChild(this.elements.thumb.imageContainer);var e=he("div",{class:this.player.config.classNames.previewThumbnails.timeContainer});this.elements.thumb.time=he("span",{},"00:00"),e.appendChild(this.elements.thumb.time),this.elements.thumb.container.appendChild(e),G(this.player.elements.progress)&&this.player.elements.progress.appendChild(this.elements.thumb.container),this.elements.scrubbing.container=he("div",{class:this.player.config.classNames.previewThumbnails.scrubbingContainer}),this.player.elements.wrapper.appendChild(this.elements.scrubbing.container)}},{key:"destroy",value:function(){this.elements.thumb.container&&this.elements.thumb.container.remove(),this.elements.scrubbing.container&&this.elements.scrubbing.container.remove()}},{key:"showImageAtCurrentTime",value:function(){var e=this;this.mouseDown?this.setScrubbingContainerSize():this.setThumbContainerSizeAndPos();var t=this.thumbnails[0].frames.findIndex((function(t){return e.seekTime>=t.startTime&&e.seekTime<=t.endTime})),i=t>=0,n=0;this.mouseDown||this.toggleThumbContainer(i),i&&(this.thumbnails.forEach((function(i,a){e.loadedImages.includes(i.frames[t].text)&&(n=a)})),t!==this.showingThumb&&(this.showingThumb=t,this.loadImage(n)))}},{key:"loadImage",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,i=this.showingThumb,n=this.thumbnails[t],a=n.urlPrefix,s=n.frames[i],r=n.frames[i].text,o=a+r;if(this.currentImageElement&&this.currentImageElement.dataset.filename===r)this.showImage(this.currentImageElement,s,t,i,r,!1),this.currentImageElement.dataset.index=i,this.removeOldImages(this.currentImageElement);else{this.loadingImage&&this.usingSprites&&(this.loadingImage.onload=null);var l=new Image;l.src=o,l.dataset.index=i,l.dataset.filename=r,this.showingThumbFilename=r,this.player.debug.log("Loading image: ".concat(o)),l.onload=function(){return e.showImage(l,s,t,i,r,!0)},this.loadingImage=l,this.removeOldImages(l)}}},{key:"showImage",value:function(e,t,i,n,a){var s=!(arguments.length>5&&void 0!==arguments[5])||arguments[5];this.player.debug.log("Showing thumb: ".concat(a,". num: ").concat(n,". qual: ").concat(i,". newimg: ").concat(s)),this.setImageSizeAndOffset(e,t),s&&(this.currentImageContainer.appendChild(e),this.currentImageElement=e,this.loadedImages.includes(a)||this.loadedImages.push(a)),this.preloadNearby(n,!0).then(this.preloadNearby(n,!1)).then(this.getHigherQuality(i,e,t,a))}},{key:"removeOldImages",value:function(e){var t=this;Array.from(this.currentImageContainer.children).forEach((function(i){if("img"===i.tagName.toLowerCase()){var n=t.usingSprites?500:1e3;if(i.dataset.index!==e.dataset.index&&!i.dataset.deleting){i.dataset.deleting=!0;var a=t.currentImageContainer;setTimeout((function(){a.removeChild(i),t.player.debug.log("Removing thumb: ".concat(i.dataset.filename))}),n)}}}))}},{key:"preloadNearby",value:function(e){var t=this,i=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return new Promise((function(n){setTimeout((function(){var a=t.thumbnails[0].frames[e].text;if(t.showingThumbFilename===a){var s;s=i?t.thumbnails[0].frames.slice(e):t.thumbnails[0].frames.slice(0,e).reverse();var r=!1;s.forEach((function(e){var i=e.text;if(i!==a&&!t.loadedImages.includes(i)){r=!0,t.player.debug.log("Preloading thumb filename: ".concat(i));var s=t.thumbnails[0].urlPrefix+i,o=new Image;o.src=s,o.onload=function(){t.player.debug.log("Preloaded thumb filename: ".concat(i)),t.loadedImages.includes(i)||t.loadedImages.push(i),n()}}})),r||n()}}),300)}))}},{key:"getHigherQuality",value:function(e,t,i,n){var a=this;if(e<this.thumbnails.length-1){var s=t.naturalHeight;this.usingSprites&&(s=i.h),s<this.thumbContainerHeight&&setTimeout((function(){a.showingThumbFilename===n&&(a.player.debug.log("Showing higher quality thumb for: ".concat(n)),a.loadImage(e+1))}),300)}}},{key:"toggleThumbContainer",value:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=this.player.config.classNames.previewThumbnails.thumbContainerShown;this.elements.thumb.container.classList.toggle(i,e),!e&&t&&(this.showingThumb=null,this.showingThumbFilename=null)}},{key:"toggleScrubbingContainer",value:function(){var e=arguments.length>0&&void 0!==arguments[0]&&arguments[0],t=this.player.config.classNames.previewThumbnails.scrubbingContainerShown;this.elements.scrubbing.container.classList.toggle(t,e),e||(this.showingThumb=null,this.showingThumbFilename=null)}},{key:"determineContainerAutoSizing",value:function(){(this.elements.thumb.imageContainer.clientHeight>20||this.elements.thumb.imageContainer.clientWidth>20)&&(this.sizeSpecifiedInCSS=!0)}},{key:"setThumbContainerSizeAndPos",value:function(){if(this.sizeSpecifiedInCSS){if(this.elements.thumb.imageContainer.clientHeight>20&&this.elements.thumb.imageContainer.clientWidth<20){var e=Math.floor(this.elements.thumb.imageContainer.clientHeight*this.thumbAspectRatio);this.elements.thumb.imageContainer.style.width="".concat(e,"px")}else if(this.elements.thumb.imageContainer.clientHeight<20&&this.elements.thumb.imageContainer.clientWidth>20){var t=Math.floor(this.elements.thumb.imageContainer.clientWidth/this.thumbAspectRatio);this.elements.thumb.imageContainer.style.height="".concat(t,"px")}}else{var i=Math.floor(this.thumbContainerHeight*this.thumbAspectRatio);this.elements.thumb.imageContainer.style.height="".concat(this.thumbContainerHeight,"px"),this.elements.thumb.imageContainer.style.width="".concat(i,"px")}this.setThumbContainerPos()}},{key:"setThumbContainerPos",value:function(){var e=this.player.elements.progress.getBoundingClientRect(),t=this.player.elements.container.getBoundingClientRect(),i=this.elements.thumb.container,n=t.left-e.left+10,a=t.right-e.left-i.clientWidth-10,s=this.mousePosX-e.left-i.clientWidth/2;s<n&&(s=n),s>a&&(s=a),i.style.left="".concat(s,"px")}},{key:"setScrubbingContainerSize",value:function(){var e=Nt(this.thumbAspectRatio,{width:this.player.media.clientWidth,height:this.player.media.clientHeight}),t=e.width,i=e.height;this.elements.scrubbing.container.style.width="".concat(t,"px"),this.elements.scrubbing.container.style.height="".concat(i,"px")}},{key:"setImageSizeAndOffset",value:function(e,t){if(this.usingSprites){var i=this.thumbContainerHeight/t.h;e.style.height="".concat(e.naturalHeight*i,"px"),e.style.width="".concat(e.naturalWidth*i,"px"),e.style.left="-".concat(t.x*i,"px"),e.style.top="-".concat(t.y*i,"px")}}},{key:"enabled",get:function(){return this.player.isHTML5&&this.player.isVideo&&this.player.config.previewThumbnails.enabled}},{key:"currentImageContainer",get:function(){return this.mouseDown?this.elements.scrubbing.container:this.elements.thumb.imageContainer}},{key:"usingSprites",get:function(){return Object.keys(this.thumbnails[0].frames[0]).includes("w")}},{key:"thumbAspectRatio",get:function(){return this.usingSprites?this.thumbnails[0].frames[0].w/this.thumbnails[0].frames[0].h:this.thumbnails[0].width/this.thumbnails[0].height}},{key:"thumbContainerHeight",get:function(){return this.mouseDown?Nt(this.thumbAspectRatio,{width:this.player.media.clientWidth,height:this.player.media.clientHeight}).height:this.sizeSpecifiedInCSS?this.elements.thumb.imageContainer.clientHeight:Math.floor(this.player.media.clientWidth/this.thumbAspectRatio/4)}},{key:"currentImageElement",get:function(){return this.mouseDown?this.currentScrubbingImageElement:this.currentThumbnailImageElement},set:function(e){this.mouseDown?this.currentScrubbingImageElement=e:this.currentThumbnailImageElement=e}}]),t}(),xt={insertElements:function(e,t){var i=this;Y(t)?pe(e,this.media,{src:t}):$(t)&&t.forEach((function(t){pe(e,i.media,t)}))},change:function(e){var t=this;le(e,"sources.length")?(Ve.cancelRequests.call(this),this.destroy.call(this,(function(){t.options.quality=[],me(t.media),t.media=null,G(t.elements.container)&&t.elements.container.removeAttribute("class");var i=e.sources,n=e.type,a=o(i,1)[0],s=a.provider,r=void 0===s?ut.html5:s,l=a.src,c="html5"===r?n:"div",u="html5"===r?{}:{src:l};Object.assign(t,{provider:r,type:n,supported:Ee.check(n,r,t.config.playsinline),media:he(c,u)}),t.elements.container.appendChild(t.media),Q(e.autoplay)&&(t.config.autoplay=e.autoplay),t.isHTML5&&(t.config.crossorigin&&t.media.setAttribute("crossorigin",""),t.config.autoplay&&t.media.setAttribute("autoplay",""),ae(e.poster)||(t.poster=e.poster),t.config.loop.active&&t.media.setAttribute("loop",""),t.config.muted&&t.media.setAttribute("muted",""),t.config.playsinline&&t.media.setAttribute("playsinline","")),yt.addStyleHook.call(t),t.isHTML5&&xt.insertElements.call(t,"source",i),t.config.title=e.title,Pt.setup.call(t),t.isHTML5&&Object.keys(e).includes("tracks")&&xt.insertElements.call(t,"track",e.tracks),(t.isHTML5||t.isEmbed&&!t.supported.ui)&&yt.build.call(t),t.isHTML5&&t.media.load(),ae(e.previewThumbnails)||(Object.assign(t.config.previewThumbnails,e.previewThumbnails),t.previewThumbnails&&t.previewThumbnails.loaded&&(t.previewThumbnails.destroy(),t.previewThumbnails=null),t.config.previewThumbnails.enabled&&(t.previewThumbnails=new Mt(t))),t.fullscreen.update()}),!0)):this.debug.warn("Invalid source format")}};var It,Lt=function(){function t(i,n){var a=this;if(e(this,t),this.timers={},this.ready=!1,this.loading=!1,this.failed=!1,this.touch=Ee.touch,this.media=i,Y(this.media)&&(this.media=document.querySelectorAll(this.media)),(window.jQuery&&this.media instanceof jQuery||J(this.media)||$(this.media))&&(this.media=this.media[0]),this.config=ce({},ot,t.defaults,n||{},function(){try{return JSON.parse(a.media.getAttribute("data-plyr-config"))}catch(e){return{}}}()),this.elements={container:null,fullscreen:null,captions:null,buttons:{},display:{},progress:{},inputs:{},settings:{popup:null,menu:null,panels:{},buttons:{}}},this.captions={active:null,currentTrack:-1,meta:new WeakMap},this.fullscreen={active:!1},this.options={speed:[],quality:[]},this.debug=new mt(this.config.debug),this.debug.log("Config",this.config),this.debug.log("Support",Ee),!W(this.media)&&G(this.media))if(this.media.plyr)this.debug.warn("Target already setup");else if(this.config.enabled)if(Ee.check().api){var s=this.media.cloneNode(!0);s.autoplay=!1,this.elements.original=s;var r=this.media.tagName.toLowerCase(),o=null,l=null;switch(r){case"div":if(o=this.media.querySelector("iframe"),G(o)){if(l=at(o.getAttribute("src")),this.provider=function(e){return/^(https?:\/\/)?(www\.)?(youtube\.com|youtube-nocookie\.com|youtu\.?be)\/.+$/.test(e)?ut.youtube:/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(e)?ut.vimeo:null}(l.toString()),this.elements.container=this.media,this.media=o,this.elements.container.className="",l.search.length){var c=["1","true"];c.includes(l.searchParams.get("autoplay"))&&(this.config.autoplay=!0),c.includes(l.searchParams.get("loop"))&&(this.config.loop.active=!0),this.isYouTube?(this.config.playsinline=c.includes(l.searchParams.get("playsinline")),this.config.youtube.hl=l.searchParams.get("hl")):this.config.playsinline=!0}}else this.provider=this.media.getAttribute(this.config.attributes.embed.provider),this.media.removeAttribute(this.config.attributes.embed.provider);if(ae(this.provider)||!Object.keys(ut).includes(this.provider))return void this.debug.error("Setup failed: Invalid provider");this.type=ht;break;case"video":case"audio":this.type=r,this.provider=ut.html5,this.media.hasAttribute("crossorigin")&&(this.config.crossorigin=!0),this.media.hasAttribute("autoplay")&&(this.config.autoplay=!0),(this.media.hasAttribute("playsinline")||this.media.hasAttribute("webkit-playsinline"))&&(this.config.playsinline=!0),this.media.hasAttribute("muted")&&(this.config.muted=!0),this.media.hasAttribute("loop")&&(this.config.loop.active=!0);break;default:return void this.debug.error("Setup failed: unsupported type")}this.supported=Ee.check(this.type,this.provider,this.config.playsinline),this.supported.api?(this.eventListeners=[],this.listeners=new vt(this),this.storage=new $e(this),this.media.plyr=this,G(this.elements.container)||(this.elements.container=he("div",{tabindex:0}),ue(this.media,this.elements.container)),yt.migrateStyles.call(this),yt.addStyleHook.call(this),Pt.setup.call(this),this.config.debug&&xe.call(this,this.elements.container,this.config.events.join(" "),(function(e){a.debug.log("event: ".concat(e.type))})),this.fullscreen=new ft(this),(this.isHTML5||this.isEmbed&&!this.supported.ui)&&yt.build.call(this),this.listeners.container(),this.listeners.global(),this.config.ads.enabled&&(this.ads=new Et(this)),this.isHTML5&&this.config.autoplay&&setTimeout((function(){return qe(a.play())}),10),this.lastSeekTime=0,this.config.previewThumbnails.enabled&&(this.previewThumbnails=new Mt(this))):this.debug.error("Setup failed: no support")}else this.debug.error("Setup failed: no support");else this.debug.error("Setup failed: disabled by config");else this.debug.error("Setup failed: no suitable element passed")}return i(t,[{key:"play",value:function(){var e=this;return X(this.media.play)?(this.ads&&this.ads.enabled&&this.ads.managerPromise.then((function(){return e.ads.play()})).catch((function(){return qe(e.media.play())})),this.media.play()):null}},{key:"pause",value:function(){return this.playing&&X(this.media.pause)?this.media.pause():null}},{key:"togglePlay",value:function(e){return(Q(e)?e:!this.playing)?this.play():this.pause()}},{key:"stop",value:function(){this.isHTML5?(this.pause(),this.restart()):X(this.media.stop)&&this.media.stop()}},{key:"restart",value:function(){this.currentTime=0}},{key:"rewind",value:function(e){this.currentTime-=K(e)?e:this.config.seekTime}},{key:"forward",value:function(e){this.currentTime+=K(e)?e:this.config.seekTime}},{key:"increaseVolume",value:function(e){var t=this.media.muted?0:this.volume;this.volume=t+(K(e)?e:0)}},{key:"decreaseVolume",value:function(e){this.increaseVolume(-e)}},{key:"toggleCaptions",value:function(e){rt.toggle.call(this,e,!1)}},{key:"airplay",value:function(){Ee.airplay&&this.media.webkitShowPlaybackTargetPicker()}},{key:"toggleControls",value:function(e){if(this.supported.ui&&!this.isAudio){var t=we(this.elements.container,this.config.classNames.hideControls),i=void 0===e?void 0:!e,n=be(this.elements.container,this.config.classNames.hideControls,i);if(n&&$(this.config.controls)&&this.config.controls.includes("settings")&&!ae(this.config.settings)&&nt.toggleMenu.call(this,!1),n!==t){var a=n?"controlshidden":"controlsshown";Oe.call(this,this.media,a)}return!n}return!1}},{key:"on",value:function(e,t){xe.call(this,this.elements.container,e,t)}},{key:"once",value:function(e,t){Le.call(this,this.elements.container,e,t)}},{key:"off",value:function(e,t){Ie(this.elements.container,e,t)}},{key:"destroy",value:function(e){var t=this,i=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(this.ready){var n=function(){document.body.style.overflow="",t.embed=null,i?(Object.keys(t.elements).length&&(me(t.elements.buttons.play),me(t.elements.captions),me(t.elements.controls),me(t.elements.wrapper),t.elements.buttons.play=null,t.elements.captions=null,t.elements.controls=null,t.elements.wrapper=null),X(e)&&e()):(_e.call(t),ge(t.elements.original,t.elements.container),Oe.call(t,t.elements.original,"destroyed",!0),X(e)&&e.call(t.elements.original),t.ready=!1,setTimeout((function(){t.elements=null,t.media=null}),200))};this.stop(),clearTimeout(this.timers.loading),clearTimeout(this.timers.controls),clearTimeout(this.timers.resized),this.isHTML5?(yt.toggleNativeControls.call(this,!0),n()):this.isYouTube?(clearInterval(this.timers.buffering),clearInterval(this.timers.playing),null!==this.embed&&X(this.embed.destroy)&&this.embed.destroy(),n()):this.isVimeo&&(null!==this.embed&&this.embed.unload().then(n),setTimeout(n,200))}}},{key:"supports",value:function(e){return Ee.mime.call(this,e)}},{key:"isHTML5",get:function(){return this.provider===ut.html5}},{key:"isEmbed",get:function(){return this.isYouTube||this.isVimeo}},{key:"isYouTube",get:function(){return this.provider===ut.youtube}},{key:"isVimeo",get:function(){return this.provider===ut.vimeo}},{key:"isVideo",get:function(){return this.type===ht}},{key:"isAudio",get:function(){return this.type===dt}},{key:"playing",get:function(){return Boolean(this.ready&&!this.paused&&!this.ended)}},{key:"paused",get:function(){return Boolean(this.media.paused)}},{key:"stopped",get:function(){return Boolean(this.paused&&0===this.currentTime)}},{key:"ended",get:function(){return Boolean(this.media.ended)}},{key:"currentTime",set:function(e){if(this.duration){var t=K(e)&&e>0;this.media.currentTime=t?Math.min(e,this.duration):0,this.debug.log("Seeking to ".concat(this.currentTime," seconds"))}},get:function(){return Number(this.media.currentTime)}},{key:"buffered",get:function(){var e=this.media.buffered;return K(e)?e:e&&e.length&&this.duration>0?e.end(0)/this.duration:0}},{key:"seeking",get:function(){return Boolean(this.media.seeking)}},{key:"duration",get:function(){var e=parseFloat(this.config.duration),t=(this.media||{}).duration,i=K(t)&&t!==1/0?t:0;return e||i}},{key:"volume",set:function(e){var t=e;Y(t)&&(t=Number(t)),K(t)||(t=this.storage.get("volume")),K(t)||(t=this.config.volume),t>1&&(t=1),t<0&&(t=0),this.config.volume=t,this.media.volume=t,!ae(e)&&this.muted&&t>0&&(this.muted=!1)},get:function(){return Number(this.media.volume)}},{key:"muted",set:function(e){var t=e;Q(t)||(t=this.storage.get("muted")),Q(t)||(t=this.config.muted),this.config.muted=t,this.media.muted=t},get:function(){return Boolean(this.media.muted)}},{key:"hasAudio",get:function(){return!this.isHTML5||(!!this.isAudio||(Boolean(this.media.mozHasAudio)||Boolean(this.media.webkitAudioDecodedByteCount)||Boolean(this.media.audioTracks&&this.media.audioTracks.length)))}},{key:"speed",set:function(e){var t=this,i=null;K(e)&&(i=e),K(i)||(i=this.storage.get("speed")),K(i)||(i=this.config.speed.selected);var n=this.minimumSpeed,a=this.maximumSpeed;i=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,i=arguments.length>2&&void 0!==arguments[2]?arguments[2]:255;return Math.min(Math.max(e,t),i)}(i,n,a),this.config.speed.selected=i,setTimeout((function(){t.media.playbackRate=i}),0)},get:function(){return Number(this.media.playbackRate)}},{key:"minimumSpeed",get:function(){return this.isYouTube?Math.min.apply(Math,l(this.options.speed)):this.isVimeo?.5:.0625}},{key:"maximumSpeed",get:function(){return this.isYouTube?Math.max.apply(Math,l(this.options.speed)):this.isVimeo?2:16}},{key:"quality",set:function(e){var t=this.config.quality,i=this.options.quality;if(i.length){var n=[!ae(e)&&Number(e),this.storage.get("quality"),t.selected,t.default].find(K),a=!0;if(!i.includes(n)){var s=function(e,t){return $(e)&&e.length?e.reduce((function(e,i){return Math.abs(i-t)<Math.abs(e-t)?i:e})):null}(i,n);this.debug.warn("Unsupported quality option: ".concat(n,", using ").concat(s," instead")),n=s,a=!1}t.selected=n,this.media.quality=n,a&&this.storage.set({quality:n})}},get:function(){return this.media.quality}},{key:"loop",set:function(e){var t=Q(e)?e:this.config.loop.active;this.config.loop.active=t,this.media.loop=t},get:function(){return Boolean(this.media.loop)}},{key:"source",set:function(e){xt.change.call(this,e)},get:function(){return this.media.currentSrc}},{key:"download",get:function(){var e=this.config.urls.download;return ne(e)?e:this.source},set:function(e){ne(e)&&(this.config.urls.download=e,nt.setDownloadUrl.call(this))}},{key:"poster",set:function(e){this.isVideo?yt.setPoster.call(this,e,!1).catch((function(){})):this.debug.warn("Poster can only be set for video")},get:function(){return this.isVideo?this.media.getAttribute("poster")||this.media.getAttribute("data-poster"):null}},{key:"ratio",get:function(){if(!this.isVideo)return null;var e=He(Fe.call(this));return $(e)?e.join(":"):e},set:function(e){this.isVideo?Y(e)&&De(e)?(this.config.ratio=e,Re.call(this)):this.debug.error("Invalid aspect ratio specified (".concat(e,")")):this.debug.warn("Aspect ratio can only be set for video")}},{key:"autoplay",set:function(e){var t=Q(e)?e:this.config.autoplay;this.config.autoplay=t},get:function(){return Boolean(this.config.autoplay)}},{key:"currentTrack",set:function(e){rt.set.call(this,e,!1)},get:function(){var e=this.captions,t=e.toggled,i=e.currentTrack;return t?i:-1}},{key:"language",set:function(e){rt.setLanguage.call(this,e,!1)},get:function(){return(rt.getCurrentTrack.call(this)||{}).language}},{key:"pip",set:function(e){if(Ee.pip){var t=Q(e)?e:!this.pip;X(this.media.webkitSetPresentationMode)&&this.media.webkitSetPresentationMode(t?lt:ct),X(this.media.requestPictureInPicture)&&(!this.pip&&t?this.media.requestPictureInPicture():this.pip&&!t&&document.exitPictureInPicture())}},get:function(){return Ee.pip?ae(this.media.webkitPresentationMode)?this.media===document.pictureInPictureElement:this.media.webkitPresentationMode===lt:null}}],[{key:"supported",value:function(e,t,i){return Ee.check(e,t,i)}},{key:"loadSprite",value:function(e,t){return Ge(e,t)}},{key:"setup",value:function(e){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=null;return Y(e)?n=Array.from(document.querySelectorAll(e)):J(e)?n=Array.from(e):$(e)&&(n=e.filter(G)),ae(n)?null:n.map((function(e){return new t(e,i)}))}}]),t}();return Lt.defaults=(It=ot,JSON.parse(JSON.stringify(It))),Lt}));
+//# sourceMappingURL=plyr.min.js.map
diff --git a/youtube/static/modules/plyr/plyr.min.js.map b/youtube/static/modules/plyr/plyr.min.js.map
new file mode 100644
index 0000000..4dcaff5
--- /dev/null
+++ b/youtube/static/modules/plyr/plyr.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["plyr.js","node_modules/.pnpm/rangetouch@2.0.1/node_modules/rangetouch/dist/rangetouch.mjs","src/js/utils/is.js","src/js/utils/animation.js","src/js/utils/browser.js","src/js/utils/objects.js","src/js/utils/elements.js","src/js/support.js","src/js/utils/events.js","src/js/utils/promise.js","src/js/utils/arrays.js","src/js/utils/style.js","src/js/html5.js","src/js/utils/strings.js","src/js/utils/i18n.js","src/js/storage.js","src/js/utils/fetch.js","src/js/utils/load-sprite.js","src/js/utils/time.js","src/js/controls.js","src/js/utils/urls.js","src/js/captions.js","src/js/config/defaults.js","src/js/config/states.js","src/js/config/types.js","src/js/console.js","src/js/fullscreen.js","src/js/utils/load-image.js","src/js/ui.js","src/js/listeners.js","node_modules/.pnpm/loadjs@4.2.0/node_modules/loadjs/dist/loadjs.umd.js","src/js/utils/load-script.js","src/js/plugins/vimeo.js","src/js/plugins/youtube.js","src/js/media.js","src/js/plugins/ads.js","src/js/utils/numbers.js","src/js/plugins/preview-thumbnails.js","src/js/source.js","src/js/plyr.js"],"names":["navigator","global","factory","exports","module","define","amd","globalThis","self","Plyr","this","_defineProperty$1","obj","key","value","arg","input","hint","prim","Symbol","toPrimitive","undefined","res","call","TypeError","String","Number","_toPrimitive","_toPropertyKey","Object","defineProperty","enumerable","configurable","writable","_defineProperties","e","t","n","length","r","_defineProperty","ownKeys","keys","getOwnPropertySymbols","filter","getOwnPropertyDescriptor","push","apply","_objectSpread2","arguments","forEach","getOwnPropertyDescriptors","defineProperties","defaults","addCSS","thumbWidth","watch","getConstructor","constructor","instanceOf","isNullOrUndefined","isObject","isString","isArray","Array","isNodeList","NodeList","is","nullOrUndefined","object","number","isNaN","string","boolean","Boolean","function","Function","array","nodeList","element","Element","event","Event","empty","round","concat","match","Math","max","getDecimalPlaces","parseFloat","toFixed","RangeTouch","_classCallCheck","document","querySelector","rangeTouch","config","init","prototype","_createClass","enabled","style","userSelect","webKitUserSelect","touchAction","listeners","set","target","i","changedTouches","o","getAttribute","s","u","c","getBoundingClientRect","a","width","clientX","left","disabled","preventDefault","get","bubbles","dispatchEvent","trigger","type","from","querySelectorAll","MutationObserver","addedNodes","includes","matches","observe","body","childList","subtree","map","documentElement","isFunction","isEmpty","weakMap","WeakMap","nodeType","ownerDocument","textNode","Text","keyboardEvent","KeyboardEvent","cue","window","TextTrackCue","VTTCue","track","TextTrack","kind","promise","Promise","then","url","URL","startsWith","hostname","_","transitionEndEvent","createElement","events","WebkitTransition","MozTransition","OTransition","transition","find","repaint","delay","setTimeout","hidden","offsetHeight","browser","isIE","documentMode","isEdge","test","userAgent","isWebKit","isIPhone","maxTouchPoints","isIPadOS","platform","isIos","getDeep","path","split","reduce","extend","sources","source","shift","assign","wrap","elements","wrapper","targets","reverse","index","child","cloneNode","parent","parentNode","sibling","nextSibling","appendChild","insertBefore","setAttributes","attributes","entries","setAttribute","text","innerText","insertElement","removeElement","removeChild","emptyElement","childNodes","lastChild","replaceElement","newChild","oldChild","replaceChild","getAttributesFromSelector","sel","existingAttributes","existing","selector","trim","className","replace","parts","charAt","class","id","toggleHidden","hide","toggleClass","force","method","classList","contains","hasClass","webkitMatchesSelector","mozMatchesSelector","msMatchesSelector","getElements","container","getElement","setFocus","focusVisible","focus","preventScroll","defaultCodecs","support","audio","video","check","provider","api","ui","rangeInput","pip","webkitSetPresentationMode","pictureInPictureEnabled","disablePictureInPicture","airplay","WebKitPlaybackTargetAvailabilityEvent","playsinline","mime","mediaType","isHTML5","media","canPlayType","textTracks","range","touch","transitions","reducedMotion","matchMedia","supportsPassiveListeners","supported","options","addEventListener","removeEventListener","toggleListener","callback","toggle","passive","capture","eventListeners","on","off","once","onceCallback","args","triggerEvent","detail","CustomEvent","plyr","unbindListeners","item","ready","resolve","silencePromise","dedupe","indexOf","closest","prev","curr","abs","supportsCSS","declaration","CSS","supports","standardRatios","out","x","y","validateAspectRatio","every","reduceAspectRatio","ratio","height","getDivider","w","h","divider","getAspectRatio","parse","embed","videoWidth","videoHeight","setAspectRatio","isVideo","padding","aspectRatio","paddingBottom","isVimeo","vimeo","premium","offsetWidth","parseInt","getComputedStyle","offset","fullscreen","active","transform","add","classNames","videoFixedRatio","roundAspectRatio","tolerance","closestRatio","html5","getSources","getQualityOptions","quality","forced","setup","player","speed","onChange","currentTime","paused","preload","readyState","playbackRate","src","play","load","cancelRequests","blankVideo","debug","log","format","toString","replaceAll","RegExp","toTitleCase","toUpperCase","slice","toLowerCase","toCamelCase","toPascalCase","getHTML","innerHTML","resources","youtube","i18n","seekTime","title","k","v","Storage","store","localStorage","getItem","json","JSON","storage","setItem","stringify","removeItem","fetch","responseType","reject","request","XMLHttpRequest","responseText","response","Error","status","open","send","error","loadSprite","prefix","hasId","isCached","exists","getElementById","update","data","insertAdjacentElement","useStorage","cached","content","result","catch","getHours","trunc","getMinutes","getSeconds","formatTime","time","displayHours","inverted","hours","mins","secs","controls","getIconUrl","iconUrl","location","host","top","cors","svg4everybody","findElements","selectors","buttons","pause","restart","rewind","fastForward","mute","settings","captions","progress","inputs","seek","volume","display","buffer","duration","seekTooltip","tooltip","warn","toggleNativeControls","createIcon","namespace","iconPath","iconPrefix","icon","createElementNS","focusable","use","setAttributeNS","createLabel","attr","join","createBadge","badge","menu","createButton","buttonType","props","label","labelPressed","iconPressed","some","control","button","createRange","min","step","autocomplete","role","updateRangeFill","createProgress","suffixKey","played","suffix","createTime","attrs","bindMenuItemShortcuts","menuItem","stopPropagation","isRadioButton","showMenuPanel","nextElementSibling","firstElementChild","previousElementSibling","lastElementChild","focusFirstMenuItem","createMenuItem","list","checked","flex","children","node","bind","currentTrack","updateTimeDisplay","updateVolume","setRange","muted","pressed","updateProgress","setProgress","val","getElementsByTagName","nodeValue","current","buffered","percent","setProperty","updateSeekTooltip","_this$config$markers","_this$config$markers$","tooltips","tipElement","visible","show","clientRect","pageX","point","markers","points","insertAdjacentHTML","timeUpdate","invert","invertTime","seeking","durationUpdate","hasDuration","displayDuration","setMarkers","toggleMenuButton","setting","updateSetting","pane","panels","default","getLabel","setQualityMenu","checkMenu","getBadge","sort","b","sorting","setCaptionsMenu","tracks","getTracks","toggled","language","unshift","setSpeedMenu","minimumSpeed","maximumSpeed","values","popup","p","firstItem","toggleMenu","composedPath","isMenuItem","getMenuSize","tab","clone","position","opacity","removeAttribute","scrollWidth","scrollHeight","size","restore","propertyName","setDownloadUrl","download","create","defaultAttributes","progressContainer","inner","home","backButton","href","urls","isEmbed","inject","floor","random","seektime","addProperty","controlPressed","labels","setMediaMetadata","mediaSession","metadata","MediaMetadata","mediaMetadata","artist","album","artwork","_this$config$markers2","_this$config$markers3","containerFragment","createDocumentFragment","pointsFragment","tipVisible","toggleTip","markerElement","marker","tip","parseUrl","safe","parser","buildUrlParams","params","URLSearchParams","isYouTube","protocol","blob","createObjectURL","languages","userLanguage","trackEvents","meta","currentTrackNode","languageExists","mode","updateCues","setLanguage","activeClass","findTrack","enableTextTrack","has","sortIsDefault","sorted","getCurrentTrack","cues","activeCues","getCueAsHTML","cueText","caption","autoplay","autopause","toggleInvert","clickToPlay","hideControls","resetOnEnd","disableContextMenu","loop","selected","keyboard","focused","fallback","iosNative","seekLabel","unmute","enableCaptions","disableCaptions","enterFullscreen","exitFullscreen","frameTitle","menuBack","normal","start","end","all","reset","advertisement","qualityBadge","sdk","iframe","googleIMA","editable","embedContainer","poster","posterEnabled","ads","playing","stopped","loading","hover","isTouch","uiSupported","noTransition","previewThumbnails","thumbContainer","thumbContainerShown","imageContainer","timeContainer","scrubbingContainer","scrubbingContainerShown","hash","publisherId","tagUrl","byline","portrait","transparent","customControls","referrerPolicy","rel","showinfo","iv_load_policy","modestbranding","noCookie","providers","types","noop","Console","console","Fullscreen","scrollPosition","scrollX","scrollY","scrollTo","overflow","viewport","head","property","hasProperty","cleanupViewport","part","activeElement","first","last","shiftKey","forceFallback","nativeSupported","requestFullscreen","webkitEnterFullscreen","toggleFallback","navigationUI","action","cancelFullScreen","exit","enter","el","parentElement","proxy","trapFocus","fullscreenEnabled","webkitFullscreenEnabled","mozFullScreenEnabled","msFullscreenEnabled","useNative","pre","getRootNode","fullscreenElement","shadowRoot","loadImage","minWidth","image","Image","handler","onload","onerror","naturalWidth","addStyleHook","build","checkPlaying","setTitle","setPoster","togglePoster","enable","backgroundImage","backgroundSize","toggleControls","checkLoading","clearTimeout","timers","controlsElement","recentTouchSeek","lastSeekTime","Date","now","migrateStyles","getPropertyValue","removeProperty","Listeners","handleKey","firstTouch","setGutter","useNativeAspectRatio","maxWidth","margin","viewportWidth","viewportHeight","clientWidth","innerWidth","clientHeight","innerHeight","resized","isAudio","ended","togglePlay","proxyEvents","defaultHandler","customHandlerKey","customHandler","returned","hasCustomHandler","inputEvent","forward","toggleCaptions","rect","currentTarget","attribute","hasAttribute","done","seekTo","loaded","startMove","endMove","startScrubbing","endScrubbing","webkitDirectionInvertedFromDevice","deltaX","deltaY","direction","sign","increaseVolume","lastKey","focusTimer","lastKeyDown","altKey","ctrlKey","metaKey","repeat","increment","decreaseVolume","usingNative","loadjs_umd","fn","createCommonjsModule","devnull","bundleIdCache","bundleResultCache","bundleCallbackQueue","subscribe","bundleIds","callbackFn","bundleId","depsNotFound","numWaiting","pathsNotFound","publish","q","splice","executeCallbacks","success","loadFile","numTries","isLegacyIECss","doc","async","maxTries","numRetries","beforeCallbackFn","before","pathname","pathStripped","relList","as","onbeforeload","ev","sheet","cssText","code","defaultPrevented","loadFiles","paths","loadjs","arg1","arg2","loadFn","returnPromise","deps","isDefined","loadScript","assurePlaybackState","hasPlayed","Vimeo","frameParams","found","parseHash","hashParam","sidedock","gesture","$2","thumbnail_url","Player","disableTextTrack","stop","restorePause","setVolume","setCurrentTime","setPlaybackRate","setMuted","currentSrc","setLoop","getVideoUrl","getVideoWidth","getVideoHeight","dimensions","setAutopause","state","getVideoTitle","getCurrentTime","getDuration","getTextTracks","strippedCues","fragment","firstChild","stripHTML","getPaused","seconds","getHost","YT","onYouTubeIframeAPIReady","getTitle","videoId","currentId","posterSrc","playerVars","hl","disablekb","cc_load_policy","cc_lang_pref","widget_referrer","onError","message","onPlaybackRateChange","instance","getPlaybackRate","onReady","playVideo","pauseVideo","stopVideo","speeds","getAvailablePlaybackRates","clearInterval","buffering","setInterval","getVideoLoadedFraction","lastBuffered","onStateChange","unMute","Ads","google","ima","manager","destroy","displayContainer","remove","startSafetyTimer","managerPromise","clearSafetyTimer","setupIMA","setVpaidMode","ImaSdkSettings","VpaidMode","ENABLED","setLocale","setDisableCustomPlaybackForIOS10Plus","AdDisplayContainer","loader","AdsLoader","AdsManagerLoadedEvent","Type","ADS_MANAGER_LOADED","onAdsManagerLoaded","AdErrorEvent","AD_ERROR","onAdError","requestAds","AdsRequest","adTagUrl","linearAdSlotWidth","linearAdSlotHeight","nonLinearAdSlotWidth","nonLinearAdSlotHeight","forceNonLinearFullSlot","setAdWillPlayMuted","countdownTimer","getRemainingTime","AdsRenderingSettings","restoreCustomPlaybackStateOnAdBreakComplete","enablePreloading","getAdsManager","cuePoints","getCuePoints","AdEvent","onAdEvent","cuePoint","seekElement","cuePercentage","ad","getAd","adData","getAdData","LOADED","pollCountdown","isLinear","STARTED","ALL_ADS_COMPLETED","loadAds","contentComplete","CONTENT_PAUSE_REQUESTED","pauseContent","CONTENT_RESUME_REQUESTED","resumeContent","LOG","adError","getMessage","cancel","addCuePoints","seekedTime","discardAdBreak","resize","ViewMode","NORMAL","initialize","initialized","zIndex","handlers","safetyTimer","AV_PUBLISHERID","AV_CHANNELID","AV_URL","cb","AV_WIDTH","AV_HEIGHT","AV_CDIM2","clamp","parseVtt","vttDataString","processedList","frame","line","startTime","lineSplit","matchTimes","endTime","fitRatio","outer","PreviewThumbnails","getThumbnails","render","determineContainerAutoSizing","sortAndResolve","thumbnails","promises","getThumbnail","thumbnail","frames","urlPrefix","substring","lastIndexOf","tempImage","naturalHeight","_this$player$config$m","_this$player$config$m2","percentage","mousePosX","thumb","showImageAtCurrentTime","toggleThumbContainer","mouseDown","toggleScrubbingContainer","ceil","lastTime","scrubbing","setScrubbingContainerSize","setThumbContainerSizeAndPos","thumbNum","findIndex","hasThumb","qualityIndex","loadedImages","showingThumb","thumbFilename","thumbUrl","currentImageElement","dataset","filename","showImage","removeOldImages","loadingImage","usingSprites","previewImage","showingThumbFilename","newImage","setImageSizeAndOffset","currentImageContainer","preloadNearby","getHigherQuality","currentImage","tagName","removeDelay","deleting","oldThumbFilename","thumbnailsClone","foundOne","newThumbFilename","thumbURL","currentQualityIndex","previewImageHeight","thumbContainerHeight","clearShowing","sizeSpecifiedInCSS","thumbAspectRatio","thumbHeight","setThumbContainerPos","scrubberRect","containerRect","right","clamped","multiplier","lastMouseMoveTime","currentScrubbingImageElement","currentThumbnailImageElement","insertElements","change","crossorigin","webkitShowPlaybackTargetPicker","isHidden","hiding","eventName","soft","original","unload","failed","jQuery","getProviderByUrl","search","truthy","searchParams","inputIsValid","fauxDuration","realDuration","Infinity","hasAudio","mozHasAudio","webkitAudioDecodedByteCount","audioTracks","updateStorage","requestPictureInPicture","exitPictureInPicture","webkitPresentationMode","pictureInPictureElement","setPreviewThumbnails","thumbnailSource","static"],"mappings":"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","file":"plyr.min.js","sourcesContent":["typeof navigator === \"object\" && (function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :\n typeof define === 'function' && define.amd ? define('Plyr', factory) :\n (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Plyr = factory());\n})(this, (function () { 'use strict';\n\n function _defineProperty$1(obj, key, value) {\n key = _toPropertyKey(key);\n if (key in obj) {\n Object.defineProperty(obj, key, {\n value: value,\n enumerable: true,\n configurable: true,\n writable: true\n });\n } else {\n obj[key] = value;\n }\n return obj;\n }\n function _toPrimitive(input, hint) {\n if (typeof input !== \"object\" || input === null) return input;\n var prim = input[Symbol.toPrimitive];\n if (prim !== undefined) {\n var res = prim.call(input, hint || \"default\");\n if (typeof res !== \"object\") return res;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (hint === \"string\" ? String : Number)(input);\n }\n function _toPropertyKey(arg) {\n var key = _toPrimitive(arg, \"string\");\n return typeof key === \"symbol\" ? key : String(key);\n }\n\n function _classCallCheck(e, t) {\n if (!(e instanceof t)) throw new TypeError(\"Cannot call a class as a function\");\n }\n function _defineProperties(e, t) {\n for (var n = 0; n < t.length; n++) {\n var r = t[n];\n r.enumerable = r.enumerable || !1, r.configurable = !0, \"value\" in r && (r.writable = !0), Object.defineProperty(e, r.key, r);\n }\n }\n function _createClass(e, t, n) {\n return t && _defineProperties(e.prototype, t), n && _defineProperties(e, n), e;\n }\n function _defineProperty(e, t, n) {\n return t in e ? Object.defineProperty(e, t, {\n value: n,\n enumerable: !0,\n configurable: !0,\n writable: !0\n }) : e[t] = n, e;\n }\n function ownKeys(e, t) {\n var n = Object.keys(e);\n if (Object.getOwnPropertySymbols) {\n var r = Object.getOwnPropertySymbols(e);\n t && (r = r.filter(function (t) {\n return Object.getOwnPropertyDescriptor(e, t).enumerable;\n })), n.push.apply(n, r);\n }\n return n;\n }\n function _objectSpread2(e) {\n for (var t = 1; t < arguments.length; t++) {\n var n = null != arguments[t] ? arguments[t] : {};\n t % 2 ? ownKeys(Object(n), !0).forEach(function (t) {\n _defineProperty(e, t, n[t]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(n)) : ownKeys(Object(n)).forEach(function (t) {\n Object.defineProperty(e, t, Object.getOwnPropertyDescriptor(n, t));\n });\n }\n return e;\n }\n var defaults$1 = {\n addCSS: !0,\n thumbWidth: 15,\n watch: !0\n };\n function matches$1(e, t) {\n return function () {\n return Array.from(document.querySelectorAll(t)).includes(this);\n }.call(e, t);\n }\n function trigger(e, t) {\n if (e && t) {\n var n = new Event(t, {\n bubbles: !0\n });\n e.dispatchEvent(n);\n }\n }\n var getConstructor$1 = function (e) {\n return null != e ? e.constructor : null;\n },\n instanceOf$1 = function (e, t) {\n return !!(e && t && e instanceof t);\n },\n isNullOrUndefined$1 = function (e) {\n return null == e;\n },\n isObject$1 = function (e) {\n return getConstructor$1(e) === Object;\n },\n isNumber$1 = function (e) {\n return getConstructor$1(e) === Number && !Number.isNaN(e);\n },\n isString$1 = function (e) {\n return getConstructor$1(e) === String;\n },\n isBoolean$1 = function (e) {\n return getConstructor$1(e) === Boolean;\n },\n isFunction$1 = function (e) {\n return getConstructor$1(e) === Function;\n },\n isArray$1 = function (e) {\n return Array.isArray(e);\n },\n isNodeList$1 = function (e) {\n return instanceOf$1(e, NodeList);\n },\n isElement$1 = function (e) {\n return instanceOf$1(e, Element);\n },\n isEvent$1 = function (e) {\n return instanceOf$1(e, Event);\n },\n isEmpty$1 = function (e) {\n return isNullOrUndefined$1(e) || (isString$1(e) || isArray$1(e) || isNodeList$1(e)) && !e.length || isObject$1(e) && !Object.keys(e).length;\n },\n is$1 = {\n nullOrUndefined: isNullOrUndefined$1,\n object: isObject$1,\n number: isNumber$1,\n string: isString$1,\n boolean: isBoolean$1,\n function: isFunction$1,\n array: isArray$1,\n nodeList: isNodeList$1,\n element: isElement$1,\n event: isEvent$1,\n empty: isEmpty$1\n };\n function getDecimalPlaces(e) {\n var t = \"\".concat(e).match(/(?:\\.(\\d+))?(?:[eE]([+-]?\\d+))?$/);\n return t ? Math.max(0, (t[1] ? t[1].length : 0) - (t[2] ? +t[2] : 0)) : 0;\n }\n function round(e, t) {\n if (1 > t) {\n var n = getDecimalPlaces(t);\n return parseFloat(e.toFixed(n));\n }\n return Math.round(e / t) * t;\n }\n var RangeTouch = function () {\n function e(t, n) {\n _classCallCheck(this, e), is$1.element(t) ? this.element = t : is$1.string(t) && (this.element = document.querySelector(t)), is$1.element(this.element) && is$1.empty(this.element.rangeTouch) && (this.config = _objectSpread2({}, defaults$1, {}, n), this.init());\n }\n return _createClass(e, [{\n key: \"init\",\n value: function () {\n e.enabled && (this.config.addCSS && (this.element.style.userSelect = \"none\", this.element.style.webKitUserSelect = \"none\", this.element.style.touchAction = \"manipulation\"), this.listeners(!0), this.element.rangeTouch = this);\n }\n }, {\n key: \"destroy\",\n value: function () {\n e.enabled && (this.config.addCSS && (this.element.style.userSelect = \"\", this.element.style.webKitUserSelect = \"\", this.element.style.touchAction = \"\"), this.listeners(!1), this.element.rangeTouch = null);\n }\n }, {\n key: \"listeners\",\n value: function (e) {\n var t = this,\n n = e ? \"addEventListener\" : \"removeEventListener\";\n [\"touchstart\", \"touchmove\", \"touchend\"].forEach(function (e) {\n t.element[n](e, function (e) {\n return t.set(e);\n }, !1);\n });\n }\n }, {\n key: \"get\",\n value: function (t) {\n if (!e.enabled || !is$1.event(t)) return null;\n var n,\n r = t.target,\n i = t.changedTouches[0],\n o = parseFloat(r.getAttribute(\"min\")) || 0,\n s = parseFloat(r.getAttribute(\"max\")) || 100,\n u = parseFloat(r.getAttribute(\"step\")) || 1,\n c = r.getBoundingClientRect(),\n a = 100 / c.width * (this.config.thumbWidth / 2) / 100;\n return 0 > (n = 100 / c.width * (i.clientX - c.left)) ? n = 0 : 100 < n && (n = 100), 50 > n ? n -= (100 - 2 * n) * a : 50 < n && (n += 2 * (n - 50) * a), o + round(n / 100 * (s - o), u);\n }\n }, {\n key: \"set\",\n value: function (t) {\n e.enabled && is$1.event(t) && !t.target.disabled && (t.preventDefault(), t.target.value = this.get(t), trigger(t.target, \"touchend\" === t.type ? \"change\" : \"input\"));\n }\n }], [{\n key: \"setup\",\n value: function (t) {\n var n = 1 < arguments.length && void 0 !== arguments[1] ? arguments[1] : {},\n r = null;\n if (is$1.empty(t) || is$1.string(t) ? r = Array.from(document.querySelectorAll(is$1.string(t) ? t : 'input[type=\"range\"]')) : is$1.element(t) ? r = [t] : is$1.nodeList(t) ? r = Array.from(t) : is$1.array(t) && (r = t.filter(is$1.element)), is$1.empty(r)) return null;\n var i = _objectSpread2({}, defaults$1, {}, n);\n if (is$1.string(t) && i.watch) {\n var o = new MutationObserver(function (n) {\n Array.from(n).forEach(function (n) {\n Array.from(n.addedNodes).forEach(function (n) {\n is$1.element(n) && matches$1(n, t) && new e(n, i);\n });\n });\n });\n o.observe(document.body, {\n childList: !0,\n subtree: !0\n });\n }\n return r.map(function (t) {\n return new e(t, n);\n });\n }\n }, {\n key: \"enabled\",\n get: function () {\n return \"ontouchstart\" in document.documentElement;\n }\n }]), e;\n }();\n\n // ==========================================================================\n // Type checking utils\n // ==========================================================================\n\n const getConstructor = input => input !== null && typeof input !== 'undefined' ? input.constructor : null;\n const instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor);\n const isNullOrUndefined = input => input === null || typeof input === 'undefined';\n const isObject = input => getConstructor(input) === Object;\n const isNumber = input => getConstructor(input) === Number && !Number.isNaN(input);\n const isString = input => getConstructor(input) === String;\n const isBoolean = input => getConstructor(input) === Boolean;\n const isFunction = input => typeof input === 'function';\n const isArray = input => Array.isArray(input);\n const isWeakMap = input => instanceOf(input, WeakMap);\n const isNodeList = input => instanceOf(input, NodeList);\n const isTextNode = input => getConstructor(input) === Text;\n const isEvent = input => instanceOf(input, Event);\n const isKeyboardEvent = input => instanceOf(input, KeyboardEvent);\n const isCue = input => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);\n const isTrack = input => instanceOf(input, TextTrack) || !isNullOrUndefined(input) && isString(input.kind);\n const isPromise = input => instanceOf(input, Promise) && isFunction(input.then);\n const isElement = input => input !== null && typeof input === 'object' && input.nodeType === 1 && typeof input.style === 'object' && typeof input.ownerDocument === 'object';\n const isEmpty = input => isNullOrUndefined(input) || (isString(input) || isArray(input) || isNodeList(input)) && !input.length || isObject(input) && !Object.keys(input).length;\n const isUrl = input => {\n // Accept a URL object\n if (instanceOf(input, window.URL)) {\n return true;\n }\n\n // Must be string from here\n if (!isString(input)) {\n return false;\n }\n\n // Add the protocol if required\n let string = input;\n if (!input.startsWith('http://') || !input.startsWith('https://')) {\n string = `http://${input}`;\n }\n try {\n return !isEmpty(new URL(string).hostname);\n } catch (_) {\n return false;\n }\n };\n var is = {\n nullOrUndefined: isNullOrUndefined,\n object: isObject,\n number: isNumber,\n string: isString,\n boolean: isBoolean,\n function: isFunction,\n array: isArray,\n weakMap: isWeakMap,\n nodeList: isNodeList,\n element: isElement,\n textNode: isTextNode,\n event: isEvent,\n keyboardEvent: isKeyboardEvent,\n cue: isCue,\n track: isTrack,\n promise: isPromise,\n url: isUrl,\n empty: isEmpty\n };\n\n // ==========================================================================\n const transitionEndEvent = (() => {\n const element = document.createElement('span');\n const events = {\n WebkitTransition: 'webkitTransitionEnd',\n MozTransition: 'transitionend',\n OTransition: 'oTransitionEnd otransitionend',\n transition: 'transitionend'\n };\n const type = Object.keys(events).find(event => element.style[event] !== undefined);\n return is.string(type) ? events[type] : false;\n })();\n\n // Force repaint of element\n function repaint(element, delay) {\n setTimeout(() => {\n try {\n // eslint-disable-next-line no-param-reassign\n element.hidden = true;\n\n // eslint-disable-next-line no-unused-expressions\n element.offsetHeight;\n\n // eslint-disable-next-line no-param-reassign\n element.hidden = false;\n } catch (_) {\n // Do nothing\n }\n }, delay);\n }\n\n // ==========================================================================\n // Browser sniffing\n // Unfortunately, due to mixed support, UA sniffing is required\n // ==========================================================================\n\n const isIE = Boolean(window.document.documentMode);\n const isEdge = /Edge/g.test(navigator.userAgent);\n const isWebKit = 'WebkitAppearance' in document.documentElement.style && !/Edge/g.test(navigator.userAgent);\n const isIPhone = /iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1;\n // navigator.platform may be deprecated but this check is still required\n const isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;\n const isIos = /iPad|iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1;\n var browser = {\n isIE,\n isEdge,\n isWebKit,\n isIPhone,\n isIPadOS,\n isIos\n };\n\n // ==========================================================================\n\n // Clone nested objects\n function cloneDeep(object) {\n return JSON.parse(JSON.stringify(object));\n }\n\n // Get a nested value in an object\n function getDeep(object, path) {\n return path.split('.').reduce((obj, key) => obj && obj[key], object);\n }\n\n // Deep extend destination object with N more objects\n function extend(target = {}, ...sources) {\n if (!sources.length) {\n return target;\n }\n const source = sources.shift();\n if (!is.object(source)) {\n return target;\n }\n Object.keys(source).forEach(key => {\n if (is.object(source[key])) {\n if (!Object.keys(target).includes(key)) {\n Object.assign(target, {\n [key]: {}\n });\n }\n extend(target[key], source[key]);\n } else {\n Object.assign(target, {\n [key]: source[key]\n });\n }\n });\n return extend(target, ...sources);\n }\n\n // ==========================================================================\n\n // Wrap an element\n function wrap(elements, wrapper) {\n // Convert `elements` to an array, if necessary.\n const targets = elements.length ? elements : [elements];\n\n // Loops backwards to prevent having to clone the wrapper on the\n // first element (see `child` below).\n Array.from(targets).reverse().forEach((element, index) => {\n const child = index > 0 ? wrapper.cloneNode(true) : wrapper;\n // Cache the current parent and sibling.\n const parent = element.parentNode;\n const sibling = element.nextSibling;\n\n // Wrap the element (is automatically removed from its current\n // parent).\n child.appendChild(element);\n\n // If the element had a sibling, insert the wrapper before\n // the sibling to maintain the HTML structure; otherwise, just\n // append it to the parent.\n if (sibling) {\n parent.insertBefore(child, sibling);\n } else {\n parent.appendChild(child);\n }\n });\n }\n\n // Set attributes\n function setAttributes(element, attributes) {\n if (!is.element(element) || is.empty(attributes)) return;\n\n // Assume null and undefined attributes should be left out,\n // Setting them would otherwise convert them to \"null\" and \"undefined\"\n Object.entries(attributes).filter(([, value]) => !is.nullOrUndefined(value)).forEach(([key, value]) => element.setAttribute(key, value));\n }\n\n // Create a DocumentFragment\n function createElement(type, attributes, text) {\n // Create a new <element>\n const element = document.createElement(type);\n\n // Set all passed attributes\n if (is.object(attributes)) {\n setAttributes(element, attributes);\n }\n\n // Add text node\n if (is.string(text)) {\n element.innerText = text;\n }\n\n // Return built element\n return element;\n }\n\n // Insert an element after another\n function insertAfter(element, target) {\n if (!is.element(element) || !is.element(target)) return;\n target.parentNode.insertBefore(element, target.nextSibling);\n }\n\n // Insert a DocumentFragment\n function insertElement(type, parent, attributes, text) {\n if (!is.element(parent)) return;\n parent.appendChild(createElement(type, attributes, text));\n }\n\n // Remove element(s)\n function removeElement(element) {\n if (is.nodeList(element) || is.array(element)) {\n Array.from(element).forEach(removeElement);\n return;\n }\n if (!is.element(element) || !is.element(element.parentNode)) {\n return;\n }\n element.parentNode.removeChild(element);\n }\n\n // Remove all child elements\n function emptyElement(element) {\n if (!is.element(element)) return;\n let {\n length\n } = element.childNodes;\n while (length > 0) {\n element.removeChild(element.lastChild);\n length -= 1;\n }\n }\n\n // Replace element\n function replaceElement(newChild, oldChild) {\n if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) return null;\n oldChild.parentNode.replaceChild(newChild, oldChild);\n return newChild;\n }\n\n // Get an attribute object from a string selector\n function getAttributesFromSelector(sel, existingAttributes) {\n // For example:\n // '.test' to { class: 'test' }\n // '#test' to { id: 'test' }\n // '[data-test=\"test\"]' to { 'data-test': 'test' }\n\n if (!is.string(sel) || is.empty(sel)) return {};\n const attributes = {};\n const existing = extend({}, existingAttributes);\n sel.split(',').forEach(s => {\n // Remove whitespace\n const selector = s.trim();\n const className = selector.replace('.', '');\n const stripped = selector.replace(/[[\\]]/g, '');\n // Get the parts and value\n const parts = stripped.split('=');\n const [key] = parts;\n const value = parts.length > 1 ? parts[1].replace(/[\"']/g, '') : '';\n // Get the first character\n const start = selector.charAt(0);\n switch (start) {\n case '.':\n // Add to existing classname\n if (is.string(existing.class)) {\n attributes.class = `${existing.class} ${className}`;\n } else {\n attributes.class = className;\n }\n break;\n case '#':\n // ID selector\n attributes.id = selector.replace('#', '');\n break;\n case '[':\n // Attribute selector\n attributes[key] = value;\n break;\n }\n });\n return extend(existing, attributes);\n }\n\n // Toggle hidden\n function toggleHidden(element, hidden) {\n if (!is.element(element)) return;\n let hide = hidden;\n if (!is.boolean(hide)) {\n hide = !element.hidden;\n }\n\n // eslint-disable-next-line no-param-reassign\n element.hidden = hide;\n }\n\n // Mirror Element.classList.toggle, with IE compatibility for \"force\" argument\n function toggleClass(element, className, force) {\n if (is.nodeList(element)) {\n return Array.from(element).map(e => toggleClass(e, className, force));\n }\n if (is.element(element)) {\n let method = 'toggle';\n if (typeof force !== 'undefined') {\n method = force ? 'add' : 'remove';\n }\n element.classList[method](className);\n return element.classList.contains(className);\n }\n return false;\n }\n\n // Has class name\n function hasClass(element, className) {\n return is.element(element) && element.classList.contains(className);\n }\n\n // Element matches selector\n function matches(element, selector) {\n const {\n prototype\n } = Element;\n function match() {\n return Array.from(document.querySelectorAll(selector)).includes(this);\n }\n const method = prototype.matches || prototype.webkitMatchesSelector || prototype.mozMatchesSelector || prototype.msMatchesSelector || match;\n return method.call(element, selector);\n }\n\n // Closest ancestor element matching selector (also tests element itself)\n function closest$1(element, selector) {\n const {\n prototype\n } = Element;\n\n // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill\n function closestElement() {\n let el = this;\n do {\n if (matches.matches(el, selector)) return el;\n el = el.parentElement || el.parentNode;\n } while (el !== null && el.nodeType === 1);\n return null;\n }\n const method = prototype.closest || closestElement;\n return method.call(element, selector);\n }\n\n // Find all elements\n function getElements(selector) {\n return this.elements.container.querySelectorAll(selector);\n }\n\n // Find a single element\n function getElement(selector) {\n return this.elements.container.querySelector(selector);\n }\n\n // Set focus and tab focus class\n function setFocus(element = null, focusVisible = false) {\n if (!is.element(element)) return;\n\n // Set regular focus\n element.focus({\n preventScroll: true,\n focusVisible\n });\n }\n\n // ==========================================================================\n\n // Default codecs for checking mimetype support\n const defaultCodecs = {\n 'audio/ogg': 'vorbis',\n 'audio/wav': '1',\n 'video/webm': 'vp8, vorbis',\n 'video/mp4': 'avc1.42E01E, mp4a.40.2',\n 'video/ogg': 'theora'\n };\n\n // Check for feature support\n const support = {\n // Basic support\n audio: 'canPlayType' in document.createElement('audio'),\n video: 'canPlayType' in document.createElement('video'),\n // Check for support\n // Basic functionality vs full UI\n check(type, provider) {\n const api = support[type] || provider !== 'html5';\n const ui = api && support.rangeInput;\n return {\n api,\n ui\n };\n },\n // Picture-in-picture support\n // Safari & Chrome only currently\n pip: (() => {\n // While iPhone's support picture-in-picture for some apps, seemingly Safari isn't one of them\n // It will throw the following error when trying to enter picture-in-picture\n // `NotSupportedError: The Picture-in-Picture mode is not supported.`\n if (browser.isIPhone) {\n return false;\n }\n\n // Safari\n // https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls\n if (is.function(createElement('video').webkitSetPresentationMode)) {\n return true;\n }\n\n // Chrome\n // https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture\n if (document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture) {\n return true;\n }\n return false;\n })(),\n // Airplay support\n // Safari only currently\n airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent),\n // Inline playback support\n // https://webkit.org/blog/6784/new-video-policies-for-ios/\n playsinline: 'playsInline' in document.createElement('video'),\n // Check for mime type support against a player instance\n // Credits: http://diveintohtml5.info/everything.html\n // Related: http://www.leanbackplayer.com/test/h5mt.html\n mime(input) {\n if (is.empty(input)) {\n return false;\n }\n const [mediaType] = input.split('/');\n let type = input;\n\n // Verify we're using HTML5 and there's no media type mismatch\n if (!this.isHTML5 || mediaType !== this.type) {\n return false;\n }\n\n // Add codec if required\n if (Object.keys(defaultCodecs).includes(type)) {\n type += `; codecs=\"${defaultCodecs[input]}\"`;\n }\n try {\n return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));\n } catch (_) {\n return false;\n }\n },\n // Check for textTracks support\n textTracks: 'textTracks' in document.createElement('video'),\n // <input type=\"range\"> Sliders\n rangeInput: (() => {\n const range = document.createElement('input');\n range.type = 'range';\n return range.type === 'range';\n })(),\n // Touch\n // NOTE: Remember a device can be mouse + touch enabled so we check on first touch event\n touch: 'ontouchstart' in document.documentElement,\n // Detect transitions support\n transitions: transitionEndEvent !== false,\n // Reduced motion iOS & MacOS setting\n // https://webkit.org/blog/7551/responsive-design-for-motion/\n reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches\n };\n\n // ==========================================================================\n\n // Check for passive event listener support\n // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md\n // https://www.youtube.com/watch?v=NPM6172J22g\n const supportsPassiveListeners = (() => {\n // Test via a getter in the options object to see if the passive property is accessed\n let supported = false;\n try {\n const options = Object.defineProperty({}, 'passive', {\n get() {\n supported = true;\n return null;\n }\n });\n window.addEventListener('test', null, options);\n window.removeEventListener('test', null, options);\n } catch (_) {\n // Do nothing\n }\n return supported;\n })();\n\n // Toggle event listener\n function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) {\n // Bail if no element, event, or callback\n if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) {\n return;\n }\n\n // Allow multiple events\n const events = event.split(' ');\n // Build options\n // Default to just the capture boolean for browsers with no passive listener support\n let options = capture;\n\n // If passive events listeners are supported\n if (supportsPassiveListeners) {\n options = {\n // Whether the listener can be passive (i.e. default never prevented)\n passive,\n // Whether the listener is a capturing listener or not\n capture\n };\n }\n\n // If a single node is passed, bind the event listener\n events.forEach(type => {\n if (this && this.eventListeners && toggle) {\n // Cache event listener\n this.eventListeners.push({\n element,\n type,\n callback,\n options\n });\n }\n element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);\n });\n }\n\n // Bind event handler\n function on(element, events = '', callback, passive = true, capture = false) {\n toggleListener.call(this, element, events, callback, true, passive, capture);\n }\n\n // Unbind event handler\n function off(element, events = '', callback, passive = true, capture = false) {\n toggleListener.call(this, element, events, callback, false, passive, capture);\n }\n\n // Bind once-only event handler\n function once(element, events = '', callback, passive = true, capture = false) {\n const onceCallback = (...args) => {\n off(element, events, onceCallback, passive, capture);\n callback.apply(this, args);\n };\n toggleListener.call(this, element, events, onceCallback, true, passive, capture);\n }\n\n // Trigger event\n function triggerEvent(element, type = '', bubbles = false, detail = {}) {\n // Bail if no element\n if (!is.element(element) || is.empty(type)) {\n return;\n }\n\n // Create and dispatch the event\n const event = new CustomEvent(type, {\n bubbles,\n detail: {\n ...detail,\n plyr: this\n }\n });\n\n // Dispatch the event\n element.dispatchEvent(event);\n }\n\n // Unbind all cached event listeners\n function unbindListeners() {\n if (this && this.eventListeners) {\n this.eventListeners.forEach(item => {\n const {\n element,\n type,\n callback,\n options\n } = item;\n element.removeEventListener(type, callback, options);\n });\n this.eventListeners = [];\n }\n }\n\n // Run method when / if player is ready\n function ready() {\n return new Promise(resolve => this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)).then(() => {});\n }\n\n /**\n * Silence a Promise-like object.\n * This is useful for avoiding non-harmful, but potentially confusing \"uncaught\n * play promise\" rejection error messages.\n * @param {Object} value An object that may or may not be `Promise`-like.\n */\n function silencePromise(value) {\n if (is.promise(value)) {\n value.then(null, () => {});\n }\n }\n\n // ==========================================================================\n\n // Remove duplicates in an array\n function dedupe(array) {\n if (!is.array(array)) {\n return array;\n }\n return array.filter((item, index) => array.indexOf(item) === index);\n }\n\n // Get the closest value in an array\n function closest(array, value) {\n if (!is.array(array) || !array.length) {\n return null;\n }\n return array.reduce((prev, curr) => Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev);\n }\n\n // ==========================================================================\n\n // Check support for a CSS declaration\n function supportsCSS(declaration) {\n if (!window || !window.CSS) {\n return false;\n }\n return window.CSS.supports(declaration);\n }\n\n // Standard/common aspect ratios\n const standardRatios = [[1, 1], [4, 3], [3, 4], [5, 4], [4, 5], [3, 2], [2, 3], [16, 10], [10, 16], [16, 9], [9, 16], [21, 9], [9, 21], [32, 9], [9, 32]].reduce((out, [x, y]) => ({\n ...out,\n [x / y]: [x, y]\n }), {});\n\n // Validate an aspect ratio\n function validateAspectRatio(input) {\n if (!is.array(input) && (!is.string(input) || !input.includes(':'))) {\n return false;\n }\n const ratio = is.array(input) ? input : input.split(':');\n return ratio.map(Number).every(is.number);\n }\n\n // Reduce an aspect ratio to it's lowest form\n function reduceAspectRatio(ratio) {\n if (!is.array(ratio) || !ratio.every(is.number)) {\n return null;\n }\n const [width, height] = ratio;\n const getDivider = (w, h) => h === 0 ? w : getDivider(h, w % h);\n const divider = getDivider(width, height);\n return [width / divider, height / divider];\n }\n\n // Calculate an aspect ratio\n function getAspectRatio(input) {\n const parse = ratio => validateAspectRatio(ratio) ? ratio.split(':').map(Number) : null;\n // Try provided ratio\n let ratio = parse(input);\n\n // Get from config\n if (ratio === null) {\n ratio = parse(this.config.ratio);\n }\n\n // Get from embed\n if (ratio === null && !is.empty(this.embed) && is.array(this.embed.ratio)) {\n ({\n ratio\n } = this.embed);\n }\n\n // Get from HTML5 video\n if (ratio === null && this.isHTML5) {\n const {\n videoWidth,\n videoHeight\n } = this.media;\n ratio = [videoWidth, videoHeight];\n }\n return reduceAspectRatio(ratio);\n }\n\n // Set aspect ratio for responsive container\n function setAspectRatio(input) {\n if (!this.isVideo) {\n return {};\n }\n const {\n wrapper\n } = this.elements;\n const ratio = getAspectRatio.call(this, input);\n if (!is.array(ratio)) {\n return {};\n }\n const [x, y] = reduceAspectRatio(ratio);\n const useNative = supportsCSS(`aspect-ratio: ${x}/${y}`);\n const padding = 100 / x * y;\n if (useNative) {\n wrapper.style.aspectRatio = `${x}/${y}`;\n } else {\n wrapper.style.paddingBottom = `${padding}%`;\n }\n\n // For Vimeo we have an extra <div> to hide the standard controls and UI\n if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) {\n const height = 100 / this.media.offsetWidth * parseInt(window.getComputedStyle(this.media).paddingBottom, 10);\n const offset = (height - padding) / (height / 50);\n if (this.fullscreen.active) {\n wrapper.style.paddingBottom = null;\n } else {\n this.media.style.transform = `translateY(-${offset}%)`;\n }\n } else if (this.isHTML5) {\n wrapper.classList.add(this.config.classNames.videoFixedRatio);\n }\n return {\n padding,\n ratio\n };\n }\n\n // Round an aspect ratio to closest standard ratio\n function roundAspectRatio(x, y, tolerance = 0.05) {\n const ratio = x / y;\n const closestRatio = closest(Object.keys(standardRatios), ratio);\n\n // Check match is within tolerance\n if (Math.abs(closestRatio - ratio) <= tolerance) {\n return standardRatios[closestRatio];\n }\n\n // No match\n return [x, y];\n }\n\n // Get the size of the viewport\n // https://stackoverflow.com/questions/1248081/how-to-get-the-browser-viewport-dimensions\n function getViewportSize() {\n const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);\n const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);\n return [width, height];\n }\n\n // ==========================================================================\n const html5 = {\n getSources() {\n if (!this.isHTML5) {\n return [];\n }\n const sources = Array.from(this.media.querySelectorAll('source'));\n\n // Filter out unsupported sources (if type is specified)\n return sources.filter(source => {\n const type = source.getAttribute('type');\n if (is.empty(type)) {\n return true;\n }\n return support.mime.call(this, type);\n });\n },\n // Get quality levels\n getQualityOptions() {\n // Whether we're forcing all options (e.g. for streaming)\n if (this.config.quality.forced) {\n return this.config.quality.options;\n }\n\n // Get sizes from <source> elements\n return html5.getSources.call(this).map(source => Number(source.getAttribute('data-res'))).filter(Boolean);\n },\n setup() {\n if (!this.isHTML5) {\n return;\n }\n const player = this;\n\n // Set speed options from config\n player.options.speed = player.config.speed.options;\n\n // Set aspect ratio if fixed\n if (!is.empty(this.config.ratio)) {\n setAspectRatio.call(player);\n }\n\n // Quality\n Object.defineProperty(player.media, 'quality', {\n get() {\n // Get sources\n const sources = html5.getSources.call(player);\n const source = sources.find(s => s.getAttribute('src') === player.source);\n\n // Return size, if match is found\n return source && Number(source.getAttribute('data-res'));\n },\n set(input) {\n if (player.quality === input) {\n return;\n }\n\n // If we're using an external handler...\n if (player.config.quality.forced && is.function(player.config.quality.onChange)) {\n player.config.quality.onChange(input);\n } else {\n // Get sources\n const sources = html5.getSources.call(player);\n // Get first match for requested size\n const source = sources.find(s => Number(s.getAttribute('data-res')) === input);\n\n // No matching source found\n if (!source) {\n return;\n }\n\n // Get current state\n const {\n currentTime,\n paused,\n preload,\n readyState,\n playbackRate\n } = player.media;\n\n // Set new source\n player.media.src = source.getAttribute('src');\n\n // Prevent loading if preload=\"none\" and the current source isn't loaded (#1044)\n if (preload !== 'none' || readyState) {\n // Restore time\n player.once('loadedmetadata', () => {\n player.speed = playbackRate;\n player.currentTime = currentTime;\n\n // Resume playing\n if (!paused) {\n silencePromise(player.play());\n }\n });\n\n // Load new source\n player.media.load();\n }\n }\n\n // Trigger change event\n triggerEvent.call(player, player.media, 'qualitychange', false, {\n quality: input\n });\n }\n });\n },\n // Cancel current network requests\n // See https://github.com/sampotts/plyr/issues/174\n cancelRequests() {\n if (!this.isHTML5) {\n return;\n }\n\n // Remove child sources\n removeElement(html5.getSources.call(this));\n\n // Set blank video src attribute\n // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error\n // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection\n this.media.setAttribute('src', this.config.blankVideo);\n\n // Load the new empty source\n // This will cancel existing requests\n // See https://github.com/sampotts/plyr/issues/174\n this.media.load();\n\n // Debugging\n this.debug.log('Cancelled network requests');\n }\n };\n\n // ==========================================================================\n\n // Generate a random ID\n function generateId(prefix) {\n return `${prefix}-${Math.floor(Math.random() * 10000)}`;\n }\n\n // Format string\n function format(input, ...args) {\n if (is.empty(input)) return input;\n return input.toString().replace(/{(\\d+)}/g, (_, i) => args[i].toString());\n }\n\n // Get percentage\n function getPercentage(current, max) {\n if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {\n return 0;\n }\n return (current / max * 100).toFixed(2);\n }\n\n // Replace all occurrences of a string in a string\n const replaceAll = (input = '', find = '', replace = '') => input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\\]/\\\\])/g, '\\\\$1'), 'g'), replace.toString());\n\n // Convert to title case\n const toTitleCase = (input = '') => input.toString().replace(/\\w\\S*/g, text => text.charAt(0).toUpperCase() + text.slice(1).toLowerCase());\n\n // Convert string to pascalCase\n function toPascalCase(input = '') {\n let string = input.toString();\n\n // Convert kebab case\n string = replaceAll(string, '-', ' ');\n\n // Convert snake case\n string = replaceAll(string, '_', ' ');\n\n // Convert to title case\n string = toTitleCase(string);\n\n // Convert to pascal case\n return replaceAll(string, ' ', '');\n }\n\n // Convert string to pascalCase\n function toCamelCase(input = '') {\n let string = input.toString();\n\n // Convert to pascal case\n string = toPascalCase(string);\n\n // Convert first character to lowercase\n return string.charAt(0).toLowerCase() + string.slice(1);\n }\n\n // Remove HTML from a string\n function stripHTML(source) {\n const fragment = document.createDocumentFragment();\n const element = document.createElement('div');\n fragment.appendChild(element);\n element.innerHTML = source;\n return fragment.firstChild.innerText;\n }\n\n // Like outerHTML, but also works for DocumentFragment\n function getHTML(element) {\n const wrapper = document.createElement('div');\n wrapper.appendChild(element);\n return wrapper.innerHTML;\n }\n\n // ==========================================================================\n\n // Skip i18n for abbreviations and brand names\n const resources = {\n pip: 'PIP',\n airplay: 'AirPlay',\n html5: 'HTML5',\n vimeo: 'Vimeo',\n youtube: 'YouTube'\n };\n const i18n = {\n get(key = '', config = {}) {\n if (is.empty(key) || is.empty(config)) {\n return '';\n }\n let string = getDeep(config.i18n, key);\n if (is.empty(string)) {\n if (Object.keys(resources).includes(key)) {\n return resources[key];\n }\n return '';\n }\n const replace = {\n '{seektime}': config.seekTime,\n '{title}': config.title\n };\n Object.entries(replace).forEach(([k, v]) => {\n string = replaceAll(string, k, v);\n });\n return string;\n }\n };\n\n class Storage {\n constructor(player) {\n _defineProperty$1(this, \"get\", key => {\n if (!Storage.supported || !this.enabled) {\n return null;\n }\n const store = window.localStorage.getItem(this.key);\n if (is.empty(store)) {\n return null;\n }\n const json = JSON.parse(store);\n return is.string(key) && key.length ? json[key] : json;\n });\n _defineProperty$1(this, \"set\", object => {\n // Bail if we don't have localStorage support or it's disabled\n if (!Storage.supported || !this.enabled) {\n return;\n }\n\n // Can only store objectst\n if (!is.object(object)) {\n return;\n }\n\n // Get current storage\n let storage = this.get();\n\n // Default to empty object\n if (is.empty(storage)) {\n storage = {};\n }\n\n // Update the working copy of the values\n extend(storage, object);\n\n // Update storage\n try {\n window.localStorage.setItem(this.key, JSON.stringify(storage));\n } catch (_) {\n // Do nothing\n }\n });\n this.enabled = player.config.storage.enabled;\n this.key = player.config.storage.key;\n }\n\n // Check for actual support (see if we can use it)\n static get supported() {\n try {\n if (!('localStorage' in window)) {\n return false;\n }\n const test = '___test';\n\n // Try to use it (it might be disabled, e.g. user is in private mode)\n // see: https://github.com/sampotts/plyr/issues/131\n window.localStorage.setItem(test, test);\n window.localStorage.removeItem(test);\n return true;\n } catch (_) {\n return false;\n }\n }\n }\n\n // ==========================================================================\n // Fetch wrapper\n // Using XHR to avoid issues with older browsers\n // ==========================================================================\n\n function fetch(url, responseType = 'text') {\n return new Promise((resolve, reject) => {\n try {\n const request = new XMLHttpRequest();\n\n // Check for CORS support\n if (!('withCredentials' in request)) {\n return;\n }\n request.addEventListener('load', () => {\n if (responseType === 'text') {\n try {\n resolve(JSON.parse(request.responseText));\n } catch (_) {\n resolve(request.responseText);\n }\n } else {\n resolve(request.response);\n }\n });\n request.addEventListener('error', () => {\n throw new Error(request.status);\n });\n request.open('GET', url, true);\n\n // Set the required response type\n request.responseType = responseType;\n request.send();\n } catch (error) {\n reject(error);\n }\n });\n }\n\n // ==========================================================================\n\n // Load an external SVG sprite\n function loadSprite(url, id) {\n if (!is.string(url)) {\n return;\n }\n const prefix = 'cache';\n const hasId = is.string(id);\n let isCached = false;\n const exists = () => document.getElementById(id) !== null;\n const update = (container, data) => {\n // eslint-disable-next-line no-param-reassign\n container.innerHTML = data;\n\n // Check again incase of race condition\n if (hasId && exists()) {\n return;\n }\n\n // Inject the SVG to the body\n document.body.insertAdjacentElement('afterbegin', container);\n };\n\n // Only load once if ID set\n if (!hasId || !exists()) {\n const useStorage = Storage.supported;\n // Create container\n const container = document.createElement('div');\n container.setAttribute('hidden', '');\n if (hasId) {\n container.setAttribute('id', id);\n }\n\n // Check in cache\n if (useStorage) {\n const cached = window.localStorage.getItem(`${prefix}-${id}`);\n isCached = cached !== null;\n if (isCached) {\n const data = JSON.parse(cached);\n update(container, data.content);\n }\n }\n\n // Get the sprite\n fetch(url).then(result => {\n if (is.empty(result)) {\n return;\n }\n if (useStorage) {\n try {\n window.localStorage.setItem(`${prefix}-${id}`, JSON.stringify({\n content: result\n }));\n } catch (_) {\n // Do nothing\n }\n }\n update(container, result);\n }).catch(() => {});\n }\n }\n\n // ==========================================================================\n\n // Time helpers\n const getHours = value => Math.trunc(value / 60 / 60 % 60, 10);\n const getMinutes = value => Math.trunc(value / 60 % 60, 10);\n const getSeconds = value => Math.trunc(value % 60, 10);\n\n // Format time to UI friendly string\n function formatTime(time = 0, displayHours = false, inverted = false) {\n // Bail if the value isn't a number\n if (!is.number(time)) {\n return formatTime(undefined, displayHours, inverted);\n }\n\n // Format time component to add leading zero\n const format = value => `0${value}`.slice(-2);\n // Breakdown to hours, mins, secs\n let hours = getHours(time);\n const mins = getMinutes(time);\n const secs = getSeconds(time);\n\n // Do we need to display hours?\n if (displayHours || hours > 0) {\n hours = `${hours}:`;\n } else {\n hours = '';\n }\n\n // Render\n return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;\n }\n\n // ==========================================================================\n\n // TODO: Don't export a massive object - break down and create class\n const controls = {\n // Get icon URL\n getIconUrl() {\n const url = new URL(this.config.iconUrl, window.location);\n const host = window.location.host ? window.location.host : window.top.location.host;\n const cors = url.host !== host || browser.isIE && !window.svg4everybody;\n return {\n url: this.config.iconUrl,\n cors\n };\n },\n // Find the UI controls\n findElements() {\n try {\n this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);\n\n // Buttons\n this.elements.buttons = {\n play: getElements.call(this, this.config.selectors.buttons.play),\n pause: getElement.call(this, this.config.selectors.buttons.pause),\n restart: getElement.call(this, this.config.selectors.buttons.restart),\n rewind: getElement.call(this, this.config.selectors.buttons.rewind),\n fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),\n mute: getElement.call(this, this.config.selectors.buttons.mute),\n pip: getElement.call(this, this.config.selectors.buttons.pip),\n airplay: getElement.call(this, this.config.selectors.buttons.airplay),\n settings: getElement.call(this, this.config.selectors.buttons.settings),\n captions: getElement.call(this, this.config.selectors.buttons.captions),\n fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen)\n };\n\n // Progress\n this.elements.progress = getElement.call(this, this.config.selectors.progress);\n\n // Inputs\n this.elements.inputs = {\n seek: getElement.call(this, this.config.selectors.inputs.seek),\n volume: getElement.call(this, this.config.selectors.inputs.volume)\n };\n\n // Display\n this.elements.display = {\n buffer: getElement.call(this, this.config.selectors.display.buffer),\n currentTime: getElement.call(this, this.config.selectors.display.currentTime),\n duration: getElement.call(this, this.config.selectors.display.duration)\n };\n\n // Seek tooltip\n if (is.element(this.elements.progress)) {\n this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);\n }\n return true;\n } catch (error) {\n // Log it\n this.debug.warn('It looks like there is a problem with your custom controls HTML', error);\n\n // Restore native video controls\n this.toggleNativeControls(true);\n return false;\n }\n },\n // Create <svg> icon\n createIcon(type, attributes) {\n const namespace = 'http://www.w3.org/2000/svg';\n const iconUrl = controls.getIconUrl.call(this);\n const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`;\n // Create <svg>\n const icon = document.createElementNS(namespace, 'svg');\n setAttributes(icon, extend(attributes, {\n 'aria-hidden': 'true',\n focusable: 'false'\n }));\n\n // Create the <use> to reference sprite\n const use = document.createElementNS(namespace, 'use');\n const path = `${iconPath}-${type}`;\n\n // Set `href` attributes\n // https://github.com/sampotts/plyr/issues/460\n // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href\n if ('href' in use) {\n use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path);\n }\n\n // Always set the older attribute even though it's \"deprecated\" (it'll be around for ages)\n use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path);\n\n // Add <use> to <svg>\n icon.appendChild(use);\n return icon;\n },\n // Create hidden text label\n createLabel(key, attr = {}) {\n const text = i18n.get(key, this.config);\n const attributes = {\n ...attr,\n class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ')\n };\n return createElement('span', attributes, text);\n },\n // Create a badge\n createBadge(text) {\n if (is.empty(text)) {\n return null;\n }\n const badge = createElement('span', {\n class: this.config.classNames.menu.value\n });\n badge.appendChild(createElement('span', {\n class: this.config.classNames.menu.badge\n }, text));\n return badge;\n },\n // Create a <button>\n createButton(buttonType, attr) {\n const attributes = extend({}, attr);\n let type = toCamelCase(buttonType);\n const props = {\n element: 'button',\n toggle: false,\n label: null,\n icon: null,\n labelPressed: null,\n iconPressed: null\n };\n ['element', 'icon', 'label'].forEach(key => {\n if (Object.keys(attributes).includes(key)) {\n props[key] = attributes[key];\n delete attributes[key];\n }\n });\n\n // Default to 'button' type to prevent form submission\n if (props.element === 'button' && !Object.keys(attributes).includes('type')) {\n attributes.type = 'button';\n }\n\n // Set class name\n if (Object.keys(attributes).includes('class')) {\n if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) {\n extend(attributes, {\n class: `${attributes.class} ${this.config.classNames.control}`\n });\n }\n } else {\n attributes.class = this.config.classNames.control;\n }\n\n // Large play button\n switch (buttonType) {\n case 'play':\n props.toggle = true;\n props.label = 'play';\n props.labelPressed = 'pause';\n props.icon = 'play';\n props.iconPressed = 'pause';\n break;\n case 'mute':\n props.toggle = true;\n props.label = 'mute';\n props.labelPressed = 'unmute';\n props.icon = 'volume';\n props.iconPressed = 'muted';\n break;\n case 'captions':\n props.toggle = true;\n props.label = 'enableCaptions';\n props.labelPressed = 'disableCaptions';\n props.icon = 'captions-off';\n props.iconPressed = 'captions-on';\n break;\n case 'fullscreen':\n props.toggle = true;\n props.label = 'enterFullscreen';\n props.labelPressed = 'exitFullscreen';\n props.icon = 'enter-fullscreen';\n props.iconPressed = 'exit-fullscreen';\n break;\n case 'play-large':\n attributes.class += ` ${this.config.classNames.control}--overlaid`;\n type = 'play';\n props.label = 'play';\n props.icon = 'play';\n break;\n default:\n if (is.empty(props.label)) {\n props.label = type;\n }\n if (is.empty(props.icon)) {\n props.icon = buttonType;\n }\n }\n const button = createElement(props.element);\n\n // Setup toggle icon and labels\n if (props.toggle) {\n // Icon\n button.appendChild(controls.createIcon.call(this, props.iconPressed, {\n class: 'icon--pressed'\n }));\n button.appendChild(controls.createIcon.call(this, props.icon, {\n class: 'icon--not-pressed'\n }));\n\n // Label/Tooltip\n button.appendChild(controls.createLabel.call(this, props.labelPressed, {\n class: 'label--pressed'\n }));\n button.appendChild(controls.createLabel.call(this, props.label, {\n class: 'label--not-pressed'\n }));\n } else {\n button.appendChild(controls.createIcon.call(this, props.icon));\n button.appendChild(controls.createLabel.call(this, props.label));\n }\n\n // Merge and set attributes\n extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));\n setAttributes(button, attributes);\n\n // We have multiple play buttons\n if (type === 'play') {\n if (!is.array(this.elements.buttons[type])) {\n this.elements.buttons[type] = [];\n }\n this.elements.buttons[type].push(button);\n } else {\n this.elements.buttons[type] = button;\n }\n return button;\n },\n // Create an <input type='range'>\n createRange(type, attributes) {\n // Seek input\n const input = createElement('input', extend(getAttributesFromSelector(this.config.selectors.inputs[type]), {\n type: 'range',\n min: 0,\n max: 100,\n step: 0.01,\n value: 0,\n autocomplete: 'off',\n // A11y fixes for https://github.com/sampotts/plyr/issues/905\n role: 'slider',\n 'aria-label': i18n.get(type, this.config),\n 'aria-valuemin': 0,\n 'aria-valuemax': 100,\n 'aria-valuenow': 0\n }, attributes));\n this.elements.inputs[type] = input;\n\n // Set the fill for webkit now\n controls.updateRangeFill.call(this, input);\n\n // Improve support on touch devices\n RangeTouch.setup(input);\n return input;\n },\n // Create a <progress>\n createProgress(type, attributes) {\n const progress = createElement('progress', extend(getAttributesFromSelector(this.config.selectors.display[type]), {\n min: 0,\n max: 100,\n value: 0,\n role: 'progressbar',\n 'aria-hidden': true\n }, attributes));\n\n // Create the label inside\n if (type !== 'volume') {\n progress.appendChild(createElement('span', null, '0'));\n const suffixKey = {\n played: 'played',\n buffer: 'buffered'\n }[type];\n const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';\n progress.innerText = `% ${suffix.toLowerCase()}`;\n }\n this.elements.display[type] = progress;\n return progress;\n },\n // Create time display\n createTime(type, attrs) {\n const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);\n const container = createElement('div', extend(attributes, {\n class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),\n 'aria-label': i18n.get(type, this.config),\n role: 'timer'\n }), '00:00');\n\n // Reference for updates\n this.elements.display[type] = container;\n return container;\n },\n // Bind keyboard shortcuts for a menu item\n // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus\n // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143\n bindMenuItemShortcuts(menuItem, type) {\n // Navigate through menus via arrow keys and space\n on.call(this, menuItem, 'keydown keyup', event => {\n // We only care about space and ⬆️ ⬇️️ ➡️\n if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowRight'].includes(event.key)) {\n return;\n }\n\n // Prevent play / seek\n event.preventDefault();\n event.stopPropagation();\n\n // We're just here to prevent the keydown bubbling\n if (event.type === 'keydown') {\n return;\n }\n const isRadioButton = matches(menuItem, '[role=\"menuitemradio\"]');\n\n // Show the respective menu\n if (!isRadioButton && [' ', 'ArrowRight'].includes(event.key)) {\n controls.showMenuPanel.call(this, type, true);\n } else {\n let target;\n if (event.key !== ' ') {\n if (event.key === 'ArrowDown' || isRadioButton && event.key === 'ArrowRight') {\n target = menuItem.nextElementSibling;\n if (!is.element(target)) {\n target = menuItem.parentNode.firstElementChild;\n }\n } else {\n target = menuItem.previousElementSibling;\n if (!is.element(target)) {\n target = menuItem.parentNode.lastElementChild;\n }\n }\n setFocus.call(this, target, true);\n }\n }\n }, false);\n\n // Enter will fire a `click` event but we still need to manage focus\n // So we bind to keyup which fires after and set focus here\n on.call(this, menuItem, 'keyup', event => {\n if (event.key !== 'Return') return;\n controls.focusFirstMenuItem.call(this, null, true);\n });\n },\n // Create a settings menu item\n createMenuItem({\n value,\n list,\n type,\n title,\n badge = null,\n checked = false\n }) {\n const attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);\n const menuItem = createElement('button', extend(attributes, {\n type: 'button',\n role: 'menuitemradio',\n class: `${this.config.classNames.control} ${attributes.class ? attributes.class : ''}`.trim(),\n 'aria-checked': checked,\n value\n }));\n const flex = createElement('span');\n\n // We have to set as HTML incase of special characters\n flex.innerHTML = title;\n if (is.element(badge)) {\n flex.appendChild(badge);\n }\n menuItem.appendChild(flex);\n\n // Replicate radio button behavior\n Object.defineProperty(menuItem, 'checked', {\n enumerable: true,\n get() {\n return menuItem.getAttribute('aria-checked') === 'true';\n },\n set(check) {\n // Ensure exclusivity\n if (check) {\n Array.from(menuItem.parentNode.children).filter(node => matches(node, '[role=\"menuitemradio\"]')).forEach(node => node.setAttribute('aria-checked', 'false'));\n }\n menuItem.setAttribute('aria-checked', check ? 'true' : 'false');\n }\n });\n this.listeners.bind(menuItem, 'click keyup', event => {\n if (is.keyboardEvent(event) && event.key !== ' ') {\n return;\n }\n event.preventDefault();\n event.stopPropagation();\n menuItem.checked = true;\n switch (type) {\n case 'language':\n this.currentTrack = Number(value);\n break;\n case 'quality':\n this.quality = value;\n break;\n case 'speed':\n this.speed = parseFloat(value);\n break;\n }\n controls.showMenuPanel.call(this, 'home', is.keyboardEvent(event));\n }, type, false);\n controls.bindMenuItemShortcuts.call(this, menuItem, type);\n list.appendChild(menuItem);\n },\n // Format a time for display\n formatTime(time = 0, inverted = false) {\n // Bail if the value isn't a number\n if (!is.number(time)) {\n return time;\n }\n\n // Always display hours if duration is over an hour\n const forceHours = getHours(this.duration) > 0;\n return formatTime(time, forceHours, inverted);\n },\n // Update the displayed time\n updateTimeDisplay(target = null, time = 0, inverted = false) {\n // Bail if there's no element to display or the value isn't a number\n if (!is.element(target) || !is.number(time)) {\n return;\n }\n\n // eslint-disable-next-line no-param-reassign\n target.innerText = controls.formatTime(time, inverted);\n },\n // Update volume UI and storage\n updateVolume() {\n if (!this.supported.ui) {\n return;\n }\n\n // Update range\n if (is.element(this.elements.inputs.volume)) {\n controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);\n }\n\n // Update mute state\n if (is.element(this.elements.buttons.mute)) {\n this.elements.buttons.mute.pressed = this.muted || this.volume === 0;\n }\n },\n // Update seek value and lower fill\n setRange(target, value = 0) {\n if (!is.element(target)) {\n return;\n }\n\n // eslint-disable-next-line\n target.value = value;\n\n // Webkit range fill\n controls.updateRangeFill.call(this, target);\n },\n // Update <progress> elements\n updateProgress(event) {\n if (!this.supported.ui || !is.event(event)) {\n return;\n }\n let value = 0;\n const setProgress = (target, input) => {\n const val = is.number(input) ? input : 0;\n const progress = is.element(target) ? target : this.elements.display.buffer;\n\n // Update value and label\n if (is.element(progress)) {\n progress.value = val;\n\n // Update text label inside\n const label = progress.getElementsByTagName('span')[0];\n if (is.element(label)) {\n label.childNodes[0].nodeValue = val;\n }\n }\n };\n if (event) {\n switch (event.type) {\n // Video playing\n case 'timeupdate':\n case 'seeking':\n case 'seeked':\n value = getPercentage(this.currentTime, this.duration);\n\n // Set seek range value only if it's a 'natural' time event\n if (event.type === 'timeupdate') {\n controls.setRange.call(this, this.elements.inputs.seek, value);\n }\n break;\n\n // Check buffer status\n case 'playing':\n case 'progress':\n setProgress(this.elements.display.buffer, this.buffered * 100);\n break;\n }\n }\n },\n // Webkit polyfill for lower fill range\n updateRangeFill(target) {\n // Get range from event if event passed\n const range = is.event(target) ? target.target : target;\n\n // Needs to be a valid <input type='range'>\n if (!is.element(range) || range.getAttribute('type') !== 'range') {\n return;\n }\n\n // Set aria values for https://github.com/sampotts/plyr/issues/905\n if (matches(range, this.config.selectors.inputs.seek)) {\n range.setAttribute('aria-valuenow', this.currentTime);\n const currentTime = controls.formatTime(this.currentTime);\n const duration = controls.formatTime(this.duration);\n const format = i18n.get('seekLabel', this.config);\n range.setAttribute('aria-valuetext', format.replace('{currentTime}', currentTime).replace('{duration}', duration));\n } else if (matches(range, this.config.selectors.inputs.volume)) {\n const percent = range.value * 100;\n range.setAttribute('aria-valuenow', percent);\n range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);\n } else {\n range.setAttribute('aria-valuenow', range.value);\n }\n\n // WebKit only\n if (!browser.isWebKit && !browser.isIPadOS) {\n return;\n }\n\n // Set CSS custom property\n range.style.setProperty('--value', `${range.value / range.max * 100}%`);\n },\n // Update hover tooltip for seeking\n updateSeekTooltip(event) {\n var _this$config$markers, _this$config$markers$;\n // Bail if setting not true\n if (!this.config.tooltips.seek || !is.element(this.elements.inputs.seek) || !is.element(this.elements.display.seekTooltip) || this.duration === 0) {\n return;\n }\n const tipElement = this.elements.display.seekTooltip;\n const visible = `${this.config.classNames.tooltip}--visible`;\n const toggle = show => toggleClass(tipElement, visible, show);\n\n // Hide on touch\n if (this.touch) {\n toggle(false);\n return;\n }\n\n // Determine percentage, if already visible\n let percent = 0;\n const clientRect = this.elements.progress.getBoundingClientRect();\n if (is.event(event)) {\n percent = 100 / clientRect.width * (event.pageX - clientRect.left);\n } else if (hasClass(tipElement, visible)) {\n percent = parseFloat(tipElement.style.left, 10);\n } else {\n return;\n }\n\n // Set bounds\n if (percent < 0) {\n percent = 0;\n } else if (percent > 100) {\n percent = 100;\n }\n const time = this.duration / 100 * percent;\n\n // Display the time a click would seek to\n tipElement.innerText = controls.formatTime(time);\n\n // Get marker point for time\n const point = (_this$config$markers = this.config.markers) === null || _this$config$markers === void 0 ? void 0 : (_this$config$markers$ = _this$config$markers.points) === null || _this$config$markers$ === void 0 ? void 0 : _this$config$markers$.find(({\n time: t\n }) => t === Math.round(time));\n\n // Append the point label to the tooltip\n if (point) {\n tipElement.insertAdjacentHTML('afterbegin', `${point.label}<br>`);\n }\n\n // Set position\n tipElement.style.left = `${percent}%`;\n\n // Show/hide the tooltip\n // If the event is a moues in/out and percentage is inside bounds\n if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {\n toggle(event.type === 'mouseenter');\n }\n },\n // Handle time change event\n timeUpdate(event) {\n // Only invert if only one time element is displayed and used for both duration and currentTime\n const invert = !is.element(this.elements.display.duration) && this.config.invertTime;\n\n // Duration\n controls.updateTimeDisplay.call(this, this.elements.display.currentTime, invert ? this.duration - this.currentTime : this.currentTime, invert);\n\n // Ignore updates while seeking\n if (event && event.type === 'timeupdate' && this.media.seeking) {\n return;\n }\n\n // Playing progress\n controls.updateProgress.call(this, event);\n },\n // Show the duration on metadataloaded or durationchange events\n durationUpdate() {\n // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false\n if (!this.supported.ui || !this.config.invertTime && this.currentTime) {\n return;\n }\n\n // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar.\n // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415\n // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062\n // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338\n if (this.duration >= 2 ** 32) {\n toggleHidden(this.elements.display.currentTime, true);\n toggleHidden(this.elements.progress, true);\n return;\n }\n\n // Update ARIA values\n if (is.element(this.elements.inputs.seek)) {\n this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);\n }\n\n // If there's a spot to display duration\n const hasDuration = is.element(this.elements.display.duration);\n\n // If there's only one time display, display duration there\n if (!hasDuration && this.config.displayDuration && this.paused) {\n controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);\n }\n\n // If there's a duration element, update content\n if (hasDuration) {\n controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);\n }\n if (this.config.markers.enabled) {\n controls.setMarkers.call(this);\n }\n\n // Update the tooltip (if visible)\n controls.updateSeekTooltip.call(this);\n },\n // Hide/show a tab\n toggleMenuButton(setting, toggle) {\n toggleHidden(this.elements.settings.buttons[setting], !toggle);\n },\n // Update the selected setting\n updateSetting(setting, container, input) {\n const pane = this.elements.settings.panels[setting];\n let value = null;\n let list = container;\n if (setting === 'captions') {\n value = this.currentTrack;\n } else {\n value = !is.empty(input) ? input : this[setting];\n\n // Get default\n if (is.empty(value)) {\n value = this.config[setting].default;\n }\n\n // Unsupported value\n if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {\n this.debug.warn(`Unsupported value of '${value}' for ${setting}`);\n return;\n }\n\n // Disabled value\n if (!this.config[setting].options.includes(value)) {\n this.debug.warn(`Disabled value of '${value}' for ${setting}`);\n return;\n }\n }\n\n // Get the list if we need to\n if (!is.element(list)) {\n list = pane && pane.querySelector('[role=\"menu\"]');\n }\n\n // If there's no list it means it's not been rendered...\n if (!is.element(list)) {\n return;\n }\n\n // Update the label\n const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`);\n label.innerHTML = controls.getLabel.call(this, setting, value);\n\n // Find the radio option and check it\n const target = list && list.querySelector(`[value=\"${value}\"]`);\n if (is.element(target)) {\n target.checked = true;\n }\n },\n // Translate a value into a nice label\n getLabel(setting, value) {\n switch (setting) {\n case 'speed':\n return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;\n case 'quality':\n if (is.number(value)) {\n const label = i18n.get(`qualityLabel.${value}`, this.config);\n if (!label.length) {\n return `${value}p`;\n }\n return label;\n }\n return toTitleCase(value);\n case 'captions':\n return captions.getLabel.call(this);\n default:\n return null;\n }\n },\n // Set the quality menu\n setQualityMenu(options) {\n // Menu required\n if (!is.element(this.elements.settings.panels.quality)) {\n return;\n }\n const type = 'quality';\n const list = this.elements.settings.panels.quality.querySelector('[role=\"menu\"]');\n\n // Set options if passed and filter based on uniqueness and config\n if (is.array(options)) {\n this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));\n }\n\n // Toggle the pane and tab\n const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;\n controls.toggleMenuButton.call(this, type, toggle);\n\n // Empty the menu\n emptyElement(list);\n\n // Check if we need to toggle the parent\n controls.checkMenu.call(this);\n\n // If we're hiding, nothing more to do\n if (!toggle) {\n return;\n }\n\n // Get the badge HTML for HD, 4K etc\n const getBadge = quality => {\n const label = i18n.get(`qualityBadge.${quality}`, this.config);\n if (!label.length) {\n return null;\n }\n return controls.createBadge.call(this, label);\n };\n\n // Sort options by the config and then render options\n this.options.quality.sort((a, b) => {\n const sorting = this.config.quality.options;\n return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;\n }).forEach(quality => {\n controls.createMenuItem.call(this, {\n value: quality,\n list,\n type,\n title: controls.getLabel.call(this, 'quality', quality),\n badge: getBadge(quality)\n });\n });\n controls.updateSetting.call(this, type, list);\n },\n // Set the looping options\n /* setLoopMenu() {\n // Menu required\n if (!is.element(this.elements.settings.panels.loop)) {\n return;\n }\n const options = ['start', 'end', 'all', 'reset'];\n const list = this.elements.settings.panels.loop.querySelector('[role=\"menu\"]');\n // Show the pane and tab\n toggleHidden(this.elements.settings.buttons.loop, false);\n toggleHidden(this.elements.settings.panels.loop, false);\n // Toggle the pane and tab\n const toggle = !is.empty(this.loop.options);\n controls.toggleMenuButton.call(this, 'loop', toggle);\n // Empty the menu\n emptyElement(list);\n options.forEach(option => {\n const item = createElement('li');\n const button = createElement(\n 'button',\n extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {\n type: 'button',\n class: this.config.classNames.control,\n 'data-plyr-loop-action': option,\n }),\n i18n.get(option, this.config)\n );\n if (['start', 'end'].includes(option)) {\n const badge = controls.createBadge.call(this, '00:00');\n button.appendChild(badge);\n }\n item.appendChild(button);\n list.appendChild(item);\n });\n }, */\n\n // Get current selected caption language\n // TODO: rework this to user the getter in the API?\n\n // Set a list of available captions languages\n setCaptionsMenu() {\n // Menu required\n if (!is.element(this.elements.settings.panels.captions)) {\n return;\n }\n\n // TODO: Captions or language? Currently it's mixed\n const type = 'captions';\n const list = this.elements.settings.panels.captions.querySelector('[role=\"menu\"]');\n const tracks = captions.getTracks.call(this);\n const toggle = Boolean(tracks.length);\n\n // Toggle the pane and tab\n controls.toggleMenuButton.call(this, type, toggle);\n\n // Empty the menu\n emptyElement(list);\n\n // Check if we need to toggle the parent\n controls.checkMenu.call(this);\n\n // If there's no captions, bail\n if (!toggle) {\n return;\n }\n\n // Generate options data\n const options = tracks.map((track, value) => ({\n value,\n checked: this.captions.toggled && this.currentTrack === value,\n title: captions.getLabel.call(this, track),\n badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),\n list,\n type: 'language'\n }));\n\n // Add the \"Disabled\" option to turn off captions\n options.unshift({\n value: -1,\n checked: !this.captions.toggled,\n title: i18n.get('disabled', this.config),\n list,\n type: 'language'\n });\n\n // Generate options\n options.forEach(controls.createMenuItem.bind(this));\n controls.updateSetting.call(this, type, list);\n },\n // Set a list of available captions languages\n setSpeedMenu() {\n // Menu required\n if (!is.element(this.elements.settings.panels.speed)) {\n return;\n }\n const type = 'speed';\n const list = this.elements.settings.panels.speed.querySelector('[role=\"menu\"]');\n\n // Filter out invalid speeds\n this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed);\n\n // Toggle the pane and tab\n const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;\n controls.toggleMenuButton.call(this, type, toggle);\n\n // Empty the menu\n emptyElement(list);\n\n // Check if we need to toggle the parent\n controls.checkMenu.call(this);\n\n // If we're hiding, nothing more to do\n if (!toggle) {\n return;\n }\n\n // Create items\n this.options.speed.forEach(speed => {\n controls.createMenuItem.call(this, {\n value: speed,\n list,\n type,\n title: controls.getLabel.call(this, 'speed', speed)\n });\n });\n controls.updateSetting.call(this, type, list);\n },\n // Check if we need to hide/show the settings menu\n checkMenu() {\n const {\n buttons\n } = this.elements.settings;\n const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);\n toggleHidden(this.elements.settings.menu, !visible);\n },\n // Focus the first menu item in a given (or visible) menu\n focusFirstMenuItem(pane, focusVisible = false) {\n if (this.elements.settings.popup.hidden) {\n return;\n }\n let target = pane;\n if (!is.element(target)) {\n target = Object.values(this.elements.settings.panels).find(p => !p.hidden);\n }\n const firstItem = target.querySelector('[role^=\"menuitem\"]');\n setFocus.call(this, firstItem, focusVisible);\n },\n // Show/hide menu\n toggleMenu(input) {\n const {\n popup\n } = this.elements.settings;\n const button = this.elements.buttons.settings;\n\n // Menu and button are required\n if (!is.element(popup) || !is.element(button)) {\n return;\n }\n\n // True toggle by default\n const {\n hidden\n } = popup;\n let show = hidden;\n if (is.boolean(input)) {\n show = input;\n } else if (is.keyboardEvent(input) && input.key === 'Escape') {\n show = false;\n } else if (is.event(input)) {\n // If Plyr is in a shadowDOM, the event target is set to the component, instead of the\n // Element in the shadowDOM. The path, if available, is complete.\n const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;\n const isMenuItem = popup.contains(target);\n\n // If the click was inside the menu or if the click\n // wasn't the button or menu item and we're trying to\n // show the menu (a doc click shouldn't show the menu)\n if (isMenuItem || !isMenuItem && input.target !== button && show) {\n return;\n }\n }\n\n // Set button attributes\n button.setAttribute('aria-expanded', show);\n\n // Show the actual popup\n toggleHidden(popup, !show);\n\n // Add class hook\n toggleClass(this.elements.container, this.config.classNames.menu.open, show);\n\n // Focus the first item if key interaction\n if (show && is.keyboardEvent(input)) {\n controls.focusFirstMenuItem.call(this, null, true);\n } else if (!show && !hidden) {\n // If closing, re-focus the button\n setFocus.call(this, button, is.keyboardEvent(input));\n }\n },\n // Get the natural size of a menu panel\n getMenuSize(tab) {\n const clone = tab.cloneNode(true);\n clone.style.position = 'absolute';\n clone.style.opacity = 0;\n clone.removeAttribute('hidden');\n\n // Append to parent so we get the \"real\" size\n tab.parentNode.appendChild(clone);\n\n // Get the sizes before we remove\n const width = clone.scrollWidth;\n const height = clone.scrollHeight;\n\n // Remove from the DOM\n removeElement(clone);\n return {\n width,\n height\n };\n },\n // Show a panel in the menu\n showMenuPanel(type = '', focusVisible = false) {\n const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);\n\n // Nothing to show, bail\n if (!is.element(target)) {\n return;\n }\n\n // Hide all other panels\n const container = target.parentNode;\n const current = Array.from(container.children).find(node => !node.hidden);\n\n // If we can do fancy animations, we'll animate the height/width\n if (support.transitions && !support.reducedMotion) {\n // Set the current width as a base\n container.style.width = `${current.scrollWidth}px`;\n container.style.height = `${current.scrollHeight}px`;\n\n // Get potential sizes\n const size = controls.getMenuSize.call(this, target);\n\n // Restore auto height/width\n const restore = event => {\n // We're only bothered about height and width on the container\n if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {\n return;\n }\n\n // Revert back to auto\n container.style.width = '';\n container.style.height = '';\n\n // Only listen once\n off.call(this, container, transitionEndEvent, restore);\n };\n\n // Listen for the transition finishing and restore auto height/width\n on.call(this, container, transitionEndEvent, restore);\n\n // Set dimensions to target\n container.style.width = `${size.width}px`;\n container.style.height = `${size.height}px`;\n }\n\n // Set attributes on current tab\n toggleHidden(current, true);\n\n // Set attributes on target\n toggleHidden(target, false);\n\n // Focus the first item\n controls.focusFirstMenuItem.call(this, target, focusVisible);\n },\n // Set the download URL\n setDownloadUrl() {\n const button = this.elements.buttons.download;\n\n // Bail if no button\n if (!is.element(button)) {\n return;\n }\n\n // Set attribute\n button.setAttribute('href', this.download);\n },\n // Build the default HTML\n create(data) {\n const {\n bindMenuItemShortcuts,\n createButton,\n createProgress,\n createRange,\n createTime,\n setQualityMenu,\n setSpeedMenu,\n showMenuPanel\n } = controls;\n this.elements.controls = null;\n\n // Larger overlaid play button\n if (is.array(this.config.controls) && this.config.controls.includes('play-large')) {\n this.elements.container.appendChild(createButton.call(this, 'play-large'));\n }\n\n // Create the container\n const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));\n this.elements.controls = container;\n\n // Default item attributes\n const defaultAttributes = {\n class: 'plyr__controls__item'\n };\n\n // Loop through controls in order\n dedupe(is.array(this.config.controls) ? this.config.controls : []).forEach(control => {\n // Restart button\n if (control === 'restart') {\n container.appendChild(createButton.call(this, 'restart', defaultAttributes));\n }\n\n // Rewind button\n if (control === 'rewind') {\n container.appendChild(createButton.call(this, 'rewind', defaultAttributes));\n }\n\n // Play/Pause button\n if (control === 'play') {\n container.appendChild(createButton.call(this, 'play', defaultAttributes));\n }\n\n // Fast forward button\n if (control === 'fast-forward') {\n container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));\n }\n\n // Progress\n if (control === 'progress') {\n const progressContainer = createElement('div', {\n class: `${defaultAttributes.class} plyr__progress__container`\n });\n const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));\n\n // Seek range slider\n progress.appendChild(createRange.call(this, 'seek', {\n id: `plyr-seek-${data.id}`\n }));\n\n // Buffer progress\n progress.appendChild(createProgress.call(this, 'buffer'));\n\n // TODO: Add loop display indicator\n\n // Seek tooltip\n if (this.config.tooltips.seek) {\n const tooltip = createElement('span', {\n class: this.config.classNames.tooltip\n }, '00:00');\n progress.appendChild(tooltip);\n this.elements.display.seekTooltip = tooltip;\n }\n this.elements.progress = progress;\n progressContainer.appendChild(this.elements.progress);\n container.appendChild(progressContainer);\n }\n\n // Media current time display\n if (control === 'current-time') {\n container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));\n }\n\n // Media duration display\n if (control === 'duration') {\n container.appendChild(createTime.call(this, 'duration', defaultAttributes));\n }\n\n // Volume controls\n if (control === 'mute' || control === 'volume') {\n let {\n volume\n } = this.elements;\n\n // Create the volume container if needed\n if (!is.element(volume) || !container.contains(volume)) {\n volume = createElement('div', extend({}, defaultAttributes, {\n class: `${defaultAttributes.class} plyr__volume`.trim()\n }));\n this.elements.volume = volume;\n container.appendChild(volume);\n }\n\n // Toggle mute button\n if (control === 'mute') {\n volume.appendChild(createButton.call(this, 'mute'));\n }\n\n // Volume range control\n // Ignored on iOS as it's handled globally\n // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html\n if (control === 'volume' && !browser.isIos && !browser.isIPadOS) {\n // Set the attributes\n const attributes = {\n max: 1,\n step: 0.05,\n value: this.config.volume\n };\n\n // Create the volume range slider\n volume.appendChild(createRange.call(this, 'volume', extend(attributes, {\n id: `plyr-volume-${data.id}`\n })));\n }\n }\n\n // Toggle captions button\n if (control === 'captions') {\n container.appendChild(createButton.call(this, 'captions', defaultAttributes));\n }\n\n // Settings button / menu\n if (control === 'settings' && !is.empty(this.config.settings)) {\n const wrapper = createElement('div', extend({}, defaultAttributes, {\n class: `${defaultAttributes.class} plyr__menu`.trim(),\n hidden: ''\n }));\n wrapper.appendChild(createButton.call(this, 'settings', {\n 'aria-haspopup': true,\n 'aria-controls': `plyr-settings-${data.id}`,\n 'aria-expanded': false\n }));\n const popup = createElement('div', {\n class: 'plyr__menu__container',\n id: `plyr-settings-${data.id}`,\n hidden: ''\n });\n const inner = createElement('div');\n const home = createElement('div', {\n id: `plyr-settings-${data.id}-home`\n });\n\n // Create the menu\n const menu = createElement('div', {\n role: 'menu'\n });\n home.appendChild(menu);\n inner.appendChild(home);\n this.elements.settings.panels.home = home;\n\n // Build the menu items\n this.config.settings.forEach(type => {\n // TODO: bundle this with the createMenuItem helper and bindings\n const menuItem = createElement('button', extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {\n type: 'button',\n class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,\n role: 'menuitem',\n 'aria-haspopup': true,\n hidden: ''\n }));\n\n // Bind menu shortcuts for keyboard users\n bindMenuItemShortcuts.call(this, menuItem, type);\n\n // Show menu on click\n on.call(this, menuItem, 'click', () => {\n showMenuPanel.call(this, type, false);\n });\n const flex = createElement('span', null, i18n.get(type, this.config));\n const value = createElement('span', {\n class: this.config.classNames.menu.value\n });\n\n // Speed contains HTML entities\n value.innerHTML = data[type];\n flex.appendChild(value);\n menuItem.appendChild(flex);\n menu.appendChild(menuItem);\n\n // Build the panes\n const pane = createElement('div', {\n id: `plyr-settings-${data.id}-${type}`,\n hidden: ''\n });\n\n // Back button\n const backButton = createElement('button', {\n type: 'button',\n class: `${this.config.classNames.control} ${this.config.classNames.control}--back`\n });\n\n // Visible label\n backButton.appendChild(createElement('span', {\n 'aria-hidden': true\n }, i18n.get(type, this.config)));\n\n // Screen reader label\n backButton.appendChild(createElement('span', {\n class: this.config.classNames.hidden\n }, i18n.get('menuBack', this.config)));\n\n // Go back via keyboard\n on.call(this, pane, 'keydown', event => {\n if (event.key !== 'ArrowLeft') return;\n\n // Prevent seek\n event.preventDefault();\n event.stopPropagation();\n\n // Show the respective menu\n showMenuPanel.call(this, 'home', true);\n }, false);\n\n // Go back via button click\n on.call(this, backButton, 'click', () => {\n showMenuPanel.call(this, 'home', false);\n });\n\n // Add to pane\n pane.appendChild(backButton);\n\n // Menu\n pane.appendChild(createElement('div', {\n role: 'menu'\n }));\n inner.appendChild(pane);\n this.elements.settings.buttons[type] = menuItem;\n this.elements.settings.panels[type] = pane;\n });\n popup.appendChild(inner);\n wrapper.appendChild(popup);\n container.appendChild(wrapper);\n this.elements.settings.popup = popup;\n this.elements.settings.menu = wrapper;\n }\n\n // Picture in picture button\n if (control === 'pip' && support.pip) {\n container.appendChild(createButton.call(this, 'pip', defaultAttributes));\n }\n\n // Airplay button\n if (control === 'airplay' && support.airplay) {\n container.appendChild(createButton.call(this, 'airplay', defaultAttributes));\n }\n\n // Download button\n if (control === 'download') {\n const attributes = extend({}, defaultAttributes, {\n element: 'a',\n href: this.download,\n target: '_blank'\n });\n\n // Set download attribute for HTML5 only\n if (this.isHTML5) {\n attributes.download = '';\n }\n const {\n download\n } = this.config.urls;\n if (!is.url(download) && this.isEmbed) {\n extend(attributes, {\n icon: `logo-${this.provider}`,\n label: this.provider\n });\n }\n container.appendChild(createButton.call(this, 'download', attributes));\n }\n\n // Toggle fullscreen button\n if (control === 'fullscreen') {\n container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));\n }\n });\n\n // Set available quality levels\n if (this.isHTML5) {\n setQualityMenu.call(this, html5.getQualityOptions.call(this));\n }\n setSpeedMenu.call(this);\n return container;\n },\n // Insert controls\n inject() {\n // Sprite\n if (this.config.loadSprite) {\n const icon = controls.getIconUrl.call(this);\n\n // Only load external sprite using AJAX\n if (icon.cors) {\n loadSprite(icon.url, 'sprite-plyr');\n }\n }\n\n // Create a unique ID\n this.id = Math.floor(Math.random() * 10000);\n\n // Null by default\n let container = null;\n this.elements.controls = null;\n\n // Set template properties\n const props = {\n id: this.id,\n seektime: this.config.seekTime,\n title: this.config.title\n };\n let update = true;\n\n // If function, run it and use output\n if (is.function(this.config.controls)) {\n this.config.controls = this.config.controls.call(this, props);\n }\n\n // Convert falsy controls to empty array (primarily for empty strings)\n if (!this.config.controls) {\n this.config.controls = [];\n }\n if (is.element(this.config.controls) || is.string(this.config.controls)) {\n // HTMLElement or Non-empty string passed as the option\n container = this.config.controls;\n } else {\n // Create controls\n container = controls.create.call(this, {\n id: this.id,\n seektime: this.config.seekTime,\n speed: this.speed,\n quality: this.quality,\n captions: captions.getLabel.call(this)\n // TODO: Looping\n // loop: 'None',\n });\n\n update = false;\n }\n\n // Replace props with their value\n const replace = input => {\n let result = input;\n Object.entries(props).forEach(([key, value]) => {\n result = replaceAll(result, `{${key}}`, value);\n });\n return result;\n };\n\n // Update markup\n if (update) {\n if (is.string(this.config.controls)) {\n container = replace(container);\n }\n }\n\n // Controls container\n let target;\n\n // Inject to custom location\n if (is.string(this.config.selectors.controls.container)) {\n target = document.querySelector(this.config.selectors.controls.container);\n }\n\n // Inject into the container by default\n if (!is.element(target)) {\n target = this.elements.container;\n }\n\n // Inject controls HTML (needs to be before captions, hence \"afterbegin\")\n const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';\n target[insertMethod]('afterbegin', container);\n\n // Find the elements if need be\n if (!is.element(this.elements.controls)) {\n controls.findElements.call(this);\n }\n\n // Add pressed property to buttons\n if (!is.empty(this.elements.buttons)) {\n const addProperty = button => {\n const className = this.config.classNames.controlPressed;\n button.setAttribute('aria-pressed', 'false');\n Object.defineProperty(button, 'pressed', {\n configurable: true,\n enumerable: true,\n get() {\n return hasClass(button, className);\n },\n set(pressed = false) {\n toggleClass(button, className, pressed);\n button.setAttribute('aria-pressed', pressed ? 'true' : 'false');\n }\n });\n };\n\n // Toggle classname when pressed property is set\n Object.values(this.elements.buttons).filter(Boolean).forEach(button => {\n if (is.array(button) || is.nodeList(button)) {\n Array.from(button).filter(Boolean).forEach(addProperty);\n } else {\n addProperty(button);\n }\n });\n }\n\n // Edge sometimes doesn't finish the paint so force a repaint\n if (browser.isEdge) {\n repaint(target);\n }\n\n // Setup tooltips\n if (this.config.tooltips.controls) {\n const {\n classNames,\n selectors\n } = this.config;\n const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;\n const labels = getElements.call(this, selector);\n Array.from(labels).forEach(label => {\n toggleClass(label, this.config.classNames.hidden, false);\n toggleClass(label, this.config.classNames.tooltip, true);\n });\n }\n },\n // Set media metadata\n setMediaMetadata() {\n try {\n if ('mediaSession' in navigator) {\n navigator.mediaSession.metadata = new window.MediaMetadata({\n title: this.config.mediaMetadata.title,\n artist: this.config.mediaMetadata.artist,\n album: this.config.mediaMetadata.album,\n artwork: this.config.mediaMetadata.artwork\n });\n }\n } catch (_) {\n // Do nothing\n }\n },\n // Add markers\n setMarkers() {\n var _this$config$markers2, _this$config$markers3;\n if (!this.duration || this.elements.markers) return;\n\n // Get valid points\n const points = (_this$config$markers2 = this.config.markers) === null || _this$config$markers2 === void 0 ? void 0 : (_this$config$markers3 = _this$config$markers2.points) === null || _this$config$markers3 === void 0 ? void 0 : _this$config$markers3.filter(({\n time\n }) => time > 0 && time < this.duration);\n if (!(points !== null && points !== void 0 && points.length)) return;\n const containerFragment = document.createDocumentFragment();\n const pointsFragment = document.createDocumentFragment();\n let tipElement = null;\n const tipVisible = `${this.config.classNames.tooltip}--visible`;\n const toggleTip = show => toggleClass(tipElement, tipVisible, show);\n\n // Inject markers to progress container\n points.forEach(point => {\n const markerElement = createElement('span', {\n class: this.config.classNames.marker\n }, '');\n const left = `${point.time / this.duration * 100}%`;\n if (tipElement) {\n // Show on hover\n markerElement.addEventListener('mouseenter', () => {\n if (point.label) return;\n tipElement.style.left = left;\n tipElement.innerHTML = point.label;\n toggleTip(true);\n });\n\n // Hide on leave\n markerElement.addEventListener('mouseleave', () => {\n toggleTip(false);\n });\n }\n markerElement.addEventListener('click', () => {\n this.currentTime = point.time;\n });\n markerElement.style.left = left;\n pointsFragment.appendChild(markerElement);\n });\n containerFragment.appendChild(pointsFragment);\n\n // Inject a tooltip if needed\n if (!this.config.tooltips.seek) {\n tipElement = createElement('span', {\n class: this.config.classNames.tooltip\n }, '');\n containerFragment.appendChild(tipElement);\n }\n this.elements.markers = {\n points: pointsFragment,\n tip: tipElement\n };\n this.elements.progress.appendChild(containerFragment);\n }\n };\n\n // ==========================================================================\n\n /**\n * Parse a string to a URL object\n * @param {String} input - the URL to be parsed\n * @param {Boolean} safe - failsafe parsing\n */\n function parseUrl(input, safe = true) {\n let url = input;\n if (safe) {\n const parser = document.createElement('a');\n parser.href = url;\n url = parser.href;\n }\n try {\n return new URL(url);\n } catch (_) {\n return null;\n }\n }\n\n // Convert object to URLSearchParams\n function buildUrlParams(input) {\n const params = new URLSearchParams();\n if (is.object(input)) {\n Object.entries(input).forEach(([key, value]) => {\n params.set(key, value);\n });\n }\n return params;\n }\n\n // ==========================================================================\n const captions = {\n // Setup captions\n setup() {\n // Requires UI support\n if (!this.supported.ui) {\n return;\n }\n\n // Only Vimeo and HTML5 video supported at this point\n if (!this.isVideo || this.isYouTube || this.isHTML5 && !support.textTracks) {\n // Clear menu and hide\n if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {\n controls.setCaptionsMenu.call(this);\n }\n return;\n }\n\n // Inject the container\n if (!is.element(this.elements.captions)) {\n this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));\n this.elements.captions.setAttribute('dir', 'auto');\n insertAfter(this.elements.captions, this.elements.wrapper);\n }\n\n // Fix IE captions if CORS is used\n // Fetch captions and inject as blobs instead (data URIs not supported!)\n if (browser.isIE && window.URL) {\n const elements = this.media.querySelectorAll('track');\n Array.from(elements).forEach(track => {\n const src = track.getAttribute('src');\n const url = parseUrl(src);\n if (url !== null && url.hostname !== window.location.href.hostname && ['http:', 'https:'].includes(url.protocol)) {\n fetch(src, 'blob').then(blob => {\n track.setAttribute('src', window.URL.createObjectURL(blob));\n }).catch(() => {\n removeElement(track);\n });\n }\n });\n }\n\n // Get and set initial data\n // The \"preferred\" options are not realized unless / until the wanted language has a match\n // * languages: Array of user's browser languages.\n // * language: The language preferred by user settings or config\n // * active: The state preferred by user settings or config\n // * toggled: The real captions state\n\n const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];\n const languages = dedupe(browserLanguages.map(language => language.split('-')[0]));\n let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();\n\n // Use first browser language when language is 'auto'\n if (language === 'auto') {\n [language] = languages;\n }\n let active = this.storage.get('captions');\n if (!is.boolean(active)) {\n ({\n active\n } = this.config.captions);\n }\n Object.assign(this.captions, {\n toggled: false,\n active,\n language,\n languages\n });\n\n // Watch changes to textTracks and update captions menu\n if (this.isHTML5) {\n const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';\n on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));\n }\n\n // Update available languages in list next tick (the event must not be triggered before the listeners)\n setTimeout(captions.update.bind(this), 0);\n },\n // Update available language options in settings based on tracks\n update() {\n const tracks = captions.getTracks.call(this, true);\n // Get the wanted language\n const {\n active,\n language,\n meta,\n currentTrackNode\n } = this.captions;\n const languageExists = Boolean(tracks.find(track => track.language === language));\n\n // Handle tracks (add event listener and \"pseudo\"-default)\n if (this.isHTML5 && this.isVideo) {\n tracks.filter(track => !meta.get(track)).forEach(track => {\n this.debug.log('Track added', track);\n\n // Attempt to store if the original dom element was \"default\"\n meta.set(track, {\n default: track.mode === 'showing'\n });\n\n // Turn off native caption rendering to avoid double captions\n // Note: mode='hidden' forces a track to download. To ensure every track\n // isn't downloaded at once, only 'showing' tracks should be reassigned\n // eslint-disable-next-line no-param-reassign\n if (track.mode === 'showing') {\n // eslint-disable-next-line no-param-reassign\n track.mode = 'hidden';\n }\n\n // Add event listener for cue changes\n on.call(this, track, 'cuechange', () => captions.updateCues.call(this));\n });\n }\n\n // Update language first time it matches, or if the previous matching track was removed\n if (languageExists && this.language !== language || !tracks.includes(currentTrackNode)) {\n captions.setLanguage.call(this, language);\n captions.toggle.call(this, active && languageExists);\n }\n\n // Enable or disable captions based on track length\n if (this.elements) {\n toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));\n }\n\n // Update available languages in list\n if (is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {\n controls.setCaptionsMenu.call(this);\n }\n },\n // Toggle captions display\n // Used internally for the toggleCaptions method, with the passive option forced to false\n toggle(input, passive = true) {\n // If there's no full support\n if (!this.supported.ui) {\n return;\n }\n const {\n toggled\n } = this.captions; // Current state\n const activeClass = this.config.classNames.captions.active;\n // Get the next state\n // If the method is called without parameter, toggle based on current value\n const active = is.nullOrUndefined(input) ? !toggled : input;\n\n // Update state and trigger event\n if (active !== toggled) {\n // When passive, don't override user preferences\n if (!passive) {\n this.captions.active = active;\n this.storage.set({\n captions: active\n });\n }\n\n // Force language if the call isn't passive and there is no matching language to toggle to\n if (!this.language && active && !passive) {\n const tracks = captions.getTracks.call(this);\n const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);\n\n // Override user preferences to avoid switching languages if a matching track is added\n this.captions.language = track.language;\n\n // Set caption, but don't store in localStorage as user preference\n captions.set.call(this, tracks.indexOf(track));\n return;\n }\n\n // Toggle button if it's enabled\n if (this.elements.buttons.captions) {\n this.elements.buttons.captions.pressed = active;\n }\n\n // Add class hook\n toggleClass(this.elements.container, activeClass, active);\n this.captions.toggled = active;\n\n // Update settings menu\n controls.updateSetting.call(this, 'captions');\n\n // Trigger event (not used internally)\n triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');\n }\n\n // Wait for the call stack to clear before setting mode='hidden'\n // on the active track - forcing the browser to download it\n setTimeout(() => {\n if (active && this.captions.toggled) {\n this.captions.currentTrackNode.mode = 'hidden';\n }\n });\n },\n // Set captions by track index\n // Used internally for the currentTrack setter with the passive option forced to false\n set(index, passive = true) {\n const tracks = captions.getTracks.call(this);\n\n // Disable captions if setting to -1\n if (index === -1) {\n captions.toggle.call(this, false, passive);\n return;\n }\n if (!is.number(index)) {\n this.debug.warn('Invalid caption argument', index);\n return;\n }\n if (!(index in tracks)) {\n this.debug.warn('Track not found', index);\n return;\n }\n if (this.captions.currentTrack !== index) {\n this.captions.currentTrack = index;\n const track = tracks[index];\n const {\n language\n } = track || {};\n\n // Store reference to node for invalidation on remove\n this.captions.currentTrackNode = track;\n\n // Update settings menu\n controls.updateSetting.call(this, 'captions');\n\n // When passive, don't override user preferences\n if (!passive) {\n this.captions.language = language;\n this.storage.set({\n language\n });\n }\n\n // Handle Vimeo captions\n if (this.isVimeo) {\n this.embed.enableTextTrack(language);\n }\n\n // Trigger event\n triggerEvent.call(this, this.media, 'languagechange');\n }\n\n // Show captions\n captions.toggle.call(this, true, passive);\n if (this.isHTML5 && this.isVideo) {\n // If we change the active track while a cue is already displayed we need to update it\n captions.updateCues.call(this);\n }\n },\n // Set captions by language\n // Used internally for the language setter with the passive option forced to false\n setLanguage(input, passive = true) {\n if (!is.string(input)) {\n this.debug.warn('Invalid language argument', input);\n return;\n }\n // Normalize\n const language = input.toLowerCase();\n this.captions.language = language;\n\n // Set currentTrack\n const tracks = captions.getTracks.call(this);\n const track = captions.findTrack.call(this, [language]);\n captions.set.call(this, tracks.indexOf(track), passive);\n },\n // Get current valid caption tracks\n // If update is false it will also ignore tracks without metadata\n // This is used to \"freeze\" the language options when captions.update is false\n getTracks(update = false) {\n // Handle media or textTracks missing or null\n const tracks = Array.from((this.media || {}).textTracks || []);\n // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)\n // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)\n return tracks.filter(track => !this.isHTML5 || update || this.captions.meta.has(track)).filter(track => ['captions', 'subtitles'].includes(track.kind));\n },\n // Match tracks based on languages and get the first\n findTrack(languages, force = false) {\n const tracks = captions.getTracks.call(this);\n const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);\n const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));\n let track;\n languages.every(language => {\n track = sorted.find(t => t.language === language);\n return !track; // Break iteration if there is a match\n });\n\n // If no match is found but is required, get first\n return track || (force ? sorted[0] : undefined);\n },\n // Get the current track\n getCurrentTrack() {\n return captions.getTracks.call(this)[this.currentTrack];\n },\n // Get UI label for track\n getLabel(track) {\n let currentTrack = track;\n if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {\n currentTrack = captions.getCurrentTrack.call(this);\n }\n if (is.track(currentTrack)) {\n if (!is.empty(currentTrack.label)) {\n return currentTrack.label;\n }\n if (!is.empty(currentTrack.language)) {\n return track.language.toUpperCase();\n }\n return i18n.get('enabled', this.config);\n }\n return i18n.get('disabled', this.config);\n },\n // Update captions using current track's active cues\n // Also optional array argument in case there isn't any track (ex: vimeo)\n updateCues(input) {\n // Requires UI\n if (!this.supported.ui) {\n return;\n }\n if (!is.element(this.elements.captions)) {\n this.debug.warn('No captions element to render to');\n return;\n }\n\n // Only accept array or empty input\n if (!is.nullOrUndefined(input) && !Array.isArray(input)) {\n this.debug.warn('updateCues: Invalid input', input);\n return;\n }\n let cues = input;\n\n // Get cues from track\n if (!cues) {\n const track = captions.getCurrentTrack.call(this);\n cues = Array.from((track || {}).activeCues || []).map(cue => cue.getCueAsHTML()).map(getHTML);\n }\n\n // Set new caption text\n const content = cues.map(cueText => cueText.trim()).join('\\n');\n const changed = content !== this.elements.captions.innerHTML;\n if (changed) {\n // Empty the container and create a new child element\n emptyElement(this.elements.captions);\n const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));\n caption.innerHTML = content;\n this.elements.captions.appendChild(caption);\n\n // Trigger event\n triggerEvent.call(this, this.media, 'cuechange');\n }\n }\n };\n\n // ==========================================================================\n // Plyr default config\n // ==========================================================================\n\n const defaults = {\n // Disable\n enabled: true,\n // Custom media title\n title: '',\n // Logging to console\n debug: false,\n // Auto play (if supported)\n autoplay: false,\n // Only allow one media playing at once (vimeo only)\n autopause: true,\n // Allow inline playback on iOS\n playsinline: true,\n // Default time to skip when rewind/fast forward\n seekTime: 10,\n // Default volume\n volume: 1,\n muted: false,\n // Pass a custom duration\n duration: null,\n // Display the media duration on load in the current time position\n // If you have opted to display both duration and currentTime, this is ignored\n displayDuration: true,\n // Invert the current time to be a countdown\n invertTime: true,\n // Clicking the currentTime inverts it's value to show time left rather than elapsed\n toggleInvert: true,\n // Force an aspect ratio\n // The format must be `'w:h'` (e.g. `'16:9'`)\n ratio: null,\n // Click video container to play/pause\n clickToPlay: true,\n // Auto hide the controls\n hideControls: true,\n // Reset to start when playback ended\n resetOnEnd: false,\n // Disable the standard context menu\n disableContextMenu: true,\n // Sprite (for icons)\n loadSprite: true,\n iconPrefix: 'plyr',\n iconUrl: 'https://cdn.plyr.io/3.7.8/plyr.svg',\n // Blank video (used to prevent errors on source change)\n blankVideo: 'https://cdn.plyr.io/static/blank.mp4',\n // Quality default\n quality: {\n default: 576,\n // The options to display in the UI, if available for the source media\n options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240],\n forced: false,\n onChange: null\n },\n // Set loops\n loop: {\n active: false\n // start: null,\n // end: null,\n },\n\n // Speed default and options to display\n speed: {\n selected: 1,\n // The options to display in the UI, if available for the source media (e.g. Vimeo and YouTube only support 0.5x-4x)\n options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4]\n },\n // Keyboard shortcut settings\n keyboard: {\n focused: true,\n global: false\n },\n // Display tooltips\n tooltips: {\n controls: false,\n seek: true\n },\n // Captions settings\n captions: {\n active: false,\n language: 'auto',\n // Listen to new tracks added after Plyr is initialized.\n // This is needed for streaming captions, but may result in unselectable options\n update: false\n },\n // Fullscreen settings\n fullscreen: {\n enabled: true,\n // Allow fullscreen?\n fallback: true,\n // Fallback using full viewport/window\n iosNative: false // Use the native fullscreen in iOS (disables custom controls)\n // Selector for the fullscreen container so contextual / non-player content can remain visible in fullscreen mode\n // Non-ancestors of the player element will be ignored\n // container: null, // defaults to the player element\n },\n\n // Local storage\n storage: {\n enabled: true,\n key: 'plyr'\n },\n // Default controls\n controls: ['play-large',\n // 'restart',\n // 'rewind',\n 'play',\n // 'fast-forward',\n 'progress', 'current-time',\n // 'duration',\n 'mute', 'volume', 'captions', 'settings', 'pip', 'airplay',\n // 'download',\n 'fullscreen'],\n settings: ['captions', 'quality', 'speed'],\n // Localisation\n i18n: {\n restart: 'Restart',\n rewind: 'Rewind {seektime}s',\n play: 'Play',\n pause: 'Pause',\n fastForward: 'Forward {seektime}s',\n seek: 'Seek',\n seekLabel: '{currentTime} of {duration}',\n played: 'Played',\n buffered: 'Buffered',\n currentTime: 'Current time',\n duration: 'Duration',\n volume: 'Volume',\n mute: 'Mute',\n unmute: 'Unmute',\n enableCaptions: 'Enable captions',\n disableCaptions: 'Disable captions',\n download: 'Download',\n enterFullscreen: 'Enter fullscreen',\n exitFullscreen: 'Exit fullscreen',\n frameTitle: 'Player for {title}',\n captions: 'Captions',\n settings: 'Settings',\n pip: 'PIP',\n menuBack: 'Go back to previous menu',\n speed: 'Speed',\n normal: 'Normal',\n quality: 'Quality',\n loop: 'Loop',\n start: 'Start',\n end: 'End',\n all: 'All',\n reset: 'Reset',\n disabled: 'Disabled',\n enabled: 'Enabled',\n advertisement: 'Ad',\n qualityBadge: {\n 2160: '4K',\n 1440: 'HD',\n 1080: 'HD',\n 720: 'HD',\n 576: 'SD',\n 480: 'SD'\n }\n },\n // URLs\n urls: {\n download: null,\n vimeo: {\n sdk: 'https://player.vimeo.com/api/player.js',\n iframe: 'https://player.vimeo.com/video/{0}?{1}',\n api: 'https://vimeo.com/api/oembed.json?url={0}'\n },\n youtube: {\n sdk: 'https://www.youtube.com/iframe_api',\n api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}'\n },\n googleIMA: {\n sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js'\n }\n },\n // Custom control listeners\n listeners: {\n seek: null,\n play: null,\n pause: null,\n restart: null,\n rewind: null,\n fastForward: null,\n mute: null,\n volume: null,\n captions: null,\n download: null,\n fullscreen: null,\n pip: null,\n airplay: null,\n speed: null,\n quality: null,\n loop: null,\n language: null\n },\n // Events to watch and bubble\n events: [\n // Events to watch on HTML5 media elements and bubble\n // https://developer.mozilla.org/en/docs/Web/Guide/Events/Media_events\n 'ended', 'progress', 'stalled', 'playing', 'waiting', 'canplay', 'canplaythrough', 'loadstart', 'loadeddata', 'loadedmetadata', 'timeupdate', 'volumechange', 'play', 'pause', 'error', 'seeking', 'seeked', 'emptied', 'ratechange', 'cuechange',\n // Custom events\n 'download', 'enterfullscreen', 'exitfullscreen', 'captionsenabled', 'captionsdisabled', 'languagechange', 'controlshidden', 'controlsshown', 'ready',\n // YouTube\n 'statechange',\n // Quality\n 'qualitychange',\n // Ads\n 'adsloaded', 'adscontentpause', 'adscontentresume', 'adstarted', 'adsmidpoint', 'adscomplete', 'adsallcomplete', 'adsimpression', 'adsclick'],\n // Selectors\n // Change these to match your template if using custom HTML\n selectors: {\n editable: 'input, textarea, select, [contenteditable]',\n container: '.plyr',\n controls: {\n container: null,\n wrapper: '.plyr__controls'\n },\n labels: '[data-plyr]',\n buttons: {\n play: '[data-plyr=\"play\"]',\n pause: '[data-plyr=\"pause\"]',\n restart: '[data-plyr=\"restart\"]',\n rewind: '[data-plyr=\"rewind\"]',\n fastForward: '[data-plyr=\"fast-forward\"]',\n mute: '[data-plyr=\"mute\"]',\n captions: '[data-plyr=\"captions\"]',\n download: '[data-plyr=\"download\"]',\n fullscreen: '[data-plyr=\"fullscreen\"]',\n pip: '[data-plyr=\"pip\"]',\n airplay: '[data-plyr=\"airplay\"]',\n settings: '[data-plyr=\"settings\"]',\n loop: '[data-plyr=\"loop\"]'\n },\n inputs: {\n seek: '[data-plyr=\"seek\"]',\n volume: '[data-plyr=\"volume\"]',\n speed: '[data-plyr=\"speed\"]',\n language: '[data-plyr=\"language\"]',\n quality: '[data-plyr=\"quality\"]'\n },\n display: {\n currentTime: '.plyr__time--current',\n duration: '.plyr__time--duration',\n buffer: '.plyr__progress__buffer',\n loop: '.plyr__progress__loop',\n // Used later\n volume: '.plyr__volume--display'\n },\n progress: '.plyr__progress',\n captions: '.plyr__captions',\n caption: '.plyr__caption'\n },\n // Class hooks added to the player in different states\n classNames: {\n type: 'plyr--{0}',\n provider: 'plyr--{0}',\n video: 'plyr__video-wrapper',\n embed: 'plyr__video-embed',\n videoFixedRatio: 'plyr__video-wrapper--fixed-ratio',\n embedContainer: 'plyr__video-embed__container',\n poster: 'plyr__poster',\n posterEnabled: 'plyr__poster-enabled',\n ads: 'plyr__ads',\n control: 'plyr__control',\n controlPressed: 'plyr__control--pressed',\n playing: 'plyr--playing',\n paused: 'plyr--paused',\n stopped: 'plyr--stopped',\n loading: 'plyr--loading',\n hover: 'plyr--hover',\n tooltip: 'plyr__tooltip',\n cues: 'plyr__cues',\n marker: 'plyr__progress__marker',\n hidden: 'plyr__sr-only',\n hideControls: 'plyr--hide-controls',\n isTouch: 'plyr--is-touch',\n uiSupported: 'plyr--full-ui',\n noTransition: 'plyr--no-transition',\n display: {\n time: 'plyr__time'\n },\n menu: {\n value: 'plyr__menu__value',\n badge: 'plyr__badge',\n open: 'plyr--menu-open'\n },\n captions: {\n enabled: 'plyr--captions-enabled',\n active: 'plyr--captions-active'\n },\n fullscreen: {\n enabled: 'plyr--fullscreen-enabled',\n fallback: 'plyr--fullscreen-fallback'\n },\n pip: {\n supported: 'plyr--pip-supported',\n active: 'plyr--pip-active'\n },\n airplay: {\n supported: 'plyr--airplay-supported',\n active: 'plyr--airplay-active'\n },\n previewThumbnails: {\n // Tooltip thumbs\n thumbContainer: 'plyr__preview-thumb',\n thumbContainerShown: 'plyr__preview-thumb--is-shown',\n imageContainer: 'plyr__preview-thumb__image-container',\n timeContainer: 'plyr__preview-thumb__time-container',\n // Scrubbing\n scrubbingContainer: 'plyr__preview-scrubbing',\n scrubbingContainerShown: 'plyr__preview-scrubbing--is-shown'\n }\n },\n // Embed attributes\n attributes: {\n embed: {\n provider: 'data-plyr-provider',\n id: 'data-plyr-embed-id',\n hash: 'data-plyr-embed-hash'\n }\n },\n // Advertisements plugin\n // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio\n ads: {\n enabled: false,\n publisherId: '',\n tagUrl: ''\n },\n // Preview Thumbnails plugin\n previewThumbnails: {\n enabled: false,\n src: ''\n },\n // Vimeo plugin\n vimeo: {\n byline: false,\n portrait: false,\n title: false,\n speed: true,\n transparent: false,\n // Custom settings from Plyr\n customControls: true,\n referrerPolicy: null,\n // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy\n // Whether the owner of the video has a Pro or Business account\n // (which allows us to properly hide controls without CSS hacks, etc)\n premium: false\n },\n // YouTube plugin\n youtube: {\n rel: 0,\n // No related vids\n showinfo: 0,\n // Hide info\n iv_load_policy: 3,\n // Hide annotations\n modestbranding: 1,\n // Hide logos as much as possible (they still show one in the corner when paused)\n // Custom settings from Plyr\n customControls: true,\n noCookie: false // Whether to use an alternative version of YouTube without cookies\n },\n\n // Media Metadata\n mediaMetadata: {\n title: '',\n artist: '',\n album: '',\n artwork: []\n },\n // Markers\n markers: {\n enabled: false,\n points: []\n }\n };\n\n // ==========================================================================\n // Plyr states\n // ==========================================================================\n\n const pip = {\n active: 'picture-in-picture',\n inactive: 'inline'\n };\n\n // ==========================================================================\n // Plyr supported types and providers\n // ==========================================================================\n\n const providers = {\n html5: 'html5',\n youtube: 'youtube',\n vimeo: 'vimeo'\n };\n const types = {\n audio: 'audio',\n video: 'video'\n };\n\n /**\n * Get provider by URL\n * @param {String} url\n */\n function getProviderByUrl(url) {\n // YouTube\n if (/^(https?:\\/\\/)?(www\\.)?(youtube\\.com|youtube-nocookie\\.com|youtu\\.?be)\\/.+$/.test(url)) {\n return providers.youtube;\n }\n\n // Vimeo\n if (/^https?:\\/\\/player.vimeo.com\\/video\\/\\d{0,9}(?=\\b|\\/)/.test(url)) {\n return providers.vimeo;\n }\n return null;\n }\n\n // ==========================================================================\n // Console wrapper\n // ==========================================================================\n\n const noop = () => {};\n class Console {\n constructor(enabled = false) {\n this.enabled = window.console && enabled;\n if (this.enabled) {\n this.log('Debugging enabled');\n }\n }\n get log() {\n // eslint-disable-next-line no-console\n return this.enabled ? Function.prototype.bind.call(console.log, console) : noop;\n }\n get warn() {\n // eslint-disable-next-line no-console\n return this.enabled ? Function.prototype.bind.call(console.warn, console) : noop;\n }\n get error() {\n // eslint-disable-next-line no-console\n return this.enabled ? Function.prototype.bind.call(console.error, console) : noop;\n }\n }\n\n class Fullscreen {\n constructor(player) {\n _defineProperty$1(this, \"onChange\", () => {\n if (!this.supported) return;\n\n // Update toggle button\n const button = this.player.elements.buttons.fullscreen;\n if (is.element(button)) {\n button.pressed = this.active;\n }\n\n // Always trigger events on the plyr / media element (not a fullscreen container) and let them bubble up\n const target = this.target === this.player.media ? this.target : this.player.elements.container;\n // Trigger an event\n triggerEvent.call(this.player, target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);\n });\n _defineProperty$1(this, \"toggleFallback\", (toggle = false) => {\n // Store or restore scroll position\n if (toggle) {\n this.scrollPosition = {\n x: window.scrollX ?? 0,\n y: window.scrollY ?? 0\n };\n } else {\n window.scrollTo(this.scrollPosition.x, this.scrollPosition.y);\n }\n\n // Toggle scroll\n document.body.style.overflow = toggle ? 'hidden' : '';\n\n // Toggle class hook\n toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle);\n\n // Force full viewport on iPhone X+\n if (browser.isIos) {\n let viewport = document.head.querySelector('meta[name=\"viewport\"]');\n const property = 'viewport-fit=cover';\n\n // Inject the viewport meta if required\n if (!viewport) {\n viewport = document.createElement('meta');\n viewport.setAttribute('name', 'viewport');\n }\n\n // Check if the property already exists\n const hasProperty = is.string(viewport.content) && viewport.content.includes(property);\n if (toggle) {\n this.cleanupViewport = !hasProperty;\n if (!hasProperty) viewport.content += `,${property}`;\n } else if (this.cleanupViewport) {\n viewport.content = viewport.content.split(',').filter(part => part.trim() !== property).join(',');\n }\n }\n\n // Toggle button and fire events\n this.onChange();\n });\n // Trap focus inside container\n _defineProperty$1(this, \"trapFocus\", event => {\n // Bail if iOS/iPadOS, not active, not the tab key\n if (browser.isIos || browser.isIPadOS || !this.active || event.key !== 'Tab') return;\n\n // Get the current focused element\n const focused = document.activeElement;\n const focusable = getElements.call(this.player, 'a[href], button:not(:disabled), input:not(:disabled), [tabindex]');\n const [first] = focusable;\n const last = focusable[focusable.length - 1];\n if (focused === last && !event.shiftKey) {\n // Move focus to first element that can be tabbed if Shift isn't used\n first.focus();\n event.preventDefault();\n } else if (focused === first && event.shiftKey) {\n // Move focus to last element that can be tabbed if Shift is used\n last.focus();\n event.preventDefault();\n }\n });\n // Update UI\n _defineProperty$1(this, \"update\", () => {\n if (this.supported) {\n let mode;\n if (this.forceFallback) mode = 'Fallback (forced)';else if (Fullscreen.nativeSupported) mode = 'Native';else mode = 'Fallback';\n this.player.debug.log(`${mode} fullscreen enabled`);\n } else {\n this.player.debug.log('Fullscreen not supported and fallback disabled');\n }\n\n // Add styling hook to show button\n toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.supported);\n });\n // Make an element fullscreen\n _defineProperty$1(this, \"enter\", () => {\n if (!this.supported) return;\n\n // iOS native fullscreen doesn't need the request step\n if (browser.isIos && this.player.config.fullscreen.iosNative) {\n if (this.player.isVimeo) {\n this.player.embed.requestFullscreen();\n } else {\n this.target.webkitEnterFullscreen();\n }\n } else if (!Fullscreen.nativeSupported || this.forceFallback) {\n this.toggleFallback(true);\n } else if (!this.prefix) {\n this.target.requestFullscreen({\n navigationUI: 'hide'\n });\n } else if (!is.empty(this.prefix)) {\n this.target[`${this.prefix}Request${this.property}`]();\n }\n });\n // Bail from fullscreen\n _defineProperty$1(this, \"exit\", () => {\n if (!this.supported) return;\n\n // iOS native fullscreen\n if (browser.isIos && this.player.config.fullscreen.iosNative) {\n if (this.player.isVimeo) {\n this.player.embed.exitFullscreen();\n } else {\n this.target.webkitEnterFullscreen();\n }\n silencePromise(this.player.play());\n } else if (!Fullscreen.nativeSupported || this.forceFallback) {\n this.toggleFallback(false);\n } else if (!this.prefix) {\n (document.cancelFullScreen || document.exitFullscreen).call(document);\n } else if (!is.empty(this.prefix)) {\n const action = this.prefix === 'moz' ? 'Cancel' : 'Exit';\n document[`${this.prefix}${action}${this.property}`]();\n }\n });\n // Toggle state\n _defineProperty$1(this, \"toggle\", () => {\n if (!this.active) this.enter();else this.exit();\n });\n // Keep reference to parent\n this.player = player;\n\n // Get prefix\n this.prefix = Fullscreen.prefix;\n this.property = Fullscreen.property;\n\n // Scroll position\n this.scrollPosition = {\n x: 0,\n y: 0\n };\n\n // Force the use of 'full window/browser' rather than fullscreen\n this.forceFallback = player.config.fullscreen.fallback === 'force';\n\n // Get the fullscreen element\n // Checks container is an ancestor, defaults to null\n this.player.elements.fullscreen = player.config.fullscreen.container && closest$1(this.player.elements.container, player.config.fullscreen.container);\n\n // Register event listeners\n // Handle event (incase user presses escape etc)\n on.call(this.player, document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => {\n // TODO: Filter for target??\n this.onChange();\n });\n\n // Fullscreen toggle on double click\n on.call(this.player, this.player.elements.container, 'dblclick', event => {\n // Ignore double click in controls\n if (is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) {\n return;\n }\n this.player.listeners.proxy(event, this.toggle, 'fullscreen');\n });\n\n // Tap focus when in fullscreen\n on.call(this, this.player.elements.container, 'keydown', event => this.trapFocus(event));\n\n // Update the UI\n this.update();\n }\n\n // Determine if native supported\n static get nativeSupported() {\n return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled);\n }\n\n // If we're actually using native\n get useNative() {\n return Fullscreen.nativeSupported && !this.forceFallback;\n }\n\n // Get the prefix for handlers\n static get prefix() {\n // No prefix\n if (is.function(document.exitFullscreen)) return '';\n\n // Check for fullscreen support by vendor prefix\n let value = '';\n const prefixes = ['webkit', 'moz', 'ms'];\n prefixes.some(pre => {\n if (is.function(document[`${pre}ExitFullscreen`]) || is.function(document[`${pre}CancelFullScreen`])) {\n value = pre;\n return true;\n }\n return false;\n });\n return value;\n }\n static get property() {\n return this.prefix === 'moz' ? 'FullScreen' : 'Fullscreen';\n }\n\n // Determine if fullscreen is supported\n get supported() {\n return [\n // Fullscreen is enabled in config\n this.player.config.fullscreen.enabled,\n // Must be a video\n this.player.isVideo,\n // Either native is supported or fallback enabled\n Fullscreen.nativeSupported || this.player.config.fullscreen.fallback,\n // YouTube has no way to trigger fullscreen, so on devices with no native support, playsinline\n // must be enabled and iosNative fullscreen must be disabled to offer the fullscreen fallback\n !this.player.isYouTube || Fullscreen.nativeSupported || !browser.isIos || this.player.config.playsinline && !this.player.config.fullscreen.iosNative].every(Boolean);\n }\n\n // Get active state\n get active() {\n if (!this.supported) return false;\n\n // Fallback using classname\n if (!Fullscreen.nativeSupported || this.forceFallback) {\n return hasClass(this.target, this.player.config.classNames.fullscreen.fallback);\n }\n const element = !this.prefix ? this.target.getRootNode().fullscreenElement : this.target.getRootNode()[`${this.prefix}${this.property}Element`];\n return element && element.shadowRoot ? element === this.target.getRootNode().host : element === this.target;\n }\n\n // Get target element\n get target() {\n return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.fullscreen ?? this.player.elements.container;\n }\n }\n\n // ==========================================================================\n // Load image avoiding xhr/fetch CORS issues\n // Server status can't be obtained this way unfortunately, so this uses \"naturalWidth\" to determine if the image has loaded\n // By default it checks if it is at least 1px, but you can add a second argument to change this\n // ==========================================================================\n\n function loadImage(src, minWidth = 1) {\n return new Promise((resolve, reject) => {\n const image = new Image();\n const handler = () => {\n delete image.onload;\n delete image.onerror;\n (image.naturalWidth >= minWidth ? resolve : reject)(image);\n };\n Object.assign(image, {\n onload: handler,\n onerror: handler,\n src\n });\n });\n }\n\n // ==========================================================================\n const ui = {\n addStyleHook() {\n toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);\n toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);\n },\n // Toggle native HTML5 media controls\n toggleNativeControls(toggle = false) {\n if (toggle && this.isHTML5) {\n this.media.setAttribute('controls', '');\n } else {\n this.media.removeAttribute('controls');\n }\n },\n // Setup the UI\n build() {\n // Re-attach media element listeners\n // TODO: Use event bubbling?\n this.listeners.media();\n\n // Don't setup interface if no support\n if (!this.supported.ui) {\n this.debug.warn(`Basic support only for ${this.provider} ${this.type}`);\n\n // Restore native controls\n ui.toggleNativeControls.call(this, true);\n\n // Bail\n return;\n }\n\n // Inject custom controls if not present\n if (!is.element(this.elements.controls)) {\n // Inject custom controls\n controls.inject.call(this);\n\n // Re-attach control listeners\n this.listeners.controls();\n }\n\n // Remove native controls\n ui.toggleNativeControls.call(this);\n\n // Setup captions for HTML5\n if (this.isHTML5) {\n captions.setup.call(this);\n }\n\n // Reset volume\n this.volume = null;\n\n // Reset mute state\n this.muted = null;\n\n // Reset loop state\n this.loop = null;\n\n // Reset quality setting\n this.quality = null;\n\n // Reset speed\n this.speed = null;\n\n // Reset volume display\n controls.updateVolume.call(this);\n\n // Reset time display\n controls.timeUpdate.call(this);\n\n // Reset duration display\n controls.durationUpdate.call(this);\n\n // Update the UI\n ui.checkPlaying.call(this);\n\n // Check for picture-in-picture support\n toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo);\n\n // Check for airplay support\n toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);\n\n // Add touch class\n toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);\n\n // Ready for API calls\n this.ready = true;\n\n // Ready event at end of execution stack\n setTimeout(() => {\n triggerEvent.call(this, this.media, 'ready');\n }, 0);\n\n // Set the title\n ui.setTitle.call(this);\n\n // Assure the poster image is set, if the property was added before the element was created\n if (this.poster) {\n ui.setPoster.call(this, this.poster, false).catch(() => {});\n }\n\n // Manually set the duration if user has overridden it.\n // The event listeners for it doesn't get called if preload is disabled (#701)\n if (this.config.duration) {\n controls.durationUpdate.call(this);\n }\n\n // Media metadata\n if (this.config.mediaMetadata) {\n controls.setMediaMetadata.call(this);\n }\n },\n // Setup aria attribute for play and iframe title\n setTitle() {\n // Find the current text\n let label = i18n.get('play', this.config);\n\n // If there's a media title set, use that for the label\n if (is.string(this.config.title) && !is.empty(this.config.title)) {\n label += `, ${this.config.title}`;\n }\n\n // If there's a play button, set label\n Array.from(this.elements.buttons.play || []).forEach(button => {\n button.setAttribute('aria-label', label);\n });\n\n // Set iframe title\n // https://github.com/sampotts/plyr/issues/124\n if (this.isEmbed) {\n const iframe = getElement.call(this, 'iframe');\n if (!is.element(iframe)) {\n return;\n }\n\n // Default to media type\n const title = !is.empty(this.config.title) ? this.config.title : 'video';\n const format = i18n.get('frameTitle', this.config);\n iframe.setAttribute('title', format.replace('{title}', title));\n }\n },\n // Toggle poster\n togglePoster(enable) {\n toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);\n },\n // Set the poster image (async)\n // Used internally for the poster setter, with the passive option forced to false\n setPoster(poster, passive = true) {\n // Don't override if call is passive\n if (passive && this.poster) {\n return Promise.reject(new Error('Poster already set'));\n }\n\n // Set property synchronously to respect the call order\n this.media.setAttribute('data-poster', poster);\n\n // Show the poster\n this.elements.poster.removeAttribute('hidden');\n\n // Wait until ui is ready\n return ready.call(this)\n // Load image\n .then(() => loadImage(poster)).catch(error => {\n // Hide poster on error unless it's been set by another call\n if (poster === this.poster) {\n ui.togglePoster.call(this, false);\n }\n // Rethrow\n throw error;\n }).then(() => {\n // Prevent race conditions\n if (poster !== this.poster) {\n throw new Error('setPoster cancelled by later call to setPoster');\n }\n }).then(() => {\n Object.assign(this.elements.poster.style, {\n backgroundImage: `url('${poster}')`,\n // Reset backgroundSize as well (since it can be set to \"cover\" for padded thumbnails for youtube)\n backgroundSize: ''\n });\n ui.togglePoster.call(this, true);\n return poster;\n });\n },\n // Check playing state\n checkPlaying(event) {\n // Class hooks\n toggleClass(this.elements.container, this.config.classNames.playing, this.playing);\n toggleClass(this.elements.container, this.config.classNames.paused, this.paused);\n toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);\n\n // Set state\n Array.from(this.elements.buttons.play || []).forEach(target => {\n Object.assign(target, {\n pressed: this.playing\n });\n target.setAttribute('aria-label', i18n.get(this.playing ? 'pause' : 'play', this.config));\n });\n\n // Only update controls on non timeupdate events\n if (is.event(event) && event.type === 'timeupdate') {\n return;\n }\n\n // Toggle controls\n ui.toggleControls.call(this);\n },\n // Check if media is loading\n checkLoading(event) {\n this.loading = ['stalled', 'waiting'].includes(event.type);\n\n // Clear timer\n clearTimeout(this.timers.loading);\n\n // Timer to prevent flicker when seeking\n this.timers.loading = setTimeout(() => {\n // Update progress bar loading class state\n toggleClass(this.elements.container, this.config.classNames.loading, this.loading);\n\n // Update controls visibility\n ui.toggleControls.call(this);\n }, this.loading ? 250 : 0);\n },\n // Toggle controls based on state and `force` argument\n toggleControls(force) {\n const {\n controls: controlsElement\n } = this.elements;\n if (controlsElement && this.config.hideControls) {\n // Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.)\n const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now();\n\n // Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide\n this.toggleControls(Boolean(force || this.loading || this.paused || controlsElement.pressed || controlsElement.hover || recentTouchSeek));\n }\n },\n // Migrate any custom properties from the media to the parent\n migrateStyles() {\n // Loop through values (as they are the keys when the object is spread 🤔)\n Object.values({\n ...this.media.style\n })\n // We're only fussed about Plyr specific properties\n .filter(key => !is.empty(key) && is.string(key) && key.startsWith('--plyr')).forEach(key => {\n // Set on the container\n this.elements.container.style.setProperty(key, this.media.style.getPropertyValue(key));\n\n // Clean up from media element\n this.media.style.removeProperty(key);\n });\n\n // Remove attribute if empty\n if (is.empty(this.media.style)) {\n this.media.removeAttribute('style');\n }\n }\n };\n\n class Listeners {\n constructor(_player) {\n // Device is touch enabled\n _defineProperty$1(this, \"firstTouch\", () => {\n const {\n player\n } = this;\n const {\n elements\n } = player;\n player.touch = true;\n\n // Add touch class\n toggleClass(elements.container, player.config.classNames.isTouch, true);\n });\n // Global window & document listeners\n _defineProperty$1(this, \"global\", (toggle = true) => {\n const {\n player\n } = this;\n\n // Keyboard shortcuts\n if (player.config.keyboard.global) {\n toggleListener.call(player, window, 'keydown keyup', this.handleKey, toggle, false);\n }\n\n // Click anywhere closes menu\n toggleListener.call(player, document.body, 'click', this.toggleMenu, toggle);\n\n // Detect touch by events\n once.call(player, document.body, 'touchstart', this.firstTouch);\n });\n // Container listeners\n _defineProperty$1(this, \"container\", () => {\n const {\n player\n } = this;\n const {\n config,\n elements,\n timers\n } = player;\n\n // Keyboard shortcuts\n if (!config.keyboard.global && config.keyboard.focused) {\n on.call(player, elements.container, 'keydown keyup', this.handleKey, false);\n }\n\n // Toggle controls on mouse events and entering fullscreen\n on.call(player, elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => {\n const {\n controls: controlsElement\n } = elements;\n\n // Remove button states for fullscreen\n if (controlsElement && event.type === 'enterfullscreen') {\n controlsElement.pressed = false;\n controlsElement.hover = false;\n }\n\n // Show, then hide after a timeout unless another control event occurs\n const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);\n let delay = 0;\n if (show) {\n ui.toggleControls.call(player, true);\n // Use longer timeout for touch devices\n delay = player.touch ? 3000 : 2000;\n }\n\n // Clear timer\n clearTimeout(timers.controls);\n\n // Set new timer to prevent flicker when seeking\n timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);\n });\n\n // Set a gutter for Vimeo\n const setGutter = () => {\n if (!player.isVimeo || player.config.vimeo.premium) {\n return;\n }\n const target = elements.wrapper;\n const {\n active\n } = player.fullscreen;\n const [videoWidth, videoHeight] = getAspectRatio.call(player);\n const useNativeAspectRatio = supportsCSS(`aspect-ratio: ${videoWidth} / ${videoHeight}`);\n\n // If not active, remove styles\n if (!active) {\n if (useNativeAspectRatio) {\n target.style.width = null;\n target.style.height = null;\n } else {\n target.style.maxWidth = null;\n target.style.margin = null;\n }\n return;\n }\n\n // Determine which dimension will overflow and constrain view\n const [viewportWidth, viewportHeight] = getViewportSize();\n const overflow = viewportWidth / viewportHeight > videoWidth / videoHeight;\n if (useNativeAspectRatio) {\n target.style.width = overflow ? 'auto' : '100%';\n target.style.height = overflow ? '100%' : 'auto';\n } else {\n target.style.maxWidth = overflow ? `${viewportHeight / videoHeight * videoWidth}px` : null;\n target.style.margin = overflow ? '0 auto' : null;\n }\n };\n\n // Handle resizing\n const resized = () => {\n clearTimeout(timers.resized);\n timers.resized = setTimeout(setGutter, 50);\n };\n on.call(player, elements.container, 'enterfullscreen exitfullscreen', event => {\n const {\n target\n } = player.fullscreen;\n\n // Ignore events not from target\n if (target !== elements.container) {\n return;\n }\n\n // If it's not an embed and no ratio specified\n if (!player.isEmbed && is.empty(player.config.ratio)) {\n return;\n }\n\n // Set Vimeo gutter\n setGutter();\n\n // Watch for resizes\n const method = event.type === 'enterfullscreen' ? on : off;\n method.call(player, window, 'resize', resized);\n });\n });\n // Listen for media events\n _defineProperty$1(this, \"media\", () => {\n const {\n player\n } = this;\n const {\n elements\n } = player;\n\n // Time change on media\n on.call(player, player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(player, event));\n\n // Display duration\n on.call(player, player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(player, event));\n\n // Handle the media finishing\n on.call(player, player.media, 'ended', () => {\n // Show poster on end\n if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) {\n // Restart\n player.restart();\n\n // Call pause otherwise IE11 will start playing the video again\n player.pause();\n }\n });\n\n // Check for buffer progress\n on.call(player, player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(player, event));\n\n // Handle volume changes\n on.call(player, player.media, 'volumechange', event => controls.updateVolume.call(player, event));\n\n // Handle play/pause\n on.call(player, player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(player, event));\n\n // Loading state\n on.call(player, player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(player, event));\n\n // Click video\n if (player.supported.ui && player.config.clickToPlay && !player.isAudio) {\n // Re-fetch the wrapper\n const wrapper = getElement.call(player, `.${player.config.classNames.video}`);\n\n // Bail if there's no wrapper (this should never happen)\n if (!is.element(wrapper)) {\n return;\n }\n\n // On click play, pause or restart\n on.call(player, elements.container, 'click', event => {\n const targets = [elements.container, wrapper];\n\n // Ignore if click if not container or in video wrapper\n if (!targets.includes(event.target) && !wrapper.contains(event.target)) {\n return;\n }\n\n // Touch devices will just show controls (if hidden)\n if (player.touch && player.config.hideControls) {\n return;\n }\n if (player.ended) {\n this.proxy(event, player.restart, 'restart');\n this.proxy(event, () => {\n silencePromise(player.play());\n }, 'play');\n } else {\n this.proxy(event, () => {\n silencePromise(player.togglePlay());\n }, 'play');\n }\n });\n }\n\n // Disable right click\n if (player.supported.ui && player.config.disableContextMenu) {\n on.call(player, elements.wrapper, 'contextmenu', event => {\n event.preventDefault();\n }, false);\n }\n\n // Volume change\n on.call(player, player.media, 'volumechange', () => {\n // Save to storage\n player.storage.set({\n volume: player.volume,\n muted: player.muted\n });\n });\n\n // Speed change\n on.call(player, player.media, 'ratechange', () => {\n // Update UI\n controls.updateSetting.call(player, 'speed');\n\n // Save to storage\n player.storage.set({\n speed: player.speed\n });\n });\n\n // Quality change\n on.call(player, player.media, 'qualitychange', event => {\n // Update UI\n controls.updateSetting.call(player, 'quality', null, event.detail.quality);\n });\n\n // Update download link when ready and if quality changes\n on.call(player, player.media, 'ready qualitychange', () => {\n controls.setDownloadUrl.call(player);\n });\n\n // Proxy events to container\n // Bubble up key events for Edge\n const proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' ');\n on.call(player, player.media, proxyEvents, event => {\n let {\n detail = {}\n } = event;\n\n // Get error details from media\n if (event.type === 'error') {\n detail = player.media.error;\n }\n triggerEvent.call(player, elements.container, event.type, true, detail);\n });\n });\n // Run default and custom handlers\n _defineProperty$1(this, \"proxy\", (event, defaultHandler, customHandlerKey) => {\n const {\n player\n } = this;\n const customHandler = player.config.listeners[customHandlerKey];\n const hasCustomHandler = is.function(customHandler);\n let returned = true;\n\n // Execute custom handler\n if (hasCustomHandler) {\n returned = customHandler.call(player, event);\n }\n\n // Only call default handler if not prevented in custom handler\n if (returned !== false && is.function(defaultHandler)) {\n defaultHandler.call(player, event);\n }\n });\n // Trigger custom and default handlers\n _defineProperty$1(this, \"bind\", (element, type, defaultHandler, customHandlerKey, passive = true) => {\n const {\n player\n } = this;\n const customHandler = player.config.listeners[customHandlerKey];\n const hasCustomHandler = is.function(customHandler);\n on.call(player, element, type, event => this.proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler);\n });\n // Listen for control events\n _defineProperty$1(this, \"controls\", () => {\n const {\n player\n } = this;\n const {\n elements\n } = player;\n // IE doesn't support input event, so we fallback to change\n const inputEvent = browser.isIE ? 'change' : 'input';\n\n // Play/pause toggle\n if (elements.buttons.play) {\n Array.from(elements.buttons.play).forEach(button => {\n this.bind(button, 'click', () => {\n silencePromise(player.togglePlay());\n }, 'play');\n });\n }\n\n // Pause\n this.bind(elements.buttons.restart, 'click', player.restart, 'restart');\n\n // Rewind\n this.bind(elements.buttons.rewind, 'click', () => {\n // Record seek time so we can prevent hiding controls for a few seconds after rewind\n player.lastSeekTime = Date.now();\n player.rewind();\n }, 'rewind');\n\n // Rewind\n this.bind(elements.buttons.fastForward, 'click', () => {\n // Record seek time so we can prevent hiding controls for a few seconds after fast forward\n player.lastSeekTime = Date.now();\n player.forward();\n }, 'fastForward');\n\n // Mute toggle\n this.bind(elements.buttons.mute, 'click', () => {\n player.muted = !player.muted;\n }, 'mute');\n\n // Captions toggle\n this.bind(elements.buttons.captions, 'click', () => player.toggleCaptions());\n\n // Download\n this.bind(elements.buttons.download, 'click', () => {\n triggerEvent.call(player, player.media, 'download');\n }, 'download');\n\n // Fullscreen toggle\n this.bind(elements.buttons.fullscreen, 'click', () => {\n player.fullscreen.toggle();\n }, 'fullscreen');\n\n // Picture-in-Picture\n this.bind(elements.buttons.pip, 'click', () => {\n player.pip = 'toggle';\n }, 'pip');\n\n // Airplay\n this.bind(elements.buttons.airplay, 'click', player.airplay, 'airplay');\n\n // Settings menu - click toggle\n this.bind(elements.buttons.settings, 'click', event => {\n // Prevent the document click listener closing the menu\n event.stopPropagation();\n event.preventDefault();\n controls.toggleMenu.call(player, event);\n }, null, false); // Can't be passive as we're preventing default\n\n // Settings menu - keyboard toggle\n // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus\n // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143\n this.bind(elements.buttons.settings, 'keyup', event => {\n if (![' ', 'Enter'].includes(event.key)) {\n return;\n }\n\n // Because return triggers a click anyway, all we need to do is set focus\n if (event.key === 'Enter') {\n controls.focusFirstMenuItem.call(player, null, true);\n return;\n }\n\n // Prevent scroll\n event.preventDefault();\n\n // Prevent playing video (Firefox)\n event.stopPropagation();\n\n // Toggle menu\n controls.toggleMenu.call(player, event);\n }, null, false // Can't be passive as we're preventing default\n );\n\n // Escape closes menu\n this.bind(elements.settings.menu, 'keydown', event => {\n if (event.key === 'Escape') {\n controls.toggleMenu.call(player, event);\n }\n });\n\n // Set range input alternative \"value\", which matches the tooltip time (#954)\n this.bind(elements.inputs.seek, 'mousedown mousemove', event => {\n const rect = elements.progress.getBoundingClientRect();\n const percent = 100 / rect.width * (event.pageX - rect.left);\n event.currentTarget.setAttribute('seek-value', percent);\n });\n\n // Pause while seeking\n this.bind(elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {\n const seek = event.currentTarget;\n const attribute = 'play-on-seeked';\n if (is.keyboardEvent(event) && !['ArrowLeft', 'ArrowRight'].includes(event.key)) {\n return;\n }\n\n // Record seek time so we can prevent hiding controls for a few seconds after seek\n player.lastSeekTime = Date.now();\n\n // Was playing before?\n const play = seek.hasAttribute(attribute);\n // Done seeking\n const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);\n\n // If we're done seeking and it was playing, resume playback\n if (play && done) {\n seek.removeAttribute(attribute);\n silencePromise(player.play());\n } else if (!done && player.playing) {\n seek.setAttribute(attribute, '');\n player.pause();\n }\n });\n\n // Fix range inputs on iOS\n // Super weird iOS bug where after you interact with an <input type=\"range\">,\n // it takes over further interactions on the page. This is a hack\n if (browser.isIos) {\n const inputs = getElements.call(player, 'input[type=\"range\"]');\n Array.from(inputs).forEach(input => this.bind(input, inputEvent, event => repaint(event.target)));\n }\n\n // Seek\n this.bind(elements.inputs.seek, inputEvent, event => {\n const seek = event.currentTarget;\n // If it exists, use seek-value instead of \"value\" for consistency with tooltip time (#954)\n let seekTo = seek.getAttribute('seek-value');\n if (is.empty(seekTo)) {\n seekTo = seek.value;\n }\n seek.removeAttribute('seek-value');\n player.currentTime = seekTo / seek.max * player.duration;\n }, 'seek');\n\n // Seek tooltip\n this.bind(elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(player, event));\n\n // Preview thumbnails plugin\n // TODO: Really need to work on some sort of plug-in wide event bus or pub-sub for this\n this.bind(elements.progress, 'mousemove touchmove', event => {\n const {\n previewThumbnails\n } = player;\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.startMove(event);\n }\n });\n\n // Hide thumbnail preview - on mouse click, mouse leave, and video play/seek. All four are required, e.g., for buffering\n this.bind(elements.progress, 'mouseleave touchend click', () => {\n const {\n previewThumbnails\n } = player;\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.endMove(false, true);\n }\n });\n\n // Show scrubbing preview\n this.bind(elements.progress, 'mousedown touchstart', event => {\n const {\n previewThumbnails\n } = player;\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.startScrubbing(event);\n }\n });\n this.bind(elements.progress, 'mouseup touchend', event => {\n const {\n previewThumbnails\n } = player;\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.endScrubbing(event);\n }\n });\n\n // Polyfill for lower fill in <input type=\"range\"> for webkit\n if (browser.isWebKit) {\n Array.from(getElements.call(player, 'input[type=\"range\"]')).forEach(element => {\n this.bind(element, 'input', event => controls.updateRangeFill.call(player, event.target));\n });\n }\n\n // Current time invert\n // Only if one time element is used for both currentTime and duration\n if (player.config.toggleInvert && !is.element(elements.display.duration)) {\n this.bind(elements.display.currentTime, 'click', () => {\n // Do nothing if we're at the start\n if (player.currentTime === 0) {\n return;\n }\n player.config.invertTime = !player.config.invertTime;\n controls.timeUpdate.call(player);\n });\n }\n\n // Volume\n this.bind(elements.inputs.volume, inputEvent, event => {\n player.volume = event.target.value;\n }, 'volume');\n\n // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)\n this.bind(elements.controls, 'mouseenter mouseleave', event => {\n elements.controls.hover = !player.touch && event.type === 'mouseenter';\n });\n\n // Also update controls.hover state for any non-player children of fullscreen element (as above)\n if (elements.fullscreen) {\n Array.from(elements.fullscreen.children).filter(c => !c.contains(elements.container)).forEach(child => {\n this.bind(child, 'mouseenter mouseleave', event => {\n if (elements.controls) {\n elements.controls.hover = !player.touch && event.type === 'mouseenter';\n }\n });\n });\n }\n\n // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)\n this.bind(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {\n elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);\n });\n\n // Show controls when they receive focus (e.g., when using keyboard tab key)\n this.bind(elements.controls, 'focusin', () => {\n const {\n config,\n timers\n } = player;\n\n // Skip transition to prevent focus from scrolling the parent element\n toggleClass(elements.controls, config.classNames.noTransition, true);\n\n // Toggle\n ui.toggleControls.call(player, true);\n\n // Restore transition\n setTimeout(() => {\n toggleClass(elements.controls, config.classNames.noTransition, false);\n }, 0);\n\n // Delay a little more for mouse users\n const delay = this.touch ? 3000 : 4000;\n\n // Clear timer\n clearTimeout(timers.controls);\n\n // Hide again after delay\n timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);\n });\n\n // Mouse wheel for volume\n this.bind(elements.inputs.volume, 'wheel', event => {\n // Detect \"natural\" scroll - supported on OS X Safari only\n // Other browsers on OS X will be inverted until support improves\n const inverted = event.webkitDirectionInvertedFromDevice;\n // Get delta from event. Invert if `inverted` is true\n const [x, y] = [event.deltaX, -event.deltaY].map(value => inverted ? -value : value);\n // Using the biggest delta, normalize to 1 or -1 (or 0 if no delta)\n const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y);\n\n // Change the volume by 2%\n player.increaseVolume(direction / 50);\n\n // Don't break page scrolling at max and min\n const {\n volume\n } = player.media;\n if (direction === 1 && volume < 1 || direction === -1 && volume > 0) {\n event.preventDefault();\n }\n }, 'volume', false);\n });\n this.player = _player;\n this.lastKey = null;\n this.focusTimer = null;\n this.lastKeyDown = null;\n this.handleKey = this.handleKey.bind(this);\n this.toggleMenu = this.toggleMenu.bind(this);\n this.firstTouch = this.firstTouch.bind(this);\n }\n\n // Handle key presses\n handleKey(event) {\n const {\n player\n } = this;\n const {\n elements\n } = player;\n const {\n key,\n type,\n altKey,\n ctrlKey,\n metaKey,\n shiftKey\n } = event;\n const pressed = type === 'keydown';\n const repeat = pressed && key === this.lastKey;\n\n // Bail if a modifier key is set\n if (altKey || ctrlKey || metaKey || shiftKey) {\n return;\n }\n\n // If the event is bubbled from the media element\n // Firefox doesn't get the key for whatever reason\n if (!key) {\n return;\n }\n\n // Seek by increment\n const seekByIncrement = increment => {\n // Divide the max duration into 10th's and times by the number value\n player.currentTime = player.duration / 10 * increment;\n };\n\n // Handle the key on keydown\n // Reset on keyup\n if (pressed) {\n // Check focused element\n // and if the focused element is not editable (e.g. text input)\n // and any that accept key input http://webaim.org/techniques/keyboard/\n const focused = document.activeElement;\n if (is.element(focused)) {\n const {\n editable\n } = player.config.selectors;\n const {\n seek\n } = elements.inputs;\n if (focused !== seek && matches(focused, editable)) {\n return;\n }\n if (event.key === ' ' && matches(focused, 'button, [role^=\"menuitem\"]')) {\n return;\n }\n }\n\n // Which keys should we prevent default\n const preventDefault = [' ', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'c', 'f', 'k', 'l', 'm'];\n\n // If the key is found prevent default (e.g. prevent scrolling for arrows)\n if (preventDefault.includes(key)) {\n event.preventDefault();\n event.stopPropagation();\n }\n switch (key) {\n case '0':\n case '1':\n case '2':\n case '3':\n case '4':\n case '5':\n case '6':\n case '7':\n case '8':\n case '9':\n if (!repeat) {\n seekByIncrement(parseInt(key, 10));\n }\n break;\n case ' ':\n case 'k':\n if (!repeat) {\n silencePromise(player.togglePlay());\n }\n break;\n case 'ArrowUp':\n player.increaseVolume(0.1);\n break;\n case 'ArrowDown':\n player.decreaseVolume(0.1);\n break;\n case 'm':\n if (!repeat) {\n player.muted = !player.muted;\n }\n break;\n case 'ArrowRight':\n player.forward();\n break;\n case 'ArrowLeft':\n player.rewind();\n break;\n case 'f':\n player.fullscreen.toggle();\n break;\n case 'c':\n if (!repeat) {\n player.toggleCaptions();\n }\n break;\n case 'l':\n player.loop = !player.loop;\n break;\n }\n\n // Escape is handle natively when in full screen\n // So we only need to worry about non native\n if (key === 'Escape' && !player.fullscreen.usingNative && player.fullscreen.active) {\n player.fullscreen.toggle();\n }\n\n // Store last key for next cycle\n this.lastKey = key;\n } else {\n this.lastKey = null;\n }\n }\n\n // Toggle menu\n toggleMenu(event) {\n controls.toggleMenu.call(this.player, event);\n }\n }\n\n var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {};\n\n function createCommonjsModule(fn, module) {\n \treturn module = { exports: {} }, fn(module, module.exports), module.exports;\n }\n\n var loadjs_umd = createCommonjsModule(function (module, exports) {\n (function (root, factory) {\n {\n module.exports = factory();\n }\n })(commonjsGlobal, function () {\n /**\n * Global dependencies.\n * @global {Object} document - DOM\n */\n\n var devnull = function () {},\n bundleIdCache = {},\n bundleResultCache = {},\n bundleCallbackQueue = {};\n\n /**\n * Subscribe to bundle load event.\n * @param {string[]} bundleIds - Bundle ids\n * @param {Function} callbackFn - The callback function\n */\n function subscribe(bundleIds, callbackFn) {\n // listify\n bundleIds = bundleIds.push ? bundleIds : [bundleIds];\n var depsNotFound = [],\n i = bundleIds.length,\n numWaiting = i,\n fn,\n bundleId,\n r,\n q;\n\n // define callback function\n fn = function (bundleId, pathsNotFound) {\n if (pathsNotFound.length) depsNotFound.push(bundleId);\n numWaiting--;\n if (!numWaiting) callbackFn(depsNotFound);\n };\n\n // register callback\n while (i--) {\n bundleId = bundleIds[i];\n\n // execute callback if in result cache\n r = bundleResultCache[bundleId];\n if (r) {\n fn(bundleId, r);\n continue;\n }\n\n // add to callback queue\n q = bundleCallbackQueue[bundleId] = bundleCallbackQueue[bundleId] || [];\n q.push(fn);\n }\n }\n\n /**\n * Publish bundle load event.\n * @param {string} bundleId - Bundle id\n * @param {string[]} pathsNotFound - List of files not found\n */\n function publish(bundleId, pathsNotFound) {\n // exit if id isn't defined\n if (!bundleId) return;\n var q = bundleCallbackQueue[bundleId];\n\n // cache result\n bundleResultCache[bundleId] = pathsNotFound;\n\n // exit if queue is empty\n if (!q) return;\n\n // empty callback queue\n while (q.length) {\n q[0](bundleId, pathsNotFound);\n q.splice(0, 1);\n }\n }\n\n /**\n * Execute callbacks.\n * @param {Object or Function} args - The callback args\n * @param {string[]} depsNotFound - List of dependencies not found\n */\n function executeCallbacks(args, depsNotFound) {\n // accept function as argument\n if (args.call) args = {\n success: args\n };\n\n // success and error callbacks\n if (depsNotFound.length) (args.error || devnull)(depsNotFound);else (args.success || devnull)(args);\n }\n\n /**\n * Load individual file.\n * @param {string} path - The file path\n * @param {Function} callbackFn - The callback function\n */\n function loadFile(path, callbackFn, args, numTries) {\n var doc = document,\n async = args.async,\n maxTries = (args.numRetries || 0) + 1,\n beforeCallbackFn = args.before || devnull,\n pathname = path.replace(/[\\?|#].*$/, ''),\n pathStripped = path.replace(/^(css|img)!/, ''),\n isLegacyIECss,\n e;\n numTries = numTries || 0;\n if (/(^css!|\\.css$)/.test(pathname)) {\n // css\n e = doc.createElement('link');\n e.rel = 'stylesheet';\n e.href = pathStripped;\n\n // tag IE9+\n isLegacyIECss = 'hideFocus' in e;\n\n // use preload in IE Edge (to detect load errors)\n if (isLegacyIECss && e.relList) {\n isLegacyIECss = 0;\n e.rel = 'preload';\n e.as = 'style';\n }\n } else if (/(^img!|\\.(png|gif|jpg|svg|webp)$)/.test(pathname)) {\n // image\n e = doc.createElement('img');\n e.src = pathStripped;\n } else {\n // javascript\n e = doc.createElement('script');\n e.src = path;\n e.async = async === undefined ? true : async;\n }\n e.onload = e.onerror = e.onbeforeload = function (ev) {\n var result = ev.type[0];\n\n // treat empty stylesheets as failures to get around lack of onerror\n // support in IE9-11\n if (isLegacyIECss) {\n try {\n if (!e.sheet.cssText.length) result = 'e';\n } catch (x) {\n // sheets objects created from load errors don't allow access to\n // `cssText` (unless error is Code:18 SecurityError)\n if (x.code != 18) result = 'e';\n }\n }\n\n // handle retries in case of load failure\n if (result == 'e') {\n // increment counter\n numTries += 1;\n\n // exit function and try again\n if (numTries < maxTries) {\n return loadFile(path, callbackFn, args, numTries);\n }\n } else if (e.rel == 'preload' && e.as == 'style') {\n // activate preloaded stylesheets\n return e.rel = 'stylesheet'; // jshint ignore:line\n }\n\n // execute callback\n callbackFn(path, result, ev.defaultPrevented);\n };\n\n // add to document (unless callback returns `false`)\n if (beforeCallbackFn(path, e) !== false) doc.head.appendChild(e);\n }\n\n /**\n * Load multiple files.\n * @param {string[]} paths - The file paths\n * @param {Function} callbackFn - The callback function\n */\n function loadFiles(paths, callbackFn, args) {\n // listify paths\n paths = paths.push ? paths : [paths];\n var numWaiting = paths.length,\n x = numWaiting,\n pathsNotFound = [],\n fn,\n i;\n\n // define callback function\n fn = function (path, result, defaultPrevented) {\n // handle error\n if (result == 'e') pathsNotFound.push(path);\n\n // handle beforeload event. If defaultPrevented then that means the load\n // will be blocked (ex. Ghostery/ABP on Safari)\n if (result == 'b') {\n if (defaultPrevented) pathsNotFound.push(path);else return;\n }\n numWaiting--;\n if (!numWaiting) callbackFn(pathsNotFound);\n };\n\n // load scripts\n for (i = 0; i < x; i++) loadFile(paths[i], fn, args);\n }\n\n /**\n * Initiate script load and register bundle.\n * @param {(string|string[])} paths - The file paths\n * @param {(string|Function|Object)} [arg1] - The (1) bundleId or (2) success\n * callback or (3) object literal with success/error arguments, numRetries,\n * etc.\n * @param {(Function|Object)} [arg2] - The (1) success callback or (2) object\n * literal with success/error arguments, numRetries, etc.\n */\n function loadjs(paths, arg1, arg2) {\n var bundleId, args;\n\n // bundleId (if string)\n if (arg1 && arg1.trim) bundleId = arg1;\n\n // args (default is {})\n args = (bundleId ? arg2 : arg1) || {};\n\n // throw error if bundle is already defined\n if (bundleId) {\n if (bundleId in bundleIdCache) {\n throw \"LoadJS\";\n } else {\n bundleIdCache[bundleId] = true;\n }\n }\n function loadFn(resolve, reject) {\n loadFiles(paths, function (pathsNotFound) {\n // execute callbacks\n executeCallbacks(args, pathsNotFound);\n\n // resolve Promise\n if (resolve) {\n executeCallbacks({\n success: resolve,\n error: reject\n }, pathsNotFound);\n }\n\n // publish bundle load event\n publish(bundleId, pathsNotFound);\n }, args);\n }\n if (args.returnPromise) return new Promise(loadFn);else loadFn();\n }\n\n /**\n * Execute callbacks when dependencies have been satisfied.\n * @param {(string|string[])} deps - List of bundle ids\n * @param {Object} args - success/error arguments\n */\n loadjs.ready = function ready(deps, args) {\n // subscribe to bundle load event\n subscribe(deps, function (depsNotFound) {\n // execute callbacks\n executeCallbacks(args, depsNotFound);\n });\n return loadjs;\n };\n\n /**\n * Manually satisfy bundle dependencies.\n * @param {string} bundleId - The bundle id\n */\n loadjs.done = function done(bundleId) {\n publish(bundleId, []);\n };\n\n /**\n * Reset loadjs dependencies statuses\n */\n loadjs.reset = function reset() {\n bundleIdCache = {};\n bundleResultCache = {};\n bundleCallbackQueue = {};\n };\n\n /**\n * Determine if bundle has already been defined\n * @param String} bundleId - The bundle id\n */\n loadjs.isDefined = function isDefined(bundleId) {\n return bundleId in bundleIdCache;\n };\n\n // export\n return loadjs;\n });\n });\n\n // ==========================================================================\n function loadScript(url) {\n return new Promise((resolve, reject) => {\n loadjs_umd(url, {\n success: resolve,\n error: reject\n });\n });\n }\n\n // ==========================================================================\n\n // Parse Vimeo ID from URL\n function parseId$1(url) {\n if (is.empty(url)) {\n return null;\n }\n if (is.number(Number(url))) {\n return url;\n }\n const regex = /^.*(vimeo.com\\/|video\\/)(\\d+).*/;\n return url.match(regex) ? RegExp.$2 : url;\n }\n\n // Try to extract a hash for private videos from the URL\n function parseHash(url) {\n /* This regex matches a hexadecimal hash if given in any of these forms:\n * - [https://player.]vimeo.com/video/{id}/{hash}[?params]\n * - [https://player.]vimeo.com/video/{id}?h={hash}[&params]\n * - [https://player.]vimeo.com/video/{id}?[params]&h={hash}\n * - video/{id}/{hash}\n * If matched, the hash is available in capture group 4\n */\n const regex = /^.*(vimeo.com\\/|video\\/)(\\d+)(\\?.*&*h=|\\/)+([\\d,a-f]+)/;\n const found = url.match(regex);\n return found && found.length === 5 ? found[4] : null;\n }\n\n // Set playback state and trigger change (only on actual change)\n function assurePlaybackState$1(play) {\n if (play && !this.embed.hasPlayed) {\n this.embed.hasPlayed = true;\n }\n if (this.media.paused === play) {\n this.media.paused = !play;\n triggerEvent.call(this, this.media, play ? 'play' : 'pause');\n }\n }\n const vimeo = {\n setup() {\n const player = this;\n\n // Add embed class for responsive\n toggleClass(player.elements.wrapper, player.config.classNames.embed, true);\n\n // Set speed options from config\n player.options.speed = player.config.speed.options;\n\n // Set intial ratio\n setAspectRatio.call(player);\n\n // Load the SDK if not already\n if (!is.object(window.Vimeo)) {\n loadScript(player.config.urls.vimeo.sdk).then(() => {\n vimeo.ready.call(player);\n }).catch(error => {\n player.debug.warn('Vimeo SDK (player.js) failed to load', error);\n });\n } else {\n vimeo.ready.call(player);\n }\n },\n // API Ready\n ready() {\n const player = this;\n const config = player.config.vimeo;\n const {\n premium,\n referrerPolicy,\n ...frameParams\n } = config;\n // Get the source URL or ID\n let source = player.media.getAttribute('src');\n let hash = '';\n // Get from <div> if needed\n if (is.empty(source)) {\n source = player.media.getAttribute(player.config.attributes.embed.id);\n // hash can also be set as attribute on the <div>\n hash = player.media.getAttribute(player.config.attributes.embed.hash);\n } else {\n hash = parseHash(source);\n }\n const hashParam = hash ? {\n h: hash\n } : {};\n\n // If the owner has a pro or premium account then we can hide controls etc\n if (premium) {\n Object.assign(frameParams, {\n controls: false,\n sidedock: false\n });\n }\n\n // Get Vimeo params for the iframe\n const params = buildUrlParams({\n loop: player.config.loop.active,\n autoplay: player.autoplay,\n muted: player.muted,\n gesture: 'media',\n playsinline: player.config.playsinline,\n // hash has to be added to iframe-URL\n ...hashParam,\n ...frameParams\n });\n const id = parseId$1(source);\n // Build an iframe\n const iframe = createElement('iframe');\n const src = format(player.config.urls.vimeo.iframe, id, params);\n iframe.setAttribute('src', src);\n iframe.setAttribute('allowfullscreen', '');\n iframe.setAttribute('allow', ['autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media', 'accelerometer', 'gyroscope'].join('; '));\n\n // Set the referrer policy if required\n if (!is.empty(referrerPolicy)) {\n iframe.setAttribute('referrerPolicy', referrerPolicy);\n }\n\n // Inject the package\n if (premium || !config.customControls) {\n iframe.setAttribute('data-poster', player.poster);\n player.media = replaceElement(iframe, player.media);\n } else {\n const wrapper = createElement('div', {\n class: player.config.classNames.embedContainer,\n 'data-poster': player.poster\n });\n wrapper.appendChild(iframe);\n player.media = replaceElement(wrapper, player.media);\n }\n\n // Get poster image\n if (!config.customControls) {\n fetch(format(player.config.urls.vimeo.api, src)).then(response => {\n if (is.empty(response) || !response.thumbnail_url) {\n return;\n }\n\n // Set and show poster\n ui.setPoster.call(player, response.thumbnail_url).catch(() => {});\n });\n }\n\n // Setup instance\n // https://github.com/vimeo/player.js\n player.embed = new window.Vimeo.Player(iframe, {\n autopause: player.config.autopause,\n muted: player.muted\n });\n player.media.paused = true;\n player.media.currentTime = 0;\n\n // Disable native text track rendering\n if (player.supported.ui) {\n player.embed.disableTextTrack();\n }\n\n // Create a faux HTML5 API using the Vimeo API\n player.media.play = () => {\n assurePlaybackState$1.call(player, true);\n return player.embed.play();\n };\n player.media.pause = () => {\n assurePlaybackState$1.call(player, false);\n return player.embed.pause();\n };\n player.media.stop = () => {\n player.pause();\n player.currentTime = 0;\n };\n\n // Seeking\n let {\n currentTime\n } = player.media;\n Object.defineProperty(player.media, 'currentTime', {\n get() {\n return currentTime;\n },\n set(time) {\n // Vimeo will automatically play on seek if the video hasn't been played before\n\n // Get current paused state and volume etc\n const {\n embed,\n media,\n paused,\n volume\n } = player;\n const restorePause = paused && !embed.hasPlayed;\n\n // Set seeking state and trigger event\n media.seeking = true;\n triggerEvent.call(player, media, 'seeking');\n\n // If paused, mute until seek is complete\n Promise.resolve(restorePause && embed.setVolume(0))\n // Seek\n .then(() => embed.setCurrentTime(time))\n // Restore paused\n .then(() => restorePause && embed.pause())\n // Restore volume\n .then(() => restorePause && embed.setVolume(volume)).catch(() => {\n // Do nothing\n });\n }\n });\n\n // Playback speed\n let speed = player.config.speed.selected;\n Object.defineProperty(player.media, 'playbackRate', {\n get() {\n return speed;\n },\n set(input) {\n player.embed.setPlaybackRate(input).then(() => {\n speed = input;\n triggerEvent.call(player, player.media, 'ratechange');\n }).catch(() => {\n // Cannot set Playback Rate, Video is probably not on Pro account\n player.options.speed = [1];\n });\n }\n });\n\n // Volume\n let {\n volume\n } = player.config;\n Object.defineProperty(player.media, 'volume', {\n get() {\n return volume;\n },\n set(input) {\n player.embed.setVolume(input).then(() => {\n volume = input;\n triggerEvent.call(player, player.media, 'volumechange');\n });\n }\n });\n\n // Muted\n let {\n muted\n } = player.config;\n Object.defineProperty(player.media, 'muted', {\n get() {\n return muted;\n },\n set(input) {\n const toggle = is.boolean(input) ? input : false;\n player.embed.setMuted(toggle ? true : player.config.muted).then(() => {\n muted = toggle;\n triggerEvent.call(player, player.media, 'volumechange');\n });\n }\n });\n\n // Loop\n let {\n loop\n } = player.config;\n Object.defineProperty(player.media, 'loop', {\n get() {\n return loop;\n },\n set(input) {\n const toggle = is.boolean(input) ? input : player.config.loop.active;\n player.embed.setLoop(toggle).then(() => {\n loop = toggle;\n });\n }\n });\n\n // Source\n let currentSrc;\n player.embed.getVideoUrl().then(value => {\n currentSrc = value;\n controls.setDownloadUrl.call(player);\n }).catch(error => {\n this.debug.warn(error);\n });\n Object.defineProperty(player.media, 'currentSrc', {\n get() {\n return currentSrc;\n }\n });\n\n // Ended\n Object.defineProperty(player.media, 'ended', {\n get() {\n return player.currentTime === player.duration;\n }\n });\n\n // Set aspect ratio based on video size\n Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => {\n const [width, height] = dimensions;\n player.embed.ratio = roundAspectRatio(width, height);\n setAspectRatio.call(this);\n });\n\n // Set autopause\n player.embed.setAutopause(player.config.autopause).then(state => {\n player.config.autopause = state;\n });\n\n // Get title\n player.embed.getVideoTitle().then(title => {\n player.config.title = title;\n ui.setTitle.call(this);\n });\n\n // Get current time\n player.embed.getCurrentTime().then(value => {\n currentTime = value;\n triggerEvent.call(player, player.media, 'timeupdate');\n });\n\n // Get duration\n player.embed.getDuration().then(value => {\n player.media.duration = value;\n triggerEvent.call(player, player.media, 'durationchange');\n });\n\n // Get captions\n player.embed.getTextTracks().then(tracks => {\n player.media.textTracks = tracks;\n captions.setup.call(player);\n });\n player.embed.on('cuechange', ({\n cues = []\n }) => {\n const strippedCues = cues.map(cue => stripHTML(cue.text));\n captions.updateCues.call(player, strippedCues);\n });\n player.embed.on('loaded', () => {\n // Assure state and events are updated on autoplay\n player.embed.getPaused().then(paused => {\n assurePlaybackState$1.call(player, !paused);\n if (!paused) {\n triggerEvent.call(player, player.media, 'playing');\n }\n });\n if (is.element(player.embed.element) && player.supported.ui) {\n const frame = player.embed.element;\n\n // Fix keyboard focus issues\n // https://github.com/sampotts/plyr/issues/317\n frame.setAttribute('tabindex', -1);\n }\n });\n player.embed.on('bufferstart', () => {\n triggerEvent.call(player, player.media, 'waiting');\n });\n player.embed.on('bufferend', () => {\n triggerEvent.call(player, player.media, 'playing');\n });\n player.embed.on('play', () => {\n assurePlaybackState$1.call(player, true);\n triggerEvent.call(player, player.media, 'playing');\n });\n player.embed.on('pause', () => {\n assurePlaybackState$1.call(player, false);\n });\n player.embed.on('timeupdate', data => {\n player.media.seeking = false;\n currentTime = data.seconds;\n triggerEvent.call(player, player.media, 'timeupdate');\n });\n player.embed.on('progress', data => {\n player.media.buffered = data.percent;\n triggerEvent.call(player, player.media, 'progress');\n\n // Check all loaded\n if (parseInt(data.percent, 10) === 1) {\n triggerEvent.call(player, player.media, 'canplaythrough');\n }\n\n // Get duration as if we do it before load, it gives an incorrect value\n // https://github.com/sampotts/plyr/issues/891\n player.embed.getDuration().then(value => {\n if (value !== player.media.duration) {\n player.media.duration = value;\n triggerEvent.call(player, player.media, 'durationchange');\n }\n });\n });\n player.embed.on('seeked', () => {\n player.media.seeking = false;\n triggerEvent.call(player, player.media, 'seeked');\n });\n player.embed.on('ended', () => {\n player.media.paused = true;\n triggerEvent.call(player, player.media, 'ended');\n });\n player.embed.on('error', detail => {\n player.media.error = detail;\n triggerEvent.call(player, player.media, 'error');\n });\n\n // Rebuild UI\n if (config.customControls) {\n setTimeout(() => ui.build.call(player), 0);\n }\n }\n };\n\n // ==========================================================================\n\n // Parse YouTube ID from URL\n function parseId(url) {\n if (is.empty(url)) {\n return null;\n }\n const regex = /^.*(youtu.be\\/|v\\/|u\\/\\w\\/|embed\\/|watch\\?v=|&v=)([^#&?]*).*/;\n return url.match(regex) ? RegExp.$2 : url;\n }\n\n // Set playback state and trigger change (only on actual change)\n function assurePlaybackState(play) {\n if (play && !this.embed.hasPlayed) {\n this.embed.hasPlayed = true;\n }\n if (this.media.paused === play) {\n this.media.paused = !play;\n triggerEvent.call(this, this.media, play ? 'play' : 'pause');\n }\n }\n function getHost(config) {\n if (config.noCookie) {\n return 'https://www.youtube-nocookie.com';\n }\n if (window.location.protocol === 'http:') {\n return 'http://www.youtube.com';\n }\n\n // Use YouTube's default\n return undefined;\n }\n const youtube = {\n setup() {\n // Add embed class for responsive\n toggleClass(this.elements.wrapper, this.config.classNames.embed, true);\n\n // Setup API\n if (is.object(window.YT) && is.function(window.YT.Player)) {\n youtube.ready.call(this);\n } else {\n // Reference current global callback\n const callback = window.onYouTubeIframeAPIReady;\n\n // Set callback to process queue\n window.onYouTubeIframeAPIReady = () => {\n // Call global callback if set\n if (is.function(callback)) {\n callback();\n }\n youtube.ready.call(this);\n };\n\n // Load the SDK\n loadScript(this.config.urls.youtube.sdk).catch(error => {\n this.debug.warn('YouTube API failed to load', error);\n });\n }\n },\n // Get the media title\n getTitle(videoId) {\n const url = format(this.config.urls.youtube.api, videoId);\n fetch(url).then(data => {\n if (is.object(data)) {\n const {\n title,\n height,\n width\n } = data;\n\n // Set title\n this.config.title = title;\n ui.setTitle.call(this);\n\n // Set aspect ratio\n this.embed.ratio = roundAspectRatio(width, height);\n }\n setAspectRatio.call(this);\n }).catch(() => {\n // Set aspect ratio\n setAspectRatio.call(this);\n });\n },\n // API ready\n ready() {\n const player = this;\n const config = player.config.youtube;\n // Ignore already setup (race condition)\n const currentId = player.media && player.media.getAttribute('id');\n if (!is.empty(currentId) && currentId.startsWith('youtube-')) {\n return;\n }\n\n // Get the source URL or ID\n let source = player.media.getAttribute('src');\n\n // Get from <div> if needed\n if (is.empty(source)) {\n source = player.media.getAttribute(this.config.attributes.embed.id);\n }\n\n // Replace the <iframe> with a <div> due to YouTube API issues\n const videoId = parseId(source);\n const id = generateId(player.provider);\n // Replace media element\n const container = createElement('div', {\n id,\n 'data-poster': config.customControls ? player.poster : undefined\n });\n player.media = replaceElement(container, player.media);\n\n // Only load the poster when using custom controls\n if (config.customControls) {\n const posterSrc = s => `https://i.ytimg.com/vi/${videoId}/${s}default.jpg`;\n\n // Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide)\n loadImage(posterSrc('maxres'), 121) // Highest quality and un-padded\n .catch(() => loadImage(posterSrc('sd'), 121)) // 480p padded 4:3\n .catch(() => loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists\n .then(image => ui.setPoster.call(player, image.src)).then(src => {\n // If the image is padded, use background-size \"cover\" instead (like youtube does too with their posters)\n if (!src.includes('maxres')) {\n player.elements.poster.style.backgroundSize = 'cover';\n }\n }).catch(() => {});\n }\n\n // Setup instance\n // https://developers.google.com/youtube/iframe_api_reference\n player.embed = new window.YT.Player(player.media, {\n videoId,\n host: getHost(config),\n playerVars: extend({}, {\n // Autoplay\n autoplay: player.config.autoplay ? 1 : 0,\n // iframe interface language\n hl: player.config.hl,\n // Only show controls if not fully supported or opted out\n controls: player.supported.ui && config.customControls ? 0 : 1,\n // Disable keyboard as we handle it\n disablekb: 1,\n // Allow iOS inline playback\n playsinline: player.config.playsinline && !player.config.fullscreen.iosNative ? 1 : 0,\n // Captions are flaky on YouTube\n cc_load_policy: player.captions.active ? 1 : 0,\n cc_lang_pref: player.config.captions.language,\n // Tracking for stats\n widget_referrer: window ? window.location.href : null\n }, config),\n events: {\n onError(event) {\n // YouTube may fire onError twice, so only handle it once\n if (!player.media.error) {\n const code = event.data;\n // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError\n const message = {\n 2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.',\n 5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.',\n 100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.',\n 101: 'The owner of the requested video does not allow it to be played in embedded players.',\n 150: 'The owner of the requested video does not allow it to be played in embedded players.'\n }[code] || 'An unknown error occurred';\n player.media.error = {\n code,\n message\n };\n triggerEvent.call(player, player.media, 'error');\n }\n },\n onPlaybackRateChange(event) {\n // Get the instance\n const instance = event.target;\n\n // Get current speed\n player.media.playbackRate = instance.getPlaybackRate();\n triggerEvent.call(player, player.media, 'ratechange');\n },\n onReady(event) {\n // Bail if onReady has already been called. See issue #1108\n if (is.function(player.media.play)) {\n return;\n }\n // Get the instance\n const instance = event.target;\n\n // Get the title\n youtube.getTitle.call(player, videoId);\n\n // Create a faux HTML5 API using the YouTube API\n player.media.play = () => {\n assurePlaybackState.call(player, true);\n instance.playVideo();\n };\n player.media.pause = () => {\n assurePlaybackState.call(player, false);\n instance.pauseVideo();\n };\n player.media.stop = () => {\n instance.stopVideo();\n };\n player.media.duration = instance.getDuration();\n player.media.paused = true;\n\n // Seeking\n player.media.currentTime = 0;\n Object.defineProperty(player.media, 'currentTime', {\n get() {\n return Number(instance.getCurrentTime());\n },\n set(time) {\n // If paused and never played, mute audio preventively (YouTube starts playing on seek if the video hasn't been played yet).\n if (player.paused && !player.embed.hasPlayed) {\n player.embed.mute();\n }\n\n // Set seeking state and trigger event\n player.media.seeking = true;\n triggerEvent.call(player, player.media, 'seeking');\n\n // Seek after events sent\n instance.seekTo(time);\n }\n });\n\n // Playback speed\n Object.defineProperty(player.media, 'playbackRate', {\n get() {\n return instance.getPlaybackRate();\n },\n set(input) {\n instance.setPlaybackRate(input);\n }\n });\n\n // Volume\n let {\n volume\n } = player.config;\n Object.defineProperty(player.media, 'volume', {\n get() {\n return volume;\n },\n set(input) {\n volume = input;\n instance.setVolume(volume * 100);\n triggerEvent.call(player, player.media, 'volumechange');\n }\n });\n\n // Muted\n let {\n muted\n } = player.config;\n Object.defineProperty(player.media, 'muted', {\n get() {\n return muted;\n },\n set(input) {\n const toggle = is.boolean(input) ? input : muted;\n muted = toggle;\n instance[toggle ? 'mute' : 'unMute']();\n instance.setVolume(volume * 100);\n triggerEvent.call(player, player.media, 'volumechange');\n }\n });\n\n // Source\n Object.defineProperty(player.media, 'currentSrc', {\n get() {\n return instance.getVideoUrl();\n }\n });\n\n // Ended\n Object.defineProperty(player.media, 'ended', {\n get() {\n return player.currentTime === player.duration;\n }\n });\n\n // Get available speeds\n const speeds = instance.getAvailablePlaybackRates();\n // Filter based on config\n player.options.speed = speeds.filter(s => player.config.speed.options.includes(s));\n\n // Set the tabindex to avoid focus entering iframe\n if (player.supported.ui && config.customControls) {\n player.media.setAttribute('tabindex', -1);\n }\n triggerEvent.call(player, player.media, 'timeupdate');\n triggerEvent.call(player, player.media, 'durationchange');\n\n // Reset timer\n clearInterval(player.timers.buffering);\n\n // Setup buffering\n player.timers.buffering = setInterval(() => {\n // Get loaded % from YouTube\n player.media.buffered = instance.getVideoLoadedFraction();\n\n // Trigger progress only when we actually buffer something\n if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) {\n triggerEvent.call(player, player.media, 'progress');\n }\n\n // Set last buffer point\n player.media.lastBuffered = player.media.buffered;\n\n // Bail if we're at 100%\n if (player.media.buffered === 1) {\n clearInterval(player.timers.buffering);\n\n // Trigger event\n triggerEvent.call(player, player.media, 'canplaythrough');\n }\n }, 200);\n\n // Rebuild UI\n if (config.customControls) {\n setTimeout(() => ui.build.call(player), 50);\n }\n },\n onStateChange(event) {\n // Get the instance\n const instance = event.target;\n\n // Reset timer\n clearInterval(player.timers.playing);\n const seeked = player.media.seeking && [1, 2].includes(event.data);\n if (seeked) {\n // Unset seeking and fire seeked event\n player.media.seeking = false;\n triggerEvent.call(player, player.media, 'seeked');\n }\n\n // Handle events\n // -1 Unstarted\n // 0 Ended\n // 1 Playing\n // 2 Paused\n // 3 Buffering\n // 5 Video cued\n switch (event.data) {\n case -1:\n // Update scrubber\n triggerEvent.call(player, player.media, 'timeupdate');\n\n // Get loaded % from YouTube\n player.media.buffered = instance.getVideoLoadedFraction();\n triggerEvent.call(player, player.media, 'progress');\n break;\n case 0:\n assurePlaybackState.call(player, false);\n\n // YouTube doesn't support loop for a single video, so mimick it.\n if (player.media.loop) {\n // YouTube needs a call to `stopVideo` before playing again\n instance.stopVideo();\n instance.playVideo();\n } else {\n triggerEvent.call(player, player.media, 'ended');\n }\n break;\n case 1:\n // Restore paused state (YouTube starts playing on seek if the video hasn't been played yet)\n if (config.customControls && !player.config.autoplay && player.media.paused && !player.embed.hasPlayed) {\n player.media.pause();\n } else {\n assurePlaybackState.call(player, true);\n triggerEvent.call(player, player.media, 'playing');\n\n // Poll to get playback progress\n player.timers.playing = setInterval(() => {\n triggerEvent.call(player, player.media, 'timeupdate');\n }, 50);\n\n // Check duration again due to YouTube bug\n // https://github.com/sampotts/plyr/issues/374\n // https://code.google.com/p/gdata-issues/issues/detail?id=8690\n if (player.media.duration !== instance.getDuration()) {\n player.media.duration = instance.getDuration();\n triggerEvent.call(player, player.media, 'durationchange');\n }\n }\n break;\n case 2:\n // Restore audio (YouTube starts playing on seek if the video hasn't been played yet)\n if (!player.muted) {\n player.embed.unMute();\n }\n assurePlaybackState.call(player, false);\n break;\n case 3:\n // Trigger waiting event to add loading classes to container as the video buffers.\n triggerEvent.call(player, player.media, 'waiting');\n break;\n }\n triggerEvent.call(player, player.elements.container, 'statechange', false, {\n code: event.data\n });\n }\n }\n });\n }\n };\n\n // ==========================================================================\n const media = {\n // Setup media\n setup() {\n // If there's no media, bail\n if (!this.media) {\n this.debug.warn('No media element found!');\n return;\n }\n\n // Add type class\n toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true);\n\n // Add provider class\n toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true);\n\n // Add video class for embeds\n // This will require changes if audio embeds are added\n if (this.isEmbed) {\n toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);\n }\n\n // Inject the player wrapper\n if (this.isVideo) {\n // Create the wrapper div\n this.elements.wrapper = createElement('div', {\n class: this.config.classNames.video\n });\n\n // Wrap the video in a container\n wrap(this.media, this.elements.wrapper);\n\n // Poster image container\n this.elements.poster = createElement('div', {\n class: this.config.classNames.poster\n });\n this.elements.wrapper.appendChild(this.elements.poster);\n }\n if (this.isHTML5) {\n html5.setup.call(this);\n } else if (this.isYouTube) {\n youtube.setup.call(this);\n } else if (this.isVimeo) {\n vimeo.setup.call(this);\n }\n }\n };\n\n const destroy = instance => {\n // Destroy our adsManager\n if (instance.manager) {\n instance.manager.destroy();\n }\n\n // Destroy our adsManager\n if (instance.elements.displayContainer) {\n instance.elements.displayContainer.destroy();\n }\n instance.elements.container.remove();\n };\n class Ads {\n /**\n * Ads constructor.\n * @param {Object} player\n * @return {Ads}\n */\n constructor(player) {\n /**\n * Load the IMA SDK\n */\n _defineProperty$1(this, \"load\", () => {\n if (!this.enabled) {\n return;\n }\n\n // Check if the Google IMA3 SDK is loaded or load it ourselves\n if (!is.object(window.google) || !is.object(window.google.ima)) {\n loadScript(this.player.config.urls.googleIMA.sdk).then(() => {\n this.ready();\n }).catch(() => {\n // Script failed to load or is blocked\n this.trigger('error', new Error('Google IMA SDK failed to load'));\n });\n } else {\n this.ready();\n }\n });\n /**\n * Get the ads instance ready\n */\n _defineProperty$1(this, \"ready\", () => {\n // Double check we're enabled\n if (!this.enabled) {\n destroy(this);\n }\n\n // Start ticking our safety timer. If the whole advertisement\n // thing doesn't resolve within our set time; we bail\n this.startSafetyTimer(12000, 'ready()');\n\n // Clear the safety timer\n this.managerPromise.then(() => {\n this.clearSafetyTimer('onAdsManagerLoaded()');\n });\n\n // Set listeners on the Plyr instance\n this.listeners();\n\n // Setup the IMA SDK\n this.setupIMA();\n });\n /**\n * In order for the SDK to display ads for our video, we need to tell it where to put them,\n * so here we define our ad container. This div is set up to render on top of the video player.\n * Using the code below, we tell the SDK to render ads within that div. We also provide a\n * handle to the content video player - the SDK will poll the current time of our player to\n * properly place mid-rolls. After we create the ad display container, we initialize it. On\n * mobile devices, this initialization is done as the result of a user action.\n */\n _defineProperty$1(this, \"setupIMA\", () => {\n // Create the container for our advertisements\n this.elements.container = createElement('div', {\n class: this.player.config.classNames.ads\n });\n this.player.elements.container.appendChild(this.elements.container);\n\n // So we can run VPAID2\n google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED);\n\n // Set language\n google.ima.settings.setLocale(this.player.config.ads.language);\n\n // Set playback for iOS10+\n google.ima.settings.setDisableCustomPlaybackForIOS10Plus(this.player.config.playsinline);\n\n // We assume the adContainer is the video container of the plyr element that will house the ads\n this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container, this.player.media);\n\n // Create ads loader\n this.loader = new google.ima.AdsLoader(this.elements.displayContainer);\n\n // Listen and respond to ads loaded and error events\n this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false);\n this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false);\n\n // Request video ads to be pre-loaded\n this.requestAds();\n });\n /**\n * Request advertisements\n */\n _defineProperty$1(this, \"requestAds\", () => {\n const {\n container\n } = this.player.elements;\n try {\n // Request video ads\n const request = new google.ima.AdsRequest();\n request.adTagUrl = this.tagUrl;\n\n // Specify the linear and nonlinear slot sizes. This helps the SDK\n // to select the correct creative if multiple are returned\n request.linearAdSlotWidth = container.offsetWidth;\n request.linearAdSlotHeight = container.offsetHeight;\n request.nonLinearAdSlotWidth = container.offsetWidth;\n request.nonLinearAdSlotHeight = container.offsetHeight;\n\n // We only overlay ads as we only support video.\n request.forceNonLinearFullSlot = false;\n\n // Mute based on current state\n request.setAdWillPlayMuted(!this.player.muted);\n this.loader.requestAds(request);\n } catch (error) {\n this.onAdError(error);\n }\n });\n /**\n * Update the ad countdown\n * @param {Boolean} start\n */\n _defineProperty$1(this, \"pollCountdown\", (start = false) => {\n if (!start) {\n clearInterval(this.countdownTimer);\n this.elements.container.removeAttribute('data-badge-text');\n return;\n }\n const update = () => {\n const time = formatTime(Math.max(this.manager.getRemainingTime(), 0));\n const label = `${i18n.get('advertisement', this.player.config)} - ${time}`;\n this.elements.container.setAttribute('data-badge-text', label);\n };\n this.countdownTimer = setInterval(update, 100);\n });\n /**\n * This method is called whenever the ads are ready inside the AdDisplayContainer\n * @param {Event} event - adsManagerLoadedEvent\n */\n _defineProperty$1(this, \"onAdsManagerLoaded\", event => {\n // Load could occur after a source change (race condition)\n if (!this.enabled) {\n return;\n }\n\n // Get the ads manager\n const settings = new google.ima.AdsRenderingSettings();\n\n // Tell the SDK to save and restore content video state on our behalf\n settings.restoreCustomPlaybackStateOnAdBreakComplete = true;\n settings.enablePreloading = true;\n\n // The SDK is polling currentTime on the contentPlayback. And needs a duration\n // so it can determine when to start the mid- and post-roll\n this.manager = event.getAdsManager(this.player, settings);\n\n // Get the cue points for any mid-rolls by filtering out the pre- and post-roll\n this.cuePoints = this.manager.getCuePoints();\n\n // Add listeners to the required events\n // Advertisement error events\n this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error));\n\n // Advertisement regular events\n Object.keys(google.ima.AdEvent.Type).forEach(type => {\n this.manager.addEventListener(google.ima.AdEvent.Type[type], e => this.onAdEvent(e));\n });\n\n // Resolve our adsManager\n this.trigger('loaded');\n });\n _defineProperty$1(this, \"addCuePoints\", () => {\n // Add advertisement cue's within the time line if available\n if (!is.empty(this.cuePoints)) {\n this.cuePoints.forEach(cuePoint => {\n if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) {\n const seekElement = this.player.elements.progress;\n if (is.element(seekElement)) {\n const cuePercentage = 100 / this.player.duration * cuePoint;\n const cue = createElement('span', {\n class: this.player.config.classNames.cues\n });\n cue.style.left = `${cuePercentage.toString()}%`;\n seekElement.appendChild(cue);\n }\n }\n });\n }\n });\n /**\n * This is where all the event handling takes place. Retrieve the ad from the event. Some\n * events (e.g. ALL_ADS_COMPLETED) don't have the ad object associated\n * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type\n * @param {Event} event\n */\n _defineProperty$1(this, \"onAdEvent\", event => {\n const {\n container\n } = this.player.elements;\n // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)\n // don't have ad object associated\n const ad = event.getAd();\n const adData = event.getAdData();\n\n // Proxy event\n const dispatchEvent = type => {\n triggerEvent.call(this.player, this.player.media, `ads${type.replace(/_/g, '').toLowerCase()}`);\n };\n\n // Bubble the event\n dispatchEvent(event.type);\n switch (event.type) {\n case google.ima.AdEvent.Type.LOADED:\n // This is the first event sent for an ad - it is possible to determine whether the\n // ad is a video ad or an overlay\n this.trigger('loaded');\n\n // Start countdown\n this.pollCountdown(true);\n if (!ad.isLinear()) {\n // Position AdDisplayContainer correctly for overlay\n ad.width = container.offsetWidth;\n ad.height = container.offsetHeight;\n }\n\n // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());\n // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());\n\n break;\n case google.ima.AdEvent.Type.STARTED:\n // Set volume to match player\n this.manager.setVolume(this.player.volume);\n break;\n case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:\n // All ads for the current videos are done. We can now request new advertisements\n // in case the video is re-played\n\n // TODO: Example for what happens when a next video in a playlist would be loaded.\n // So here we load a new video when all ads are done.\n // Then we load new ads within a new adsManager. When the video\n // Is started - after - the ads are loaded, then we get ads.\n // You can also easily test cancelling and reloading by running\n // player.ads.cancel() and player.ads.play from the console I guess.\n // this.player.source = {\n // type: 'video',\n // title: 'View From A Blue Moon',\n // sources: [{\n // src:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', type:\n // 'video/mp4', }], poster:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', tracks:\n // [ { kind: 'captions', label: 'English', srclang: 'en', src:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt',\n // default: true, }, { kind: 'captions', label: 'French', srclang: 'fr', src:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', }, ],\n // };\n\n // TODO: So there is still this thing where a video should only be allowed to start\n // playing when the IMA SDK is ready or has failed\n\n if (this.player.ended) {\n this.loadAds();\n } else {\n // The SDK won't allow new ads to be called without receiving a contentComplete()\n this.loader.contentComplete();\n }\n break;\n case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:\n // This event indicates the ad has started - the video player can adjust the UI,\n // for example display a pause button and remaining time. Fired when content should\n // be paused. This usually happens right before an ad is about to cover the content\n\n this.pauseContent();\n break;\n case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:\n // This event indicates the ad has finished - the video player can perform\n // appropriate UI actions, such as removing the timer for remaining time detection.\n // Fired when content should be resumed. This usually happens when an ad finishes\n // or collapses\n\n this.pollCountdown();\n this.resumeContent();\n break;\n case google.ima.AdEvent.Type.LOG:\n if (adData.adError) {\n this.player.debug.warn(`Non-fatal ad error: ${adData.adError.getMessage()}`);\n }\n break;\n }\n });\n /**\n * Any ad error handling comes through here\n * @param {Event} event\n */\n _defineProperty$1(this, \"onAdError\", event => {\n this.cancel();\n this.player.debug.warn('Ads error', event);\n });\n /**\n * Setup hooks for Plyr and window events. This ensures\n * the mid- and post-roll launch at the correct time. And\n * resize the advertisement when the player resizes\n */\n _defineProperty$1(this, \"listeners\", () => {\n const {\n container\n } = this.player.elements;\n let time;\n this.player.on('canplay', () => {\n this.addCuePoints();\n });\n this.player.on('ended', () => {\n this.loader.contentComplete();\n });\n this.player.on('timeupdate', () => {\n time = this.player.currentTime;\n });\n this.player.on('seeked', () => {\n const seekedTime = this.player.currentTime;\n if (is.empty(this.cuePoints)) {\n return;\n }\n this.cuePoints.forEach((cuePoint, index) => {\n if (time < cuePoint && cuePoint < seekedTime) {\n this.manager.discardAdBreak();\n this.cuePoints.splice(index, 1);\n }\n });\n });\n\n // Listen to the resizing of the window. And resize ad accordingly\n // TODO: eventually implement ResizeObserver\n window.addEventListener('resize', () => {\n if (this.manager) {\n this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);\n }\n });\n });\n /**\n * Initialize the adsManager and start playing advertisements\n */\n _defineProperty$1(this, \"play\", () => {\n const {\n container\n } = this.player.elements;\n if (!this.managerPromise) {\n this.resumeContent();\n }\n\n // Play the requested advertisement whenever the adsManager is ready\n this.managerPromise.then(() => {\n // Set volume to match player\n this.manager.setVolume(this.player.volume);\n\n // Initialize the container. Must be done via a user action on mobile devices\n this.elements.displayContainer.initialize();\n try {\n if (!this.initialized) {\n // Initialize the ads manager. Ad rules playlist will start at this time\n this.manager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);\n\n // Call play to start showing the ad. Single video and overlay ads will\n // start at this time; the call will be ignored for ad rules\n this.manager.start();\n }\n this.initialized = true;\n } catch (adError) {\n // An error may be thrown if there was a problem with the\n // VAST response\n this.onAdError(adError);\n }\n }).catch(() => {});\n });\n /**\n * Resume our video\n */\n _defineProperty$1(this, \"resumeContent\", () => {\n // Hide the advertisement container\n this.elements.container.style.zIndex = '';\n\n // Ad is stopped\n this.playing = false;\n\n // Play video\n silencePromise(this.player.media.play());\n });\n /**\n * Pause our video\n */\n _defineProperty$1(this, \"pauseContent\", () => {\n // Show the advertisement container\n this.elements.container.style.zIndex = 3;\n\n // Ad is playing\n this.playing = true;\n\n // Pause our video.\n this.player.media.pause();\n });\n /**\n * Destroy the adsManager so we can grab new ads after this. If we don't then we're not\n * allowed to call new ads based on google policies, as they interpret this as an accidental\n * video requests. https://developers.google.com/interactive-\n * media-ads/docs/sdks/android/faq#8\n */\n _defineProperty$1(this, \"cancel\", () => {\n // Pause our video\n if (this.initialized) {\n this.resumeContent();\n }\n\n // Tell our instance that we're done for now\n this.trigger('error');\n\n // Re-create our adsManager\n this.loadAds();\n });\n /**\n * Re-create our adsManager\n */\n _defineProperty$1(this, \"loadAds\", () => {\n // Tell our adsManager to go bye bye\n this.managerPromise.then(() => {\n // Destroy our adsManager\n if (this.manager) {\n this.manager.destroy();\n }\n\n // Re-set our adsManager promises\n this.managerPromise = new Promise(resolve => {\n this.on('loaded', resolve);\n this.player.debug.log(this.manager);\n });\n // Now that the manager has been destroyed set it to also be un-initialized\n this.initialized = false;\n\n // Now request some new advertisements\n this.requestAds();\n }).catch(() => {});\n });\n /**\n * Handles callbacks after an ad event was invoked\n * @param {String} event - Event type\n * @param args\n */\n _defineProperty$1(this, \"trigger\", (event, ...args) => {\n const handlers = this.events[event];\n if (is.array(handlers)) {\n handlers.forEach(handler => {\n if (is.function(handler)) {\n handler.apply(this, args);\n }\n });\n }\n });\n /**\n * Add event listeners\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n * @return {Ads}\n */\n _defineProperty$1(this, \"on\", (event, callback) => {\n if (!is.array(this.events[event])) {\n this.events[event] = [];\n }\n this.events[event].push(callback);\n return this;\n });\n /**\n * Setup a safety timer for when the ad network doesn't respond for whatever reason.\n * The advertisement has 12 seconds to get its things together. We stop this timer when the\n * advertisement is playing, or when a user action is required to start, then we clear the\n * timer on ad ready\n * @param {Number} time\n * @param {String} from\n */\n _defineProperty$1(this, \"startSafetyTimer\", (time, from) => {\n this.player.debug.log(`Safety timer invoked from: ${from}`);\n this.safetyTimer = setTimeout(() => {\n this.cancel();\n this.clearSafetyTimer('startSafetyTimer()');\n }, time);\n });\n /**\n * Clear our safety timer(s)\n * @param {String} from\n */\n _defineProperty$1(this, \"clearSafetyTimer\", from => {\n if (!is.nullOrUndefined(this.safetyTimer)) {\n this.player.debug.log(`Safety timer cleared from: ${from}`);\n clearTimeout(this.safetyTimer);\n this.safetyTimer = null;\n }\n });\n this.player = player;\n this.config = player.config.ads;\n this.playing = false;\n this.initialized = false;\n this.elements = {\n container: null,\n displayContainer: null\n };\n this.manager = null;\n this.loader = null;\n this.cuePoints = null;\n this.events = {};\n this.safetyTimer = null;\n this.countdownTimer = null;\n\n // Setup a promise to resolve when the IMA manager is ready\n this.managerPromise = new Promise((resolve, reject) => {\n // The ad is loaded and ready\n this.on('loaded', resolve);\n\n // Ads failed\n this.on('error', reject);\n });\n this.load();\n }\n get enabled() {\n const {\n config\n } = this;\n return this.player.isHTML5 && this.player.isVideo && config.enabled && (!is.empty(config.publisherId) || is.url(config.tagUrl));\n }\n // Build the tag URL\n get tagUrl() {\n const {\n config\n } = this;\n if (is.url(config.tagUrl)) {\n return config.tagUrl;\n }\n const params = {\n AV_PUBLISHERID: '58c25bb0073ef448b1087ad6',\n AV_CHANNELID: '5a0458dc28a06145e4519d21',\n AV_URL: window.location.hostname,\n cb: Date.now(),\n AV_WIDTH: 640,\n AV_HEIGHT: 480,\n AV_CDIM2: config.publisherId\n };\n const base = 'https://go.aniview.com/api/adserver6/vast/';\n return `${base}?${buildUrlParams(params)}`;\n }\n }\n\n /**\n * Returns a number whose value is limited to the given range.\n *\n * Example: limit the output of this computation to between 0 and 255\n * (x * 255).clamp(0, 255)\n *\n * @param {Number} input\n * @param {Number} min The lower boundary of the output range\n * @param {Number} max The upper boundary of the output range\n * @returns A number within the bounds of min and max\n * @type Number\n */\n function clamp(input = 0, min = 0, max = 255) {\n return Math.min(Math.max(input, min), max);\n }\n\n // Arg: vttDataString example: \"WEBVTT\\n\\n1\\n00:00:05.000 --> 00:00:10.000\\n1080p-00001.jpg\"\n const parseVtt = vttDataString => {\n const processedList = [];\n const frames = vttDataString.split(/\\r\\n\\r\\n|\\n\\n|\\r\\r/);\n frames.forEach(frame => {\n const result = {};\n const lines = frame.split(/\\r\\n|\\n|\\r/);\n lines.forEach(line => {\n if (!is.number(result.startTime)) {\n // The line with start and end times on it is the first line of interest\n const matchTimes = line.match(/([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})( ?--> ?)([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})/); // Note that this currently ignores caption formatting directives that are optionally on the end of this line - fine for non-captions VTT\n\n if (matchTimes) {\n result.startTime = Number(matchTimes[1] || 0) * 60 * 60 + Number(matchTimes[2]) * 60 + Number(matchTimes[3]) + Number(`0.${matchTimes[4]}`);\n result.endTime = Number(matchTimes[6] || 0) * 60 * 60 + Number(matchTimes[7]) * 60 + Number(matchTimes[8]) + Number(`0.${matchTimes[9]}`);\n }\n } else if (!is.empty(line.trim()) && is.empty(result.text)) {\n // If we already have the startTime, then we're definitely up to the text line(s)\n const lineSplit = line.trim().split('#xywh=');\n [result.text] = lineSplit;\n\n // If there's content in lineSplit[1], then we have sprites. If not, then it's just one frame per image\n if (lineSplit[1]) {\n [result.x, result.y, result.w, result.h] = lineSplit[1].split(',');\n }\n }\n });\n if (result.text) {\n processedList.push(result);\n }\n });\n return processedList;\n };\n\n /**\n * Preview thumbnails for seek hover and scrubbing\n * Seeking: Hover over the seek bar (desktop only): shows a small preview container above the seek bar\n * Scrubbing: Click and drag the seek bar (desktop and mobile): shows the preview image over the entire video, as if the video is scrubbing at very high speed\n *\n * Notes:\n * - Thumbs are set via JS settings on Plyr init, not HTML5 'track' property. Using the track property would be a bit gross, because it doesn't support custom 'kinds'. kind=metadata might be used for something else, and we want to allow multiple thumbnails tracks. Tracks must have a unique combination of 'kind' and 'label'. We would have to do something like kind=metadata,label=thumbnails1 / kind=metadata,label=thumbnails2. Square peg, round hole\n * - VTT info: the image URL is relative to the VTT, not the current document. But if the url starts with a slash, it will naturally be relative to the current domain. https://support.jwplayer.com/articles/how-to-add-preview-thumbnails\n * - This implementation uses multiple separate img elements. Other implementations use background-image on one element. This would be nice and simple, but Firefox and Safari have flickering issues with replacing backgrounds of larger images. It seems that YouTube perhaps only avoids this because they don't have the option for high-res previews (even the fullscreen ones, when mousedown/seeking). Images appear over the top of each other, and previous ones are discarded once the new ones have been rendered\n */\n\n const fitRatio = (ratio, outer) => {\n const targetRatio = outer.width / outer.height;\n const result = {};\n if (ratio > targetRatio) {\n result.width = outer.width;\n result.height = 1 / ratio * outer.width;\n } else {\n result.height = outer.height;\n result.width = ratio * outer.height;\n }\n return result;\n };\n class PreviewThumbnails {\n /**\n * PreviewThumbnails constructor.\n * @param {Plyr} player\n * @return {PreviewThumbnails}\n */\n constructor(player) {\n _defineProperty$1(this, \"load\", () => {\n // Toggle the regular seek tooltip\n if (this.player.elements.display.seekTooltip) {\n this.player.elements.display.seekTooltip.hidden = this.enabled;\n }\n if (!this.enabled) return;\n this.getThumbnails().then(() => {\n if (!this.enabled) {\n return;\n }\n\n // Render DOM elements\n this.render();\n\n // Check to see if thumb container size was specified manually in CSS\n this.determineContainerAutoSizing();\n\n // Set up listeners\n this.listeners();\n this.loaded = true;\n });\n });\n // Download VTT files and parse them\n _defineProperty$1(this, \"getThumbnails\", () => {\n return new Promise(resolve => {\n const {\n src\n } = this.player.config.previewThumbnails;\n if (is.empty(src)) {\n throw new Error('Missing previewThumbnails.src config attribute');\n }\n\n // Resolve promise\n const sortAndResolve = () => {\n // Sort smallest to biggest (e.g., [120p, 480p, 1080p])\n this.thumbnails.sort((x, y) => x.height - y.height);\n this.player.debug.log('Preview thumbnails', this.thumbnails);\n resolve();\n };\n\n // Via callback()\n if (is.function(src)) {\n src(thumbnails => {\n this.thumbnails = thumbnails;\n sortAndResolve();\n });\n }\n // VTT urls\n else {\n // If string, convert into single-element list\n const urls = is.string(src) ? [src] : src;\n // Loop through each src URL. Download and process the VTT file, storing the resulting data in this.thumbnails\n const promises = urls.map(u => this.getThumbnail(u));\n // Resolve\n Promise.all(promises).then(sortAndResolve);\n }\n });\n });\n // Process individual VTT file\n _defineProperty$1(this, \"getThumbnail\", url => {\n return new Promise(resolve => {\n fetch(url).then(response => {\n const thumbnail = {\n frames: parseVtt(response),\n height: null,\n urlPrefix: ''\n };\n\n // If the URLs don't start with '/', then we need to set their relative path to be the location of the VTT file\n // If the URLs do start with '/', then they obviously don't need a prefix, so it will remain blank\n // If the thumbnail URLs start with with none of '/', 'http://' or 'https://', then we need to set their relative path to be the location of the VTT file\n if (!thumbnail.frames[0].text.startsWith('/') && !thumbnail.frames[0].text.startsWith('http://') && !thumbnail.frames[0].text.startsWith('https://')) {\n thumbnail.urlPrefix = url.substring(0, url.lastIndexOf('/') + 1);\n }\n\n // Download the first frame, so that we can determine/set the height of this thumbnailsDef\n const tempImage = new Image();\n tempImage.onload = () => {\n thumbnail.height = tempImage.naturalHeight;\n thumbnail.width = tempImage.naturalWidth;\n this.thumbnails.push(thumbnail);\n resolve();\n };\n tempImage.src = thumbnail.urlPrefix + thumbnail.frames[0].text;\n });\n });\n });\n _defineProperty$1(this, \"startMove\", event => {\n if (!this.loaded) return;\n if (!is.event(event) || !['touchmove', 'mousemove'].includes(event.type)) return;\n\n // Wait until media has a duration\n if (!this.player.media.duration) return;\n if (event.type === 'touchmove') {\n // Calculate seek hover position as approx video seconds\n this.seekTime = this.player.media.duration * (this.player.elements.inputs.seek.value / 100);\n } else {\n var _this$player$config$m, _this$player$config$m2;\n // Calculate seek hover position as approx video seconds\n const clientRect = this.player.elements.progress.getBoundingClientRect();\n const percentage = 100 / clientRect.width * (event.pageX - clientRect.left);\n this.seekTime = this.player.media.duration * (percentage / 100);\n if (this.seekTime < 0) {\n // The mousemove fires for 10+px out to the left\n this.seekTime = 0;\n }\n if (this.seekTime > this.player.media.duration - 1) {\n // Took 1 second off the duration for safety, because different players can disagree on the real duration of a video\n this.seekTime = this.player.media.duration - 1;\n }\n this.mousePosX = event.pageX;\n\n // Set time text inside image container\n this.elements.thumb.time.innerText = formatTime(this.seekTime);\n\n // Get marker point for time\n const point = (_this$player$config$m = this.player.config.markers) === null || _this$player$config$m === void 0 ? void 0 : (_this$player$config$m2 = _this$player$config$m.points) === null || _this$player$config$m2 === void 0 ? void 0 : _this$player$config$m2.find(({\n time: t\n }) => t === Math.round(this.seekTime));\n\n // Append the point label to the tooltip\n if (point) {\n // this.elements.thumb.time.innerText.concat('\\n');\n this.elements.thumb.time.insertAdjacentHTML('afterbegin', `${point.label}<br>`);\n }\n }\n\n // Download and show image\n this.showImageAtCurrentTime();\n });\n _defineProperty$1(this, \"endMove\", () => {\n this.toggleThumbContainer(false, true);\n });\n _defineProperty$1(this, \"startScrubbing\", event => {\n // Only act on left mouse button (0), or touch device (event.button does not exist or is false)\n if (is.nullOrUndefined(event.button) || event.button === false || event.button === 0) {\n this.mouseDown = true;\n\n // Wait until media has a duration\n if (this.player.media.duration) {\n this.toggleScrubbingContainer(true);\n this.toggleThumbContainer(false, true);\n\n // Download and show image\n this.showImageAtCurrentTime();\n }\n }\n });\n _defineProperty$1(this, \"endScrubbing\", () => {\n this.mouseDown = false;\n\n // Hide scrubbing preview. But wait until the video has successfully seeked before hiding the scrubbing preview\n if (Math.ceil(this.lastTime) === Math.ceil(this.player.media.currentTime)) {\n // The video was already seeked/loaded at the chosen time - hide immediately\n this.toggleScrubbingContainer(false);\n } else {\n // The video hasn't seeked yet. Wait for that\n once.call(this.player, this.player.media, 'timeupdate', () => {\n // Re-check mousedown - we might have already started scrubbing again\n if (!this.mouseDown) {\n this.toggleScrubbingContainer(false);\n }\n });\n }\n });\n /**\n * Setup hooks for Plyr and window events\n */\n _defineProperty$1(this, \"listeners\", () => {\n // Hide thumbnail preview - on mouse click, mouse leave (in listeners.js for now), and video play/seek. All four are required, e.g., for buffering\n this.player.on('play', () => {\n this.toggleThumbContainer(false, true);\n });\n this.player.on('seeked', () => {\n this.toggleThumbContainer(false);\n });\n this.player.on('timeupdate', () => {\n this.lastTime = this.player.media.currentTime;\n });\n });\n /**\n * Create HTML elements for image containers\n */\n _defineProperty$1(this, \"render\", () => {\n // Create HTML element: plyr__preview-thumbnail-container\n this.elements.thumb.container = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.thumbContainer\n });\n\n // Wrapper for the image for styling\n this.elements.thumb.imageContainer = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.imageContainer\n });\n this.elements.thumb.container.appendChild(this.elements.thumb.imageContainer);\n\n // Create HTML element, parent+span: time text (e.g., 01:32:00)\n const timeContainer = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.timeContainer\n });\n this.elements.thumb.time = createElement('span', {}, '00:00');\n timeContainer.appendChild(this.elements.thumb.time);\n this.elements.thumb.imageContainer.appendChild(timeContainer);\n\n // Inject the whole thumb\n if (is.element(this.player.elements.progress)) {\n this.player.elements.progress.appendChild(this.elements.thumb.container);\n }\n\n // Create HTML element: plyr__preview-scrubbing-container\n this.elements.scrubbing.container = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.scrubbingContainer\n });\n this.player.elements.wrapper.appendChild(this.elements.scrubbing.container);\n });\n _defineProperty$1(this, \"destroy\", () => {\n if (this.elements.thumb.container) {\n this.elements.thumb.container.remove();\n }\n if (this.elements.scrubbing.container) {\n this.elements.scrubbing.container.remove();\n }\n });\n _defineProperty$1(this, \"showImageAtCurrentTime\", () => {\n if (this.mouseDown) {\n this.setScrubbingContainerSize();\n } else {\n this.setThumbContainerSizeAndPos();\n }\n\n // Find the desired thumbnail index\n // TODO: Handle a video longer than the thumbs where thumbNum is null\n const thumbNum = this.thumbnails[0].frames.findIndex(frame => this.seekTime >= frame.startTime && this.seekTime <= frame.endTime);\n const hasThumb = thumbNum >= 0;\n let qualityIndex = 0;\n\n // Show the thumb container if we're not scrubbing\n if (!this.mouseDown) {\n this.toggleThumbContainer(hasThumb);\n }\n\n // No matching thumb found\n if (!hasThumb) {\n return;\n }\n\n // Check to see if we've already downloaded higher quality versions of this image\n this.thumbnails.forEach((thumbnail, index) => {\n if (this.loadedImages.includes(thumbnail.frames[thumbNum].text)) {\n qualityIndex = index;\n }\n });\n\n // Only proceed if either thumb num or thumbfilename has changed\n if (thumbNum !== this.showingThumb) {\n this.showingThumb = thumbNum;\n this.loadImage(qualityIndex);\n }\n });\n // Show the image that's currently specified in this.showingThumb\n _defineProperty$1(this, \"loadImage\", (qualityIndex = 0) => {\n const thumbNum = this.showingThumb;\n const thumbnail = this.thumbnails[qualityIndex];\n const {\n urlPrefix\n } = thumbnail;\n const frame = thumbnail.frames[thumbNum];\n const thumbFilename = thumbnail.frames[thumbNum].text;\n const thumbUrl = urlPrefix + thumbFilename;\n if (!this.currentImageElement || this.currentImageElement.dataset.filename !== thumbFilename) {\n // If we're already loading a previous image, remove its onload handler - we don't want it to load after this one\n // Only do this if not using sprites. Without sprites we really want to show as many images as possible, as a best-effort\n if (this.loadingImage && this.usingSprites) {\n this.loadingImage.onload = null;\n }\n\n // We're building and adding a new image. In other implementations of similar functionality (YouTube), background image\n // is instead used. But this causes issues with larger images in Firefox and Safari - switching between background\n // images causes a flicker. Putting a new image over the top does not\n const previewImage = new Image();\n previewImage.src = thumbUrl;\n previewImage.dataset.index = thumbNum;\n previewImage.dataset.filename = thumbFilename;\n this.showingThumbFilename = thumbFilename;\n this.player.debug.log(`Loading image: ${thumbUrl}`);\n\n // For some reason, passing the named function directly causes it to execute immediately. So I've wrapped it in an anonymous function...\n previewImage.onload = () => this.showImage(previewImage, frame, qualityIndex, thumbNum, thumbFilename, true);\n this.loadingImage = previewImage;\n this.removeOldImages(previewImage);\n } else {\n // Update the existing image\n this.showImage(this.currentImageElement, frame, qualityIndex, thumbNum, thumbFilename, false);\n this.currentImageElement.dataset.index = thumbNum;\n this.removeOldImages(this.currentImageElement);\n }\n });\n _defineProperty$1(this, \"showImage\", (previewImage, frame, qualityIndex, thumbNum, thumbFilename, newImage = true) => {\n this.player.debug.log(`Showing thumb: ${thumbFilename}. num: ${thumbNum}. qual: ${qualityIndex}. newimg: ${newImage}`);\n this.setImageSizeAndOffset(previewImage, frame);\n if (newImage) {\n this.currentImageContainer.appendChild(previewImage);\n this.currentImageElement = previewImage;\n if (!this.loadedImages.includes(thumbFilename)) {\n this.loadedImages.push(thumbFilename);\n }\n }\n\n // Preload images before and after the current one\n // Show higher quality of the same frame\n // Each step here has a short time delay, and only continues if still hovering/seeking the same spot. This is to protect slow connections from overloading\n this.preloadNearby(thumbNum, true).then(this.preloadNearby(thumbNum, false)).then(this.getHigherQuality(qualityIndex, previewImage, frame, thumbFilename));\n });\n // Remove all preview images that aren't the designated current image\n _defineProperty$1(this, \"removeOldImages\", currentImage => {\n // Get a list of all images, convert it from a DOM list to an array\n Array.from(this.currentImageContainer.children).forEach(image => {\n if (image.tagName.toLowerCase() !== 'img') {\n return;\n }\n const removeDelay = this.usingSprites ? 500 : 1000;\n if (image.dataset.index !== currentImage.dataset.index && !image.dataset.deleting) {\n // Wait 200ms, as the new image can take some time to show on certain browsers (even though it was downloaded before showing). This will prevent flicker, and show some generosity towards slower clients\n // First set attribute 'deleting' to prevent multi-handling of this on repeat firing of this function\n // eslint-disable-next-line no-param-reassign\n image.dataset.deleting = true;\n\n // This has to be set before the timeout - to prevent issues switching between hover and scrub\n const {\n currentImageContainer\n } = this;\n setTimeout(() => {\n currentImageContainer.removeChild(image);\n this.player.debug.log(`Removing thumb: ${image.dataset.filename}`);\n }, removeDelay);\n }\n });\n });\n // Preload images before and after the current one. Only if the user is still hovering/seeking the same frame\n // This will only preload the lowest quality\n _defineProperty$1(this, \"preloadNearby\", (thumbNum, forward = true) => {\n return new Promise(resolve => {\n setTimeout(() => {\n const oldThumbFilename = this.thumbnails[0].frames[thumbNum].text;\n if (this.showingThumbFilename === oldThumbFilename) {\n // Find the nearest thumbs with different filenames. Sometimes it'll be the next index, but in the case of sprites, it might be 100+ away\n let thumbnailsClone;\n if (forward) {\n thumbnailsClone = this.thumbnails[0].frames.slice(thumbNum);\n } else {\n thumbnailsClone = this.thumbnails[0].frames.slice(0, thumbNum).reverse();\n }\n let foundOne = false;\n thumbnailsClone.forEach(frame => {\n const newThumbFilename = frame.text;\n if (newThumbFilename !== oldThumbFilename) {\n // Found one with a different filename. Make sure it hasn't already been loaded on this page visit\n if (!this.loadedImages.includes(newThumbFilename)) {\n foundOne = true;\n this.player.debug.log(`Preloading thumb filename: ${newThumbFilename}`);\n const {\n urlPrefix\n } = this.thumbnails[0];\n const thumbURL = urlPrefix + newThumbFilename;\n const previewImage = new Image();\n previewImage.src = thumbURL;\n previewImage.onload = () => {\n this.player.debug.log(`Preloaded thumb filename: ${newThumbFilename}`);\n if (!this.loadedImages.includes(newThumbFilename)) this.loadedImages.push(newThumbFilename);\n\n // We don't resolve until the thumb is loaded\n resolve();\n };\n }\n }\n });\n\n // If there are none to preload then we want to resolve immediately\n if (!foundOne) {\n resolve();\n }\n }\n }, 300);\n });\n });\n // If user has been hovering current image for half a second, look for a higher quality one\n _defineProperty$1(this, \"getHigherQuality\", (currentQualityIndex, previewImage, frame, thumbFilename) => {\n if (currentQualityIndex < this.thumbnails.length - 1) {\n // Only use the higher quality version if it's going to look any better - if the current thumb is of a lower pixel density than the thumbnail container\n let previewImageHeight = previewImage.naturalHeight;\n if (this.usingSprites) {\n previewImageHeight = frame.h;\n }\n if (previewImageHeight < this.thumbContainerHeight) {\n // Recurse back to the loadImage function - show a higher quality one, but only if the viewer is on this frame for a while\n setTimeout(() => {\n // Make sure the mouse hasn't already moved on and started hovering at another image\n if (this.showingThumbFilename === thumbFilename) {\n this.player.debug.log(`Showing higher quality thumb for: ${thumbFilename}`);\n this.loadImage(currentQualityIndex + 1);\n }\n }, 300);\n }\n }\n });\n _defineProperty$1(this, \"toggleThumbContainer\", (toggle = false, clearShowing = false) => {\n const className = this.player.config.classNames.previewThumbnails.thumbContainerShown;\n this.elements.thumb.container.classList.toggle(className, toggle);\n if (!toggle && clearShowing) {\n this.showingThumb = null;\n this.showingThumbFilename = null;\n }\n });\n _defineProperty$1(this, \"toggleScrubbingContainer\", (toggle = false) => {\n const className = this.player.config.classNames.previewThumbnails.scrubbingContainerShown;\n this.elements.scrubbing.container.classList.toggle(className, toggle);\n if (!toggle) {\n this.showingThumb = null;\n this.showingThumbFilename = null;\n }\n });\n _defineProperty$1(this, \"determineContainerAutoSizing\", () => {\n if (this.elements.thumb.imageContainer.clientHeight > 20 || this.elements.thumb.imageContainer.clientWidth > 20) {\n // This will prevent auto sizing in this.setThumbContainerSizeAndPos()\n this.sizeSpecifiedInCSS = true;\n }\n });\n // Set the size to be about a quarter of the size of video. Unless option dynamicSize === false, in which case it needs to be set in CSS\n _defineProperty$1(this, \"setThumbContainerSizeAndPos\", () => {\n const {\n imageContainer\n } = this.elements.thumb;\n if (!this.sizeSpecifiedInCSS) {\n const thumbWidth = Math.floor(this.thumbContainerHeight * this.thumbAspectRatio);\n imageContainer.style.height = `${this.thumbContainerHeight}px`;\n imageContainer.style.width = `${thumbWidth}px`;\n } else if (imageContainer.clientHeight > 20 && imageContainer.clientWidth < 20) {\n const thumbWidth = Math.floor(imageContainer.clientHeight * this.thumbAspectRatio);\n imageContainer.style.width = `${thumbWidth}px`;\n } else if (imageContainer.clientHeight < 20 && imageContainer.clientWidth > 20) {\n const thumbHeight = Math.floor(imageContainer.clientWidth / this.thumbAspectRatio);\n imageContainer.style.height = `${thumbHeight}px`;\n }\n this.setThumbContainerPos();\n });\n _defineProperty$1(this, \"setThumbContainerPos\", () => {\n const scrubberRect = this.player.elements.progress.getBoundingClientRect();\n const containerRect = this.player.elements.container.getBoundingClientRect();\n const {\n container\n } = this.elements.thumb;\n // Find the lowest and highest desired left-position, so we don't slide out the side of the video container\n const min = containerRect.left - scrubberRect.left + 10;\n const max = containerRect.right - scrubberRect.left - container.clientWidth - 10;\n // Set preview container position to: mousepos, minus seekbar.left, minus half of previewContainer.clientWidth\n const position = this.mousePosX - scrubberRect.left - container.clientWidth / 2;\n const clamped = clamp(position, min, max);\n\n // Move the popover position\n container.style.left = `${clamped}px`;\n\n // The arrow can follow the cursor\n container.style.setProperty('--preview-arrow-offset', `${position - clamped}px`);\n });\n // Can't use 100% width, in case the video is a different aspect ratio to the video container\n _defineProperty$1(this, \"setScrubbingContainerSize\", () => {\n const {\n width,\n height\n } = fitRatio(this.thumbAspectRatio, {\n width: this.player.media.clientWidth,\n height: this.player.media.clientHeight\n });\n this.elements.scrubbing.container.style.width = `${width}px`;\n this.elements.scrubbing.container.style.height = `${height}px`;\n });\n // Sprites need to be offset to the correct location\n _defineProperty$1(this, \"setImageSizeAndOffset\", (previewImage, frame) => {\n if (!this.usingSprites) return;\n\n // Find difference between height and preview container height\n const multiplier = this.thumbContainerHeight / frame.h;\n\n // eslint-disable-next-line no-param-reassign\n previewImage.style.height = `${previewImage.naturalHeight * multiplier}px`;\n // eslint-disable-next-line no-param-reassign\n previewImage.style.width = `${previewImage.naturalWidth * multiplier}px`;\n // eslint-disable-next-line no-param-reassign\n previewImage.style.left = `-${frame.x * multiplier}px`;\n // eslint-disable-next-line no-param-reassign\n previewImage.style.top = `-${frame.y * multiplier}px`;\n });\n this.player = player;\n this.thumbnails = [];\n this.loaded = false;\n this.lastMouseMoveTime = Date.now();\n this.mouseDown = false;\n this.loadedImages = [];\n this.elements = {\n thumb: {},\n scrubbing: {}\n };\n this.load();\n }\n get enabled() {\n return this.player.isHTML5 && this.player.isVideo && this.player.config.previewThumbnails.enabled;\n }\n get currentImageContainer() {\n return this.mouseDown ? this.elements.scrubbing.container : this.elements.thumb.imageContainer;\n }\n get usingSprites() {\n return Object.keys(this.thumbnails[0].frames[0]).includes('w');\n }\n get thumbAspectRatio() {\n if (this.usingSprites) {\n return this.thumbnails[0].frames[0].w / this.thumbnails[0].frames[0].h;\n }\n return this.thumbnails[0].width / this.thumbnails[0].height;\n }\n get thumbContainerHeight() {\n if (this.mouseDown) {\n const {\n height\n } = fitRatio(this.thumbAspectRatio, {\n width: this.player.media.clientWidth,\n height: this.player.media.clientHeight\n });\n return height;\n }\n\n // If css is used this needs to return the css height for sprites to work (see setImageSizeAndOffset)\n if (this.sizeSpecifiedInCSS) {\n return this.elements.thumb.imageContainer.clientHeight;\n }\n return Math.floor(this.player.media.clientWidth / this.thumbAspectRatio / 4);\n }\n get currentImageElement() {\n return this.mouseDown ? this.currentScrubbingImageElement : this.currentThumbnailImageElement;\n }\n set currentImageElement(element) {\n if (this.mouseDown) {\n this.currentScrubbingImageElement = element;\n } else {\n this.currentThumbnailImageElement = element;\n }\n }\n }\n\n // ==========================================================================\n const source = {\n // Add elements to HTML5 media (source, tracks, etc)\n insertElements(type, attributes) {\n if (is.string(attributes)) {\n insertElement(type, this.media, {\n src: attributes\n });\n } else if (is.array(attributes)) {\n attributes.forEach(attribute => {\n insertElement(type, this.media, attribute);\n });\n }\n },\n // Update source\n // Sources are not checked for support so be careful\n change(input) {\n if (!getDeep(input, 'sources.length')) {\n this.debug.warn('Invalid source format');\n return;\n }\n\n // Cancel current network requests\n html5.cancelRequests.call(this);\n\n // Destroy instance and re-setup\n this.destroy.call(this, () => {\n // Reset quality options\n this.options.quality = [];\n\n // Remove elements\n removeElement(this.media);\n this.media = null;\n\n // Reset class name\n if (is.element(this.elements.container)) {\n this.elements.container.removeAttribute('class');\n }\n\n // Set the type and provider\n const {\n sources,\n type\n } = input;\n const [{\n provider = providers.html5,\n src\n }] = sources;\n const tagName = provider === 'html5' ? type : 'div';\n const attributes = provider === 'html5' ? {} : {\n src\n };\n Object.assign(this, {\n provider,\n type,\n // Check for support\n supported: support.check(type, provider, this.config.playsinline),\n // Create new element\n media: createElement(tagName, attributes)\n });\n\n // Inject the new element\n this.elements.container.appendChild(this.media);\n\n // Autoplay the new source?\n if (is.boolean(input.autoplay)) {\n this.config.autoplay = input.autoplay;\n }\n\n // Set attributes for audio and video\n if (this.isHTML5) {\n if (this.config.crossorigin) {\n this.media.setAttribute('crossorigin', '');\n }\n if (this.config.autoplay) {\n this.media.setAttribute('autoplay', '');\n }\n if (!is.empty(input.poster)) {\n this.poster = input.poster;\n }\n if (this.config.loop.active) {\n this.media.setAttribute('loop', '');\n }\n if (this.config.muted) {\n this.media.setAttribute('muted', '');\n }\n if (this.config.playsinline) {\n this.media.setAttribute('playsinline', '');\n }\n }\n\n // Restore class hook\n ui.addStyleHook.call(this);\n\n // Set new sources for html5\n if (this.isHTML5) {\n source.insertElements.call(this, 'source', sources);\n }\n\n // Set video title\n this.config.title = input.title;\n\n // Set up from scratch\n media.setup.call(this);\n\n // HTML5 stuff\n if (this.isHTML5) {\n // Setup captions\n if (Object.keys(input).includes('tracks')) {\n source.insertElements.call(this, 'track', input.tracks);\n }\n }\n\n // If HTML5 or embed but not fully supported, setupInterface and call ready now\n if (this.isHTML5 || this.isEmbed && !this.supported.ui) {\n // Setup interface\n ui.build.call(this);\n }\n\n // Load HTML5 sources\n if (this.isHTML5) {\n this.media.load();\n }\n\n // Update previewThumbnails config & reload plugin\n if (!is.empty(input.previewThumbnails)) {\n Object.assign(this.config.previewThumbnails, input.previewThumbnails);\n\n // Cleanup previewThumbnails plugin if it was loaded\n if (this.previewThumbnails && this.previewThumbnails.loaded) {\n this.previewThumbnails.destroy();\n this.previewThumbnails = null;\n }\n\n // Create new instance if it is still enabled\n if (this.config.previewThumbnails.enabled) {\n this.previewThumbnails = new PreviewThumbnails(this);\n }\n }\n\n // Update the fullscreen support\n this.fullscreen.update();\n }, true);\n }\n };\n\n // Private properties\n // TODO: Use a WeakMap for private globals\n // const globals = new WeakMap();\n\n // Plyr instance\n class Plyr {\n constructor(target, options) {\n /**\n * Play the media, or play the advertisement (if they are not blocked)\n */\n _defineProperty$1(this, \"play\", () => {\n if (!is.function(this.media.play)) {\n return null;\n }\n\n // Intecept play with ads\n if (this.ads && this.ads.enabled) {\n this.ads.managerPromise.then(() => this.ads.play()).catch(() => silencePromise(this.media.play()));\n }\n\n // Return the promise (for HTML5)\n return this.media.play();\n });\n /**\n * Pause the media\n */\n _defineProperty$1(this, \"pause\", () => {\n if (!this.playing || !is.function(this.media.pause)) {\n return null;\n }\n return this.media.pause();\n });\n /**\n * Toggle playback based on current status\n * @param {Boolean} input\n */\n _defineProperty$1(this, \"togglePlay\", input => {\n // Toggle based on current state if nothing passed\n const toggle = is.boolean(input) ? input : !this.playing;\n if (toggle) {\n return this.play();\n }\n return this.pause();\n });\n /**\n * Stop playback\n */\n _defineProperty$1(this, \"stop\", () => {\n if (this.isHTML5) {\n this.pause();\n this.restart();\n } else if (is.function(this.media.stop)) {\n this.media.stop();\n }\n });\n /**\n * Restart playback\n */\n _defineProperty$1(this, \"restart\", () => {\n this.currentTime = 0;\n });\n /**\n * Rewind\n * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime\n */\n _defineProperty$1(this, \"rewind\", seekTime => {\n this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime;\n });\n /**\n * Fast forward\n * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime\n */\n _defineProperty$1(this, \"forward\", seekTime => {\n this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime;\n });\n /**\n * Increase volume\n * @param {Boolean} step - How much to decrease by (between 0 and 1)\n */\n _defineProperty$1(this, \"increaseVolume\", step => {\n const volume = this.media.muted ? 0 : this.volume;\n this.volume = volume + (is.number(step) ? step : 0);\n });\n /**\n * Decrease volume\n * @param {Boolean} step - How much to decrease by (between 0 and 1)\n */\n _defineProperty$1(this, \"decreaseVolume\", step => {\n this.increaseVolume(-step);\n });\n /**\n * Trigger the airplay dialog\n * TODO: update player with state, support, enabled\n */\n _defineProperty$1(this, \"airplay\", () => {\n // Show dialog if supported\n if (support.airplay) {\n this.media.webkitShowPlaybackTargetPicker();\n }\n });\n /**\n * Toggle the player controls\n * @param {Boolean} [toggle] - Whether to show the controls\n */\n _defineProperty$1(this, \"toggleControls\", toggle => {\n // Don't toggle if missing UI support or if it's audio\n if (this.supported.ui && !this.isAudio) {\n // Get state before change\n const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);\n // Negate the argument if not undefined since adding the class to hides the controls\n const force = typeof toggle === 'undefined' ? undefined : !toggle;\n // Apply and get updated state\n const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);\n\n // Close menu\n if (hiding && is.array(this.config.controls) && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {\n controls.toggleMenu.call(this, false);\n }\n\n // Trigger event on change\n if (hiding !== isHidden) {\n const eventName = hiding ? 'controlshidden' : 'controlsshown';\n triggerEvent.call(this, this.media, eventName);\n }\n return !hiding;\n }\n return false;\n });\n /**\n * Add event listeners\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n */\n _defineProperty$1(this, \"on\", (event, callback) => {\n on.call(this, this.elements.container, event, callback);\n });\n /**\n * Add event listeners once\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n */\n _defineProperty$1(this, \"once\", (event, callback) => {\n once.call(this, this.elements.container, event, callback);\n });\n /**\n * Remove event listeners\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n */\n _defineProperty$1(this, \"off\", (event, callback) => {\n off(this.elements.container, event, callback);\n });\n /**\n * Destroy an instance\n * Event listeners are removed when elements are removed\n * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory\n * @param {Function} callback - Callback for when destroy is complete\n * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc)\n */\n _defineProperty$1(this, \"destroy\", (callback, soft = false) => {\n if (!this.ready) {\n return;\n }\n const done = () => {\n // Reset overflow (incase destroyed while in fullscreen)\n document.body.style.overflow = '';\n\n // GC for embed\n this.embed = null;\n\n // If it's a soft destroy, make minimal changes\n if (soft) {\n if (Object.keys(this.elements).length) {\n // Remove elements\n removeElement(this.elements.buttons.play);\n removeElement(this.elements.captions);\n removeElement(this.elements.controls);\n removeElement(this.elements.wrapper);\n\n // Clear for GC\n this.elements.buttons.play = null;\n this.elements.captions = null;\n this.elements.controls = null;\n this.elements.wrapper = null;\n }\n\n // Callback\n if (is.function(callback)) {\n callback();\n }\n } else {\n // Unbind listeners\n unbindListeners.call(this);\n\n // Cancel current network requests\n html5.cancelRequests.call(this);\n\n // Replace the container with the original element provided\n replaceElement(this.elements.original, this.elements.container);\n\n // Event\n triggerEvent.call(this, this.elements.original, 'destroyed', true);\n\n // Callback\n if (is.function(callback)) {\n callback.call(this.elements.original);\n }\n\n // Reset state\n this.ready = false;\n\n // Clear for garbage collection\n setTimeout(() => {\n this.elements = null;\n this.media = null;\n }, 200);\n }\n };\n\n // Stop playback\n this.stop();\n\n // Clear timeouts\n clearTimeout(this.timers.loading);\n clearTimeout(this.timers.controls);\n clearTimeout(this.timers.resized);\n\n // Provider specific stuff\n if (this.isHTML5) {\n // Restore native video controls\n ui.toggleNativeControls.call(this, true);\n\n // Clean up\n done();\n } else if (this.isYouTube) {\n // Clear timers\n clearInterval(this.timers.buffering);\n clearInterval(this.timers.playing);\n\n // Destroy YouTube API\n if (this.embed !== null && is.function(this.embed.destroy)) {\n this.embed.destroy();\n }\n\n // Clean up\n done();\n } else if (this.isVimeo) {\n // Destroy Vimeo API\n // then clean up (wait, to prevent postmessage errors)\n if (this.embed !== null) {\n this.embed.unload().then(done);\n }\n\n // Vimeo does not always return\n setTimeout(done, 200);\n }\n });\n /**\n * Check for support for a mime type (HTML5 only)\n * @param {String} type - Mime type\n */\n _defineProperty$1(this, \"supports\", type => support.mime.call(this, type));\n this.timers = {};\n\n // State\n this.ready = false;\n this.loading = false;\n this.failed = false;\n\n // Touch device\n this.touch = support.touch;\n\n // Set the media element\n this.media = target;\n\n // String selector passed\n if (is.string(this.media)) {\n this.media = document.querySelectorAll(this.media);\n }\n\n // jQuery, NodeList or Array passed, use first element\n if (window.jQuery && this.media instanceof jQuery || is.nodeList(this.media) || is.array(this.media)) {\n // eslint-disable-next-line\n this.media = this.media[0];\n }\n\n // Set config\n this.config = extend({}, defaults, Plyr.defaults, options || {}, (() => {\n try {\n return JSON.parse(this.media.getAttribute('data-plyr-config'));\n } catch (_) {\n return {};\n }\n })());\n\n // Elements cache\n this.elements = {\n container: null,\n fullscreen: null,\n captions: null,\n buttons: {},\n display: {},\n progress: {},\n inputs: {},\n settings: {\n popup: null,\n menu: null,\n panels: {},\n buttons: {}\n }\n };\n\n // Captions\n this.captions = {\n active: null,\n currentTrack: -1,\n meta: new WeakMap()\n };\n\n // Fullscreen\n this.fullscreen = {\n active: false\n };\n\n // Options\n this.options = {\n speed: [],\n quality: []\n };\n\n // Debugging\n // TODO: move to globals\n this.debug = new Console(this.config.debug);\n\n // Log config options and support\n this.debug.log('Config', this.config);\n this.debug.log('Support', support);\n\n // We need an element to setup\n if (is.nullOrUndefined(this.media) || !is.element(this.media)) {\n this.debug.error('Setup failed: no suitable element passed');\n return;\n }\n\n // Bail if the element is initialized\n if (this.media.plyr) {\n this.debug.warn('Target already setup');\n return;\n }\n\n // Bail if not enabled\n if (!this.config.enabled) {\n this.debug.error('Setup failed: disabled by config');\n return;\n }\n\n // Bail if disabled or no basic support\n // You may want to disable certain UAs etc\n if (!support.check().api) {\n this.debug.error('Setup failed: no support');\n return;\n }\n\n // Cache original element state for .destroy()\n const clone = this.media.cloneNode(true);\n clone.autoplay = false;\n this.elements.original = clone;\n\n // Set media type based on tag or data attribute\n // Supported: video, audio, vimeo, youtube\n const _type = this.media.tagName.toLowerCase();\n // Embed properties\n let iframe = null;\n let url = null;\n\n // Different setup based on type\n switch (_type) {\n case 'div':\n // Find the frame\n iframe = this.media.querySelector('iframe');\n\n // <iframe> type\n if (is.element(iframe)) {\n // Detect provider\n url = parseUrl(iframe.getAttribute('src'));\n this.provider = getProviderByUrl(url.toString());\n\n // Rework elements\n this.elements.container = this.media;\n this.media = iframe;\n\n // Reset classname\n this.elements.container.className = '';\n\n // Get attributes from URL and set config\n if (url.search.length) {\n const truthy = ['1', 'true'];\n if (truthy.includes(url.searchParams.get('autoplay'))) {\n this.config.autoplay = true;\n }\n if (truthy.includes(url.searchParams.get('loop'))) {\n this.config.loop.active = true;\n }\n\n // TODO: replace fullscreen.iosNative with this playsinline config option\n // YouTube requires the playsinline in the URL\n if (this.isYouTube) {\n this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));\n this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?\n } else {\n this.config.playsinline = true;\n }\n }\n } else {\n // <div> with attributes\n this.provider = this.media.getAttribute(this.config.attributes.embed.provider);\n\n // Remove attribute\n this.media.removeAttribute(this.config.attributes.embed.provider);\n }\n\n // Unsupported or missing provider\n if (is.empty(this.provider) || !Object.values(providers).includes(this.provider)) {\n this.debug.error('Setup failed: Invalid provider');\n return;\n }\n\n // Audio will come later for external providers\n this.type = types.video;\n break;\n case 'video':\n case 'audio':\n this.type = _type;\n this.provider = providers.html5;\n\n // Get config from attributes\n if (this.media.hasAttribute('crossorigin')) {\n this.config.crossorigin = true;\n }\n if (this.media.hasAttribute('autoplay')) {\n this.config.autoplay = true;\n }\n if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {\n this.config.playsinline = true;\n }\n if (this.media.hasAttribute('muted')) {\n this.config.muted = true;\n }\n if (this.media.hasAttribute('loop')) {\n this.config.loop.active = true;\n }\n break;\n default:\n this.debug.error('Setup failed: unsupported type');\n return;\n }\n\n // Check for support again but with type\n this.supported = support.check(this.type, this.provider);\n\n // If no support for even API, bail\n if (!this.supported.api) {\n this.debug.error('Setup failed: no support');\n return;\n }\n this.eventListeners = [];\n\n // Create listeners\n this.listeners = new Listeners(this);\n\n // Setup local storage for user settings\n this.storage = new Storage(this);\n\n // Store reference\n this.media.plyr = this;\n\n // Wrap media\n if (!is.element(this.elements.container)) {\n this.elements.container = createElement('div');\n wrap(this.media, this.elements.container);\n }\n\n // Migrate custom properties from media to container (so they work 😉)\n ui.migrateStyles.call(this);\n\n // Add style hook\n ui.addStyleHook.call(this);\n\n // Setup media\n media.setup.call(this);\n\n // Listen for events if debugging\n if (this.config.debug) {\n on.call(this, this.elements.container, this.config.events.join(' '), event => {\n this.debug.log(`event: ${event.type}`);\n });\n }\n\n // Setup fullscreen\n this.fullscreen = new Fullscreen(this);\n\n // Setup interface\n // If embed but not fully supported, build interface now to avoid flash of controls\n if (this.isHTML5 || this.isEmbed && !this.supported.ui) {\n ui.build.call(this);\n }\n\n // Container listeners\n this.listeners.container();\n\n // Global listeners\n this.listeners.global();\n\n // Setup ads if provided\n if (this.config.ads.enabled) {\n this.ads = new Ads(this);\n }\n\n // Autoplay if required\n if (this.isHTML5 && this.config.autoplay) {\n this.once('canplay', () => silencePromise(this.play()));\n }\n\n // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek\n this.lastSeekTime = 0;\n\n // Setup preview thumbnails if enabled\n if (this.config.previewThumbnails.enabled) {\n this.previewThumbnails = new PreviewThumbnails(this);\n }\n }\n\n // ---------------------------------------\n // API\n // ---------------------------------------\n\n /**\n * Types and provider helpers\n */\n get isHTML5() {\n return this.provider === providers.html5;\n }\n get isEmbed() {\n return this.isYouTube || this.isVimeo;\n }\n get isYouTube() {\n return this.provider === providers.youtube;\n }\n get isVimeo() {\n return this.provider === providers.vimeo;\n }\n get isVideo() {\n return this.type === types.video;\n }\n get isAudio() {\n return this.type === types.audio;\n }\n /**\n * Get playing state\n */\n get playing() {\n return Boolean(this.ready && !this.paused && !this.ended);\n }\n\n /**\n * Get paused state\n */\n get paused() {\n return Boolean(this.media.paused);\n }\n\n /**\n * Get stopped state\n */\n get stopped() {\n return Boolean(this.paused && this.currentTime === 0);\n }\n\n /**\n * Get ended state\n */\n get ended() {\n return Boolean(this.media.ended);\n }\n /**\n * Seek to a time\n * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start)\n */\n set currentTime(input) {\n // Bail if media duration isn't available yet\n if (!this.duration) {\n return;\n }\n\n // Validate input\n const inputIsValid = is.number(input) && input > 0;\n\n // Set\n this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;\n\n // Logging\n this.debug.log(`Seeking to ${this.currentTime} seconds`);\n }\n\n /**\n * Get current time\n */\n get currentTime() {\n return Number(this.media.currentTime);\n }\n\n /**\n * Get buffered\n */\n get buffered() {\n const {\n buffered\n } = this.media;\n\n // YouTube / Vimeo return a float between 0-1\n if (is.number(buffered)) {\n return buffered;\n }\n\n // HTML5\n // TODO: Handle buffered chunks of the media\n // (i.e. seek to another section buffers only that section)\n if (buffered && buffered.length && this.duration > 0) {\n return buffered.end(0) / this.duration;\n }\n return 0;\n }\n\n /**\n * Get seeking status\n */\n get seeking() {\n return Boolean(this.media.seeking);\n }\n\n /**\n * Get the duration of the current media\n */\n get duration() {\n // Faux duration set via config\n const fauxDuration = parseFloat(this.config.duration);\n // Media duration can be NaN or Infinity before the media has loaded\n const realDuration = (this.media || {}).duration;\n const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration;\n\n // If config duration is funky, use regular duration\n return fauxDuration || duration;\n }\n\n /**\n * Set the player volume\n * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage\n */\n set volume(value) {\n let volume = value;\n const max = 1;\n const min = 0;\n if (is.string(volume)) {\n volume = Number(volume);\n }\n\n // Load volume from storage if no value specified\n if (!is.number(volume)) {\n volume = this.storage.get('volume');\n }\n\n // Use config if all else fails\n if (!is.number(volume)) {\n ({\n volume\n } = this.config);\n }\n\n // Maximum is volumeMax\n if (volume > max) {\n volume = max;\n }\n // Minimum is volumeMin\n if (volume < min) {\n volume = min;\n }\n\n // Update config\n this.config.volume = volume;\n\n // Set the player volume\n this.media.volume = volume;\n\n // If muted, and we're increasing volume manually, reset muted state\n if (!is.empty(value) && this.muted && volume > 0) {\n this.muted = false;\n }\n }\n\n /**\n * Get the current player volume\n */\n get volume() {\n return Number(this.media.volume);\n }\n /**\n * Set muted state\n * @param {Boolean} mute\n */\n set muted(mute) {\n let toggle = mute;\n\n // Load muted state from storage\n if (!is.boolean(toggle)) {\n toggle = this.storage.get('muted');\n }\n\n // Use config if all else fails\n if (!is.boolean(toggle)) {\n toggle = this.config.muted;\n }\n\n // Update config\n this.config.muted = toggle;\n\n // Set mute on the player\n this.media.muted = toggle;\n }\n\n /**\n * Get current muted state\n */\n get muted() {\n return Boolean(this.media.muted);\n }\n\n /**\n * Check if the media has audio\n */\n get hasAudio() {\n // Assume yes for all non HTML5 (as we can't tell...)\n if (!this.isHTML5) {\n return true;\n }\n if (this.isAudio) {\n return true;\n }\n\n // Get audio tracks\n return Boolean(this.media.mozHasAudio) || Boolean(this.media.webkitAudioDecodedByteCount) || Boolean(this.media.audioTracks && this.media.audioTracks.length);\n }\n\n /**\n * Set playback speed\n * @param {Number} input - the speed of playback (0.5-2.0)\n */\n set speed(input) {\n let speed = null;\n if (is.number(input)) {\n speed = input;\n }\n if (!is.number(speed)) {\n speed = this.storage.get('speed');\n }\n if (!is.number(speed)) {\n speed = this.config.speed.selected;\n }\n\n // Clamp to min/max\n const {\n minimumSpeed: min,\n maximumSpeed: max\n } = this;\n speed = clamp(speed, min, max);\n\n // Update config\n this.config.speed.selected = speed;\n\n // Set media speed\n setTimeout(() => {\n if (this.media) {\n this.media.playbackRate = speed;\n }\n }, 0);\n }\n\n /**\n * Get current playback speed\n */\n get speed() {\n return Number(this.media.playbackRate);\n }\n\n /**\n * Get the minimum allowed speed\n */\n get minimumSpeed() {\n if (this.isYouTube) {\n // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate\n return Math.min(...this.options.speed);\n }\n if (this.isVimeo) {\n // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror\n return 0.5;\n }\n\n // https://stackoverflow.com/a/32320020/1191319\n return 0.0625;\n }\n\n /**\n * Get the maximum allowed speed\n */\n get maximumSpeed() {\n if (this.isYouTube) {\n // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate\n return Math.max(...this.options.speed);\n }\n if (this.isVimeo) {\n // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror\n return 2;\n }\n\n // https://stackoverflow.com/a/32320020/1191319\n return 16;\n }\n\n /**\n * Set playback quality\n * Currently HTML5 & YouTube only\n * @param {Number} input - Quality level\n */\n set quality(input) {\n const config = this.config.quality;\n const options = this.options.quality;\n if (!options.length) {\n return;\n }\n let quality = [!is.empty(input) && Number(input), this.storage.get('quality'), config.selected, config.default].find(is.number);\n let updateStorage = true;\n if (!options.includes(quality)) {\n const value = closest(options, quality);\n this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);\n quality = value;\n\n // Don't update storage if quality is not supported\n updateStorage = false;\n }\n\n // Update config\n config.selected = quality;\n\n // Set quality\n this.media.quality = quality;\n\n // Save to storage\n if (updateStorage) {\n this.storage.set({\n quality\n });\n }\n }\n\n /**\n * Get current quality level\n */\n get quality() {\n return this.media.quality;\n }\n\n /**\n * Toggle loop\n * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config\n * @param {Boolean} input - Whether to loop or not\n */\n set loop(input) {\n const toggle = is.boolean(input) ? input : this.config.loop.active;\n this.config.loop.active = toggle;\n this.media.loop = toggle;\n\n // Set default to be a true toggle\n /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';\n switch (type) {\n case 'start':\n if (this.config.loop.end && this.config.loop.end <= this.currentTime) {\n this.config.loop.end = null;\n }\n this.config.loop.start = this.currentTime;\n // this.config.loop.indicator.start = this.elements.display.played.value;\n break;\n case 'end':\n if (this.config.loop.start >= this.currentTime) {\n return this;\n }\n this.config.loop.end = this.currentTime;\n // this.config.loop.indicator.end = this.elements.display.played.value;\n break;\n case 'all':\n this.config.loop.start = 0;\n this.config.loop.end = this.duration - 2;\n this.config.loop.indicator.start = 0;\n this.config.loop.indicator.end = 100;\n break;\n case 'toggle':\n if (this.config.loop.active) {\n this.config.loop.start = 0;\n this.config.loop.end = null;\n } else {\n this.config.loop.start = 0;\n this.config.loop.end = this.duration - 2;\n }\n break;\n default:\n this.config.loop.start = 0;\n this.config.loop.end = null;\n break;\n } */\n }\n\n /**\n * Get current loop state\n */\n get loop() {\n return Boolean(this.media.loop);\n }\n\n /**\n * Set new media source\n * @param {Object} input - The new source object (see docs)\n */\n set source(input) {\n source.change.call(this, input);\n }\n\n /**\n * Get current source\n */\n get source() {\n return this.media.currentSrc;\n }\n\n /**\n * Get a download URL (either source or custom)\n */\n get download() {\n const {\n download\n } = this.config.urls;\n return is.url(download) ? download : this.source;\n }\n\n /**\n * Set the download URL\n */\n set download(input) {\n if (!is.url(input)) {\n return;\n }\n this.config.urls.download = input;\n controls.setDownloadUrl.call(this);\n }\n\n /**\n * Set the poster image for a video\n * @param {String} input - the URL for the new poster image\n */\n set poster(input) {\n if (!this.isVideo) {\n this.debug.warn('Poster can only be set for video');\n return;\n }\n ui.setPoster.call(this, input, false).catch(() => {});\n }\n\n /**\n * Get the current poster image\n */\n get poster() {\n if (!this.isVideo) {\n return null;\n }\n return this.media.getAttribute('poster') || this.media.getAttribute('data-poster');\n }\n\n /**\n * Get the current aspect ratio in use\n */\n get ratio() {\n if (!this.isVideo) {\n return null;\n }\n const ratio = reduceAspectRatio(getAspectRatio.call(this));\n return is.array(ratio) ? ratio.join(':') : ratio;\n }\n\n /**\n * Set video aspect ratio\n */\n set ratio(input) {\n if (!this.isVideo) {\n this.debug.warn('Aspect ratio can only be set for video');\n return;\n }\n if (!is.string(input) || !validateAspectRatio(input)) {\n this.debug.error(`Invalid aspect ratio specified (${input})`);\n return;\n }\n this.config.ratio = reduceAspectRatio(input);\n setAspectRatio.call(this);\n }\n\n /**\n * Set the autoplay state\n * @param {Boolean} input - Whether to autoplay or not\n */\n set autoplay(input) {\n this.config.autoplay = is.boolean(input) ? input : this.config.autoplay;\n }\n\n /**\n * Get the current autoplay state\n */\n get autoplay() {\n return Boolean(this.config.autoplay);\n }\n\n /**\n * Toggle captions\n * @param {Boolean} input - Whether to enable captions\n */\n toggleCaptions(input) {\n captions.toggle.call(this, input, false);\n }\n\n /**\n * Set the caption track by index\n * @param {Number} input - Caption index\n */\n set currentTrack(input) {\n captions.set.call(this, input, false);\n captions.setup.call(this);\n }\n\n /**\n * Get the current caption track index (-1 if disabled)\n */\n get currentTrack() {\n const {\n toggled,\n currentTrack\n } = this.captions;\n return toggled ? currentTrack : -1;\n }\n\n /**\n * Set the wanted language for captions\n * Since tracks can be added later it won't update the actual caption track until there is a matching track\n * @param {String} input - Two character ISO language code (e.g. EN, FR, PT, etc)\n */\n set language(input) {\n captions.setLanguage.call(this, input, false);\n }\n\n /**\n * Get the current track's language\n */\n get language() {\n return (captions.getCurrentTrack.call(this) || {}).language;\n }\n\n /**\n * Toggle picture-in-picture playback on WebKit/MacOS\n * TODO: update player with state, support, enabled\n * TODO: detect outside changes\n */\n set pip(input) {\n // Bail if no support\n if (!support.pip) {\n return;\n }\n\n // Toggle based on current state if not passed\n const toggle = is.boolean(input) ? input : !this.pip;\n\n // Toggle based on current state\n // Safari\n if (is.function(this.media.webkitSetPresentationMode)) {\n this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive);\n }\n\n // Chrome\n if (is.function(this.media.requestPictureInPicture)) {\n if (!this.pip && toggle) {\n this.media.requestPictureInPicture();\n } else if (this.pip && !toggle) {\n document.exitPictureInPicture();\n }\n }\n }\n\n /**\n * Get the current picture-in-picture state\n */\n get pip() {\n if (!support.pip) {\n return null;\n }\n\n // Safari\n if (!is.empty(this.media.webkitPresentationMode)) {\n return this.media.webkitPresentationMode === pip.active;\n }\n\n // Chrome\n return this.media === document.pictureInPictureElement;\n }\n\n /**\n * Sets the preview thumbnails for the current source\n */\n setPreviewThumbnails(thumbnailSource) {\n if (this.previewThumbnails && this.previewThumbnails.loaded) {\n this.previewThumbnails.destroy();\n this.previewThumbnails = null;\n }\n Object.assign(this.config.previewThumbnails, thumbnailSource);\n\n // Create new instance if it is still enabled\n if (this.config.previewThumbnails.enabled) {\n this.previewThumbnails = new PreviewThumbnails(this);\n }\n }\n /**\n * Check for support\n * @param {String} type - Player type (audio/video)\n * @param {String} provider - Provider (html5/youtube/vimeo)\n */\n static supported(type, provider) {\n return support.check(type, provider);\n }\n\n /**\n * Load an SVG sprite into the page\n * @param {String} url - URL for the SVG sprite\n * @param {String} [id] - Unique ID\n */\n static loadSprite(url, id) {\n return loadSprite(url, id);\n }\n\n /**\n * Setup multiple instances\n * @param {*} selector\n * @param {Object} options\n */\n static setup(selector, options = {}) {\n let targets = null;\n if (is.string(selector)) {\n targets = Array.from(document.querySelectorAll(selector));\n } else if (is.nodeList(selector)) {\n targets = Array.from(selector);\n } else if (is.array(selector)) {\n targets = selector.filter(is.element);\n }\n if (is.empty(targets)) {\n return null;\n }\n return targets.map(t => new Plyr(t, options));\n }\n }\n Plyr.defaults = cloneDeep(defaults);\n\n return Plyr;\n\n}));\n//# sourceMappingURL=plyr.js.map\n","function _classCallCheck(e,t){if(!(e instanceof t))throw new TypeError(\"Cannot call a class as a function\")}function _defineProperties(e,t){for(var n=0;n<t.length;n++){var r=t[n];r.enumerable=r.enumerable||!1,r.configurable=!0,\"value\"in r&&(r.writable=!0),Object.defineProperty(e,r.key,r)}}function _createClass(e,t,n){return t&&_defineProperties(e.prototype,t),n&&_defineProperties(e,n),e}function _defineProperty(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function ownKeys(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function _objectSpread2(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?ownKeys(Object(n),!0).forEach((function(t){_defineProperty(e,t,n[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):ownKeys(Object(n)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))}))}return e}var defaults={addCSS:!0,thumbWidth:15,watch:!0};function matches(e,t){return function(){return Array.from(document.querySelectorAll(t)).includes(this)}.call(e,t)}function trigger(e,t){if(e&&t){var n=new Event(t,{bubbles:!0});e.dispatchEvent(n)}}var getConstructor=function(e){return null!=e?e.constructor:null},instanceOf=function(e,t){return!!(e&&t&&e instanceof t)},isNullOrUndefined=function(e){return null==e},isObject=function(e){return getConstructor(e)===Object},isNumber=function(e){return getConstructor(e)===Number&&!Number.isNaN(e)},isString=function(e){return getConstructor(e)===String},isBoolean=function(e){return getConstructor(e)===Boolean},isFunction=function(e){return getConstructor(e)===Function},isArray=function(e){return Array.isArray(e)},isNodeList=function(e){return instanceOf(e,NodeList)},isElement=function(e){return instanceOf(e,Element)},isEvent=function(e){return instanceOf(e,Event)},isEmpty=function(e){return isNullOrUndefined(e)||(isString(e)||isArray(e)||isNodeList(e))&&!e.length||isObject(e)&&!Object.keys(e).length},is={nullOrUndefined:isNullOrUndefined,object:isObject,number:isNumber,string:isString,boolean:isBoolean,function:isFunction,array:isArray,nodeList:isNodeList,element:isElement,event:isEvent,empty:isEmpty};function getDecimalPlaces(e){var t=\"\".concat(e).match(/(?:\\.(\\d+))?(?:[eE]([+-]?\\d+))?$/);return t?Math.max(0,(t[1]?t[1].length:0)-(t[2]?+t[2]:0)):0}function round(e,t){if(1>t){var n=getDecimalPlaces(t);return parseFloat(e.toFixed(n))}return Math.round(e/t)*t}var RangeTouch=function(){function e(t,n){_classCallCheck(this,e),is.element(t)?this.element=t:is.string(t)&&(this.element=document.querySelector(t)),is.element(this.element)&&is.empty(this.element.rangeTouch)&&(this.config=_objectSpread2({},defaults,{},n),this.init())}return _createClass(e,[{key:\"init\",value:function(){e.enabled&&(this.config.addCSS&&(this.element.style.userSelect=\"none\",this.element.style.webKitUserSelect=\"none\",this.element.style.touchAction=\"manipulation\"),this.listeners(!0),this.element.rangeTouch=this)}},{key:\"destroy\",value:function(){e.enabled&&(this.config.addCSS&&(this.element.style.userSelect=\"\",this.element.style.webKitUserSelect=\"\",this.element.style.touchAction=\"\"),this.listeners(!1),this.element.rangeTouch=null)}},{key:\"listeners\",value:function(e){var t=this,n=e?\"addEventListener\":\"removeEventListener\";[\"touchstart\",\"touchmove\",\"touchend\"].forEach((function(e){t.element[n](e,(function(e){return t.set(e)}),!1)}))}},{key:\"get\",value:function(t){if(!e.enabled||!is.event(t))return null;var n,r=t.target,i=t.changedTouches[0],o=parseFloat(r.getAttribute(\"min\"))||0,s=parseFloat(r.getAttribute(\"max\"))||100,u=parseFloat(r.getAttribute(\"step\"))||1,c=r.getBoundingClientRect(),a=100/c.width*(this.config.thumbWidth/2)/100;return 0>(n=100/c.width*(i.clientX-c.left))?n=0:100<n&&(n=100),50>n?n-=(100-2*n)*a:50<n&&(n+=2*(n-50)*a),o+round(n/100*(s-o),u)}},{key:\"set\",value:function(t){e.enabled&&is.event(t)&&!t.target.disabled&&(t.preventDefault(),t.target.value=this.get(t),trigger(t.target,\"touchend\"===t.type?\"change\":\"input\"))}}],[{key:\"setup\",value:function(t){var n=1<arguments.length&&void 0!==arguments[1]?arguments[1]:{},r=null;if(is.empty(t)||is.string(t)?r=Array.from(document.querySelectorAll(is.string(t)?t:'input[type=\"range\"]')):is.element(t)?r=[t]:is.nodeList(t)?r=Array.from(t):is.array(t)&&(r=t.filter(is.element)),is.empty(r))return null;var i=_objectSpread2({},defaults,{},n);if(is.string(t)&&i.watch){var o=new MutationObserver((function(n){Array.from(n).forEach((function(n){Array.from(n.addedNodes).forEach((function(n){is.element(n)&&matches(n,t)&&new e(n,i)}))}))}));o.observe(document.body,{childList:!0,subtree:!0})}return r.map((function(t){return new e(t,n)}))}},{key:\"enabled\",get:function(){return\"ontouchstart\"in document.documentElement}}]),e}();export default RangeTouch;","// ==========================================================================\n// Type checking utils\n// ==========================================================================\n\nconst getConstructor = (input) => (input !== null && typeof input !== 'undefined' ? input.constructor : null);\nconst instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor);\nconst isNullOrUndefined = (input) => input === null || typeof input === 'undefined';\nconst isObject = (input) => getConstructor(input) === Object;\nconst isNumber = (input) => getConstructor(input) === Number && !Number.isNaN(input);\nconst isString = (input) => getConstructor(input) === String;\nconst isBoolean = (input) => getConstructor(input) === Boolean;\nconst isFunction = (input) => typeof input === 'function';\nconst isArray = (input) => Array.isArray(input);\nconst isWeakMap = (input) => instanceOf(input, WeakMap);\nconst isNodeList = (input) => instanceOf(input, NodeList);\nconst isTextNode = (input) => getConstructor(input) === Text;\nconst isEvent = (input) => instanceOf(input, Event);\nconst isKeyboardEvent = (input) => instanceOf(input, KeyboardEvent);\nconst isCue = (input) => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);\nconst isTrack = (input) => instanceOf(input, TextTrack) || (!isNullOrUndefined(input) && isString(input.kind));\nconst isPromise = (input) => instanceOf(input, Promise) && isFunction(input.then);\n\nconst isElement = (input) =>\n input !== null &&\n typeof input === 'object' &&\n input.nodeType === 1 &&\n typeof input.style === 'object' &&\n typeof input.ownerDocument === 'object';\n\nconst isEmpty = (input) =>\n isNullOrUndefined(input) ||\n ((isString(input) || isArray(input) || isNodeList(input)) && !input.length) ||\n (isObject(input) && !Object.keys(input).length);\n\nconst isUrl = (input) => {\n // Accept a URL object\n if (instanceOf(input, window.URL)) {\n return true;\n }\n\n // Must be string from here\n if (!isString(input)) {\n return false;\n }\n\n // Add the protocol if required\n let string = input;\n if (!input.startsWith('http://') || !input.startsWith('https://')) {\n string = `http://${input}`;\n }\n\n try {\n return !isEmpty(new URL(string).hostname);\n } catch (_) {\n return false;\n }\n};\n\nexport default {\n nullOrUndefined: isNullOrUndefined,\n object: isObject,\n number: isNumber,\n string: isString,\n boolean: isBoolean,\n function: isFunction,\n array: isArray,\n weakMap: isWeakMap,\n nodeList: isNodeList,\n element: isElement,\n textNode: isTextNode,\n event: isEvent,\n keyboardEvent: isKeyboardEvent,\n cue: isCue,\n track: isTrack,\n promise: isPromise,\n url: isUrl,\n empty: isEmpty,\n};\n","// ==========================================================================\n// Animation utils\n// ==========================================================================\n\nimport is from './is';\n\nexport const transitionEndEvent = (() => {\n const element = document.createElement('span');\n\n const events = {\n WebkitTransition: 'webkitTransitionEnd',\n MozTransition: 'transitionend',\n OTransition: 'oTransitionEnd otransitionend',\n transition: 'transitionend',\n };\n\n const type = Object.keys(events).find((event) => element.style[event] !== undefined);\n\n return is.string(type) ? events[type] : false;\n})();\n\n// Force repaint of element\nexport function repaint(element, delay) {\n setTimeout(() => {\n try {\n // eslint-disable-next-line no-param-reassign\n element.hidden = true;\n\n // eslint-disable-next-line no-unused-expressions\n element.offsetHeight;\n\n // eslint-disable-next-line no-param-reassign\n element.hidden = false;\n } catch (_) {\n // Do nothing\n }\n }, delay);\n}\n","// ==========================================================================\n// Browser sniffing\n// Unfortunately, due to mixed support, UA sniffing is required\n// ==========================================================================\n\nconst isIE = Boolean(window.document.documentMode);\nconst isEdge = /Edge/g.test(navigator.userAgent);\nconst isWebKit = 'WebkitAppearance' in document.documentElement.style && !/Edge/g.test(navigator.userAgent);\nconst isIPhone = /iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1;\n// navigator.platform may be deprecated but this check is still required\nconst isIPadOS = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;\nconst isIos = /iPad|iPhone|iPod/gi.test(navigator.userAgent) && navigator.maxTouchPoints > 1;\n\nexport default {\n isIE,\n isEdge,\n isWebKit,\n isIPhone,\n isIPadOS,\n isIos,\n};\n","// ==========================================================================\n// Object utils\n// ==========================================================================\n\nimport is from './is';\n\n// Clone nested objects\nexport function cloneDeep(object) {\n return JSON.parse(JSON.stringify(object));\n}\n\n// Get a nested value in an object\nexport function getDeep(object, path) {\n return path.split('.').reduce((obj, key) => obj && obj[key], object);\n}\n\n// Deep extend destination object with N more objects\nexport function extend(target = {}, ...sources) {\n if (!sources.length) {\n return target;\n }\n\n const source = sources.shift();\n\n if (!is.object(source)) {\n return target;\n }\n\n Object.keys(source).forEach((key) => {\n if (is.object(source[key])) {\n if (!Object.keys(target).includes(key)) {\n Object.assign(target, { [key]: {} });\n }\n\n extend(target[key], source[key]);\n } else {\n Object.assign(target, { [key]: source[key] });\n }\n });\n\n return extend(target, ...sources);\n}\n","// ==========================================================================\n// Element utils\n// ==========================================================================\n\nimport is from './is';\nimport { extend } from './objects';\n\n// Wrap an element\nexport function wrap(elements, wrapper) {\n // Convert `elements` to an array, if necessary.\n const targets = elements.length ? elements : [elements];\n\n // Loops backwards to prevent having to clone the wrapper on the\n // first element (see `child` below).\n Array.from(targets)\n .reverse()\n .forEach((element, index) => {\n const child = index > 0 ? wrapper.cloneNode(true) : wrapper;\n // Cache the current parent and sibling.\n const parent = element.parentNode;\n const sibling = element.nextSibling;\n\n // Wrap the element (is automatically removed from its current\n // parent).\n child.appendChild(element);\n\n // If the element had a sibling, insert the wrapper before\n // the sibling to maintain the HTML structure; otherwise, just\n // append it to the parent.\n if (sibling) {\n parent.insertBefore(child, sibling);\n } else {\n parent.appendChild(child);\n }\n });\n}\n\n// Set attributes\nexport function setAttributes(element, attributes) {\n if (!is.element(element) || is.empty(attributes)) return;\n\n // Assume null and undefined attributes should be left out,\n // Setting them would otherwise convert them to \"null\" and \"undefined\"\n Object.entries(attributes)\n .filter(([, value]) => !is.nullOrUndefined(value))\n .forEach(([key, value]) => element.setAttribute(key, value));\n}\n\n// Create a DocumentFragment\nexport function createElement(type, attributes, text) {\n // Create a new <element>\n const element = document.createElement(type);\n\n // Set all passed attributes\n if (is.object(attributes)) {\n setAttributes(element, attributes);\n }\n\n // Add text node\n if (is.string(text)) {\n element.innerText = text;\n }\n\n // Return built element\n return element;\n}\n\n// Insert an element after another\nexport function insertAfter(element, target) {\n if (!is.element(element) || !is.element(target)) return;\n\n target.parentNode.insertBefore(element, target.nextSibling);\n}\n\n// Insert a DocumentFragment\nexport function insertElement(type, parent, attributes, text) {\n if (!is.element(parent)) return;\n\n parent.appendChild(createElement(type, attributes, text));\n}\n\n// Remove element(s)\nexport function removeElement(element) {\n if (is.nodeList(element) || is.array(element)) {\n Array.from(element).forEach(removeElement);\n return;\n }\n\n if (!is.element(element) || !is.element(element.parentNode)) {\n return;\n }\n\n element.parentNode.removeChild(element);\n}\n\n// Remove all child elements\nexport function emptyElement(element) {\n if (!is.element(element)) return;\n\n let { length } = element.childNodes;\n\n while (length > 0) {\n element.removeChild(element.lastChild);\n length -= 1;\n }\n}\n\n// Replace element\nexport function replaceElement(newChild, oldChild) {\n if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) return null;\n\n oldChild.parentNode.replaceChild(newChild, oldChild);\n\n return newChild;\n}\n\n// Get an attribute object from a string selector\nexport function getAttributesFromSelector(sel, existingAttributes) {\n // For example:\n // '.test' to { class: 'test' }\n // '#test' to { id: 'test' }\n // '[data-test=\"test\"]' to { 'data-test': 'test' }\n\n if (!is.string(sel) || is.empty(sel)) return {};\n\n const attributes = {};\n const existing = extend({}, existingAttributes);\n\n sel.split(',').forEach((s) => {\n // Remove whitespace\n const selector = s.trim();\n const className = selector.replace('.', '');\n const stripped = selector.replace(/[[\\]]/g, '');\n // Get the parts and value\n const parts = stripped.split('=');\n const [key] = parts;\n const value = parts.length > 1 ? parts[1].replace(/[\"']/g, '') : '';\n // Get the first character\n const start = selector.charAt(0);\n\n switch (start) {\n case '.':\n // Add to existing classname\n if (is.string(existing.class)) {\n attributes.class = `${existing.class} ${className}`;\n } else {\n attributes.class = className;\n }\n break;\n\n case '#':\n // ID selector\n attributes.id = selector.replace('#', '');\n break;\n\n case '[':\n // Attribute selector\n attributes[key] = value;\n\n break;\n\n default:\n break;\n }\n });\n\n return extend(existing, attributes);\n}\n\n// Toggle hidden\nexport function toggleHidden(element, hidden) {\n if (!is.element(element)) return;\n\n let hide = hidden;\n\n if (!is.boolean(hide)) {\n hide = !element.hidden;\n }\n\n // eslint-disable-next-line no-param-reassign\n element.hidden = hide;\n}\n\n// Mirror Element.classList.toggle, with IE compatibility for \"force\" argument\nexport function toggleClass(element, className, force) {\n if (is.nodeList(element)) {\n return Array.from(element).map((e) => toggleClass(e, className, force));\n }\n\n if (is.element(element)) {\n let method = 'toggle';\n if (typeof force !== 'undefined') {\n method = force ? 'add' : 'remove';\n }\n\n element.classList[method](className);\n return element.classList.contains(className);\n }\n\n return false;\n}\n\n// Has class name\nexport function hasClass(element, className) {\n return is.element(element) && element.classList.contains(className);\n}\n\n// Element matches selector\nexport function matches(element, selector) {\n const { prototype } = Element;\n\n function match() {\n return Array.from(document.querySelectorAll(selector)).includes(this);\n }\n\n const method =\n prototype.matches ||\n prototype.webkitMatchesSelector ||\n prototype.mozMatchesSelector ||\n prototype.msMatchesSelector ||\n match;\n\n return method.call(element, selector);\n}\n\n// Closest ancestor element matching selector (also tests element itself)\nexport function closest(element, selector) {\n const { prototype } = Element;\n\n // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill\n function closestElement() {\n let el = this;\n\n do {\n if (matches.matches(el, selector)) return el;\n el = el.parentElement || el.parentNode;\n } while (el !== null && el.nodeType === 1);\n return null;\n }\n\n const method = prototype.closest || closestElement;\n\n return method.call(element, selector);\n}\n\n// Find all elements\nexport function getElements(selector) {\n return this.elements.container.querySelectorAll(selector);\n}\n\n// Find a single element\nexport function getElement(selector) {\n return this.elements.container.querySelector(selector);\n}\n\n// Set focus and tab focus class\nexport function setFocus(element = null, focusVisible = false) {\n if (!is.element(element)) return;\n\n // Set regular focus\n element.focus({ preventScroll: true, focusVisible });\n}\n","// ==========================================================================\n// Plyr support checks\n// ==========================================================================\n\nimport { transitionEndEvent } from './utils/animation';\nimport browser from './utils/browser';\nimport { createElement } from './utils/elements';\nimport is from './utils/is';\n\n// Default codecs for checking mimetype support\nconst defaultCodecs = {\n 'audio/ogg': 'vorbis',\n 'audio/wav': '1',\n 'video/webm': 'vp8, vorbis',\n 'video/mp4': 'avc1.42E01E, mp4a.40.2',\n 'video/ogg': 'theora',\n};\n\n// Check for feature support\nconst support = {\n // Basic support\n audio: 'canPlayType' in document.createElement('audio'),\n video: 'canPlayType' in document.createElement('video'),\n\n // Check for support\n // Basic functionality vs full UI\n check(type, provider) {\n const api = support[type] || provider !== 'html5';\n const ui = api && support.rangeInput;\n\n return {\n api,\n ui,\n };\n },\n\n // Picture-in-picture support\n // Safari & Chrome only currently\n pip: (() => {\n // While iPhone's support picture-in-picture for some apps, seemingly Safari isn't one of them\n // It will throw the following error when trying to enter picture-in-picture\n // `NotSupportedError: The Picture-in-Picture mode is not supported.`\n if (browser.isIPhone) {\n return false;\n }\n\n // Safari\n // https://developer.apple.com/documentation/webkitjs/adding_picture_in_picture_to_your_safari_media_controls\n if (is.function(createElement('video').webkitSetPresentationMode)) {\n return true;\n }\n\n // Chrome\n // https://developers.google.com/web/updates/2018/10/watch-video-using-picture-in-picture\n if (document.pictureInPictureEnabled && !createElement('video').disablePictureInPicture) {\n return true;\n }\n\n return false;\n })(),\n\n // Airplay support\n // Safari only currently\n airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent),\n\n // Inline playback support\n // https://webkit.org/blog/6784/new-video-policies-for-ios/\n playsinline: 'playsInline' in document.createElement('video'),\n\n // Check for mime type support against a player instance\n // Credits: http://diveintohtml5.info/everything.html\n // Related: http://www.leanbackplayer.com/test/h5mt.html\n mime(input) {\n if (is.empty(input)) {\n return false;\n }\n\n const [mediaType] = input.split('/');\n let type = input;\n\n // Verify we're using HTML5 and there's no media type mismatch\n if (!this.isHTML5 || mediaType !== this.type) {\n return false;\n }\n\n // Add codec if required\n if (Object.keys(defaultCodecs).includes(type)) {\n type += `; codecs=\"${defaultCodecs[input]}\"`;\n }\n\n try {\n return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));\n } catch (_) {\n return false;\n }\n },\n\n // Check for textTracks support\n textTracks: 'textTracks' in document.createElement('video'),\n\n // <input type=\"range\"> Sliders\n rangeInput: (() => {\n const range = document.createElement('input');\n range.type = 'range';\n return range.type === 'range';\n })(),\n\n // Touch\n // NOTE: Remember a device can be mouse + touch enabled so we check on first touch event\n touch: 'ontouchstart' in document.documentElement,\n\n // Detect transitions support\n transitions: transitionEndEvent !== false,\n\n // Reduced motion iOS & MacOS setting\n // https://webkit.org/blog/7551/responsive-design-for-motion/\n reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches,\n};\n\nexport default support;\n","// ==========================================================================\n// Event utils\n// ==========================================================================\n\nimport is from './is';\n\n// Check for passive event listener support\n// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md\n// https://www.youtube.com/watch?v=NPM6172J22g\nconst supportsPassiveListeners = (() => {\n // Test via a getter in the options object to see if the passive property is accessed\n let supported = false;\n try {\n const options = Object.defineProperty({}, 'passive', {\n get() {\n supported = true;\n return null;\n },\n });\n window.addEventListener('test', null, options);\n window.removeEventListener('test', null, options);\n } catch (_) {\n // Do nothing\n }\n\n return supported;\n})();\n\n// Toggle event listener\nexport function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) {\n // Bail if no element, event, or callback\n if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) {\n return;\n }\n\n // Allow multiple events\n const events = event.split(' ');\n // Build options\n // Default to just the capture boolean for browsers with no passive listener support\n let options = capture;\n\n // If passive events listeners are supported\n if (supportsPassiveListeners) {\n options = {\n // Whether the listener can be passive (i.e. default never prevented)\n passive,\n // Whether the listener is a capturing listener or not\n capture,\n };\n }\n\n // If a single node is passed, bind the event listener\n events.forEach((type) => {\n if (this && this.eventListeners && toggle) {\n // Cache event listener\n this.eventListeners.push({ element, type, callback, options });\n }\n\n element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);\n });\n}\n\n// Bind event handler\nexport function on(element, events = '', callback, passive = true, capture = false) {\n toggleListener.call(this, element, events, callback, true, passive, capture);\n}\n\n// Unbind event handler\nexport function off(element, events = '', callback, passive = true, capture = false) {\n toggleListener.call(this, element, events, callback, false, passive, capture);\n}\n\n// Bind once-only event handler\nexport function once(element, events = '', callback, passive = true, capture = false) {\n const onceCallback = (...args) => {\n off(element, events, onceCallback, passive, capture);\n callback.apply(this, args);\n };\n\n toggleListener.call(this, element, events, onceCallback, true, passive, capture);\n}\n\n// Trigger event\nexport function triggerEvent(element, type = '', bubbles = false, detail = {}) {\n // Bail if no element\n if (!is.element(element) || is.empty(type)) {\n return;\n }\n\n // Create and dispatch the event\n const event = new CustomEvent(type, {\n bubbles,\n detail: { ...detail, plyr: this },\n });\n\n // Dispatch the event\n element.dispatchEvent(event);\n}\n\n// Unbind all cached event listeners\nexport function unbindListeners() {\n if (this && this.eventListeners) {\n this.eventListeners.forEach((item) => {\n const { element, type, callback, options } = item;\n element.removeEventListener(type, callback, options);\n });\n\n this.eventListeners = [];\n }\n}\n\n// Run method when / if player is ready\nexport function ready() {\n return new Promise((resolve) =>\n this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve),\n ).then(() => {});\n}\n","import is from './is';\n/**\n * Silence a Promise-like object.\n * This is useful for avoiding non-harmful, but potentially confusing \"uncaught\n * play promise\" rejection error messages.\n * @param {Object} value An object that may or may not be `Promise`-like.\n */\nexport function silencePromise(value) {\n if (is.promise(value)) {\n value.then(null, () => {});\n }\n}\n\nexport default { silencePromise };\n","// ==========================================================================\n// Array utils\n// ==========================================================================\n\nimport is from './is';\n\n// Remove duplicates in an array\nexport function dedupe(array) {\n if (!is.array(array)) {\n return array;\n }\n\n return array.filter((item, index) => array.indexOf(item) === index);\n}\n\n// Get the closest value in an array\nexport function closest(array, value) {\n if (!is.array(array) || !array.length) {\n return null;\n }\n\n return array.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));\n}\n","// ==========================================================================\n// Style utils\n// ==========================================================================\n\nimport { closest } from './arrays';\nimport is from './is';\n\n// Check support for a CSS declaration\nexport function supportsCSS(declaration) {\n if (!window || !window.CSS) {\n return false;\n }\n\n return window.CSS.supports(declaration);\n}\n\n// Standard/common aspect ratios\nconst standardRatios = [\n [1, 1],\n [4, 3],\n [3, 4],\n [5, 4],\n [4, 5],\n [3, 2],\n [2, 3],\n [16, 10],\n [10, 16],\n [16, 9],\n [9, 16],\n [21, 9],\n [9, 21],\n [32, 9],\n [9, 32],\n].reduce((out, [x, y]) => ({ ...out, [x / y]: [x, y] }), {});\n\n// Validate an aspect ratio\nexport function validateAspectRatio(input) {\n if (!is.array(input) && (!is.string(input) || !input.includes(':'))) {\n return false;\n }\n\n const ratio = is.array(input) ? input : input.split(':');\n\n return ratio.map(Number).every(is.number);\n}\n\n// Reduce an aspect ratio to it's lowest form\nexport function reduceAspectRatio(ratio) {\n if (!is.array(ratio) || !ratio.every(is.number)) {\n return null;\n }\n\n const [width, height] = ratio;\n const getDivider = (w, h) => (h === 0 ? w : getDivider(h, w % h));\n const divider = getDivider(width, height);\n\n return [width / divider, height / divider];\n}\n\n// Calculate an aspect ratio\nexport function getAspectRatio(input) {\n const parse = (ratio) => (validateAspectRatio(ratio) ? ratio.split(':').map(Number) : null);\n // Try provided ratio\n let ratio = parse(input);\n\n // Get from config\n if (ratio === null) {\n ratio = parse(this.config.ratio);\n }\n\n // Get from embed\n if (ratio === null && !is.empty(this.embed) && is.array(this.embed.ratio)) {\n ({ ratio } = this.embed);\n }\n\n // Get from HTML5 video\n if (ratio === null && this.isHTML5) {\n const { videoWidth, videoHeight } = this.media;\n ratio = [videoWidth, videoHeight];\n }\n\n return reduceAspectRatio(ratio);\n}\n\n// Set aspect ratio for responsive container\nexport function setAspectRatio(input) {\n if (!this.isVideo) {\n return {};\n }\n\n const { wrapper } = this.elements;\n const ratio = getAspectRatio.call(this, input);\n\n if (!is.array(ratio)) {\n return {};\n }\n\n const [x, y] = reduceAspectRatio(ratio);\n const useNative = supportsCSS(`aspect-ratio: ${x}/${y}`);\n const padding = (100 / x) * y;\n\n if (useNative) {\n wrapper.style.aspectRatio = `${x}/${y}`;\n } else {\n wrapper.style.paddingBottom = `${padding}%`;\n }\n\n // For Vimeo we have an extra <div> to hide the standard controls and UI\n if (this.isVimeo && !this.config.vimeo.premium && this.supported.ui) {\n const height = (100 / this.media.offsetWidth) * parseInt(window.getComputedStyle(this.media).paddingBottom, 10);\n const offset = (height - padding) / (height / 50);\n\n if (this.fullscreen.active) {\n wrapper.style.paddingBottom = null;\n } else {\n this.media.style.transform = `translateY(-${offset}%)`;\n }\n } else if (this.isHTML5) {\n wrapper.classList.add(this.config.classNames.videoFixedRatio);\n }\n\n return { padding, ratio };\n}\n\n// Round an aspect ratio to closest standard ratio\nexport function roundAspectRatio(x, y, tolerance = 0.05) {\n const ratio = x / y;\n const closestRatio = closest(Object.keys(standardRatios), ratio);\n\n // Check match is within tolerance\n if (Math.abs(closestRatio - ratio) <= tolerance) {\n return standardRatios[closestRatio];\n }\n\n // No match\n return [x, y];\n}\n\n// Get the size of the viewport\n// https://stackoverflow.com/questions/1248081/how-to-get-the-browser-viewport-dimensions\nexport function getViewportSize() {\n const width = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);\n const height = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);\n return [width, height];\n}\n","// ==========================================================================\n// Plyr HTML5 helpers\n// ==========================================================================\n\nimport support from './support';\nimport { removeElement } from './utils/elements';\nimport { triggerEvent } from './utils/events';\nimport is from './utils/is';\nimport { silencePromise } from './utils/promise';\nimport { setAspectRatio } from './utils/style';\n\nconst html5 = {\n getSources() {\n if (!this.isHTML5) {\n return [];\n }\n\n const sources = Array.from(this.media.querySelectorAll('source'));\n\n // Filter out unsupported sources (if type is specified)\n return sources.filter((source) => {\n const type = source.getAttribute('type');\n\n if (is.empty(type)) {\n return true;\n }\n\n return support.mime.call(this, type);\n });\n },\n\n // Get quality levels\n getQualityOptions() {\n // Whether we're forcing all options (e.g. for streaming)\n if (this.config.quality.forced) {\n return this.config.quality.options;\n }\n\n // Get sizes from <source> elements\n return html5.getSources\n .call(this)\n .map((source) => Number(source.getAttribute('data-res')))\n .filter(Boolean);\n },\n\n setup() {\n if (!this.isHTML5) {\n return;\n }\n\n const player = this;\n\n // Set speed options from config\n player.options.speed = player.config.speed.options;\n\n // Set aspect ratio if fixed\n if (!is.empty(this.config.ratio)) {\n setAspectRatio.call(player);\n }\n\n // Quality\n Object.defineProperty(player.media, 'quality', {\n get() {\n // Get sources\n const sources = html5.getSources.call(player);\n const source = sources.find((s) => s.getAttribute('src') === player.source);\n\n // Return size, if match is found\n return source && Number(source.getAttribute('data-res'));\n },\n set(input) {\n if (player.quality === input) {\n return;\n }\n\n // If we're using an external handler...\n if (player.config.quality.forced && is.function(player.config.quality.onChange)) {\n player.config.quality.onChange(input);\n } else {\n // Get sources\n const sources = html5.getSources.call(player);\n // Get first match for requested size\n const source = sources.find((s) => Number(s.getAttribute('data-res')) === input);\n\n // No matching source found\n if (!source) {\n return;\n }\n\n // Get current state\n const { currentTime, paused, preload, readyState, playbackRate } = player.media;\n\n // Set new source\n player.media.src = source.getAttribute('src');\n\n // Prevent loading if preload=\"none\" and the current source isn't loaded (#1044)\n if (preload !== 'none' || readyState) {\n // Restore time\n player.once('loadedmetadata', () => {\n player.speed = playbackRate;\n player.currentTime = currentTime;\n\n // Resume playing\n if (!paused) {\n silencePromise(player.play());\n }\n });\n\n // Load new source\n player.media.load();\n }\n }\n\n // Trigger change event\n triggerEvent.call(player, player.media, 'qualitychange', false, {\n quality: input,\n });\n },\n });\n },\n\n // Cancel current network requests\n // See https://github.com/sampotts/plyr/issues/174\n cancelRequests() {\n if (!this.isHTML5) {\n return;\n }\n\n // Remove child sources\n removeElement(html5.getSources.call(this));\n\n // Set blank video src attribute\n // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error\n // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection\n this.media.setAttribute('src', this.config.blankVideo);\n\n // Load the new empty source\n // This will cancel existing requests\n // See https://github.com/sampotts/plyr/issues/174\n this.media.load();\n\n // Debugging\n this.debug.log('Cancelled network requests');\n },\n};\n\nexport default html5;\n","// ==========================================================================\n// String utils\n// ==========================================================================\n\nimport is from './is';\n\n// Generate a random ID\nexport function generateId(prefix) {\n return `${prefix}-${Math.floor(Math.random() * 10000)}`;\n}\n\n// Format string\nexport function format(input, ...args) {\n if (is.empty(input)) return input;\n\n return input.toString().replace(/{(\\d+)}/g, (_, i) => args[i].toString());\n}\n\n// Get percentage\nexport function getPercentage(current, max) {\n if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {\n return 0;\n }\n\n return ((current / max) * 100).toFixed(2);\n}\n\n// Replace all occurrences of a string in a string\nexport const replaceAll = (input = '', find = '', replace = '') =>\n input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\\]/\\\\])/g, '\\\\$1'), 'g'), replace.toString());\n\n// Convert to title case\nexport const toTitleCase = (input = '') =>\n input.toString().replace(/\\w\\S*/g, (text) => text.charAt(0).toUpperCase() + text.slice(1).toLowerCase());\n\n// Convert string to pascalCase\nexport function toPascalCase(input = '') {\n let string = input.toString();\n\n // Convert kebab case\n string = replaceAll(string, '-', ' ');\n\n // Convert snake case\n string = replaceAll(string, '_', ' ');\n\n // Convert to title case\n string = toTitleCase(string);\n\n // Convert to pascal case\n return replaceAll(string, ' ', '');\n}\n\n// Convert string to pascalCase\nexport function toCamelCase(input = '') {\n let string = input.toString();\n\n // Convert to pascal case\n string = toPascalCase(string);\n\n // Convert first character to lowercase\n return string.charAt(0).toLowerCase() + string.slice(1);\n}\n\n// Remove HTML from a string\nexport function stripHTML(source) {\n const fragment = document.createDocumentFragment();\n const element = document.createElement('div');\n fragment.appendChild(element);\n element.innerHTML = source;\n return fragment.firstChild.innerText;\n}\n\n// Like outerHTML, but also works for DocumentFragment\nexport function getHTML(element) {\n const wrapper = document.createElement('div');\n wrapper.appendChild(element);\n return wrapper.innerHTML;\n}\n","// ==========================================================================\n// Plyr internationalization\n// ==========================================================================\n\nimport is from './is';\nimport { getDeep } from './objects';\nimport { replaceAll } from './strings';\n\n// Skip i18n for abbreviations and brand names\nconst resources = {\n pip: 'PIP',\n airplay: 'AirPlay',\n html5: 'HTML5',\n vimeo: 'Vimeo',\n youtube: 'YouTube',\n};\n\nconst i18n = {\n get(key = '', config = {}) {\n if (is.empty(key) || is.empty(config)) {\n return '';\n }\n\n let string = getDeep(config.i18n, key);\n\n if (is.empty(string)) {\n if (Object.keys(resources).includes(key)) {\n return resources[key];\n }\n\n return '';\n }\n\n const replace = {\n '{seektime}': config.seekTime,\n '{title}': config.title,\n };\n\n Object.entries(replace).forEach(([k, v]) => {\n string = replaceAll(string, k, v);\n });\n\n return string;\n },\n};\n\nexport default i18n;\n","// ==========================================================================\n// Plyr storage\n// ==========================================================================\n\nimport is from './utils/is';\nimport { extend } from './utils/objects';\n\nclass Storage {\n constructor(player) {\n this.enabled = player.config.storage.enabled;\n this.key = player.config.storage.key;\n }\n\n // Check for actual support (see if we can use it)\n static get supported() {\n try {\n if (!('localStorage' in window)) {\n return false;\n }\n\n const test = '___test';\n\n // Try to use it (it might be disabled, e.g. user is in private mode)\n // see: https://github.com/sampotts/plyr/issues/131\n window.localStorage.setItem(test, test);\n window.localStorage.removeItem(test);\n\n return true;\n } catch (_) {\n return false;\n }\n }\n\n get = (key) => {\n if (!Storage.supported || !this.enabled) {\n return null;\n }\n\n const store = window.localStorage.getItem(this.key);\n\n if (is.empty(store)) {\n return null;\n }\n\n const json = JSON.parse(store);\n\n return is.string(key) && key.length ? json[key] : json;\n };\n\n set = (object) => {\n // Bail if we don't have localStorage support or it's disabled\n if (!Storage.supported || !this.enabled) {\n return;\n }\n\n // Can only store objectst\n if (!is.object(object)) {\n return;\n }\n\n // Get current storage\n let storage = this.get();\n\n // Default to empty object\n if (is.empty(storage)) {\n storage = {};\n }\n\n // Update the working copy of the values\n extend(storage, object);\n\n // Update storage\n try {\n window.localStorage.setItem(this.key, JSON.stringify(storage));\n } catch (_) {\n // Do nothing\n }\n };\n}\n\nexport default Storage;\n","// ==========================================================================\n// Fetch wrapper\n// Using XHR to avoid issues with older browsers\n// ==========================================================================\n\nexport default function fetch(url, responseType = 'text') {\n return new Promise((resolve, reject) => {\n try {\n const request = new XMLHttpRequest();\n\n // Check for CORS support\n if (!('withCredentials' in request)) {\n return;\n }\n\n request.addEventListener('load', () => {\n if (responseType === 'text') {\n try {\n resolve(JSON.parse(request.responseText));\n } catch (_) {\n resolve(request.responseText);\n }\n } else {\n resolve(request.response);\n }\n });\n\n request.addEventListener('error', () => {\n throw new Error(request.status);\n });\n\n request.open('GET', url, true);\n\n // Set the required response type\n request.responseType = responseType;\n\n request.send();\n } catch (error) {\n reject(error);\n }\n });\n}\n","// ==========================================================================\n// Sprite loader\n// ==========================================================================\n\nimport Storage from '../storage';\nimport fetch from './fetch';\nimport is from './is';\n\n// Load an external SVG sprite\nexport default function loadSprite(url, id) {\n if (!is.string(url)) {\n return;\n }\n\n const prefix = 'cache';\n const hasId = is.string(id);\n let isCached = false;\n const exists = () => document.getElementById(id) !== null;\n\n const update = (container, data) => {\n // eslint-disable-next-line no-param-reassign\n container.innerHTML = data;\n\n // Check again incase of race condition\n if (hasId && exists()) {\n return;\n }\n\n // Inject the SVG to the body\n document.body.insertAdjacentElement('afterbegin', container);\n };\n\n // Only load once if ID set\n if (!hasId || !exists()) {\n const useStorage = Storage.supported;\n // Create container\n const container = document.createElement('div');\n container.setAttribute('hidden', '');\n\n if (hasId) {\n container.setAttribute('id', id);\n }\n\n // Check in cache\n if (useStorage) {\n const cached = window.localStorage.getItem(`${prefix}-${id}`);\n isCached = cached !== null;\n\n if (isCached) {\n const data = JSON.parse(cached);\n update(container, data.content);\n }\n }\n\n // Get the sprite\n fetch(url)\n .then((result) => {\n if (is.empty(result)) {\n return;\n }\n\n if (useStorage) {\n try {\n window.localStorage.setItem(\n `${prefix}-${id}`,\n JSON.stringify({\n content: result,\n }),\n );\n } catch (_) {\n // Do nothing\n }\n }\n\n update(container, result);\n })\n .catch(() => {});\n }\n}\n","// ==========================================================================\n// Time utils\n// ==========================================================================\n\nimport is from './is';\n\n// Time helpers\nexport const getHours = (value) => Math.trunc((value / 60 / 60) % 60, 10);\nexport const getMinutes = (value) => Math.trunc((value / 60) % 60, 10);\nexport const getSeconds = (value) => Math.trunc(value % 60, 10);\n\n// Format time to UI friendly string\nexport function formatTime(time = 0, displayHours = false, inverted = false) {\n // Bail if the value isn't a number\n if (!is.number(time)) {\n return formatTime(undefined, displayHours, inverted);\n }\n\n // Format time component to add leading zero\n const format = (value) => `0${value}`.slice(-2);\n // Breakdown to hours, mins, secs\n let hours = getHours(time);\n const mins = getMinutes(time);\n const secs = getSeconds(time);\n\n // Do we need to display hours?\n if (displayHours || hours > 0) {\n hours = `${hours}:`;\n } else {\n hours = '';\n }\n\n // Render\n return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;\n}\n","// ==========================================================================\n// Plyr controls\n// TODO: This needs to be split into smaller files and cleaned up\n// ==========================================================================\n\nimport RangeTouch from 'rangetouch';\n\nimport captions from './captions';\nimport html5 from './html5';\nimport support from './support';\nimport { repaint, transitionEndEvent } from './utils/animation';\nimport { dedupe } from './utils/arrays';\nimport browser from './utils/browser';\nimport {\n createElement,\n emptyElement,\n getAttributesFromSelector,\n getElement,\n getElements,\n hasClass,\n matches,\n removeElement,\n setAttributes,\n setFocus,\n toggleClass,\n toggleHidden,\n} from './utils/elements';\nimport { off, on } from './utils/events';\nimport i18n from './utils/i18n';\nimport is from './utils/is';\nimport loadSprite from './utils/load-sprite';\nimport { extend } from './utils/objects';\nimport { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings';\nimport { formatTime, getHours } from './utils/time';\n\n// TODO: Don't export a massive object - break down and create class\nconst controls = {\n // Get icon URL\n getIconUrl() {\n const url = new URL(this.config.iconUrl, window.location);\n const host = window.location.host ? window.location.host : window.top.location.host;\n const cors = url.host !== host || (browser.isIE && !window.svg4everybody);\n\n return {\n url: this.config.iconUrl,\n cors,\n };\n },\n\n // Find the UI controls\n findElements() {\n try {\n this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);\n\n // Buttons\n this.elements.buttons = {\n play: getElements.call(this, this.config.selectors.buttons.play),\n pause: getElement.call(this, this.config.selectors.buttons.pause),\n restart: getElement.call(this, this.config.selectors.buttons.restart),\n rewind: getElement.call(this, this.config.selectors.buttons.rewind),\n fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),\n mute: getElement.call(this, this.config.selectors.buttons.mute),\n pip: getElement.call(this, this.config.selectors.buttons.pip),\n airplay: getElement.call(this, this.config.selectors.buttons.airplay),\n settings: getElement.call(this, this.config.selectors.buttons.settings),\n captions: getElement.call(this, this.config.selectors.buttons.captions),\n fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen),\n };\n\n // Progress\n this.elements.progress = getElement.call(this, this.config.selectors.progress);\n\n // Inputs\n this.elements.inputs = {\n seek: getElement.call(this, this.config.selectors.inputs.seek),\n volume: getElement.call(this, this.config.selectors.inputs.volume),\n };\n\n // Display\n this.elements.display = {\n buffer: getElement.call(this, this.config.selectors.display.buffer),\n currentTime: getElement.call(this, this.config.selectors.display.currentTime),\n duration: getElement.call(this, this.config.selectors.display.duration),\n };\n\n // Seek tooltip\n if (is.element(this.elements.progress)) {\n this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);\n }\n\n return true;\n } catch (error) {\n // Log it\n this.debug.warn('It looks like there is a problem with your custom controls HTML', error);\n\n // Restore native video controls\n this.toggleNativeControls(true);\n\n return false;\n }\n },\n\n // Create <svg> icon\n createIcon(type, attributes) {\n const namespace = 'http://www.w3.org/2000/svg';\n const iconUrl = controls.getIconUrl.call(this);\n const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`;\n // Create <svg>\n const icon = document.createElementNS(namespace, 'svg');\n setAttributes(\n icon,\n extend(attributes, {\n 'aria-hidden': 'true',\n focusable: 'false',\n }),\n );\n\n // Create the <use> to reference sprite\n const use = document.createElementNS(namespace, 'use');\n const path = `${iconPath}-${type}`;\n\n // Set `href` attributes\n // https://github.com/sampotts/plyr/issues/460\n // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href\n if ('href' in use) {\n use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path);\n }\n\n // Always set the older attribute even though it's \"deprecated\" (it'll be around for ages)\n use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path);\n\n // Add <use> to <svg>\n icon.appendChild(use);\n\n return icon;\n },\n\n // Create hidden text label\n createLabel(key, attr = {}) {\n const text = i18n.get(key, this.config);\n const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') };\n\n return createElement('span', attributes, text);\n },\n\n // Create a badge\n createBadge(text) {\n if (is.empty(text)) {\n return null;\n }\n\n const badge = createElement('span', {\n class: this.config.classNames.menu.value,\n });\n\n badge.appendChild(\n createElement(\n 'span',\n {\n class: this.config.classNames.menu.badge,\n },\n text,\n ),\n );\n\n return badge;\n },\n\n // Create a <button>\n createButton(buttonType, attr) {\n const attributes = extend({}, attr);\n let type = toCamelCase(buttonType);\n\n const props = {\n element: 'button',\n toggle: false,\n label: null,\n icon: null,\n labelPressed: null,\n iconPressed: null,\n };\n\n ['element', 'icon', 'label'].forEach((key) => {\n if (Object.keys(attributes).includes(key)) {\n props[key] = attributes[key];\n delete attributes[key];\n }\n });\n\n // Default to 'button' type to prevent form submission\n if (props.element === 'button' && !Object.keys(attributes).includes('type')) {\n attributes.type = 'button';\n }\n\n // Set class name\n if (Object.keys(attributes).includes('class')) {\n if (!attributes.class.split(' ').some((c) => c === this.config.classNames.control)) {\n extend(attributes, {\n class: `${attributes.class} ${this.config.classNames.control}`,\n });\n }\n } else {\n attributes.class = this.config.classNames.control;\n }\n\n // Large play button\n switch (buttonType) {\n case 'play':\n props.toggle = true;\n props.label = 'play';\n props.labelPressed = 'pause';\n props.icon = 'play';\n props.iconPressed = 'pause';\n break;\n\n case 'mute':\n props.toggle = true;\n props.label = 'mute';\n props.labelPressed = 'unmute';\n props.icon = 'volume';\n props.iconPressed = 'muted';\n break;\n\n case 'captions':\n props.toggle = true;\n props.label = 'enableCaptions';\n props.labelPressed = 'disableCaptions';\n props.icon = 'captions-off';\n props.iconPressed = 'captions-on';\n break;\n\n case 'fullscreen':\n props.toggle = true;\n props.label = 'enterFullscreen';\n props.labelPressed = 'exitFullscreen';\n props.icon = 'enter-fullscreen';\n props.iconPressed = 'exit-fullscreen';\n break;\n\n case 'play-large':\n attributes.class += ` ${this.config.classNames.control}--overlaid`;\n type = 'play';\n props.label = 'play';\n props.icon = 'play';\n break;\n\n default:\n if (is.empty(props.label)) {\n props.label = type;\n }\n if (is.empty(props.icon)) {\n props.icon = buttonType;\n }\n }\n\n const button = createElement(props.element);\n\n // Setup toggle icon and labels\n if (props.toggle) {\n // Icon\n button.appendChild(\n controls.createIcon.call(this, props.iconPressed, {\n class: 'icon--pressed',\n }),\n );\n button.appendChild(\n controls.createIcon.call(this, props.icon, {\n class: 'icon--not-pressed',\n }),\n );\n\n // Label/Tooltip\n button.appendChild(\n controls.createLabel.call(this, props.labelPressed, {\n class: 'label--pressed',\n }),\n );\n button.appendChild(\n controls.createLabel.call(this, props.label, {\n class: 'label--not-pressed',\n }),\n );\n } else {\n button.appendChild(controls.createIcon.call(this, props.icon));\n button.appendChild(controls.createLabel.call(this, props.label));\n }\n\n // Merge and set attributes\n extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));\n setAttributes(button, attributes);\n\n // We have multiple play buttons\n if (type === 'play') {\n if (!is.array(this.elements.buttons[type])) {\n this.elements.buttons[type] = [];\n }\n\n this.elements.buttons[type].push(button);\n } else {\n this.elements.buttons[type] = button;\n }\n\n return button;\n },\n\n // Create an <input type='range'>\n createRange(type, attributes) {\n // Seek input\n const input = createElement(\n 'input',\n extend(\n getAttributesFromSelector(this.config.selectors.inputs[type]),\n {\n type: 'range',\n min: 0,\n max: 100,\n step: 0.01,\n value: 0,\n autocomplete: 'off',\n // A11y fixes for https://github.com/sampotts/plyr/issues/905\n role: 'slider',\n 'aria-label': i18n.get(type, this.config),\n 'aria-valuemin': 0,\n 'aria-valuemax': 100,\n 'aria-valuenow': 0,\n },\n attributes,\n ),\n );\n\n this.elements.inputs[type] = input;\n\n // Set the fill for webkit now\n controls.updateRangeFill.call(this, input);\n\n // Improve support on touch devices\n RangeTouch.setup(input);\n\n return input;\n },\n\n // Create a <progress>\n createProgress(type, attributes) {\n const progress = createElement(\n 'progress',\n extend(\n getAttributesFromSelector(this.config.selectors.display[type]),\n {\n min: 0,\n max: 100,\n value: 0,\n role: 'progressbar',\n 'aria-hidden': true,\n },\n attributes,\n ),\n );\n\n // Create the label inside\n if (type !== 'volume') {\n progress.appendChild(createElement('span', null, '0'));\n\n const suffixKey = {\n played: 'played',\n buffer: 'buffered',\n }[type];\n const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';\n\n progress.innerText = `% ${suffix.toLowerCase()}`;\n }\n\n this.elements.display[type] = progress;\n\n return progress;\n },\n\n // Create time display\n createTime(type, attrs) {\n const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);\n\n const container = createElement(\n 'div',\n extend(attributes, {\n class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),\n 'aria-label': i18n.get(type, this.config),\n role: 'timer',\n }),\n '00:00',\n );\n\n // Reference for updates\n this.elements.display[type] = container;\n\n return container;\n },\n\n // Bind keyboard shortcuts for a menu item\n // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus\n // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143\n bindMenuItemShortcuts(menuItem, type) {\n // Navigate through menus via arrow keys and space\n on.call(\n this,\n menuItem,\n 'keydown keyup',\n (event) => {\n // We only care about space and ⬆️ ⬇️️ ➡️\n if (![' ', 'ArrowUp', 'ArrowDown', 'ArrowRight'].includes(event.key)) {\n return;\n }\n\n // Prevent play / seek\n event.preventDefault();\n event.stopPropagation();\n\n // We're just here to prevent the keydown bubbling\n if (event.type === 'keydown') {\n return;\n }\n\n const isRadioButton = matches(menuItem, '[role=\"menuitemradio\"]');\n\n // Show the respective menu\n if (!isRadioButton && [' ', 'ArrowRight'].includes(event.key)) {\n controls.showMenuPanel.call(this, type, true);\n } else {\n let target;\n\n if (event.key !== ' ') {\n if (event.key === 'ArrowDown' || (isRadioButton && event.key === 'ArrowRight')) {\n target = menuItem.nextElementSibling;\n\n if (!is.element(target)) {\n target = menuItem.parentNode.firstElementChild;\n }\n } else {\n target = menuItem.previousElementSibling;\n\n if (!is.element(target)) {\n target = menuItem.parentNode.lastElementChild;\n }\n }\n\n setFocus.call(this, target, true);\n }\n }\n },\n false,\n );\n\n // Enter will fire a `click` event but we still need to manage focus\n // So we bind to keyup which fires after and set focus here\n on.call(this, menuItem, 'keyup', (event) => {\n if (event.key !== 'Return') return;\n\n controls.focusFirstMenuItem.call(this, null, true);\n });\n },\n\n // Create a settings menu item\n createMenuItem({ value, list, type, title, badge = null, checked = false }) {\n const attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);\n\n const menuItem = createElement(\n 'button',\n extend(attributes, {\n type: 'button',\n role: 'menuitemradio',\n class: `${this.config.classNames.control} ${attributes.class ? attributes.class : ''}`.trim(),\n 'aria-checked': checked,\n value,\n }),\n );\n\n const flex = createElement('span');\n\n // We have to set as HTML incase of special characters\n flex.innerHTML = title;\n\n if (is.element(badge)) {\n flex.appendChild(badge);\n }\n\n menuItem.appendChild(flex);\n\n // Replicate radio button behavior\n Object.defineProperty(menuItem, 'checked', {\n enumerable: true,\n get() {\n return menuItem.getAttribute('aria-checked') === 'true';\n },\n set(check) {\n // Ensure exclusivity\n if (check) {\n Array.from(menuItem.parentNode.children)\n .filter((node) => matches(node, '[role=\"menuitemradio\"]'))\n .forEach((node) => node.setAttribute('aria-checked', 'false'));\n }\n\n menuItem.setAttribute('aria-checked', check ? 'true' : 'false');\n },\n });\n\n this.listeners.bind(\n menuItem,\n 'click keyup',\n (event) => {\n if (is.keyboardEvent(event) && event.key !== ' ') {\n return;\n }\n\n event.preventDefault();\n event.stopPropagation();\n\n menuItem.checked = true;\n\n switch (type) {\n case 'language':\n this.currentTrack = Number(value);\n break;\n\n case 'quality':\n this.quality = value;\n break;\n\n case 'speed':\n this.speed = parseFloat(value);\n break;\n\n default:\n break;\n }\n\n controls.showMenuPanel.call(this, 'home', is.keyboardEvent(event));\n },\n type,\n false,\n );\n\n controls.bindMenuItemShortcuts.call(this, menuItem, type);\n\n list.appendChild(menuItem);\n },\n\n // Format a time for display\n formatTime(time = 0, inverted = false) {\n // Bail if the value isn't a number\n if (!is.number(time)) {\n return time;\n }\n\n // Always display hours if duration is over an hour\n const forceHours = getHours(this.duration) > 0;\n\n return formatTime(time, forceHours, inverted);\n },\n\n // Update the displayed time\n updateTimeDisplay(target = null, time = 0, inverted = false) {\n // Bail if there's no element to display or the value isn't a number\n if (!is.element(target) || !is.number(time)) {\n return;\n }\n\n // eslint-disable-next-line no-param-reassign\n target.innerText = controls.formatTime(time, inverted);\n },\n\n // Update volume UI and storage\n updateVolume() {\n if (!this.supported.ui) {\n return;\n }\n\n // Update range\n if (is.element(this.elements.inputs.volume)) {\n controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);\n }\n\n // Update mute state\n if (is.element(this.elements.buttons.mute)) {\n this.elements.buttons.mute.pressed = this.muted || this.volume === 0;\n }\n },\n\n // Update seek value and lower fill\n setRange(target, value = 0) {\n if (!is.element(target)) {\n return;\n }\n\n // eslint-disable-next-line\n target.value = value;\n\n // Webkit range fill\n controls.updateRangeFill.call(this, target);\n },\n\n // Update <progress> elements\n updateProgress(event) {\n if (!this.supported.ui || !is.event(event)) {\n return;\n }\n\n let value = 0;\n\n const setProgress = (target, input) => {\n const val = is.number(input) ? input : 0;\n const progress = is.element(target) ? target : this.elements.display.buffer;\n\n // Update value and label\n if (is.element(progress)) {\n progress.value = val;\n\n // Update text label inside\n const label = progress.getElementsByTagName('span')[0];\n if (is.element(label)) {\n label.childNodes[0].nodeValue = val;\n }\n }\n };\n\n if (event) {\n switch (event.type) {\n // Video playing\n case 'timeupdate':\n case 'seeking':\n case 'seeked':\n value = getPercentage(this.currentTime, this.duration);\n\n // Set seek range value only if it's a 'natural' time event\n if (event.type === 'timeupdate') {\n controls.setRange.call(this, this.elements.inputs.seek, value);\n }\n\n break;\n\n // Check buffer status\n case 'playing':\n case 'progress':\n setProgress(this.elements.display.buffer, this.buffered * 100);\n\n break;\n\n default:\n break;\n }\n }\n },\n\n // Webkit polyfill for lower fill range\n updateRangeFill(target) {\n // Get range from event if event passed\n const range = is.event(target) ? target.target : target;\n\n // Needs to be a valid <input type='range'>\n if (!is.element(range) || range.getAttribute('type') !== 'range') {\n return;\n }\n\n // Set aria values for https://github.com/sampotts/plyr/issues/905\n if (matches(range, this.config.selectors.inputs.seek)) {\n range.setAttribute('aria-valuenow', this.currentTime);\n const currentTime = controls.formatTime(this.currentTime);\n const duration = controls.formatTime(this.duration);\n const format = i18n.get('seekLabel', this.config);\n range.setAttribute(\n 'aria-valuetext',\n format.replace('{currentTime}', currentTime).replace('{duration}', duration),\n );\n } else if (matches(range, this.config.selectors.inputs.volume)) {\n const percent = range.value * 100;\n range.setAttribute('aria-valuenow', percent);\n range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);\n } else {\n range.setAttribute('aria-valuenow', range.value);\n }\n\n // WebKit only\n if (!browser.isWebKit && !browser.isIPadOS) {\n return;\n }\n\n // Set CSS custom property\n range.style.setProperty('--value', `${(range.value / range.max) * 100}%`);\n },\n\n // Update hover tooltip for seeking\n updateSeekTooltip(event) {\n // Bail if setting not true\n if (\n !this.config.tooltips.seek ||\n !is.element(this.elements.inputs.seek) ||\n !is.element(this.elements.display.seekTooltip) ||\n this.duration === 0\n ) {\n return;\n }\n\n const tipElement = this.elements.display.seekTooltip;\n const visible = `${this.config.classNames.tooltip}--visible`;\n const toggle = (show) => toggleClass(tipElement, visible, show);\n\n // Hide on touch\n if (this.touch) {\n toggle(false);\n return;\n }\n\n // Determine percentage, if already visible\n let percent = 0;\n const clientRect = this.elements.progress.getBoundingClientRect();\n\n if (is.event(event)) {\n percent = (100 / clientRect.width) * (event.pageX - clientRect.left);\n } else if (hasClass(tipElement, visible)) {\n percent = parseFloat(tipElement.style.left, 10);\n } else {\n return;\n }\n\n // Set bounds\n if (percent < 0) {\n percent = 0;\n } else if (percent > 100) {\n percent = 100;\n }\n\n const time = (this.duration / 100) * percent;\n\n // Display the time a click would seek to\n tipElement.innerText = controls.formatTime(time);\n\n // Get marker point for time\n const point = this.config.markers?.points?.find(({ time: t }) => t === Math.round(time));\n\n // Append the point label to the tooltip\n if (point) {\n tipElement.insertAdjacentHTML('afterbegin', `${point.label}<br>`);\n }\n\n // Set position\n tipElement.style.left = `${percent}%`;\n\n // Show/hide the tooltip\n // If the event is a moues in/out and percentage is inside bounds\n if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {\n toggle(event.type === 'mouseenter');\n }\n },\n\n // Handle time change event\n timeUpdate(event) {\n // Only invert if only one time element is displayed and used for both duration and currentTime\n const invert = !is.element(this.elements.display.duration) && this.config.invertTime;\n\n // Duration\n controls.updateTimeDisplay.call(\n this,\n this.elements.display.currentTime,\n invert ? this.duration - this.currentTime : this.currentTime,\n invert,\n );\n\n // Ignore updates while seeking\n if (event && event.type === 'timeupdate' && this.media.seeking) {\n return;\n }\n\n // Playing progress\n controls.updateProgress.call(this, event);\n },\n\n // Show the duration on metadataloaded or durationchange events\n durationUpdate() {\n // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false\n if (!this.supported.ui || (!this.config.invertTime && this.currentTime)) {\n return;\n }\n\n // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar.\n // https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415\n // https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062\n // https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338\n if (this.duration >= 2 ** 32) {\n toggleHidden(this.elements.display.currentTime, true);\n toggleHidden(this.elements.progress, true);\n return;\n }\n\n // Update ARIA values\n if (is.element(this.elements.inputs.seek)) {\n this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);\n }\n\n // If there's a spot to display duration\n const hasDuration = is.element(this.elements.display.duration);\n\n // If there's only one time display, display duration there\n if (!hasDuration && this.config.displayDuration && this.paused) {\n controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);\n }\n\n // If there's a duration element, update content\n if (hasDuration) {\n controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);\n }\n\n if (this.config.markers.enabled) {\n controls.setMarkers.call(this);\n }\n\n // Update the tooltip (if visible)\n controls.updateSeekTooltip.call(this);\n },\n\n // Hide/show a tab\n toggleMenuButton(setting, toggle) {\n toggleHidden(this.elements.settings.buttons[setting], !toggle);\n },\n\n // Update the selected setting\n updateSetting(setting, container, input) {\n const pane = this.elements.settings.panels[setting];\n let value = null;\n let list = container;\n\n if (setting === 'captions') {\n value = this.currentTrack;\n } else {\n value = !is.empty(input) ? input : this[setting];\n\n // Get default\n if (is.empty(value)) {\n value = this.config[setting].default;\n }\n\n // Unsupported value\n if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {\n this.debug.warn(`Unsupported value of '${value}' for ${setting}`);\n return;\n }\n\n // Disabled value\n if (!this.config[setting].options.includes(value)) {\n this.debug.warn(`Disabled value of '${value}' for ${setting}`);\n return;\n }\n }\n\n // Get the list if we need to\n if (!is.element(list)) {\n list = pane && pane.querySelector('[role=\"menu\"]');\n }\n\n // If there's no list it means it's not been rendered...\n if (!is.element(list)) {\n return;\n }\n\n // Update the label\n const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`);\n label.innerHTML = controls.getLabel.call(this, setting, value);\n\n // Find the radio option and check it\n const target = list && list.querySelector(`[value=\"${value}\"]`);\n\n if (is.element(target)) {\n target.checked = true;\n }\n },\n\n // Translate a value into a nice label\n getLabel(setting, value) {\n switch (setting) {\n case 'speed':\n return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;\n\n case 'quality':\n if (is.number(value)) {\n const label = i18n.get(`qualityLabel.${value}`, this.config);\n\n if (!label.length) {\n return `${value}p`;\n }\n\n return label;\n }\n\n return toTitleCase(value);\n\n case 'captions':\n return captions.getLabel.call(this);\n\n default:\n return null;\n }\n },\n\n // Set the quality menu\n setQualityMenu(options) {\n // Menu required\n if (!is.element(this.elements.settings.panels.quality)) {\n return;\n }\n\n const type = 'quality';\n const list = this.elements.settings.panels.quality.querySelector('[role=\"menu\"]');\n\n // Set options if passed and filter based on uniqueness and config\n if (is.array(options)) {\n this.options.quality = dedupe(options).filter((quality) => this.config.quality.options.includes(quality));\n }\n\n // Toggle the pane and tab\n const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;\n controls.toggleMenuButton.call(this, type, toggle);\n\n // Empty the menu\n emptyElement(list);\n\n // Check if we need to toggle the parent\n controls.checkMenu.call(this);\n\n // If we're hiding, nothing more to do\n if (!toggle) {\n return;\n }\n\n // Get the badge HTML for HD, 4K etc\n const getBadge = (quality) => {\n const label = i18n.get(`qualityBadge.${quality}`, this.config);\n\n if (!label.length) {\n return null;\n }\n\n return controls.createBadge.call(this, label);\n };\n\n // Sort options by the config and then render options\n this.options.quality\n .sort((a, b) => {\n const sorting = this.config.quality.options;\n return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;\n })\n .forEach((quality) => {\n controls.createMenuItem.call(this, {\n value: quality,\n list,\n type,\n title: controls.getLabel.call(this, 'quality', quality),\n badge: getBadge(quality),\n });\n });\n\n controls.updateSetting.call(this, type, list);\n },\n\n // Set the looping options\n /* setLoopMenu() {\n // Menu required\n if (!is.element(this.elements.settings.panels.loop)) {\n return;\n }\n\n const options = ['start', 'end', 'all', 'reset'];\n const list = this.elements.settings.panels.loop.querySelector('[role=\"menu\"]');\n\n // Show the pane and tab\n toggleHidden(this.elements.settings.buttons.loop, false);\n toggleHidden(this.elements.settings.panels.loop, false);\n\n // Toggle the pane and tab\n const toggle = !is.empty(this.loop.options);\n controls.toggleMenuButton.call(this, 'loop', toggle);\n\n // Empty the menu\n emptyElement(list);\n\n options.forEach(option => {\n const item = createElement('li');\n\n const button = createElement(\n 'button',\n extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {\n type: 'button',\n class: this.config.classNames.control,\n 'data-plyr-loop-action': option,\n }),\n i18n.get(option, this.config)\n );\n\n if (['start', 'end'].includes(option)) {\n const badge = controls.createBadge.call(this, '00:00');\n button.appendChild(badge);\n }\n\n item.appendChild(button);\n list.appendChild(item);\n });\n }, */\n\n // Get current selected caption language\n // TODO: rework this to user the getter in the API?\n\n // Set a list of available captions languages\n setCaptionsMenu() {\n // Menu required\n if (!is.element(this.elements.settings.panels.captions)) {\n return;\n }\n\n // TODO: Captions or language? Currently it's mixed\n const type = 'captions';\n const list = this.elements.settings.panels.captions.querySelector('[role=\"menu\"]');\n const tracks = captions.getTracks.call(this);\n const toggle = Boolean(tracks.length);\n\n // Toggle the pane and tab\n controls.toggleMenuButton.call(this, type, toggle);\n\n // Empty the menu\n emptyElement(list);\n\n // Check if we need to toggle the parent\n controls.checkMenu.call(this);\n\n // If there's no captions, bail\n if (!toggle) {\n return;\n }\n\n // Generate options data\n const options = tracks.map((track, value) => ({\n value,\n checked: this.captions.toggled && this.currentTrack === value,\n title: captions.getLabel.call(this, track),\n badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),\n list,\n type: 'language',\n }));\n\n // Add the \"Disabled\" option to turn off captions\n options.unshift({\n value: -1,\n checked: !this.captions.toggled,\n title: i18n.get('disabled', this.config),\n list,\n type: 'language',\n });\n\n // Generate options\n options.forEach(controls.createMenuItem.bind(this));\n\n controls.updateSetting.call(this, type, list);\n },\n\n // Set a list of available captions languages\n setSpeedMenu() {\n // Menu required\n if (!is.element(this.elements.settings.panels.speed)) {\n return;\n }\n\n const type = 'speed';\n const list = this.elements.settings.panels.speed.querySelector('[role=\"menu\"]');\n\n // Filter out invalid speeds\n this.options.speed = this.options.speed.filter((o) => o >= this.minimumSpeed && o <= this.maximumSpeed);\n\n // Toggle the pane and tab\n const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;\n controls.toggleMenuButton.call(this, type, toggle);\n\n // Empty the menu\n emptyElement(list);\n\n // Check if we need to toggle the parent\n controls.checkMenu.call(this);\n\n // If we're hiding, nothing more to do\n if (!toggle) {\n return;\n }\n\n // Create items\n this.options.speed.forEach((speed) => {\n controls.createMenuItem.call(this, {\n value: speed,\n list,\n type,\n title: controls.getLabel.call(this, 'speed', speed),\n });\n });\n\n controls.updateSetting.call(this, type, list);\n },\n\n // Check if we need to hide/show the settings menu\n checkMenu() {\n const { buttons } = this.elements.settings;\n const visible = !is.empty(buttons) && Object.values(buttons).some((button) => !button.hidden);\n\n toggleHidden(this.elements.settings.menu, !visible);\n },\n\n // Focus the first menu item in a given (or visible) menu\n focusFirstMenuItem(pane, focusVisible = false) {\n if (this.elements.settings.popup.hidden) {\n return;\n }\n\n let target = pane;\n\n if (!is.element(target)) {\n target = Object.values(this.elements.settings.panels).find((p) => !p.hidden);\n }\n\n const firstItem = target.querySelector('[role^=\"menuitem\"]');\n\n setFocus.call(this, firstItem, focusVisible);\n },\n\n // Show/hide menu\n toggleMenu(input) {\n const { popup } = this.elements.settings;\n const button = this.elements.buttons.settings;\n\n // Menu and button are required\n if (!is.element(popup) || !is.element(button)) {\n return;\n }\n\n // True toggle by default\n const { hidden } = popup;\n let show = hidden;\n\n if (is.boolean(input)) {\n show = input;\n } else if (is.keyboardEvent(input) && input.key === 'Escape') {\n show = false;\n } else if (is.event(input)) {\n // If Plyr is in a shadowDOM, the event target is set to the component, instead of the\n // Element in the shadowDOM. The path, if available, is complete.\n const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;\n const isMenuItem = popup.contains(target);\n\n // If the click was inside the menu or if the click\n // wasn't the button or menu item and we're trying to\n // show the menu (a doc click shouldn't show the menu)\n if (isMenuItem || (!isMenuItem && input.target !== button && show)) {\n return;\n }\n }\n\n // Set button attributes\n button.setAttribute('aria-expanded', show);\n\n // Show the actual popup\n toggleHidden(popup, !show);\n\n // Add class hook\n toggleClass(this.elements.container, this.config.classNames.menu.open, show);\n\n // Focus the first item if key interaction\n if (show && is.keyboardEvent(input)) {\n controls.focusFirstMenuItem.call(this, null, true);\n } else if (!show && !hidden) {\n // If closing, re-focus the button\n setFocus.call(this, button, is.keyboardEvent(input));\n }\n },\n\n // Get the natural size of a menu panel\n getMenuSize(tab) {\n const clone = tab.cloneNode(true);\n clone.style.position = 'absolute';\n clone.style.opacity = 0;\n clone.removeAttribute('hidden');\n\n // Append to parent so we get the \"real\" size\n tab.parentNode.appendChild(clone);\n\n // Get the sizes before we remove\n const width = clone.scrollWidth;\n const height = clone.scrollHeight;\n\n // Remove from the DOM\n removeElement(clone);\n\n return {\n width,\n height,\n };\n },\n\n // Show a panel in the menu\n showMenuPanel(type = '', focusVisible = false) {\n const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);\n\n // Nothing to show, bail\n if (!is.element(target)) {\n return;\n }\n\n // Hide all other panels\n const container = target.parentNode;\n const current = Array.from(container.children).find((node) => !node.hidden);\n\n // If we can do fancy animations, we'll animate the height/width\n if (support.transitions && !support.reducedMotion) {\n // Set the current width as a base\n container.style.width = `${current.scrollWidth}px`;\n container.style.height = `${current.scrollHeight}px`;\n\n // Get potential sizes\n const size = controls.getMenuSize.call(this, target);\n\n // Restore auto height/width\n const restore = (event) => {\n // We're only bothered about height and width on the container\n if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {\n return;\n }\n\n // Revert back to auto\n container.style.width = '';\n container.style.height = '';\n\n // Only listen once\n off.call(this, container, transitionEndEvent, restore);\n };\n\n // Listen for the transition finishing and restore auto height/width\n on.call(this, container, transitionEndEvent, restore);\n\n // Set dimensions to target\n container.style.width = `${size.width}px`;\n container.style.height = `${size.height}px`;\n }\n\n // Set attributes on current tab\n toggleHidden(current, true);\n\n // Set attributes on target\n toggleHidden(target, false);\n\n // Focus the first item\n controls.focusFirstMenuItem.call(this, target, focusVisible);\n },\n\n // Set the download URL\n setDownloadUrl() {\n const button = this.elements.buttons.download;\n\n // Bail if no button\n if (!is.element(button)) {\n return;\n }\n\n // Set attribute\n button.setAttribute('href', this.download);\n },\n\n // Build the default HTML\n create(data) {\n const {\n bindMenuItemShortcuts,\n createButton,\n createProgress,\n createRange,\n createTime,\n setQualityMenu,\n setSpeedMenu,\n showMenuPanel,\n } = controls;\n this.elements.controls = null;\n\n // Larger overlaid play button\n if (is.array(this.config.controls) && this.config.controls.includes('play-large')) {\n this.elements.container.appendChild(createButton.call(this, 'play-large'));\n }\n\n // Create the container\n const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));\n this.elements.controls = container;\n\n // Default item attributes\n const defaultAttributes = { class: 'plyr__controls__item' };\n\n // Loop through controls in order\n dedupe(is.array(this.config.controls) ? this.config.controls : []).forEach((control) => {\n // Restart button\n if (control === 'restart') {\n container.appendChild(createButton.call(this, 'restart', defaultAttributes));\n }\n\n // Rewind button\n if (control === 'rewind') {\n container.appendChild(createButton.call(this, 'rewind', defaultAttributes));\n }\n\n // Play/Pause button\n if (control === 'play') {\n container.appendChild(createButton.call(this, 'play', defaultAttributes));\n }\n\n // Fast forward button\n if (control === 'fast-forward') {\n container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));\n }\n\n // Progress\n if (control === 'progress') {\n const progressContainer = createElement('div', {\n class: `${defaultAttributes.class} plyr__progress__container`,\n });\n\n const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));\n\n // Seek range slider\n progress.appendChild(\n createRange.call(this, 'seek', {\n id: `plyr-seek-${data.id}`,\n }),\n );\n\n // Buffer progress\n progress.appendChild(createProgress.call(this, 'buffer'));\n\n // TODO: Add loop display indicator\n\n // Seek tooltip\n if (this.config.tooltips.seek) {\n const tooltip = createElement(\n 'span',\n {\n class: this.config.classNames.tooltip,\n },\n '00:00',\n );\n\n progress.appendChild(tooltip);\n this.elements.display.seekTooltip = tooltip;\n }\n\n this.elements.progress = progress;\n progressContainer.appendChild(this.elements.progress);\n container.appendChild(progressContainer);\n }\n\n // Media current time display\n if (control === 'current-time') {\n container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));\n }\n\n // Media duration display\n if (control === 'duration') {\n container.appendChild(createTime.call(this, 'duration', defaultAttributes));\n }\n\n // Volume controls\n if (control === 'mute' || control === 'volume') {\n let { volume } = this.elements;\n\n // Create the volume container if needed\n if (!is.element(volume) || !container.contains(volume)) {\n volume = createElement(\n 'div',\n extend({}, defaultAttributes, {\n class: `${defaultAttributes.class} plyr__volume`.trim(),\n }),\n );\n\n this.elements.volume = volume;\n\n container.appendChild(volume);\n }\n\n // Toggle mute button\n if (control === 'mute') {\n volume.appendChild(createButton.call(this, 'mute'));\n }\n\n // Volume range control\n // Ignored on iOS as it's handled globally\n // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html\n if (control === 'volume' && !browser.isIos && !browser.isIPadOS) {\n // Set the attributes\n const attributes = {\n max: 1,\n step: 0.05,\n value: this.config.volume,\n };\n\n // Create the volume range slider\n volume.appendChild(\n createRange.call(\n this,\n 'volume',\n extend(attributes, {\n id: `plyr-volume-${data.id}`,\n }),\n ),\n );\n }\n }\n\n // Toggle captions button\n if (control === 'captions') {\n container.appendChild(createButton.call(this, 'captions', defaultAttributes));\n }\n\n // Settings button / menu\n if (control === 'settings' && !is.empty(this.config.settings)) {\n const wrapper = createElement(\n 'div',\n extend({}, defaultAttributes, {\n class: `${defaultAttributes.class} plyr__menu`.trim(),\n hidden: '',\n }),\n );\n\n wrapper.appendChild(\n createButton.call(this, 'settings', {\n 'aria-haspopup': true,\n 'aria-controls': `plyr-settings-${data.id}`,\n 'aria-expanded': false,\n }),\n );\n\n const popup = createElement('div', {\n class: 'plyr__menu__container',\n id: `plyr-settings-${data.id}`,\n hidden: '',\n });\n\n const inner = createElement('div');\n\n const home = createElement('div', {\n id: `plyr-settings-${data.id}-home`,\n });\n\n // Create the menu\n const menu = createElement('div', {\n role: 'menu',\n });\n\n home.appendChild(menu);\n inner.appendChild(home);\n this.elements.settings.panels.home = home;\n\n // Build the menu items\n this.config.settings.forEach((type) => {\n // TODO: bundle this with the createMenuItem helper and bindings\n const menuItem = createElement(\n 'button',\n extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {\n type: 'button',\n class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,\n role: 'menuitem',\n 'aria-haspopup': true,\n hidden: '',\n }),\n );\n\n // Bind menu shortcuts for keyboard users\n bindMenuItemShortcuts.call(this, menuItem, type);\n\n // Show menu on click\n on.call(this, menuItem, 'click', () => {\n showMenuPanel.call(this, type, false);\n });\n\n const flex = createElement('span', null, i18n.get(type, this.config));\n\n const value = createElement('span', {\n class: this.config.classNames.menu.value,\n });\n\n // Speed contains HTML entities\n value.innerHTML = data[type];\n\n flex.appendChild(value);\n menuItem.appendChild(flex);\n menu.appendChild(menuItem);\n\n // Build the panes\n const pane = createElement('div', {\n id: `plyr-settings-${data.id}-${type}`,\n hidden: '',\n });\n\n // Back button\n const backButton = createElement('button', {\n type: 'button',\n class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,\n });\n\n // Visible label\n backButton.appendChild(\n createElement(\n 'span',\n {\n 'aria-hidden': true,\n },\n i18n.get(type, this.config),\n ),\n );\n\n // Screen reader label\n backButton.appendChild(\n createElement(\n 'span',\n {\n class: this.config.classNames.hidden,\n },\n i18n.get('menuBack', this.config),\n ),\n );\n\n // Go back via keyboard\n on.call(\n this,\n pane,\n 'keydown',\n (event) => {\n if (event.key !== 'ArrowLeft') return;\n\n // Prevent seek\n event.preventDefault();\n event.stopPropagation();\n\n // Show the respective menu\n showMenuPanel.call(this, 'home', true);\n },\n false,\n );\n\n // Go back via button click\n on.call(this, backButton, 'click', () => {\n showMenuPanel.call(this, 'home', false);\n });\n\n // Add to pane\n pane.appendChild(backButton);\n\n // Menu\n pane.appendChild(\n createElement('div', {\n role: 'menu',\n }),\n );\n\n inner.appendChild(pane);\n\n this.elements.settings.buttons[type] = menuItem;\n this.elements.settings.panels[type] = pane;\n });\n\n popup.appendChild(inner);\n wrapper.appendChild(popup);\n container.appendChild(wrapper);\n\n this.elements.settings.popup = popup;\n this.elements.settings.menu = wrapper;\n }\n\n // Picture in picture button\n if (control === 'pip' && support.pip) {\n container.appendChild(createButton.call(this, 'pip', defaultAttributes));\n }\n\n // Airplay button\n if (control === 'airplay' && support.airplay) {\n container.appendChild(createButton.call(this, 'airplay', defaultAttributes));\n }\n\n // Download button\n if (control === 'download') {\n const attributes = extend({}, defaultAttributes, {\n element: 'a',\n href: this.download,\n target: '_blank',\n });\n\n // Set download attribute for HTML5 only\n if (this.isHTML5) {\n attributes.download = '';\n }\n\n const { download } = this.config.urls;\n\n if (!is.url(download) && this.isEmbed) {\n extend(attributes, {\n icon: `logo-${this.provider}`,\n label: this.provider,\n });\n }\n\n container.appendChild(createButton.call(this, 'download', attributes));\n }\n\n // Toggle fullscreen button\n if (control === 'fullscreen') {\n container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));\n }\n });\n\n // Set available quality levels\n if (this.isHTML5) {\n setQualityMenu.call(this, html5.getQualityOptions.call(this));\n }\n\n setSpeedMenu.call(this);\n\n return container;\n },\n\n // Insert controls\n inject() {\n // Sprite\n if (this.config.loadSprite) {\n const icon = controls.getIconUrl.call(this);\n\n // Only load external sprite using AJAX\n if (icon.cors) {\n loadSprite(icon.url, 'sprite-plyr');\n }\n }\n\n // Create a unique ID\n this.id = Math.floor(Math.random() * 10000);\n\n // Null by default\n let container = null;\n this.elements.controls = null;\n\n // Set template properties\n const props = {\n id: this.id,\n seektime: this.config.seekTime,\n title: this.config.title,\n };\n let update = true;\n\n // If function, run it and use output\n if (is.function(this.config.controls)) {\n this.config.controls = this.config.controls.call(this, props);\n }\n\n // Convert falsy controls to empty array (primarily for empty strings)\n if (!this.config.controls) {\n this.config.controls = [];\n }\n\n if (is.element(this.config.controls) || is.string(this.config.controls)) {\n // HTMLElement or Non-empty string passed as the option\n container = this.config.controls;\n } else {\n // Create controls\n container = controls.create.call(this, {\n id: this.id,\n seektime: this.config.seekTime,\n speed: this.speed,\n quality: this.quality,\n captions: captions.getLabel.call(this),\n // TODO: Looping\n // loop: 'None',\n });\n update = false;\n }\n\n // Replace props with their value\n const replace = (input) => {\n let result = input;\n\n Object.entries(props).forEach(([key, value]) => {\n result = replaceAll(result, `{${key}}`, value);\n });\n\n return result;\n };\n\n // Update markup\n if (update) {\n if (is.string(this.config.controls)) {\n container = replace(container);\n }\n }\n\n // Controls container\n let target;\n\n // Inject to custom location\n if (is.string(this.config.selectors.controls.container)) {\n target = document.querySelector(this.config.selectors.controls.container);\n }\n\n // Inject into the container by default\n if (!is.element(target)) {\n target = this.elements.container;\n }\n\n // Inject controls HTML (needs to be before captions, hence \"afterbegin\")\n const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';\n target[insertMethod]('afterbegin', container);\n\n // Find the elements if need be\n if (!is.element(this.elements.controls)) {\n controls.findElements.call(this);\n }\n\n // Add pressed property to buttons\n if (!is.empty(this.elements.buttons)) {\n const addProperty = (button) => {\n const className = this.config.classNames.controlPressed;\n button.setAttribute('aria-pressed', 'false');\n\n Object.defineProperty(button, 'pressed', {\n configurable: true,\n enumerable: true,\n get() {\n return hasClass(button, className);\n },\n set(pressed = false) {\n toggleClass(button, className, pressed);\n button.setAttribute('aria-pressed', pressed ? 'true' : 'false');\n },\n });\n };\n\n // Toggle classname when pressed property is set\n Object.values(this.elements.buttons)\n .filter(Boolean)\n .forEach((button) => {\n if (is.array(button) || is.nodeList(button)) {\n Array.from(button).filter(Boolean).forEach(addProperty);\n } else {\n addProperty(button);\n }\n });\n }\n\n // Edge sometimes doesn't finish the paint so force a repaint\n if (browser.isEdge) {\n repaint(target);\n }\n\n // Setup tooltips\n if (this.config.tooltips.controls) {\n const { classNames, selectors } = this.config;\n const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;\n const labels = getElements.call(this, selector);\n\n Array.from(labels).forEach((label) => {\n toggleClass(label, this.config.classNames.hidden, false);\n toggleClass(label, this.config.classNames.tooltip, true);\n });\n }\n },\n\n // Set media metadata\n setMediaMetadata() {\n try {\n if ('mediaSession' in navigator) {\n navigator.mediaSession.metadata = new window.MediaMetadata({\n title: this.config.mediaMetadata.title,\n artist: this.config.mediaMetadata.artist,\n album: this.config.mediaMetadata.album,\n artwork: this.config.mediaMetadata.artwork,\n });\n }\n } catch (_) {\n // Do nothing\n }\n },\n\n // Add markers\n setMarkers() {\n if (!this.duration || this.elements.markers) return;\n\n // Get valid points\n const points = this.config.markers?.points?.filter(({ time }) => time > 0 && time < this.duration);\n if (!points?.length) return;\n\n const containerFragment = document.createDocumentFragment();\n const pointsFragment = document.createDocumentFragment();\n let tipElement = null;\n const tipVisible = `${this.config.classNames.tooltip}--visible`;\n const toggleTip = (show) => toggleClass(tipElement, tipVisible, show);\n\n // Inject markers to progress container\n points.forEach((point) => {\n const markerElement = createElement(\n 'span',\n {\n class: this.config.classNames.marker,\n },\n '',\n );\n\n const left = `${(point.time / this.duration) * 100}%`;\n\n if (tipElement) {\n // Show on hover\n markerElement.addEventListener('mouseenter', () => {\n if (point.label) return;\n tipElement.style.left = left;\n tipElement.innerHTML = point.label;\n toggleTip(true);\n });\n\n // Hide on leave\n markerElement.addEventListener('mouseleave', () => {\n toggleTip(false);\n });\n }\n\n markerElement.addEventListener('click', () => {\n this.currentTime = point.time;\n });\n\n markerElement.style.left = left;\n pointsFragment.appendChild(markerElement);\n });\n\n containerFragment.appendChild(pointsFragment);\n\n // Inject a tooltip if needed\n if (!this.config.tooltips.seek) {\n tipElement = createElement(\n 'span',\n {\n class: this.config.classNames.tooltip,\n },\n '',\n );\n\n containerFragment.appendChild(tipElement);\n }\n\n this.elements.markers = {\n points: pointsFragment,\n tip: tipElement,\n };\n\n this.elements.progress.appendChild(containerFragment);\n },\n};\n\nexport default controls;\n","// ==========================================================================\n// URL utils\n// ==========================================================================\n\nimport is from './is';\n\n/**\n * Parse a string to a URL object\n * @param {String} input - the URL to be parsed\n * @param {Boolean} safe - failsafe parsing\n */\nexport function parseUrl(input, safe = true) {\n let url = input;\n\n if (safe) {\n const parser = document.createElement('a');\n parser.href = url;\n url = parser.href;\n }\n\n try {\n return new URL(url);\n } catch (_) {\n return null;\n }\n}\n\n// Convert object to URLSearchParams\nexport function buildUrlParams(input) {\n const params = new URLSearchParams();\n\n if (is.object(input)) {\n Object.entries(input).forEach(([key, value]) => {\n params.set(key, value);\n });\n }\n\n return params;\n}\n","// ==========================================================================\n// Plyr Captions\n// TODO: Create as class\n// ==========================================================================\n\nimport controls from './controls';\nimport support from './support';\nimport { dedupe } from './utils/arrays';\nimport browser from './utils/browser';\nimport {\n createElement,\n emptyElement,\n getAttributesFromSelector,\n insertAfter,\n removeElement,\n toggleClass,\n} from './utils/elements';\nimport { on, triggerEvent } from './utils/events';\nimport fetch from './utils/fetch';\nimport i18n from './utils/i18n';\nimport is from './utils/is';\nimport { getHTML } from './utils/strings';\nimport { parseUrl } from './utils/urls';\n\nconst captions = {\n // Setup captions\n setup() {\n // Requires UI support\n if (!this.supported.ui) {\n return;\n }\n\n // Only Vimeo and HTML5 video supported at this point\n if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {\n // Clear menu and hide\n if (\n is.array(this.config.controls) &&\n this.config.controls.includes('settings') &&\n this.config.settings.includes('captions')\n ) {\n controls.setCaptionsMenu.call(this);\n }\n\n return;\n }\n\n // Inject the container\n if (!is.element(this.elements.captions)) {\n this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));\n this.elements.captions.setAttribute('dir', 'auto');\n\n insertAfter(this.elements.captions, this.elements.wrapper);\n }\n\n // Fix IE captions if CORS is used\n // Fetch captions and inject as blobs instead (data URIs not supported!)\n if (browser.isIE && window.URL) {\n const elements = this.media.querySelectorAll('track');\n\n Array.from(elements).forEach((track) => {\n const src = track.getAttribute('src');\n const url = parseUrl(src);\n\n if (\n url !== null &&\n url.hostname !== window.location.href.hostname &&\n ['http:', 'https:'].includes(url.protocol)\n ) {\n fetch(src, 'blob')\n .then((blob) => {\n track.setAttribute('src', window.URL.createObjectURL(blob));\n })\n .catch(() => {\n removeElement(track);\n });\n }\n });\n }\n\n // Get and set initial data\n // The \"preferred\" options are not realized unless / until the wanted language has a match\n // * languages: Array of user's browser languages.\n // * language: The language preferred by user settings or config\n // * active: The state preferred by user settings or config\n // * toggled: The real captions state\n\n const browserLanguages = navigator.languages || [navigator.language || navigator.userLanguage || 'en'];\n const languages = dedupe(browserLanguages.map((language) => language.split('-')[0]));\n let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();\n\n // Use first browser language when language is 'auto'\n if (language === 'auto') {\n [language] = languages;\n }\n\n let active = this.storage.get('captions');\n if (!is.boolean(active)) {\n ({ active } = this.config.captions);\n }\n\n Object.assign(this.captions, {\n toggled: false,\n active,\n language,\n languages,\n });\n\n // Watch changes to textTracks and update captions menu\n if (this.isHTML5) {\n const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';\n on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));\n }\n\n // Update available languages in list next tick (the event must not be triggered before the listeners)\n setTimeout(captions.update.bind(this), 0);\n },\n\n // Update available language options in settings based on tracks\n update() {\n const tracks = captions.getTracks.call(this, true);\n // Get the wanted language\n const { active, language, meta, currentTrackNode } = this.captions;\n const languageExists = Boolean(tracks.find((track) => track.language === language));\n\n // Handle tracks (add event listener and \"pseudo\"-default)\n if (this.isHTML5 && this.isVideo) {\n tracks\n .filter((track) => !meta.get(track))\n .forEach((track) => {\n this.debug.log('Track added', track);\n\n // Attempt to store if the original dom element was \"default\"\n meta.set(track, {\n default: track.mode === 'showing',\n });\n\n // Turn off native caption rendering to avoid double captions\n // Note: mode='hidden' forces a track to download. To ensure every track\n // isn't downloaded at once, only 'showing' tracks should be reassigned\n // eslint-disable-next-line no-param-reassign\n if (track.mode === 'showing') {\n // eslint-disable-next-line no-param-reassign\n track.mode = 'hidden';\n }\n\n // Add event listener for cue changes\n on.call(this, track, 'cuechange', () => captions.updateCues.call(this));\n });\n }\n\n // Update language first time it matches, or if the previous matching track was removed\n if ((languageExists && this.language !== language) || !tracks.includes(currentTrackNode)) {\n captions.setLanguage.call(this, language);\n captions.toggle.call(this, active && languageExists);\n }\n\n // Enable or disable captions based on track length\n if (this.elements) {\n toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));\n }\n\n // Update available languages in list\n if (\n is.array(this.config.controls) &&\n this.config.controls.includes('settings') &&\n this.config.settings.includes('captions')\n ) {\n controls.setCaptionsMenu.call(this);\n }\n },\n\n // Toggle captions display\n // Used internally for the toggleCaptions method, with the passive option forced to false\n toggle(input, passive = true) {\n // If there's no full support\n if (!this.supported.ui) {\n return;\n }\n\n const { toggled } = this.captions; // Current state\n const activeClass = this.config.classNames.captions.active;\n // Get the next state\n // If the method is called without parameter, toggle based on current value\n const active = is.nullOrUndefined(input) ? !toggled : input;\n\n // Update state and trigger event\n if (active !== toggled) {\n // When passive, don't override user preferences\n if (!passive) {\n this.captions.active = active;\n this.storage.set({ captions: active });\n }\n\n // Force language if the call isn't passive and there is no matching language to toggle to\n if (!this.language && active && !passive) {\n const tracks = captions.getTracks.call(this);\n const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);\n\n // Override user preferences to avoid switching languages if a matching track is added\n this.captions.language = track.language;\n\n // Set caption, but don't store in localStorage as user preference\n captions.set.call(this, tracks.indexOf(track));\n return;\n }\n\n // Toggle button if it's enabled\n if (this.elements.buttons.captions) {\n this.elements.buttons.captions.pressed = active;\n }\n\n // Add class hook\n toggleClass(this.elements.container, activeClass, active);\n\n this.captions.toggled = active;\n\n // Update settings menu\n controls.updateSetting.call(this, 'captions');\n\n // Trigger event (not used internally)\n triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');\n }\n\n // Wait for the call stack to clear before setting mode='hidden'\n // on the active track - forcing the browser to download it\n setTimeout(() => {\n if (active && this.captions.toggled) {\n this.captions.currentTrackNode.mode = 'hidden';\n }\n });\n },\n\n // Set captions by track index\n // Used internally for the currentTrack setter with the passive option forced to false\n set(index, passive = true) {\n const tracks = captions.getTracks.call(this);\n\n // Disable captions if setting to -1\n if (index === -1) {\n captions.toggle.call(this, false, passive);\n return;\n }\n\n if (!is.number(index)) {\n this.debug.warn('Invalid caption argument', index);\n return;\n }\n\n if (!(index in tracks)) {\n this.debug.warn('Track not found', index);\n return;\n }\n\n if (this.captions.currentTrack !== index) {\n this.captions.currentTrack = index;\n const track = tracks[index];\n const { language } = track || {};\n\n // Store reference to node for invalidation on remove\n this.captions.currentTrackNode = track;\n\n // Update settings menu\n controls.updateSetting.call(this, 'captions');\n\n // When passive, don't override user preferences\n if (!passive) {\n this.captions.language = language;\n this.storage.set({ language });\n }\n\n // Handle Vimeo captions\n if (this.isVimeo) {\n this.embed.enableTextTrack(language);\n }\n\n // Trigger event\n triggerEvent.call(this, this.media, 'languagechange');\n }\n\n // Show captions\n captions.toggle.call(this, true, passive);\n\n if (this.isHTML5 && this.isVideo) {\n // If we change the active track while a cue is already displayed we need to update it\n captions.updateCues.call(this);\n }\n },\n\n // Set captions by language\n // Used internally for the language setter with the passive option forced to false\n setLanguage(input, passive = true) {\n if (!is.string(input)) {\n this.debug.warn('Invalid language argument', input);\n return;\n }\n // Normalize\n const language = input.toLowerCase();\n this.captions.language = language;\n\n // Set currentTrack\n const tracks = captions.getTracks.call(this);\n const track = captions.findTrack.call(this, [language]);\n captions.set.call(this, tracks.indexOf(track), passive);\n },\n\n // Get current valid caption tracks\n // If update is false it will also ignore tracks without metadata\n // This is used to \"freeze\" the language options when captions.update is false\n getTracks(update = false) {\n // Handle media or textTracks missing or null\n const tracks = Array.from((this.media || {}).textTracks || []);\n // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)\n // Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)\n return tracks\n .filter((track) => !this.isHTML5 || update || this.captions.meta.has(track))\n .filter((track) => ['captions', 'subtitles'].includes(track.kind));\n },\n\n // Match tracks based on languages and get the first\n findTrack(languages, force = false) {\n const tracks = captions.getTracks.call(this);\n const sortIsDefault = (track) => Number((this.captions.meta.get(track) || {}).default);\n const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));\n let track;\n\n languages.every((language) => {\n track = sorted.find((t) => t.language === language);\n return !track; // Break iteration if there is a match\n });\n\n // If no match is found but is required, get first\n return track || (force ? sorted[0] : undefined);\n },\n\n // Get the current track\n getCurrentTrack() {\n return captions.getTracks.call(this)[this.currentTrack];\n },\n\n // Get UI label for track\n getLabel(track) {\n let currentTrack = track;\n\n if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {\n currentTrack = captions.getCurrentTrack.call(this);\n }\n\n if (is.track(currentTrack)) {\n if (!is.empty(currentTrack.label)) {\n return currentTrack.label;\n }\n\n if (!is.empty(currentTrack.language)) {\n return track.language.toUpperCase();\n }\n\n return i18n.get('enabled', this.config);\n }\n\n return i18n.get('disabled', this.config);\n },\n\n // Update captions using current track's active cues\n // Also optional array argument in case there isn't any track (ex: vimeo)\n updateCues(input) {\n // Requires UI\n if (!this.supported.ui) {\n return;\n }\n\n if (!is.element(this.elements.captions)) {\n this.debug.warn('No captions element to render to');\n return;\n }\n\n // Only accept array or empty input\n if (!is.nullOrUndefined(input) && !Array.isArray(input)) {\n this.debug.warn('updateCues: Invalid input', input);\n return;\n }\n\n let cues = input;\n\n // Get cues from track\n if (!cues) {\n const track = captions.getCurrentTrack.call(this);\n\n cues = Array.from((track || {}).activeCues || [])\n .map((cue) => cue.getCueAsHTML())\n .map(getHTML);\n }\n\n // Set new caption text\n const content = cues.map((cueText) => cueText.trim()).join('\\n');\n const changed = content !== this.elements.captions.innerHTML;\n\n if (changed) {\n // Empty the container and create a new child element\n emptyElement(this.elements.captions);\n const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));\n caption.innerHTML = content;\n this.elements.captions.appendChild(caption);\n\n // Trigger event\n triggerEvent.call(this, this.media, 'cuechange');\n }\n },\n};\n\nexport default captions;\n","// ==========================================================================\n// Plyr default config\n// ==========================================================================\n\nconst defaults = {\n // Disable\n enabled: true,\n\n // Custom media title\n title: '',\n\n // Logging to console\n debug: false,\n\n // Auto play (if supported)\n autoplay: false,\n\n // Only allow one media playing at once (vimeo only)\n autopause: true,\n\n // Allow inline playback on iOS\n playsinline: true,\n\n // Default time to skip when rewind/fast forward\n seekTime: 10,\n\n // Default volume\n volume: 1,\n muted: false,\n\n // Pass a custom duration\n duration: null,\n\n // Display the media duration on load in the current time position\n // If you have opted to display both duration and currentTime, this is ignored\n displayDuration: true,\n\n // Invert the current time to be a countdown\n invertTime: true,\n\n // Clicking the currentTime inverts it's value to show time left rather than elapsed\n toggleInvert: true,\n\n // Force an aspect ratio\n // The format must be `'w:h'` (e.g. `'16:9'`)\n ratio: null,\n\n // Click video container to play/pause\n clickToPlay: true,\n\n // Auto hide the controls\n hideControls: true,\n\n // Reset to start when playback ended\n resetOnEnd: false,\n\n // Disable the standard context menu\n disableContextMenu: true,\n\n // Sprite (for icons)\n loadSprite: true,\n iconPrefix: 'plyr',\n iconUrl: 'https://cdn.plyr.io/3.7.8/plyr.svg',\n\n // Blank video (used to prevent errors on source change)\n blankVideo: 'https://cdn.plyr.io/static/blank.mp4',\n\n // Quality default\n quality: {\n default: 576,\n // The options to display in the UI, if available for the source media\n options: [4320, 2880, 2160, 1440, 1080, 720, 576, 480, 360, 240],\n forced: false,\n onChange: null,\n },\n\n // Set loops\n loop: {\n active: false,\n // start: null,\n // end: null,\n },\n\n // Speed default and options to display\n speed: {\n selected: 1,\n // The options to display in the UI, if available for the source media (e.g. Vimeo and YouTube only support 0.5x-4x)\n options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 4],\n },\n\n // Keyboard shortcut settings\n keyboard: {\n focused: true,\n global: false,\n },\n\n // Display tooltips\n tooltips: {\n controls: false,\n seek: true,\n },\n\n // Captions settings\n captions: {\n active: false,\n language: 'auto',\n // Listen to new tracks added after Plyr is initialized.\n // This is needed for streaming captions, but may result in unselectable options\n update: false,\n },\n\n // Fullscreen settings\n fullscreen: {\n enabled: true, // Allow fullscreen?\n fallback: true, // Fallback using full viewport/window\n iosNative: false, // Use the native fullscreen in iOS (disables custom controls)\n // Selector for the fullscreen container so contextual / non-player content can remain visible in fullscreen mode\n // Non-ancestors of the player element will be ignored\n // container: null, // defaults to the player element\n },\n\n // Local storage\n storage: {\n enabled: true,\n key: 'plyr',\n },\n\n // Default controls\n controls: [\n 'play-large',\n // 'restart',\n // 'rewind',\n 'play',\n // 'fast-forward',\n 'progress',\n 'current-time',\n // 'duration',\n 'mute',\n 'volume',\n 'captions',\n 'settings',\n 'pip',\n 'airplay',\n // 'download',\n 'fullscreen',\n ],\n settings: ['captions', 'quality', 'speed'],\n\n // Localisation\n i18n: {\n restart: 'Restart',\n rewind: 'Rewind {seektime}s',\n play: 'Play',\n pause: 'Pause',\n fastForward: 'Forward {seektime}s',\n seek: 'Seek',\n seekLabel: '{currentTime} of {duration}',\n played: 'Played',\n buffered: 'Buffered',\n currentTime: 'Current time',\n duration: 'Duration',\n volume: 'Volume',\n mute: 'Mute',\n unmute: 'Unmute',\n enableCaptions: 'Enable captions',\n disableCaptions: 'Disable captions',\n download: 'Download',\n enterFullscreen: 'Enter fullscreen',\n exitFullscreen: 'Exit fullscreen',\n frameTitle: 'Player for {title}',\n captions: 'Captions',\n settings: 'Settings',\n pip: 'PIP',\n menuBack: 'Go back to previous menu',\n speed: 'Speed',\n normal: 'Normal',\n quality: 'Quality',\n loop: 'Loop',\n start: 'Start',\n end: 'End',\n all: 'All',\n reset: 'Reset',\n disabled: 'Disabled',\n enabled: 'Enabled',\n advertisement: 'Ad',\n qualityBadge: {\n 2160: '4K',\n 1440: 'HD',\n 1080: 'HD',\n 720: 'HD',\n 576: 'SD',\n 480: 'SD',\n },\n },\n\n // URLs\n urls: {\n download: null,\n vimeo: {\n sdk: 'https://player.vimeo.com/api/player.js',\n iframe: 'https://player.vimeo.com/video/{0}?{1}',\n api: 'https://vimeo.com/api/oembed.json?url={0}',\n },\n youtube: {\n sdk: 'https://www.youtube.com/iframe_api',\n api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}',\n },\n googleIMA: {\n sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js',\n },\n },\n\n // Custom control listeners\n listeners: {\n seek: null,\n play: null,\n pause: null,\n restart: null,\n rewind: null,\n fastForward: null,\n mute: null,\n volume: null,\n captions: null,\n download: null,\n fullscreen: null,\n pip: null,\n airplay: null,\n speed: null,\n quality: null,\n loop: null,\n language: null,\n },\n\n // Events to watch and bubble\n events: [\n // Events to watch on HTML5 media elements and bubble\n // https://developer.mozilla.org/en/docs/Web/Guide/Events/Media_events\n 'ended',\n 'progress',\n 'stalled',\n 'playing',\n 'waiting',\n 'canplay',\n 'canplaythrough',\n 'loadstart',\n 'loadeddata',\n 'loadedmetadata',\n 'timeupdate',\n 'volumechange',\n 'play',\n 'pause',\n 'error',\n 'seeking',\n 'seeked',\n 'emptied',\n 'ratechange',\n 'cuechange',\n\n // Custom events\n 'download',\n 'enterfullscreen',\n 'exitfullscreen',\n 'captionsenabled',\n 'captionsdisabled',\n 'languagechange',\n 'controlshidden',\n 'controlsshown',\n 'ready',\n\n // YouTube\n 'statechange',\n\n // Quality\n 'qualitychange',\n\n // Ads\n 'adsloaded',\n 'adscontentpause',\n 'adscontentresume',\n 'adstarted',\n 'adsmidpoint',\n 'adscomplete',\n 'adsallcomplete',\n 'adsimpression',\n 'adsclick',\n ],\n\n // Selectors\n // Change these to match your template if using custom HTML\n selectors: {\n editable: 'input, textarea, select, [contenteditable]',\n container: '.plyr',\n controls: {\n container: null,\n wrapper: '.plyr__controls',\n },\n labels: '[data-plyr]',\n buttons: {\n play: '[data-plyr=\"play\"]',\n pause: '[data-plyr=\"pause\"]',\n restart: '[data-plyr=\"restart\"]',\n rewind: '[data-plyr=\"rewind\"]',\n fastForward: '[data-plyr=\"fast-forward\"]',\n mute: '[data-plyr=\"mute\"]',\n captions: '[data-plyr=\"captions\"]',\n download: '[data-plyr=\"download\"]',\n fullscreen: '[data-plyr=\"fullscreen\"]',\n pip: '[data-plyr=\"pip\"]',\n airplay: '[data-plyr=\"airplay\"]',\n settings: '[data-plyr=\"settings\"]',\n loop: '[data-plyr=\"loop\"]',\n },\n inputs: {\n seek: '[data-plyr=\"seek\"]',\n volume: '[data-plyr=\"volume\"]',\n speed: '[data-plyr=\"speed\"]',\n language: '[data-plyr=\"language\"]',\n quality: '[data-plyr=\"quality\"]',\n },\n display: {\n currentTime: '.plyr__time--current',\n duration: '.plyr__time--duration',\n buffer: '.plyr__progress__buffer',\n loop: '.plyr__progress__loop', // Used later\n volume: '.plyr__volume--display',\n },\n progress: '.plyr__progress',\n captions: '.plyr__captions',\n caption: '.plyr__caption',\n },\n\n // Class hooks added to the player in different states\n classNames: {\n type: 'plyr--{0}',\n provider: 'plyr--{0}',\n video: 'plyr__video-wrapper',\n embed: 'plyr__video-embed',\n videoFixedRatio: 'plyr__video-wrapper--fixed-ratio',\n embedContainer: 'plyr__video-embed__container',\n poster: 'plyr__poster',\n posterEnabled: 'plyr__poster-enabled',\n ads: 'plyr__ads',\n control: 'plyr__control',\n controlPressed: 'plyr__control--pressed',\n playing: 'plyr--playing',\n paused: 'plyr--paused',\n stopped: 'plyr--stopped',\n loading: 'plyr--loading',\n hover: 'plyr--hover',\n tooltip: 'plyr__tooltip',\n cues: 'plyr__cues',\n marker: 'plyr__progress__marker',\n hidden: 'plyr__sr-only',\n hideControls: 'plyr--hide-controls',\n isTouch: 'plyr--is-touch',\n uiSupported: 'plyr--full-ui',\n noTransition: 'plyr--no-transition',\n display: {\n time: 'plyr__time',\n },\n menu: {\n value: 'plyr__menu__value',\n badge: 'plyr__badge',\n open: 'plyr--menu-open',\n },\n captions: {\n enabled: 'plyr--captions-enabled',\n active: 'plyr--captions-active',\n },\n fullscreen: {\n enabled: 'plyr--fullscreen-enabled',\n fallback: 'plyr--fullscreen-fallback',\n },\n pip: {\n supported: 'plyr--pip-supported',\n active: 'plyr--pip-active',\n },\n airplay: {\n supported: 'plyr--airplay-supported',\n active: 'plyr--airplay-active',\n },\n previewThumbnails: {\n // Tooltip thumbs\n thumbContainer: 'plyr__preview-thumb',\n thumbContainerShown: 'plyr__preview-thumb--is-shown',\n imageContainer: 'plyr__preview-thumb__image-container',\n timeContainer: 'plyr__preview-thumb__time-container',\n // Scrubbing\n scrubbingContainer: 'plyr__preview-scrubbing',\n scrubbingContainerShown: 'plyr__preview-scrubbing--is-shown',\n },\n },\n\n // Embed attributes\n attributes: {\n embed: {\n provider: 'data-plyr-provider',\n id: 'data-plyr-embed-id',\n hash: 'data-plyr-embed-hash',\n },\n },\n\n // Advertisements plugin\n // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio\n ads: {\n enabled: false,\n publisherId: '',\n tagUrl: '',\n },\n\n // Preview Thumbnails plugin\n previewThumbnails: {\n enabled: false,\n src: '',\n },\n\n // Vimeo plugin\n vimeo: {\n byline: false,\n portrait: false,\n title: false,\n speed: true,\n transparent: false,\n // Custom settings from Plyr\n customControls: true,\n referrerPolicy: null, // https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameElement/referrerPolicy\n // Whether the owner of the video has a Pro or Business account\n // (which allows us to properly hide controls without CSS hacks, etc)\n premium: false,\n },\n\n // YouTube plugin\n youtube: {\n rel: 0, // No related vids\n showinfo: 0, // Hide info\n iv_load_policy: 3, // Hide annotations\n modestbranding: 1, // Hide logos as much as possible (they still show one in the corner when paused)\n // Custom settings from Plyr\n customControls: true,\n noCookie: false, // Whether to use an alternative version of YouTube without cookies\n },\n\n // Media Metadata\n mediaMetadata: {\n title: '',\n artist: '',\n album: '',\n artwork: [],\n },\n\n // Markers\n markers: {\n enabled: false,\n points: [],\n },\n};\n\nexport default defaults;\n","// ==========================================================================\n// Plyr states\n// ==========================================================================\n\nexport const pip = {\n active: 'picture-in-picture',\n inactive: 'inline',\n};\n\nexport default { pip };\n","// ==========================================================================\n// Plyr supported types and providers\n// ==========================================================================\n\nexport const providers = {\n html5: 'html5',\n youtube: 'youtube',\n vimeo: 'vimeo',\n};\n\nexport const types = {\n audio: 'audio',\n video: 'video',\n};\n\n/**\n * Get provider by URL\n * @param {String} url\n */\nexport function getProviderByUrl(url) {\n // YouTube\n if (/^(https?:\\/\\/)?(www\\.)?(youtube\\.com|youtube-nocookie\\.com|youtu\\.?be)\\/.+$/.test(url)) {\n return providers.youtube;\n }\n\n // Vimeo\n if (/^https?:\\/\\/player.vimeo.com\\/video\\/\\d{0,9}(?=\\b|\\/)/.test(url)) {\n return providers.vimeo;\n }\n\n return null;\n}\n\nexport default { providers, types };\n","// ==========================================================================\n// Console wrapper\n// ==========================================================================\n\nconst noop = () => {};\n\nexport default class Console {\n constructor(enabled = false) {\n this.enabled = window.console && enabled;\n\n if (this.enabled) {\n this.log('Debugging enabled');\n }\n }\n\n get log() {\n // eslint-disable-next-line no-console\n return this.enabled ? Function.prototype.bind.call(console.log, console) : noop;\n }\n\n get warn() {\n // eslint-disable-next-line no-console\n return this.enabled ? Function.prototype.bind.call(console.warn, console) : noop;\n }\n\n get error() {\n // eslint-disable-next-line no-console\n return this.enabled ? Function.prototype.bind.call(console.error, console) : noop;\n }\n}\n","// ==========================================================================\n// Fullscreen wrapper\n// https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API#prefixing\n// https://webkit.org/blog/7929/designing-websites-for-iphone-x/\n// ==========================================================================\n\nimport browser from './utils/browser';\nimport { closest, getElements, hasClass, toggleClass } from './utils/elements';\nimport { on, triggerEvent } from './utils/events';\nimport is from './utils/is';\nimport { silencePromise } from './utils/promise';\n\nclass Fullscreen {\n constructor(player) {\n // Keep reference to parent\n this.player = player;\n\n // Get prefix\n this.prefix = Fullscreen.prefix;\n this.property = Fullscreen.property;\n\n // Scroll position\n this.scrollPosition = { x: 0, y: 0 };\n\n // Force the use of 'full window/browser' rather than fullscreen\n this.forceFallback = player.config.fullscreen.fallback === 'force';\n\n // Get the fullscreen element\n // Checks container is an ancestor, defaults to null\n this.player.elements.fullscreen =\n player.config.fullscreen.container && closest(this.player.elements.container, player.config.fullscreen.container);\n\n // Register event listeners\n // Handle event (incase user presses escape etc)\n on.call(\n this.player,\n document,\n this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`,\n () => {\n // TODO: Filter for target??\n this.onChange();\n },\n );\n\n // Fullscreen toggle on double click\n on.call(this.player, this.player.elements.container, 'dblclick', (event) => {\n // Ignore double click in controls\n if (is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) {\n return;\n }\n\n this.player.listeners.proxy(event, this.toggle, 'fullscreen');\n });\n\n // Tap focus when in fullscreen\n on.call(this, this.player.elements.container, 'keydown', (event) => this.trapFocus(event));\n\n // Update the UI\n this.update();\n }\n\n // Determine if native supported\n static get nativeSupported() {\n return !!(\n document.fullscreenEnabled ||\n document.webkitFullscreenEnabled ||\n document.mozFullScreenEnabled ||\n document.msFullscreenEnabled\n );\n }\n\n // If we're actually using native\n get useNative() {\n return Fullscreen.nativeSupported && !this.forceFallback;\n }\n\n // Get the prefix for handlers\n static get prefix() {\n // No prefix\n if (is.function(document.exitFullscreen)) return '';\n\n // Check for fullscreen support by vendor prefix\n let value = '';\n const prefixes = ['webkit', 'moz', 'ms'];\n\n prefixes.some((pre) => {\n if (is.function(document[`${pre}ExitFullscreen`]) || is.function(document[`${pre}CancelFullScreen`])) {\n value = pre;\n return true;\n }\n\n return false;\n });\n\n return value;\n }\n\n static get property() {\n return this.prefix === 'moz' ? 'FullScreen' : 'Fullscreen';\n }\n\n // Determine if fullscreen is supported\n get supported() {\n return [\n // Fullscreen is enabled in config\n this.player.config.fullscreen.enabled,\n // Must be a video\n this.player.isVideo,\n // Either native is supported or fallback enabled\n Fullscreen.nativeSupported || this.player.config.fullscreen.fallback,\n // YouTube has no way to trigger fullscreen, so on devices with no native support, playsinline\n // must be enabled and iosNative fullscreen must be disabled to offer the fullscreen fallback\n !this.player.isYouTube ||\n Fullscreen.nativeSupported ||\n !browser.isIos ||\n (this.player.config.playsinline && !this.player.config.fullscreen.iosNative),\n ].every(Boolean);\n }\n\n // Get active state\n get active() {\n if (!this.supported) return false;\n\n // Fallback using classname\n if (!Fullscreen.nativeSupported || this.forceFallback) {\n return hasClass(this.target, this.player.config.classNames.fullscreen.fallback);\n }\n\n const element = !this.prefix\n ? this.target.getRootNode().fullscreenElement\n : this.target.getRootNode()[`${this.prefix}${this.property}Element`];\n\n return element && element.shadowRoot ? element === this.target.getRootNode().host : element === this.target;\n }\n\n // Get target element\n get target() {\n return browser.isIos && this.player.config.fullscreen.iosNative\n ? this.player.media\n : this.player.elements.fullscreen ?? this.player.elements.container;\n }\n\n onChange = () => {\n if (!this.supported) return;\n\n // Update toggle button\n const button = this.player.elements.buttons.fullscreen;\n if (is.element(button)) {\n button.pressed = this.active;\n }\n\n // Always trigger events on the plyr / media element (not a fullscreen container) and let them bubble up\n const target = this.target === this.player.media ? this.target : this.player.elements.container;\n // Trigger an event\n triggerEvent.call(this.player, target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);\n };\n\n toggleFallback = (toggle = false) => {\n // Store or restore scroll position\n if (toggle) {\n this.scrollPosition = {\n x: window.scrollX ?? 0,\n y: window.scrollY ?? 0,\n };\n } else {\n window.scrollTo(this.scrollPosition.x, this.scrollPosition.y);\n }\n\n // Toggle scroll\n document.body.style.overflow = toggle ? 'hidden' : '';\n\n // Toggle class hook\n toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle);\n\n // Force full viewport on iPhone X+\n if (browser.isIos) {\n let viewport = document.head.querySelector('meta[name=\"viewport\"]');\n const property = 'viewport-fit=cover';\n\n // Inject the viewport meta if required\n if (!viewport) {\n viewport = document.createElement('meta');\n viewport.setAttribute('name', 'viewport');\n }\n\n // Check if the property already exists\n const hasProperty = is.string(viewport.content) && viewport.content.includes(property);\n\n if (toggle) {\n this.cleanupViewport = !hasProperty;\n if (!hasProperty) viewport.content += `,${property}`;\n } else if (this.cleanupViewport) {\n viewport.content = viewport.content\n .split(',')\n .filter((part) => part.trim() !== property)\n .join(',');\n }\n }\n\n // Toggle button and fire events\n this.onChange();\n };\n\n // Trap focus inside container\n trapFocus = (event) => {\n // Bail if iOS/iPadOS, not active, not the tab key\n if (browser.isIos || browser.isIPadOS || !this.active || event.key !== 'Tab') return;\n\n // Get the current focused element\n const focused = document.activeElement;\n const focusable = getElements.call(this.player, 'a[href], button:not(:disabled), input:not(:disabled), [tabindex]');\n const [first] = focusable;\n const last = focusable[focusable.length - 1];\n\n if (focused === last && !event.shiftKey) {\n // Move focus to first element that can be tabbed if Shift isn't used\n first.focus();\n event.preventDefault();\n } else if (focused === first && event.shiftKey) {\n // Move focus to last element that can be tabbed if Shift is used\n last.focus();\n event.preventDefault();\n }\n };\n\n // Update UI\n update = () => {\n if (this.supported) {\n let mode;\n\n if (this.forceFallback) mode = 'Fallback (forced)';\n else if (Fullscreen.nativeSupported) mode = 'Native';\n else mode = 'Fallback';\n\n this.player.debug.log(`${mode} fullscreen enabled`);\n } else {\n this.player.debug.log('Fullscreen not supported and fallback disabled');\n }\n\n // Add styling hook to show button\n toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.supported);\n };\n\n // Make an element fullscreen\n enter = () => {\n if (!this.supported) return;\n\n // iOS native fullscreen doesn't need the request step\n if (browser.isIos && this.player.config.fullscreen.iosNative) {\n if (this.player.isVimeo) {\n this.player.embed.requestFullscreen();\n } else {\n this.target.webkitEnterFullscreen();\n }\n } else if (!Fullscreen.nativeSupported || this.forceFallback) {\n this.toggleFallback(true);\n } else if (!this.prefix) {\n this.target.requestFullscreen({ navigationUI: 'hide' });\n } else if (!is.empty(this.prefix)) {\n this.target[`${this.prefix}Request${this.property}`]();\n }\n };\n\n // Bail from fullscreen\n exit = () => {\n if (!this.supported) return;\n\n // iOS native fullscreen\n if (browser.isIos && this.player.config.fullscreen.iosNative) {\n if (this.player.isVimeo) {\n this.player.embed.exitFullscreen();\n } else {\n this.target.webkitEnterFullscreen();\n }\n silencePromise(this.player.play());\n } else if (!Fullscreen.nativeSupported || this.forceFallback) {\n this.toggleFallback(false);\n } else if (!this.prefix) {\n (document.cancelFullScreen || document.exitFullscreen).call(document);\n } else if (!is.empty(this.prefix)) {\n const action = this.prefix === 'moz' ? 'Cancel' : 'Exit';\n document[`${this.prefix}${action}${this.property}`]();\n }\n };\n\n // Toggle state\n toggle = () => {\n if (!this.active) this.enter();\n else this.exit();\n };\n}\n\nexport default Fullscreen;\n","// ==========================================================================\n// Load image avoiding xhr/fetch CORS issues\n// Server status can't be obtained this way unfortunately, so this uses \"naturalWidth\" to determine if the image has loaded\n// By default it checks if it is at least 1px, but you can add a second argument to change this\n// ==========================================================================\n\nexport default function loadImage(src, minWidth = 1) {\n return new Promise((resolve, reject) => {\n const image = new Image();\n\n const handler = () => {\n delete image.onload;\n delete image.onerror;\n (image.naturalWidth >= minWidth ? resolve : reject)(image);\n };\n\n Object.assign(image, { onload: handler, onerror: handler, src });\n });\n}\n","// ==========================================================================\n// Plyr UI\n// ==========================================================================\n\nimport captions from './captions';\nimport controls from './controls';\nimport support from './support';\nimport { getElement, toggleClass } from './utils/elements';\nimport { ready, triggerEvent } from './utils/events';\nimport i18n from './utils/i18n';\nimport is from './utils/is';\nimport loadImage from './utils/load-image';\n\nconst ui = {\n addStyleHook() {\n toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);\n toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);\n },\n\n // Toggle native HTML5 media controls\n toggleNativeControls(toggle = false) {\n if (toggle && this.isHTML5) {\n this.media.setAttribute('controls', '');\n } else {\n this.media.removeAttribute('controls');\n }\n },\n\n // Setup the UI\n build() {\n // Re-attach media element listeners\n // TODO: Use event bubbling?\n this.listeners.media();\n\n // Don't setup interface if no support\n if (!this.supported.ui) {\n this.debug.warn(`Basic support only for ${this.provider} ${this.type}`);\n\n // Restore native controls\n ui.toggleNativeControls.call(this, true);\n\n // Bail\n return;\n }\n\n // Inject custom controls if not present\n if (!is.element(this.elements.controls)) {\n // Inject custom controls\n controls.inject.call(this);\n\n // Re-attach control listeners\n this.listeners.controls();\n }\n\n // Remove native controls\n ui.toggleNativeControls.call(this);\n\n // Setup captions for HTML5\n if (this.isHTML5) {\n captions.setup.call(this);\n }\n\n // Reset volume\n this.volume = null;\n\n // Reset mute state\n this.muted = null;\n\n // Reset loop state\n this.loop = null;\n\n // Reset quality setting\n this.quality = null;\n\n // Reset speed\n this.speed = null;\n\n // Reset volume display\n controls.updateVolume.call(this);\n\n // Reset time display\n controls.timeUpdate.call(this);\n\n // Reset duration display\n controls.durationUpdate.call(this);\n\n // Update the UI\n ui.checkPlaying.call(this);\n\n // Check for picture-in-picture support\n toggleClass(\n this.elements.container,\n this.config.classNames.pip.supported,\n support.pip && this.isHTML5 && this.isVideo,\n );\n\n // Check for airplay support\n toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);\n\n // Add touch class\n toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);\n\n // Ready for API calls\n this.ready = true;\n\n // Ready event at end of execution stack\n setTimeout(() => {\n triggerEvent.call(this, this.media, 'ready');\n }, 0);\n\n // Set the title\n ui.setTitle.call(this);\n\n // Assure the poster image is set, if the property was added before the element was created\n if (this.poster) {\n ui.setPoster.call(this, this.poster, false).catch(() => {});\n }\n\n // Manually set the duration if user has overridden it.\n // The event listeners for it doesn't get called if preload is disabled (#701)\n if (this.config.duration) {\n controls.durationUpdate.call(this);\n }\n\n // Media metadata\n if (this.config.mediaMetadata) {\n controls.setMediaMetadata.call(this);\n }\n },\n\n // Setup aria attribute for play and iframe title\n setTitle() {\n // Find the current text\n let label = i18n.get('play', this.config);\n\n // If there's a media title set, use that for the label\n if (is.string(this.config.title) && !is.empty(this.config.title)) {\n label += `, ${this.config.title}`;\n }\n\n // If there's a play button, set label\n Array.from(this.elements.buttons.play || []).forEach((button) => {\n button.setAttribute('aria-label', label);\n });\n\n // Set iframe title\n // https://github.com/sampotts/plyr/issues/124\n if (this.isEmbed) {\n const iframe = getElement.call(this, 'iframe');\n\n if (!is.element(iframe)) {\n return;\n }\n\n // Default to media type\n const title = !is.empty(this.config.title) ? this.config.title : 'video';\n const format = i18n.get('frameTitle', this.config);\n\n iframe.setAttribute('title', format.replace('{title}', title));\n }\n },\n\n // Toggle poster\n togglePoster(enable) {\n toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);\n },\n\n // Set the poster image (async)\n // Used internally for the poster setter, with the passive option forced to false\n setPoster(poster, passive = true) {\n // Don't override if call is passive\n if (passive && this.poster) {\n return Promise.reject(new Error('Poster already set'));\n }\n\n // Set property synchronously to respect the call order\n this.media.setAttribute('data-poster', poster);\n\n // Show the poster\n this.elements.poster.removeAttribute('hidden');\n\n // Wait until ui is ready\n return (\n ready\n .call(this)\n // Load image\n .then(() => loadImage(poster))\n .catch((error) => {\n // Hide poster on error unless it's been set by another call\n if (poster === this.poster) {\n ui.togglePoster.call(this, false);\n }\n // Rethrow\n throw error;\n })\n .then(() => {\n // Prevent race conditions\n if (poster !== this.poster) {\n throw new Error('setPoster cancelled by later call to setPoster');\n }\n })\n .then(() => {\n Object.assign(this.elements.poster.style, {\n backgroundImage: `url('${poster}')`,\n // Reset backgroundSize as well (since it can be set to \"cover\" for padded thumbnails for youtube)\n backgroundSize: '',\n });\n\n ui.togglePoster.call(this, true);\n\n return poster;\n })\n );\n },\n\n // Check playing state\n checkPlaying(event) {\n // Class hooks\n toggleClass(this.elements.container, this.config.classNames.playing, this.playing);\n toggleClass(this.elements.container, this.config.classNames.paused, this.paused);\n toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);\n\n // Set state\n Array.from(this.elements.buttons.play || []).forEach((target) => {\n Object.assign(target, { pressed: this.playing });\n target.setAttribute('aria-label', i18n.get(this.playing ? 'pause' : 'play', this.config));\n });\n\n // Only update controls on non timeupdate events\n if (is.event(event) && event.type === 'timeupdate') {\n return;\n }\n\n // Toggle controls\n ui.toggleControls.call(this);\n },\n\n // Check if media is loading\n checkLoading(event) {\n this.loading = ['stalled', 'waiting'].includes(event.type);\n\n // Clear timer\n clearTimeout(this.timers.loading);\n\n // Timer to prevent flicker when seeking\n this.timers.loading = setTimeout(\n () => {\n // Update progress bar loading class state\n toggleClass(this.elements.container, this.config.classNames.loading, this.loading);\n\n // Update controls visibility\n ui.toggleControls.call(this);\n },\n this.loading ? 250 : 0,\n );\n },\n\n // Toggle controls based on state and `force` argument\n toggleControls(force) {\n const { controls: controlsElement } = this.elements;\n\n if (controlsElement && this.config.hideControls) {\n // Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.)\n const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now();\n\n // Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide\n this.toggleControls(\n Boolean(\n force || this.loading || this.paused || controlsElement.pressed || controlsElement.hover || recentTouchSeek,\n ),\n );\n }\n },\n\n // Migrate any custom properties from the media to the parent\n migrateStyles() {\n // Loop through values (as they are the keys when the object is spread 🤔)\n Object.values({ ...this.media.style })\n // We're only fussed about Plyr specific properties\n .filter((key) => !is.empty(key) && is.string(key) && key.startsWith('--plyr'))\n .forEach((key) => {\n // Set on the container\n this.elements.container.style.setProperty(key, this.media.style.getPropertyValue(key));\n\n // Clean up from media element\n this.media.style.removeProperty(key);\n });\n\n // Remove attribute if empty\n if (is.empty(this.media.style)) {\n this.media.removeAttribute('style');\n }\n },\n};\n\nexport default ui;\n","// ==========================================================================\n// Plyr Event Listeners\n// ==========================================================================\n\nimport controls from './controls';\nimport ui from './ui';\nimport { repaint } from './utils/animation';\nimport browser from './utils/browser';\nimport { getElement, getElements, matches, toggleClass } from './utils/elements';\nimport { off, on, once, toggleListener, triggerEvent } from './utils/events';\nimport is from './utils/is';\nimport { silencePromise } from './utils/promise';\nimport { getAspectRatio, getViewportSize, supportsCSS } from './utils/style';\n\nclass Listeners {\n constructor(player) {\n this.player = player;\n this.lastKey = null;\n this.focusTimer = null;\n this.lastKeyDown = null;\n\n this.handleKey = this.handleKey.bind(this);\n this.toggleMenu = this.toggleMenu.bind(this);\n this.firstTouch = this.firstTouch.bind(this);\n }\n\n // Handle key presses\n handleKey(event) {\n const { player } = this;\n const { elements } = player;\n const { key, type, altKey, ctrlKey, metaKey, shiftKey } = event;\n const pressed = type === 'keydown';\n const repeat = pressed && key === this.lastKey;\n\n // Bail if a modifier key is set\n if (altKey || ctrlKey || metaKey || shiftKey) {\n return;\n }\n\n // If the event is bubbled from the media element\n // Firefox doesn't get the key for whatever reason\n if (!key) {\n return;\n }\n\n // Seek by increment\n const seekByIncrement = (increment) => {\n // Divide the max duration into 10th's and times by the number value\n player.currentTime = (player.duration / 10) * increment;\n };\n\n // Handle the key on keydown\n // Reset on keyup\n if (pressed) {\n // Check focused element\n // and if the focused element is not editable (e.g. text input)\n // and any that accept key input http://webaim.org/techniques/keyboard/\n const focused = document.activeElement;\n if (is.element(focused)) {\n const { editable } = player.config.selectors;\n const { seek } = elements.inputs;\n\n if (focused !== seek && matches(focused, editable)) {\n return;\n }\n\n if (event.key === ' ' && matches(focused, 'button, [role^=\"menuitem\"]')) {\n return;\n }\n }\n\n // Which keys should we prevent default\n const preventDefault = [\n ' ',\n 'ArrowLeft',\n 'ArrowUp',\n 'ArrowRight',\n 'ArrowDown',\n\n '0',\n '1',\n '2',\n '3',\n '4',\n '5',\n '6',\n '7',\n '8',\n '9',\n\n 'c',\n 'f',\n 'k',\n 'l',\n 'm',\n ];\n\n // If the key is found prevent default (e.g. prevent scrolling for arrows)\n if (preventDefault.includes(key)) {\n event.preventDefault();\n event.stopPropagation();\n }\n\n switch (key) {\n case '0':\n case '1':\n case '2':\n case '3':\n case '4':\n case '5':\n case '6':\n case '7':\n case '8':\n case '9':\n if (!repeat) {\n seekByIncrement(parseInt(key, 10));\n }\n break;\n\n case ' ':\n case 'k':\n if (!repeat) {\n silencePromise(player.togglePlay());\n }\n break;\n\n case 'ArrowUp':\n player.increaseVolume(0.1);\n break;\n\n case 'ArrowDown':\n player.decreaseVolume(0.1);\n break;\n\n case 'm':\n if (!repeat) {\n player.muted = !player.muted;\n }\n break;\n\n case 'ArrowRight':\n player.forward();\n break;\n\n case 'ArrowLeft':\n player.rewind();\n break;\n\n case 'f':\n player.fullscreen.toggle();\n break;\n\n case 'c':\n if (!repeat) {\n player.toggleCaptions();\n }\n break;\n\n case 'l':\n player.loop = !player.loop;\n break;\n\n default:\n break;\n }\n\n // Escape is handle natively when in full screen\n // So we only need to worry about non native\n if (key === 'Escape' && !player.fullscreen.usingNative && player.fullscreen.active) {\n player.fullscreen.toggle();\n }\n\n // Store last key for next cycle\n this.lastKey = key;\n } else {\n this.lastKey = null;\n }\n }\n\n // Toggle menu\n toggleMenu(event) {\n controls.toggleMenu.call(this.player, event);\n }\n\n // Device is touch enabled\n firstTouch = () => {\n const { player } = this;\n const { elements } = player;\n\n player.touch = true;\n\n // Add touch class\n toggleClass(elements.container, player.config.classNames.isTouch, true);\n };\n\n // Global window & document listeners\n global = (toggle = true) => {\n const { player } = this;\n\n // Keyboard shortcuts\n if (player.config.keyboard.global) {\n toggleListener.call(player, window, 'keydown keyup', this.handleKey, toggle, false);\n }\n\n // Click anywhere closes menu\n toggleListener.call(player, document.body, 'click', this.toggleMenu, toggle);\n\n // Detect touch by events\n once.call(player, document.body, 'touchstart', this.firstTouch);\n };\n\n // Container listeners\n container = () => {\n const { player } = this;\n const { config, elements, timers } = player;\n\n // Keyboard shortcuts\n if (!config.keyboard.global && config.keyboard.focused) {\n on.call(player, elements.container, 'keydown keyup', this.handleKey, false);\n }\n\n // Toggle controls on mouse events and entering fullscreen\n on.call(\n player,\n elements.container,\n 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen',\n (event) => {\n const { controls: controlsElement } = elements;\n\n // Remove button states for fullscreen\n if (controlsElement && event.type === 'enterfullscreen') {\n controlsElement.pressed = false;\n controlsElement.hover = false;\n }\n\n // Show, then hide after a timeout unless another control event occurs\n const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);\n let delay = 0;\n\n if (show) {\n ui.toggleControls.call(player, true);\n // Use longer timeout for touch devices\n delay = player.touch ? 3000 : 2000;\n }\n\n // Clear timer\n clearTimeout(timers.controls);\n\n // Set new timer to prevent flicker when seeking\n timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);\n },\n );\n\n // Set a gutter for Vimeo\n const setGutter = () => {\n if (!player.isVimeo || player.config.vimeo.premium) {\n return;\n }\n\n const target = elements.wrapper;\n const { active } = player.fullscreen;\n const [videoWidth, videoHeight] = getAspectRatio.call(player);\n const useNativeAspectRatio = supportsCSS(`aspect-ratio: ${videoWidth} / ${videoHeight}`);\n\n // If not active, remove styles\n if (!active) {\n if (useNativeAspectRatio) {\n target.style.width = null;\n target.style.height = null;\n } else {\n target.style.maxWidth = null;\n target.style.margin = null;\n }\n return;\n }\n\n // Determine which dimension will overflow and constrain view\n const [viewportWidth, viewportHeight] = getViewportSize();\n const overflow = viewportWidth / viewportHeight > videoWidth / videoHeight;\n\n if (useNativeAspectRatio) {\n target.style.width = overflow ? 'auto' : '100%';\n target.style.height = overflow ? '100%' : 'auto';\n } else {\n target.style.maxWidth = overflow ? `${(viewportHeight / videoHeight) * videoWidth}px` : null;\n target.style.margin = overflow ? '0 auto' : null;\n }\n };\n\n // Handle resizing\n const resized = () => {\n clearTimeout(timers.resized);\n timers.resized = setTimeout(setGutter, 50);\n };\n\n on.call(player, elements.container, 'enterfullscreen exitfullscreen', (event) => {\n const { target } = player.fullscreen;\n\n // Ignore events not from target\n if (target !== elements.container) {\n return;\n }\n\n // If it's not an embed and no ratio specified\n if (!player.isEmbed && is.empty(player.config.ratio)) {\n return;\n }\n\n // Set Vimeo gutter\n setGutter();\n\n // Watch for resizes\n const method = event.type === 'enterfullscreen' ? on : off;\n method.call(player, window, 'resize', resized);\n });\n };\n\n // Listen for media events\n media = () => {\n const { player } = this;\n const { elements } = player;\n\n // Time change on media\n on.call(player, player.media, 'timeupdate seeking seeked', (event) => controls.timeUpdate.call(player, event));\n\n // Display duration\n on.call(player, player.media, 'durationchange loadeddata loadedmetadata', (event) =>\n controls.durationUpdate.call(player, event),\n );\n\n // Handle the media finishing\n on.call(player, player.media, 'ended', () => {\n // Show poster on end\n if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) {\n // Restart\n player.restart();\n\n // Call pause otherwise IE11 will start playing the video again\n player.pause();\n }\n });\n\n // Check for buffer progress\n on.call(player, player.media, 'progress playing seeking seeked', (event) =>\n controls.updateProgress.call(player, event),\n );\n\n // Handle volume changes\n on.call(player, player.media, 'volumechange', (event) => controls.updateVolume.call(player, event));\n\n // Handle play/pause\n on.call(player, player.media, 'playing play pause ended emptied timeupdate', (event) =>\n ui.checkPlaying.call(player, event),\n );\n\n // Loading state\n on.call(player, player.media, 'waiting canplay seeked playing', (event) => ui.checkLoading.call(player, event));\n\n // Click video\n if (player.supported.ui && player.config.clickToPlay && !player.isAudio) {\n // Re-fetch the wrapper\n const wrapper = getElement.call(player, `.${player.config.classNames.video}`);\n\n // Bail if there's no wrapper (this should never happen)\n if (!is.element(wrapper)) {\n return;\n }\n\n // On click play, pause or restart\n on.call(player, elements.container, 'click', (event) => {\n const targets = [elements.container, wrapper];\n\n // Ignore if click if not container or in video wrapper\n if (!targets.includes(event.target) && !wrapper.contains(event.target)) {\n return;\n }\n\n // Touch devices will just show controls (if hidden)\n if (player.touch && player.config.hideControls) {\n return;\n }\n\n if (player.ended) {\n this.proxy(event, player.restart, 'restart');\n this.proxy(\n event,\n () => {\n silencePromise(player.play());\n },\n 'play',\n );\n } else {\n this.proxy(\n event,\n () => {\n silencePromise(player.togglePlay());\n },\n 'play',\n );\n }\n });\n }\n\n // Disable right click\n if (player.supported.ui && player.config.disableContextMenu) {\n on.call(\n player,\n elements.wrapper,\n 'contextmenu',\n (event) => {\n event.preventDefault();\n },\n false,\n );\n }\n\n // Volume change\n on.call(player, player.media, 'volumechange', () => {\n // Save to storage\n player.storage.set({\n volume: player.volume,\n muted: player.muted,\n });\n });\n\n // Speed change\n on.call(player, player.media, 'ratechange', () => {\n // Update UI\n controls.updateSetting.call(player, 'speed');\n\n // Save to storage\n player.storage.set({ speed: player.speed });\n });\n\n // Quality change\n on.call(player, player.media, 'qualitychange', (event) => {\n // Update UI\n controls.updateSetting.call(player, 'quality', null, event.detail.quality);\n });\n\n // Update download link when ready and if quality changes\n on.call(player, player.media, 'ready qualitychange', () => {\n controls.setDownloadUrl.call(player);\n });\n\n // Proxy events to container\n // Bubble up key events for Edge\n const proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' ');\n\n on.call(player, player.media, proxyEvents, (event) => {\n let { detail = {} } = event;\n\n // Get error details from media\n if (event.type === 'error') {\n detail = player.media.error;\n }\n\n triggerEvent.call(player, elements.container, event.type, true, detail);\n });\n };\n\n // Run default and custom handlers\n proxy = (event, defaultHandler, customHandlerKey) => {\n const { player } = this;\n const customHandler = player.config.listeners[customHandlerKey];\n const hasCustomHandler = is.function(customHandler);\n let returned = true;\n\n // Execute custom handler\n if (hasCustomHandler) {\n returned = customHandler.call(player, event);\n }\n\n // Only call default handler if not prevented in custom handler\n if (returned !== false && is.function(defaultHandler)) {\n defaultHandler.call(player, event);\n }\n };\n\n // Trigger custom and default handlers\n bind = (element, type, defaultHandler, customHandlerKey, passive = true) => {\n const { player } = this;\n const customHandler = player.config.listeners[customHandlerKey];\n const hasCustomHandler = is.function(customHandler);\n\n on.call(\n player,\n element,\n type,\n (event) => this.proxy(event, defaultHandler, customHandlerKey),\n passive && !hasCustomHandler,\n );\n };\n\n // Listen for control events\n controls = () => {\n const { player } = this;\n const { elements } = player;\n // IE doesn't support input event, so we fallback to change\n const inputEvent = browser.isIE ? 'change' : 'input';\n\n // Play/pause toggle\n if (elements.buttons.play) {\n Array.from(elements.buttons.play).forEach((button) => {\n this.bind(\n button,\n 'click',\n () => {\n silencePromise(player.togglePlay());\n },\n 'play',\n );\n });\n }\n\n // Pause\n this.bind(elements.buttons.restart, 'click', player.restart, 'restart');\n\n // Rewind\n this.bind(\n elements.buttons.rewind,\n 'click',\n () => {\n // Record seek time so we can prevent hiding controls for a few seconds after rewind\n player.lastSeekTime = Date.now();\n player.rewind();\n },\n 'rewind',\n );\n\n // Rewind\n this.bind(\n elements.buttons.fastForward,\n 'click',\n () => {\n // Record seek time so we can prevent hiding controls for a few seconds after fast forward\n player.lastSeekTime = Date.now();\n player.forward();\n },\n 'fastForward',\n );\n\n // Mute toggle\n this.bind(\n elements.buttons.mute,\n 'click',\n () => {\n player.muted = !player.muted;\n },\n 'mute',\n );\n\n // Captions toggle\n this.bind(elements.buttons.captions, 'click', () => player.toggleCaptions());\n\n // Download\n this.bind(\n elements.buttons.download,\n 'click',\n () => {\n triggerEvent.call(player, player.media, 'download');\n },\n 'download',\n );\n\n // Fullscreen toggle\n this.bind(\n elements.buttons.fullscreen,\n 'click',\n () => {\n player.fullscreen.toggle();\n },\n 'fullscreen',\n );\n\n // Picture-in-Picture\n this.bind(\n elements.buttons.pip,\n 'click',\n () => {\n player.pip = 'toggle';\n },\n 'pip',\n );\n\n // Airplay\n this.bind(elements.buttons.airplay, 'click', player.airplay, 'airplay');\n\n // Settings menu - click toggle\n this.bind(\n elements.buttons.settings,\n 'click',\n (event) => {\n // Prevent the document click listener closing the menu\n event.stopPropagation();\n event.preventDefault();\n\n controls.toggleMenu.call(player, event);\n },\n null,\n false,\n ); // Can't be passive as we're preventing default\n\n // Settings menu - keyboard toggle\n // We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus\n // https://bugzilla.mozilla.org/show_bug.cgi?id=1220143\n this.bind(\n elements.buttons.settings,\n 'keyup',\n (event) => {\n if (![' ', 'Enter'].includes(event.key)) {\n return;\n }\n\n // Because return triggers a click anyway, all we need to do is set focus\n if (event.key === 'Enter') {\n controls.focusFirstMenuItem.call(player, null, true);\n return;\n }\n\n // Prevent scroll\n event.preventDefault();\n\n // Prevent playing video (Firefox)\n event.stopPropagation();\n\n // Toggle menu\n controls.toggleMenu.call(player, event);\n },\n null,\n false, // Can't be passive as we're preventing default\n );\n\n // Escape closes menu\n this.bind(elements.settings.menu, 'keydown', (event) => {\n if (event.key === 'Escape') {\n controls.toggleMenu.call(player, event);\n }\n });\n\n // Set range input alternative \"value\", which matches the tooltip time (#954)\n this.bind(elements.inputs.seek, 'mousedown mousemove', (event) => {\n const rect = elements.progress.getBoundingClientRect();\n const percent = (100 / rect.width) * (event.pageX - rect.left);\n event.currentTarget.setAttribute('seek-value', percent);\n });\n\n // Pause while seeking\n this.bind(elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', (event) => {\n const seek = event.currentTarget;\n const attribute = 'play-on-seeked';\n\n if (is.keyboardEvent(event) && !['ArrowLeft', 'ArrowRight'].includes(event.key)) {\n return;\n }\n\n // Record seek time so we can prevent hiding controls for a few seconds after seek\n player.lastSeekTime = Date.now();\n\n // Was playing before?\n const play = seek.hasAttribute(attribute);\n // Done seeking\n const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);\n\n // If we're done seeking and it was playing, resume playback\n if (play && done) {\n seek.removeAttribute(attribute);\n silencePromise(player.play());\n } else if (!done && player.playing) {\n seek.setAttribute(attribute, '');\n player.pause();\n }\n });\n\n // Fix range inputs on iOS\n // Super weird iOS bug where after you interact with an <input type=\"range\">,\n // it takes over further interactions on the page. This is a hack\n if (browser.isIos) {\n const inputs = getElements.call(player, 'input[type=\"range\"]');\n Array.from(inputs).forEach((input) => this.bind(input, inputEvent, (event) => repaint(event.target)));\n }\n\n // Seek\n this.bind(\n elements.inputs.seek,\n inputEvent,\n (event) => {\n const seek = event.currentTarget;\n // If it exists, use seek-value instead of \"value\" for consistency with tooltip time (#954)\n let seekTo = seek.getAttribute('seek-value');\n\n if (is.empty(seekTo)) {\n seekTo = seek.value;\n }\n\n seek.removeAttribute('seek-value');\n\n player.currentTime = (seekTo / seek.max) * player.duration;\n },\n 'seek',\n );\n\n // Seek tooltip\n this.bind(elements.progress, 'mouseenter mouseleave mousemove', (event) =>\n controls.updateSeekTooltip.call(player, event),\n );\n\n // Preview thumbnails plugin\n // TODO: Really need to work on some sort of plug-in wide event bus or pub-sub for this\n this.bind(elements.progress, 'mousemove touchmove', (event) => {\n const { previewThumbnails } = player;\n\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.startMove(event);\n }\n });\n\n // Hide thumbnail preview - on mouse click, mouse leave, and video play/seek. All four are required, e.g., for buffering\n this.bind(elements.progress, 'mouseleave touchend click', () => {\n const { previewThumbnails } = player;\n\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.endMove(false, true);\n }\n });\n\n // Show scrubbing preview\n this.bind(elements.progress, 'mousedown touchstart', (event) => {\n const { previewThumbnails } = player;\n\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.startScrubbing(event);\n }\n });\n\n this.bind(elements.progress, 'mouseup touchend', (event) => {\n const { previewThumbnails } = player;\n\n if (previewThumbnails && previewThumbnails.loaded) {\n previewThumbnails.endScrubbing(event);\n }\n });\n\n // Polyfill for lower fill in <input type=\"range\"> for webkit\n if (browser.isWebKit) {\n Array.from(getElements.call(player, 'input[type=\"range\"]')).forEach((element) => {\n this.bind(element, 'input', (event) => controls.updateRangeFill.call(player, event.target));\n });\n }\n\n // Current time invert\n // Only if one time element is used for both currentTime and duration\n if (player.config.toggleInvert && !is.element(elements.display.duration)) {\n this.bind(elements.display.currentTime, 'click', () => {\n // Do nothing if we're at the start\n if (player.currentTime === 0) {\n return;\n }\n\n player.config.invertTime = !player.config.invertTime;\n\n controls.timeUpdate.call(player);\n });\n }\n\n // Volume\n this.bind(\n elements.inputs.volume,\n inputEvent,\n (event) => {\n player.volume = event.target.value;\n },\n 'volume',\n );\n\n // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)\n this.bind(elements.controls, 'mouseenter mouseleave', (event) => {\n elements.controls.hover = !player.touch && event.type === 'mouseenter';\n });\n\n // Also update controls.hover state for any non-player children of fullscreen element (as above)\n if (elements.fullscreen) {\n Array.from(elements.fullscreen.children)\n .filter((c) => !c.contains(elements.container))\n .forEach((child) => {\n this.bind(child, 'mouseenter mouseleave', (event) => {\n if (elements.controls) {\n elements.controls.hover = !player.touch && event.type === 'mouseenter';\n }\n });\n });\n }\n\n // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)\n this.bind(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', (event) => {\n elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);\n });\n\n // Show controls when they receive focus (e.g., when using keyboard tab key)\n this.bind(elements.controls, 'focusin', () => {\n const { config, timers } = player;\n\n // Skip transition to prevent focus from scrolling the parent element\n toggleClass(elements.controls, config.classNames.noTransition, true);\n\n // Toggle\n ui.toggleControls.call(player, true);\n\n // Restore transition\n setTimeout(() => {\n toggleClass(elements.controls, config.classNames.noTransition, false);\n }, 0);\n\n // Delay a little more for mouse users\n const delay = this.touch ? 3000 : 4000;\n\n // Clear timer\n clearTimeout(timers.controls);\n\n // Hide again after delay\n timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);\n });\n\n // Mouse wheel for volume\n this.bind(\n elements.inputs.volume,\n 'wheel',\n (event) => {\n // Detect \"natural\" scroll - supported on OS X Safari only\n // Other browsers on OS X will be inverted until support improves\n const inverted = event.webkitDirectionInvertedFromDevice;\n // Get delta from event. Invert if `inverted` is true\n const [x, y] = [event.deltaX, -event.deltaY].map((value) => (inverted ? -value : value));\n // Using the biggest delta, normalize to 1 or -1 (or 0 if no delta)\n const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y);\n\n // Change the volume by 2%\n player.increaseVolume(direction / 50);\n\n // Don't break page scrolling at max and min\n const { volume } = player.media;\n if ((direction === 1 && volume < 1) || (direction === -1 && volume > 0)) {\n event.preventDefault();\n }\n },\n 'volume',\n false,\n );\n };\n}\n\nexport default Listeners;\n","(function(root, factory) {\n if (typeof define === 'function' && define.amd) {\n define([], factory);\n } else if (typeof exports === 'object') {\n module.exports = factory();\n } else {\n root.loadjs = factory();\n }\n}(this, function() {\n/**\n * Global dependencies.\n * @global {Object} document - DOM\n */\n\nvar devnull = function() {},\n bundleIdCache = {},\n bundleResultCache = {},\n bundleCallbackQueue = {};\n\n\n/**\n * Subscribe to bundle load event.\n * @param {string[]} bundleIds - Bundle ids\n * @param {Function} callbackFn - The callback function\n */\nfunction subscribe(bundleIds, callbackFn) {\n // listify\n bundleIds = bundleIds.push ? bundleIds : [bundleIds];\n\n var depsNotFound = [],\n i = bundleIds.length,\n numWaiting = i,\n fn,\n bundleId,\n r,\n q;\n\n // define callback function\n fn = function (bundleId, pathsNotFound) {\n if (pathsNotFound.length) depsNotFound.push(bundleId);\n\n numWaiting--;\n if (!numWaiting) callbackFn(depsNotFound);\n };\n\n // register callback\n while (i--) {\n bundleId = bundleIds[i];\n\n // execute callback if in result cache\n r = bundleResultCache[bundleId];\n if (r) {\n fn(bundleId, r);\n continue;\n }\n\n // add to callback queue\n q = bundleCallbackQueue[bundleId] = bundleCallbackQueue[bundleId] || [];\n q.push(fn);\n }\n}\n\n\n/**\n * Publish bundle load event.\n * @param {string} bundleId - Bundle id\n * @param {string[]} pathsNotFound - List of files not found\n */\nfunction publish(bundleId, pathsNotFound) {\n // exit if id isn't defined\n if (!bundleId) return;\n\n var q = bundleCallbackQueue[bundleId];\n\n // cache result\n bundleResultCache[bundleId] = pathsNotFound;\n\n // exit if queue is empty\n if (!q) return;\n\n // empty callback queue\n while (q.length) {\n q[0](bundleId, pathsNotFound);\n q.splice(0, 1);\n }\n}\n\n\n/**\n * Execute callbacks.\n * @param {Object or Function} args - The callback args\n * @param {string[]} depsNotFound - List of dependencies not found\n */\nfunction executeCallbacks(args, depsNotFound) {\n // accept function as argument\n if (args.call) args = {success: args};\n\n // success and error callbacks\n if (depsNotFound.length) (args.error || devnull)(depsNotFound);\n else (args.success || devnull)(args);\n}\n\n\n/**\n * Load individual file.\n * @param {string} path - The file path\n * @param {Function} callbackFn - The callback function\n */\nfunction loadFile(path, callbackFn, args, numTries) {\n var doc = document,\n async = args.async,\n maxTries = (args.numRetries || 0) + 1,\n beforeCallbackFn = args.before || devnull,\n pathname = path.replace(/[\\?|#].*$/, ''),\n pathStripped = path.replace(/^(css|img)!/, ''),\n isLegacyIECss,\n e;\n\n numTries = numTries || 0;\n\n if (/(^css!|\\.css$)/.test(pathname)) {\n // css\n e = doc.createElement('link');\n e.rel = 'stylesheet';\n e.href = pathStripped;\n\n // tag IE9+\n isLegacyIECss = 'hideFocus' in e;\n\n // use preload in IE Edge (to detect load errors)\n if (isLegacyIECss && e.relList) {\n isLegacyIECss = 0;\n e.rel = 'preload';\n e.as = 'style';\n }\n } else if (/(^img!|\\.(png|gif|jpg|svg|webp)$)/.test(pathname)) {\n // image\n e = doc.createElement('img');\n e.src = pathStripped; \n } else {\n // javascript\n e = doc.createElement('script');\n e.src = path;\n e.async = async === undefined ? true : async;\n }\n\n e.onload = e.onerror = e.onbeforeload = function (ev) {\n var result = ev.type[0];\n\n // treat empty stylesheets as failures to get around lack of onerror\n // support in IE9-11\n if (isLegacyIECss) {\n try {\n if (!e.sheet.cssText.length) result = 'e';\n } catch (x) {\n // sheets objects created from load errors don't allow access to\n // `cssText` (unless error is Code:18 SecurityError)\n if (x.code != 18) result = 'e';\n }\n }\n\n // handle retries in case of load failure\n if (result == 'e') {\n // increment counter\n numTries += 1;\n\n // exit function and try again\n if (numTries < maxTries) {\n return loadFile(path, callbackFn, args, numTries);\n }\n } else if (e.rel == 'preload' && e.as == 'style') {\n // activate preloaded stylesheets\n return e.rel = 'stylesheet'; // jshint ignore:line\n }\n \n // execute callback\n callbackFn(path, result, ev.defaultPrevented);\n };\n\n // add to document (unless callback returns `false`)\n if (beforeCallbackFn(path, e) !== false) doc.head.appendChild(e);\n}\n\n\n/**\n * Load multiple files.\n * @param {string[]} paths - The file paths\n * @param {Function} callbackFn - The callback function\n */\nfunction loadFiles(paths, callbackFn, args) {\n // listify paths\n paths = paths.push ? paths : [paths];\n\n var numWaiting = paths.length,\n x = numWaiting,\n pathsNotFound = [],\n fn,\n i;\n\n // define callback function\n fn = function(path, result, defaultPrevented) {\n // handle error\n if (result == 'e') pathsNotFound.push(path);\n\n // handle beforeload event. If defaultPrevented then that means the load\n // will be blocked (ex. Ghostery/ABP on Safari)\n if (result == 'b') {\n if (defaultPrevented) pathsNotFound.push(path);\n else return;\n }\n\n numWaiting--;\n if (!numWaiting) callbackFn(pathsNotFound);\n };\n\n // load scripts\n for (i=0; i < x; i++) loadFile(paths[i], fn, args);\n}\n\n\n/**\n * Initiate script load and register bundle.\n * @param {(string|string[])} paths - The file paths\n * @param {(string|Function|Object)} [arg1] - The (1) bundleId or (2) success\n * callback or (3) object literal with success/error arguments, numRetries,\n * etc.\n * @param {(Function|Object)} [arg2] - The (1) success callback or (2) object\n * literal with success/error arguments, numRetries, etc.\n */\nfunction loadjs(paths, arg1, arg2) {\n var bundleId,\n args;\n\n // bundleId (if string)\n if (arg1 && arg1.trim) bundleId = arg1;\n\n // args (default is {})\n args = (bundleId ? arg2 : arg1) || {};\n\n // throw error if bundle is already defined\n if (bundleId) {\n if (bundleId in bundleIdCache) {\n throw \"LoadJS\";\n } else {\n bundleIdCache[bundleId] = true;\n }\n }\n\n function loadFn(resolve, reject) {\n loadFiles(paths, function (pathsNotFound) {\n // execute callbacks\n executeCallbacks(args, pathsNotFound);\n \n // resolve Promise\n if (resolve) {\n executeCallbacks({success: resolve, error: reject}, pathsNotFound);\n }\n\n // publish bundle load event\n publish(bundleId, pathsNotFound);\n }, args);\n }\n \n if (args.returnPromise) return new Promise(loadFn);\n else loadFn();\n}\n\n\n/**\n * Execute callbacks when dependencies have been satisfied.\n * @param {(string|string[])} deps - List of bundle ids\n * @param {Object} args - success/error arguments\n */\nloadjs.ready = function ready(deps, args) {\n // subscribe to bundle load event\n subscribe(deps, function (depsNotFound) {\n // execute callbacks\n executeCallbacks(args, depsNotFound);\n });\n\n return loadjs;\n};\n\n\n/**\n * Manually satisfy bundle dependencies.\n * @param {string} bundleId - The bundle id\n */\nloadjs.done = function done(bundleId) {\n publish(bundleId, []);\n};\n\n\n/**\n * Reset loadjs dependencies statuses\n */\nloadjs.reset = function reset() {\n bundleIdCache = {};\n bundleResultCache = {};\n bundleCallbackQueue = {};\n};\n\n\n/**\n * Determine if bundle has already been defined\n * @param String} bundleId - The bundle id\n */\nloadjs.isDefined = function isDefined(bundleId) {\n return bundleId in bundleIdCache;\n};\n\n\n// export\nreturn loadjs;\n\n}));\n","// ==========================================================================\n// Load an external script\n// ==========================================================================\n\nimport loadjs from 'loadjs';\n\nexport default function loadScript(url) {\n return new Promise((resolve, reject) => {\n loadjs(url, {\n success: resolve,\n error: reject,\n });\n });\n}\n","// ==========================================================================\n// Vimeo plugin\n// ==========================================================================\n\nimport captions from '../captions';\nimport controls from '../controls';\nimport ui from '../ui';\nimport { createElement, replaceElement, toggleClass } from '../utils/elements';\nimport { triggerEvent } from '../utils/events';\nimport fetch from '../utils/fetch';\nimport is from '../utils/is';\nimport loadScript from '../utils/load-script';\nimport { format, stripHTML } from '../utils/strings';\nimport { roundAspectRatio, setAspectRatio } from '../utils/style';\nimport { buildUrlParams } from '../utils/urls';\n\n// Parse Vimeo ID from URL\nfunction parseId(url) {\n if (is.empty(url)) {\n return null;\n }\n\n if (is.number(Number(url))) {\n return url;\n }\n\n const regex = /^.*(vimeo.com\\/|video\\/)(\\d+).*/;\n return url.match(regex) ? RegExp.$2 : url;\n}\n\n// Try to extract a hash for private videos from the URL\nfunction parseHash(url) {\n /* This regex matches a hexadecimal hash if given in any of these forms:\n * - [https://player.]vimeo.com/video/{id}/{hash}[?params]\n * - [https://player.]vimeo.com/video/{id}?h={hash}[&params]\n * - [https://player.]vimeo.com/video/{id}?[params]&h={hash}\n * - video/{id}/{hash}\n * If matched, the hash is available in capture group 4\n */\n const regex = /^.*(vimeo.com\\/|video\\/)(\\d+)(\\?.*&*h=|\\/)+([\\d,a-f]+)/;\n const found = url.match(regex);\n\n return found && found.length === 5 ? found[4] : null;\n}\n\n// Set playback state and trigger change (only on actual change)\nfunction assurePlaybackState(play) {\n if (play && !this.embed.hasPlayed) {\n this.embed.hasPlayed = true;\n }\n if (this.media.paused === play) {\n this.media.paused = !play;\n triggerEvent.call(this, this.media, play ? 'play' : 'pause');\n }\n}\n\nconst vimeo = {\n setup() {\n const player = this;\n\n // Add embed class for responsive\n toggleClass(player.elements.wrapper, player.config.classNames.embed, true);\n\n // Set speed options from config\n player.options.speed = player.config.speed.options;\n\n // Set intial ratio\n setAspectRatio.call(player);\n\n // Load the SDK if not already\n if (!is.object(window.Vimeo)) {\n loadScript(player.config.urls.vimeo.sdk)\n .then(() => {\n vimeo.ready.call(player);\n })\n .catch((error) => {\n player.debug.warn('Vimeo SDK (player.js) failed to load', error);\n });\n } else {\n vimeo.ready.call(player);\n }\n },\n\n // API Ready\n ready() {\n const player = this;\n const config = player.config.vimeo;\n const { premium, referrerPolicy, ...frameParams } = config;\n // Get the source URL or ID\n let source = player.media.getAttribute('src');\n let hash = '';\n // Get from <div> if needed\n if (is.empty(source)) {\n source = player.media.getAttribute(player.config.attributes.embed.id);\n // hash can also be set as attribute on the <div>\n hash = player.media.getAttribute(player.config.attributes.embed.hash);\n } else {\n hash = parseHash(source);\n }\n const hashParam = hash ? { h: hash } : {};\n\n // If the owner has a pro or premium account then we can hide controls etc\n if (premium) {\n Object.assign(frameParams, {\n controls: false,\n sidedock: false,\n });\n }\n\n // Get Vimeo params for the iframe\n const params = buildUrlParams({\n loop: player.config.loop.active,\n autoplay: player.autoplay,\n muted: player.muted,\n gesture: 'media',\n playsinline: player.config.playsinline,\n // hash has to be added to iframe-URL\n ...hashParam,\n ...frameParams,\n });\n\n const id = parseId(source);\n // Build an iframe\n const iframe = createElement('iframe');\n const src = format(player.config.urls.vimeo.iframe, id, params);\n iframe.setAttribute('src', src);\n iframe.setAttribute('allowfullscreen', '');\n iframe.setAttribute(\n 'allow',\n ['autoplay', 'fullscreen', 'picture-in-picture', 'encrypted-media', 'accelerometer', 'gyroscope'].join('; '),\n );\n\n // Set the referrer policy if required\n if (!is.empty(referrerPolicy)) {\n iframe.setAttribute('referrerPolicy', referrerPolicy);\n }\n\n // Inject the package\n if (premium || !config.customControls) {\n iframe.setAttribute('data-poster', player.poster);\n player.media = replaceElement(iframe, player.media);\n } else {\n const wrapper = createElement('div', {\n class: player.config.classNames.embedContainer,\n 'data-poster': player.poster,\n });\n wrapper.appendChild(iframe);\n player.media = replaceElement(wrapper, player.media);\n }\n\n // Get poster image\n if (!config.customControls) {\n fetch(format(player.config.urls.vimeo.api, src)).then((response) => {\n if (is.empty(response) || !response.thumbnail_url) {\n return;\n }\n\n // Set and show poster\n ui.setPoster.call(player, response.thumbnail_url).catch(() => {});\n });\n }\n\n // Setup instance\n // https://github.com/vimeo/player.js\n player.embed = new window.Vimeo.Player(iframe, {\n autopause: player.config.autopause,\n muted: player.muted,\n });\n\n player.media.paused = true;\n player.media.currentTime = 0;\n\n // Disable native text track rendering\n if (player.supported.ui) {\n player.embed.disableTextTrack();\n }\n\n // Create a faux HTML5 API using the Vimeo API\n player.media.play = () => {\n assurePlaybackState.call(player, true);\n return player.embed.play();\n };\n\n player.media.pause = () => {\n assurePlaybackState.call(player, false);\n return player.embed.pause();\n };\n\n player.media.stop = () => {\n player.pause();\n player.currentTime = 0;\n };\n\n // Seeking\n let { currentTime } = player.media;\n Object.defineProperty(player.media, 'currentTime', {\n get() {\n return currentTime;\n },\n set(time) {\n // Vimeo will automatically play on seek if the video hasn't been played before\n\n // Get current paused state and volume etc\n const { embed, media, paused, volume } = player;\n const restorePause = paused && !embed.hasPlayed;\n\n // Set seeking state and trigger event\n media.seeking = true;\n triggerEvent.call(player, media, 'seeking');\n\n // If paused, mute until seek is complete\n Promise.resolve(restorePause && embed.setVolume(0))\n // Seek\n .then(() => embed.setCurrentTime(time))\n // Restore paused\n .then(() => restorePause && embed.pause())\n // Restore volume\n .then(() => restorePause && embed.setVolume(volume))\n .catch(() => {\n // Do nothing\n });\n },\n });\n\n // Playback speed\n let speed = player.config.speed.selected;\n Object.defineProperty(player.media, 'playbackRate', {\n get() {\n return speed;\n },\n set(input) {\n player.embed\n .setPlaybackRate(input)\n .then(() => {\n speed = input;\n triggerEvent.call(player, player.media, 'ratechange');\n })\n .catch(() => {\n // Cannot set Playback Rate, Video is probably not on Pro account\n player.options.speed = [1];\n });\n },\n });\n\n // Volume\n let { volume } = player.config;\n Object.defineProperty(player.media, 'volume', {\n get() {\n return volume;\n },\n set(input) {\n player.embed.setVolume(input).then(() => {\n volume = input;\n triggerEvent.call(player, player.media, 'volumechange');\n });\n },\n });\n\n // Muted\n let { muted } = player.config;\n Object.defineProperty(player.media, 'muted', {\n get() {\n return muted;\n },\n set(input) {\n const toggle = is.boolean(input) ? input : false;\n\n player.embed.setMuted(toggle ? true : player.config.muted).then(() => {\n muted = toggle;\n triggerEvent.call(player, player.media, 'volumechange');\n });\n },\n });\n\n // Loop\n let { loop } = player.config;\n Object.defineProperty(player.media, 'loop', {\n get() {\n return loop;\n },\n set(input) {\n const toggle = is.boolean(input) ? input : player.config.loop.active;\n\n player.embed.setLoop(toggle).then(() => {\n loop = toggle;\n });\n },\n });\n\n // Source\n let currentSrc;\n player.embed\n .getVideoUrl()\n .then((value) => {\n currentSrc = value;\n controls.setDownloadUrl.call(player);\n })\n .catch((error) => {\n this.debug.warn(error);\n });\n\n Object.defineProperty(player.media, 'currentSrc', {\n get() {\n return currentSrc;\n },\n });\n\n // Ended\n Object.defineProperty(player.media, 'ended', {\n get() {\n return player.currentTime === player.duration;\n },\n });\n\n // Set aspect ratio based on video size\n Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then((dimensions) => {\n const [width, height] = dimensions;\n player.embed.ratio = roundAspectRatio(width, height);\n setAspectRatio.call(this);\n });\n\n // Set autopause\n player.embed.setAutopause(player.config.autopause).then((state) => {\n player.config.autopause = state;\n });\n\n // Get title\n player.embed.getVideoTitle().then((title) => {\n player.config.title = title;\n ui.setTitle.call(this);\n });\n\n // Get current time\n player.embed.getCurrentTime().then((value) => {\n currentTime = value;\n triggerEvent.call(player, player.media, 'timeupdate');\n });\n\n // Get duration\n player.embed.getDuration().then((value) => {\n player.media.duration = value;\n triggerEvent.call(player, player.media, 'durationchange');\n });\n\n // Get captions\n player.embed.getTextTracks().then((tracks) => {\n player.media.textTracks = tracks;\n captions.setup.call(player);\n });\n\n player.embed.on('cuechange', ({ cues = [] }) => {\n const strippedCues = cues.map((cue) => stripHTML(cue.text));\n captions.updateCues.call(player, strippedCues);\n });\n\n player.embed.on('loaded', () => {\n // Assure state and events are updated on autoplay\n player.embed.getPaused().then((paused) => {\n assurePlaybackState.call(player, !paused);\n if (!paused) {\n triggerEvent.call(player, player.media, 'playing');\n }\n });\n\n if (is.element(player.embed.element) && player.supported.ui) {\n const frame = player.embed.element;\n\n // Fix keyboard focus issues\n // https://github.com/sampotts/plyr/issues/317\n frame.setAttribute('tabindex', -1);\n }\n });\n\n player.embed.on('bufferstart', () => {\n triggerEvent.call(player, player.media, 'waiting');\n });\n\n player.embed.on('bufferend', () => {\n triggerEvent.call(player, player.media, 'playing');\n });\n\n player.embed.on('play', () => {\n assurePlaybackState.call(player, true);\n triggerEvent.call(player, player.media, 'playing');\n });\n\n player.embed.on('pause', () => {\n assurePlaybackState.call(player, false);\n });\n\n player.embed.on('timeupdate', (data) => {\n player.media.seeking = false;\n currentTime = data.seconds;\n triggerEvent.call(player, player.media, 'timeupdate');\n });\n\n player.embed.on('progress', (data) => {\n player.media.buffered = data.percent;\n triggerEvent.call(player, player.media, 'progress');\n\n // Check all loaded\n if (parseInt(data.percent, 10) === 1) {\n triggerEvent.call(player, player.media, 'canplaythrough');\n }\n\n // Get duration as if we do it before load, it gives an incorrect value\n // https://github.com/sampotts/plyr/issues/891\n player.embed.getDuration().then((value) => {\n if (value !== player.media.duration) {\n player.media.duration = value;\n triggerEvent.call(player, player.media, 'durationchange');\n }\n });\n });\n\n player.embed.on('seeked', () => {\n player.media.seeking = false;\n triggerEvent.call(player, player.media, 'seeked');\n });\n\n player.embed.on('ended', () => {\n player.media.paused = true;\n triggerEvent.call(player, player.media, 'ended');\n });\n\n player.embed.on('error', (detail) => {\n player.media.error = detail;\n triggerEvent.call(player, player.media, 'error');\n });\n\n // Rebuild UI\n if (config.customControls) {\n setTimeout(() => ui.build.call(player), 0);\n }\n },\n};\n\nexport default vimeo;\n","// ==========================================================================\n// YouTube plugin\n// ==========================================================================\n\nimport ui from '../ui';\nimport { createElement, replaceElement, toggleClass } from '../utils/elements';\nimport { triggerEvent } from '../utils/events';\nimport fetch from '../utils/fetch';\nimport is from '../utils/is';\nimport loadImage from '../utils/load-image';\nimport loadScript from '../utils/load-script';\nimport { extend } from '../utils/objects';\nimport { format, generateId } from '../utils/strings';\nimport { roundAspectRatio, setAspectRatio } from '../utils/style';\n\n// Parse YouTube ID from URL\nfunction parseId(url) {\n if (is.empty(url)) {\n return null;\n }\n\n const regex = /^.*(youtu.be\\/|v\\/|u\\/\\w\\/|embed\\/|watch\\?v=|&v=)([^#&?]*).*/;\n return url.match(regex) ? RegExp.$2 : url;\n}\n\n// Set playback state and trigger change (only on actual change)\nfunction assurePlaybackState(play) {\n if (play && !this.embed.hasPlayed) {\n this.embed.hasPlayed = true;\n }\n if (this.media.paused === play) {\n this.media.paused = !play;\n triggerEvent.call(this, this.media, play ? 'play' : 'pause');\n }\n}\n\nfunction getHost(config) {\n if (config.noCookie) {\n return 'https://www.youtube-nocookie.com';\n }\n\n if (window.location.protocol === 'http:') {\n return 'http://www.youtube.com';\n }\n\n // Use YouTube's default\n return undefined;\n}\n\nconst youtube = {\n setup() {\n // Add embed class for responsive\n toggleClass(this.elements.wrapper, this.config.classNames.embed, true);\n\n // Setup API\n if (is.object(window.YT) && is.function(window.YT.Player)) {\n youtube.ready.call(this);\n } else {\n // Reference current global callback\n const callback = window.onYouTubeIframeAPIReady;\n\n // Set callback to process queue\n window.onYouTubeIframeAPIReady = () => {\n // Call global callback if set\n if (is.function(callback)) {\n callback();\n }\n\n youtube.ready.call(this);\n };\n\n // Load the SDK\n loadScript(this.config.urls.youtube.sdk).catch((error) => {\n this.debug.warn('YouTube API failed to load', error);\n });\n }\n },\n\n // Get the media title\n getTitle(videoId) {\n const url = format(this.config.urls.youtube.api, videoId);\n\n fetch(url)\n .then((data) => {\n if (is.object(data)) {\n const { title, height, width } = data;\n\n // Set title\n this.config.title = title;\n ui.setTitle.call(this);\n\n // Set aspect ratio\n this.embed.ratio = roundAspectRatio(width, height);\n }\n\n setAspectRatio.call(this);\n })\n .catch(() => {\n // Set aspect ratio\n setAspectRatio.call(this);\n });\n },\n\n // API ready\n ready() {\n const player = this;\n const config = player.config.youtube;\n // Ignore already setup (race condition)\n const currentId = player.media && player.media.getAttribute('id');\n if (!is.empty(currentId) && currentId.startsWith('youtube-')) {\n return;\n }\n\n // Get the source URL or ID\n let source = player.media.getAttribute('src');\n\n // Get from <div> if needed\n if (is.empty(source)) {\n source = player.media.getAttribute(this.config.attributes.embed.id);\n }\n\n // Replace the <iframe> with a <div> due to YouTube API issues\n const videoId = parseId(source);\n const id = generateId(player.provider);\n // Replace media element\n const container = createElement('div', { id, 'data-poster': config.customControls ? player.poster : undefined });\n player.media = replaceElement(container, player.media);\n\n // Only load the poster when using custom controls\n if (config.customControls) {\n const posterSrc = (s) => `https://i.ytimg.com/vi/${videoId}/${s}default.jpg`;\n\n // Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide)\n loadImage(posterSrc('maxres'), 121) // Highest quality and un-padded\n .catch(() => loadImage(posterSrc('sd'), 121)) // 480p padded 4:3\n .catch(() => loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists\n .then((image) => ui.setPoster.call(player, image.src))\n .then((src) => {\n // If the image is padded, use background-size \"cover\" instead (like youtube does too with their posters)\n if (!src.includes('maxres')) {\n player.elements.poster.style.backgroundSize = 'cover';\n }\n })\n .catch(() => {});\n }\n\n // Setup instance\n // https://developers.google.com/youtube/iframe_api_reference\n player.embed = new window.YT.Player(player.media, {\n videoId,\n host: getHost(config),\n playerVars: extend(\n {},\n {\n // Autoplay\n autoplay: player.config.autoplay ? 1 : 0,\n // iframe interface language\n hl: player.config.hl,\n // Only show controls if not fully supported or opted out\n controls: player.supported.ui && config.customControls ? 0 : 1,\n // Disable keyboard as we handle it\n disablekb: 1,\n // Allow iOS inline playback\n playsinline: player.config.playsinline && !player.config.fullscreen.iosNative ? 1 : 0,\n // Captions are flaky on YouTube\n cc_load_policy: player.captions.active ? 1 : 0,\n cc_lang_pref: player.config.captions.language,\n // Tracking for stats\n widget_referrer: window ? window.location.href : null,\n },\n config,\n ),\n events: {\n onError(event) {\n // YouTube may fire onError twice, so only handle it once\n if (!player.media.error) {\n const code = event.data;\n // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError\n const message =\n {\n 2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.',\n 5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.',\n 100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.',\n 101: 'The owner of the requested video does not allow it to be played in embedded players.',\n 150: 'The owner of the requested video does not allow it to be played in embedded players.',\n }[code] || 'An unknown error occurred';\n\n player.media.error = { code, message };\n\n triggerEvent.call(player, player.media, 'error');\n }\n },\n onPlaybackRateChange(event) {\n // Get the instance\n const instance = event.target;\n\n // Get current speed\n player.media.playbackRate = instance.getPlaybackRate();\n\n triggerEvent.call(player, player.media, 'ratechange');\n },\n onReady(event) {\n // Bail if onReady has already been called. See issue #1108\n if (is.function(player.media.play)) {\n return;\n }\n // Get the instance\n const instance = event.target;\n\n // Get the title\n youtube.getTitle.call(player, videoId);\n\n // Create a faux HTML5 API using the YouTube API\n player.media.play = () => {\n assurePlaybackState.call(player, true);\n instance.playVideo();\n };\n\n player.media.pause = () => {\n assurePlaybackState.call(player, false);\n instance.pauseVideo();\n };\n\n player.media.stop = () => {\n instance.stopVideo();\n };\n\n player.media.duration = instance.getDuration();\n player.media.paused = true;\n\n // Seeking\n player.media.currentTime = 0;\n Object.defineProperty(player.media, 'currentTime', {\n get() {\n return Number(instance.getCurrentTime());\n },\n set(time) {\n // If paused and never played, mute audio preventively (YouTube starts playing on seek if the video hasn't been played yet).\n if (player.paused && !player.embed.hasPlayed) {\n player.embed.mute();\n }\n\n // Set seeking state and trigger event\n player.media.seeking = true;\n triggerEvent.call(player, player.media, 'seeking');\n\n // Seek after events sent\n instance.seekTo(time);\n },\n });\n\n // Playback speed\n Object.defineProperty(player.media, 'playbackRate', {\n get() {\n return instance.getPlaybackRate();\n },\n set(input) {\n instance.setPlaybackRate(input);\n },\n });\n\n // Volume\n let { volume } = player.config;\n Object.defineProperty(player.media, 'volume', {\n get() {\n return volume;\n },\n set(input) {\n volume = input;\n instance.setVolume(volume * 100);\n triggerEvent.call(player, player.media, 'volumechange');\n },\n });\n\n // Muted\n let { muted } = player.config;\n Object.defineProperty(player.media, 'muted', {\n get() {\n return muted;\n },\n set(input) {\n const toggle = is.boolean(input) ? input : muted;\n muted = toggle;\n instance[toggle ? 'mute' : 'unMute']();\n instance.setVolume(volume * 100);\n triggerEvent.call(player, player.media, 'volumechange');\n },\n });\n\n // Source\n Object.defineProperty(player.media, 'currentSrc', {\n get() {\n return instance.getVideoUrl();\n },\n });\n\n // Ended\n Object.defineProperty(player.media, 'ended', {\n get() {\n return player.currentTime === player.duration;\n },\n });\n\n // Get available speeds\n const speeds = instance.getAvailablePlaybackRates();\n // Filter based on config\n player.options.speed = speeds.filter((s) => player.config.speed.options.includes(s));\n\n // Set the tabindex to avoid focus entering iframe\n if (player.supported.ui && config.customControls) {\n player.media.setAttribute('tabindex', -1);\n }\n\n triggerEvent.call(player, player.media, 'timeupdate');\n triggerEvent.call(player, player.media, 'durationchange');\n\n // Reset timer\n clearInterval(player.timers.buffering);\n\n // Setup buffering\n player.timers.buffering = setInterval(() => {\n // Get loaded % from YouTube\n player.media.buffered = instance.getVideoLoadedFraction();\n\n // Trigger progress only when we actually buffer something\n if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) {\n triggerEvent.call(player, player.media, 'progress');\n }\n\n // Set last buffer point\n player.media.lastBuffered = player.media.buffered;\n\n // Bail if we're at 100%\n if (player.media.buffered === 1) {\n clearInterval(player.timers.buffering);\n\n // Trigger event\n triggerEvent.call(player, player.media, 'canplaythrough');\n }\n }, 200);\n\n // Rebuild UI\n if (config.customControls) {\n setTimeout(() => ui.build.call(player), 50);\n }\n },\n onStateChange(event) {\n // Get the instance\n const instance = event.target;\n\n // Reset timer\n clearInterval(player.timers.playing);\n\n const seeked = player.media.seeking && [1, 2].includes(event.data);\n\n if (seeked) {\n // Unset seeking and fire seeked event\n player.media.seeking = false;\n triggerEvent.call(player, player.media, 'seeked');\n }\n\n // Handle events\n // -1 Unstarted\n // 0 Ended\n // 1 Playing\n // 2 Paused\n // 3 Buffering\n // 5 Video cued\n switch (event.data) {\n case -1:\n // Update scrubber\n triggerEvent.call(player, player.media, 'timeupdate');\n\n // Get loaded % from YouTube\n player.media.buffered = instance.getVideoLoadedFraction();\n triggerEvent.call(player, player.media, 'progress');\n\n break;\n\n case 0:\n assurePlaybackState.call(player, false);\n\n // YouTube doesn't support loop for a single video, so mimick it.\n if (player.media.loop) {\n // YouTube needs a call to `stopVideo` before playing again\n instance.stopVideo();\n instance.playVideo();\n } else {\n triggerEvent.call(player, player.media, 'ended');\n }\n\n break;\n\n case 1:\n // Restore paused state (YouTube starts playing on seek if the video hasn't been played yet)\n if (config.customControls && !player.config.autoplay && player.media.paused && !player.embed.hasPlayed) {\n player.media.pause();\n } else {\n assurePlaybackState.call(player, true);\n\n triggerEvent.call(player, player.media, 'playing');\n\n // Poll to get playback progress\n player.timers.playing = setInterval(() => {\n triggerEvent.call(player, player.media, 'timeupdate');\n }, 50);\n\n // Check duration again due to YouTube bug\n // https://github.com/sampotts/plyr/issues/374\n // https://code.google.com/p/gdata-issues/issues/detail?id=8690\n if (player.media.duration !== instance.getDuration()) {\n player.media.duration = instance.getDuration();\n triggerEvent.call(player, player.media, 'durationchange');\n }\n }\n\n break;\n\n case 2:\n // Restore audio (YouTube starts playing on seek if the video hasn't been played yet)\n if (!player.muted) {\n player.embed.unMute();\n }\n assurePlaybackState.call(player, false);\n\n break;\n\n case 3:\n // Trigger waiting event to add loading classes to container as the video buffers.\n triggerEvent.call(player, player.media, 'waiting');\n\n break;\n\n default:\n break;\n }\n\n triggerEvent.call(player, player.elements.container, 'statechange', false, {\n code: event.data,\n });\n },\n },\n });\n },\n};\n\nexport default youtube;\n","// ==========================================================================\n// Plyr Media\n// ==========================================================================\n\nimport html5 from './html5';\nimport vimeo from './plugins/vimeo';\nimport youtube from './plugins/youtube';\nimport { createElement, toggleClass, wrap } from './utils/elements';\n\nconst media = {\n // Setup media\n setup() {\n // If there's no media, bail\n if (!this.media) {\n this.debug.warn('No media element found!');\n return;\n }\n\n // Add type class\n toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true);\n\n // Add provider class\n toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true);\n\n // Add video class for embeds\n // This will require changes if audio embeds are added\n if (this.isEmbed) {\n toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);\n }\n\n // Inject the player wrapper\n if (this.isVideo) {\n // Create the wrapper div\n this.elements.wrapper = createElement('div', {\n class: this.config.classNames.video,\n });\n\n // Wrap the video in a container\n wrap(this.media, this.elements.wrapper);\n\n // Poster image container\n this.elements.poster = createElement('div', {\n class: this.config.classNames.poster,\n });\n\n this.elements.wrapper.appendChild(this.elements.poster);\n }\n\n if (this.isHTML5) {\n html5.setup.call(this);\n } else if (this.isYouTube) {\n youtube.setup.call(this);\n } else if (this.isVimeo) {\n vimeo.setup.call(this);\n }\n },\n};\n\nexport default media;\n","// ==========================================================================\n// Advertisement plugin using Google IMA HTML5 SDK\n// Create an account with our ad partner, vi here:\n// https://www.vi.ai/publisher-video-monetization/\n// ==========================================================================\n\n/* global google */\n\nimport { createElement } from '../utils/elements';\nimport { triggerEvent } from '../utils/events';\nimport i18n from '../utils/i18n';\nimport is from '../utils/is';\nimport loadScript from '../utils/load-script';\nimport { silencePromise } from '../utils/promise';\nimport { formatTime } from '../utils/time';\nimport { buildUrlParams } from '../utils/urls';\n\nconst destroy = (instance) => {\n // Destroy our adsManager\n if (instance.manager) {\n instance.manager.destroy();\n }\n\n // Destroy our adsManager\n if (instance.elements.displayContainer) {\n instance.elements.displayContainer.destroy();\n }\n\n instance.elements.container.remove();\n};\n\nclass Ads {\n /**\n * Ads constructor.\n * @param {Object} player\n * @return {Ads}\n */\n constructor(player) {\n this.player = player;\n this.config = player.config.ads;\n this.playing = false;\n this.initialized = false;\n this.elements = {\n container: null,\n displayContainer: null,\n };\n this.manager = null;\n this.loader = null;\n this.cuePoints = null;\n this.events = {};\n this.safetyTimer = null;\n this.countdownTimer = null;\n\n // Setup a promise to resolve when the IMA manager is ready\n this.managerPromise = new Promise((resolve, reject) => {\n // The ad is loaded and ready\n this.on('loaded', resolve);\n\n // Ads failed\n this.on('error', reject);\n });\n\n this.load();\n }\n\n get enabled() {\n const { config } = this;\n\n return (\n this.player.isHTML5 &&\n this.player.isVideo &&\n config.enabled &&\n (!is.empty(config.publisherId) || is.url(config.tagUrl))\n );\n }\n\n /**\n * Load the IMA SDK\n */\n load = () => {\n if (!this.enabled) {\n return;\n }\n\n // Check if the Google IMA3 SDK is loaded or load it ourselves\n if (!is.object(window.google) || !is.object(window.google.ima)) {\n loadScript(this.player.config.urls.googleIMA.sdk)\n .then(() => {\n this.ready();\n })\n .catch(() => {\n // Script failed to load or is blocked\n this.trigger('error', new Error('Google IMA SDK failed to load'));\n });\n } else {\n this.ready();\n }\n };\n\n /**\n * Get the ads instance ready\n */\n ready = () => {\n // Double check we're enabled\n if (!this.enabled) {\n destroy(this);\n }\n\n // Start ticking our safety timer. If the whole advertisement\n // thing doesn't resolve within our set time; we bail\n this.startSafetyTimer(12000, 'ready()');\n\n // Clear the safety timer\n this.managerPromise.then(() => {\n this.clearSafetyTimer('onAdsManagerLoaded()');\n });\n\n // Set listeners on the Plyr instance\n this.listeners();\n\n // Setup the IMA SDK\n this.setupIMA();\n };\n\n // Build the tag URL\n get tagUrl() {\n const { config } = this;\n\n if (is.url(config.tagUrl)) {\n return config.tagUrl;\n }\n\n const params = {\n AV_PUBLISHERID: '58c25bb0073ef448b1087ad6',\n AV_CHANNELID: '5a0458dc28a06145e4519d21',\n AV_URL: window.location.hostname,\n cb: Date.now(),\n AV_WIDTH: 640,\n AV_HEIGHT: 480,\n AV_CDIM2: config.publisherId,\n };\n\n const base = 'https://go.aniview.com/api/adserver6/vast/';\n\n return `${base}?${buildUrlParams(params)}`;\n }\n\n /**\n * In order for the SDK to display ads for our video, we need to tell it where to put them,\n * so here we define our ad container. This div is set up to render on top of the video player.\n * Using the code below, we tell the SDK to render ads within that div. We also provide a\n * handle to the content video player - the SDK will poll the current time of our player to\n * properly place mid-rolls. After we create the ad display container, we initialize it. On\n * mobile devices, this initialization is done as the result of a user action.\n */\n setupIMA = () => {\n // Create the container for our advertisements\n this.elements.container = createElement('div', {\n class: this.player.config.classNames.ads,\n });\n\n this.player.elements.container.appendChild(this.elements.container);\n\n // So we can run VPAID2\n google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED);\n\n // Set language\n google.ima.settings.setLocale(this.player.config.ads.language);\n\n // Set playback for iOS10+\n google.ima.settings.setDisableCustomPlaybackForIOS10Plus(this.player.config.playsinline);\n\n // We assume the adContainer is the video container of the plyr element that will house the ads\n this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container, this.player.media);\n\n // Create ads loader\n this.loader = new google.ima.AdsLoader(this.elements.displayContainer);\n\n // Listen and respond to ads loaded and error events\n this.loader.addEventListener(\n google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,\n (event) => this.onAdsManagerLoaded(event),\n false,\n );\n this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, (error) => this.onAdError(error), false);\n\n // Request video ads to be pre-loaded\n this.requestAds();\n };\n\n /**\n * Request advertisements\n */\n requestAds = () => {\n const { container } = this.player.elements;\n\n try {\n // Request video ads\n const request = new google.ima.AdsRequest();\n request.adTagUrl = this.tagUrl;\n\n // Specify the linear and nonlinear slot sizes. This helps the SDK\n // to select the correct creative if multiple are returned\n request.linearAdSlotWidth = container.offsetWidth;\n request.linearAdSlotHeight = container.offsetHeight;\n request.nonLinearAdSlotWidth = container.offsetWidth;\n request.nonLinearAdSlotHeight = container.offsetHeight;\n\n // We only overlay ads as we only support video.\n request.forceNonLinearFullSlot = false;\n\n // Mute based on current state\n request.setAdWillPlayMuted(!this.player.muted);\n\n this.loader.requestAds(request);\n } catch (error) {\n this.onAdError(error);\n }\n };\n\n /**\n * Update the ad countdown\n * @param {Boolean} start\n */\n pollCountdown = (start = false) => {\n if (!start) {\n clearInterval(this.countdownTimer);\n this.elements.container.removeAttribute('data-badge-text');\n return;\n }\n\n const update = () => {\n const time = formatTime(Math.max(this.manager.getRemainingTime(), 0));\n const label = `${i18n.get('advertisement', this.player.config)} - ${time}`;\n this.elements.container.setAttribute('data-badge-text', label);\n };\n\n this.countdownTimer = setInterval(update, 100);\n };\n\n /**\n * This method is called whenever the ads are ready inside the AdDisplayContainer\n * @param {Event} event - adsManagerLoadedEvent\n */\n onAdsManagerLoaded = (event) => {\n // Load could occur after a source change (race condition)\n if (!this.enabled) {\n return;\n }\n\n // Get the ads manager\n const settings = new google.ima.AdsRenderingSettings();\n\n // Tell the SDK to save and restore content video state on our behalf\n settings.restoreCustomPlaybackStateOnAdBreakComplete = true;\n settings.enablePreloading = true;\n\n // The SDK is polling currentTime on the contentPlayback. And needs a duration\n // so it can determine when to start the mid- and post-roll\n this.manager = event.getAdsManager(this.player, settings);\n\n // Get the cue points for any mid-rolls by filtering out the pre- and post-roll\n this.cuePoints = this.manager.getCuePoints();\n\n // Add listeners to the required events\n // Advertisement error events\n this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, (error) => this.onAdError(error));\n\n // Advertisement regular events\n Object.keys(google.ima.AdEvent.Type).forEach((type) => {\n this.manager.addEventListener(google.ima.AdEvent.Type[type], (e) => this.onAdEvent(e));\n });\n\n // Resolve our adsManager\n this.trigger('loaded');\n };\n\n addCuePoints = () => {\n // Add advertisement cue's within the time line if available\n if (!is.empty(this.cuePoints)) {\n this.cuePoints.forEach((cuePoint) => {\n if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) {\n const seekElement = this.player.elements.progress;\n\n if (is.element(seekElement)) {\n const cuePercentage = (100 / this.player.duration) * cuePoint;\n const cue = createElement('span', {\n class: this.player.config.classNames.cues,\n });\n\n cue.style.left = `${cuePercentage.toString()}%`;\n seekElement.appendChild(cue);\n }\n }\n });\n }\n };\n\n /**\n * This is where all the event handling takes place. Retrieve the ad from the event. Some\n * events (e.g. ALL_ADS_COMPLETED) don't have the ad object associated\n * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type\n * @param {Event} event\n */\n onAdEvent = (event) => {\n const { container } = this.player.elements;\n // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)\n // don't have ad object associated\n const ad = event.getAd();\n const adData = event.getAdData();\n\n // Proxy event\n const dispatchEvent = (type) => {\n triggerEvent.call(this.player, this.player.media, `ads${type.replace(/_/g, '').toLowerCase()}`);\n };\n\n // Bubble the event\n dispatchEvent(event.type);\n\n switch (event.type) {\n case google.ima.AdEvent.Type.LOADED:\n // This is the first event sent for an ad - it is possible to determine whether the\n // ad is a video ad or an overlay\n this.trigger('loaded');\n\n // Start countdown\n this.pollCountdown(true);\n\n if (!ad.isLinear()) {\n // Position AdDisplayContainer correctly for overlay\n ad.width = container.offsetWidth;\n ad.height = container.offsetHeight;\n }\n\n // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());\n // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());\n\n break;\n\n case google.ima.AdEvent.Type.STARTED:\n // Set volume to match player\n this.manager.setVolume(this.player.volume);\n\n break;\n\n case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:\n // All ads for the current videos are done. We can now request new advertisements\n // in case the video is re-played\n\n // TODO: Example for what happens when a next video in a playlist would be loaded.\n // So here we load a new video when all ads are done.\n // Then we load new ads within a new adsManager. When the video\n // Is started - after - the ads are loaded, then we get ads.\n // You can also easily test cancelling and reloading by running\n // player.ads.cancel() and player.ads.play from the console I guess.\n // this.player.source = {\n // type: 'video',\n // title: 'View From A Blue Moon',\n // sources: [{\n // src:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', type:\n // 'video/mp4', }], poster:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', tracks:\n // [ { kind: 'captions', label: 'English', srclang: 'en', src:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt',\n // default: true, }, { kind: 'captions', label: 'French', srclang: 'fr', src:\n // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', }, ],\n // };\n\n // TODO: So there is still this thing where a video should only be allowed to start\n // playing when the IMA SDK is ready or has failed\n\n if (this.player.ended) {\n this.loadAds();\n } else {\n // The SDK won't allow new ads to be called without receiving a contentComplete()\n this.loader.contentComplete();\n }\n\n break;\n\n case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:\n // This event indicates the ad has started - the video player can adjust the UI,\n // for example display a pause button and remaining time. Fired when content should\n // be paused. This usually happens right before an ad is about to cover the content\n\n this.pauseContent();\n\n break;\n\n case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:\n // This event indicates the ad has finished - the video player can perform\n // appropriate UI actions, such as removing the timer for remaining time detection.\n // Fired when content should be resumed. This usually happens when an ad finishes\n // or collapses\n\n this.pollCountdown();\n\n this.resumeContent();\n\n break;\n\n case google.ima.AdEvent.Type.LOG:\n if (adData.adError) {\n this.player.debug.warn(`Non-fatal ad error: ${adData.adError.getMessage()}`);\n }\n\n break;\n\n default:\n break;\n }\n };\n\n /**\n * Any ad error handling comes through here\n * @param {Event} event\n */\n onAdError = (event) => {\n this.cancel();\n this.player.debug.warn('Ads error', event);\n };\n\n /**\n * Setup hooks for Plyr and window events. This ensures\n * the mid- and post-roll launch at the correct time. And\n * resize the advertisement when the player resizes\n */\n listeners = () => {\n const { container } = this.player.elements;\n let time;\n\n this.player.on('canplay', () => {\n this.addCuePoints();\n });\n\n this.player.on('ended', () => {\n this.loader.contentComplete();\n });\n\n this.player.on('timeupdate', () => {\n time = this.player.currentTime;\n });\n\n this.player.on('seeked', () => {\n const seekedTime = this.player.currentTime;\n\n if (is.empty(this.cuePoints)) {\n return;\n }\n\n this.cuePoints.forEach((cuePoint, index) => {\n if (time < cuePoint && cuePoint < seekedTime) {\n this.manager.discardAdBreak();\n this.cuePoints.splice(index, 1);\n }\n });\n });\n\n // Listen to the resizing of the window. And resize ad accordingly\n // TODO: eventually implement ResizeObserver\n window.addEventListener('resize', () => {\n if (this.manager) {\n this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);\n }\n });\n };\n\n /**\n * Initialize the adsManager and start playing advertisements\n */\n play = () => {\n const { container } = this.player.elements;\n\n if (!this.managerPromise) {\n this.resumeContent();\n }\n\n // Play the requested advertisement whenever the adsManager is ready\n this.managerPromise\n .then(() => {\n // Set volume to match player\n this.manager.setVolume(this.player.volume);\n\n // Initialize the container. Must be done via a user action on mobile devices\n this.elements.displayContainer.initialize();\n\n try {\n if (!this.initialized) {\n // Initialize the ads manager. Ad rules playlist will start at this time\n this.manager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);\n\n // Call play to start showing the ad. Single video and overlay ads will\n // start at this time; the call will be ignored for ad rules\n this.manager.start();\n }\n\n this.initialized = true;\n } catch (adError) {\n // An error may be thrown if there was a problem with the\n // VAST response\n this.onAdError(adError);\n }\n })\n .catch(() => {});\n };\n\n /**\n * Resume our video\n */\n resumeContent = () => {\n // Hide the advertisement container\n this.elements.container.style.zIndex = '';\n\n // Ad is stopped\n this.playing = false;\n\n // Play video\n silencePromise(this.player.media.play());\n };\n\n /**\n * Pause our video\n */\n pauseContent = () => {\n // Show the advertisement container\n this.elements.container.style.zIndex = 3;\n\n // Ad is playing\n this.playing = true;\n\n // Pause our video.\n this.player.media.pause();\n };\n\n /**\n * Destroy the adsManager so we can grab new ads after this. If we don't then we're not\n * allowed to call new ads based on google policies, as they interpret this as an accidental\n * video requests. https://developers.google.com/interactive-\n * media-ads/docs/sdks/android/faq#8\n */\n cancel = () => {\n // Pause our video\n if (this.initialized) {\n this.resumeContent();\n }\n\n // Tell our instance that we're done for now\n this.trigger('error');\n\n // Re-create our adsManager\n this.loadAds();\n };\n\n /**\n * Re-create our adsManager\n */\n loadAds = () => {\n // Tell our adsManager to go bye bye\n this.managerPromise\n .then(() => {\n // Destroy our adsManager\n if (this.manager) {\n this.manager.destroy();\n }\n\n // Re-set our adsManager promises\n this.managerPromise = new Promise((resolve) => {\n this.on('loaded', resolve);\n this.player.debug.log(this.manager);\n });\n // Now that the manager has been destroyed set it to also be un-initialized\n this.initialized = false;\n\n // Now request some new advertisements\n this.requestAds();\n })\n .catch(() => {});\n };\n\n /**\n * Handles callbacks after an ad event was invoked\n * @param {String} event - Event type\n * @param args\n */\n trigger = (event, ...args) => {\n const handlers = this.events[event];\n\n if (is.array(handlers)) {\n handlers.forEach((handler) => {\n if (is.function(handler)) {\n handler.apply(this, args);\n }\n });\n }\n };\n\n /**\n * Add event listeners\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n * @return {Ads}\n */\n on = (event, callback) => {\n if (!is.array(this.events[event])) {\n this.events[event] = [];\n }\n\n this.events[event].push(callback);\n\n return this;\n };\n\n /**\n * Setup a safety timer for when the ad network doesn't respond for whatever reason.\n * The advertisement has 12 seconds to get its things together. We stop this timer when the\n * advertisement is playing, or when a user action is required to start, then we clear the\n * timer on ad ready\n * @param {Number} time\n * @param {String} from\n */\n startSafetyTimer = (time, from) => {\n this.player.debug.log(`Safety timer invoked from: ${from}`);\n\n this.safetyTimer = setTimeout(() => {\n this.cancel();\n this.clearSafetyTimer('startSafetyTimer()');\n }, time);\n };\n\n /**\n * Clear our safety timer(s)\n * @param {String} from\n */\n clearSafetyTimer = (from) => {\n if (!is.nullOrUndefined(this.safetyTimer)) {\n this.player.debug.log(`Safety timer cleared from: ${from}`);\n\n clearTimeout(this.safetyTimer);\n this.safetyTimer = null;\n }\n };\n}\n\nexport default Ads;\n","/**\n * Returns a number whose value is limited to the given range.\n *\n * Example: limit the output of this computation to between 0 and 255\n * (x * 255).clamp(0, 255)\n *\n * @param {Number} input\n * @param {Number} min The lower boundary of the output range\n * @param {Number} max The upper boundary of the output range\n * @returns A number within the bounds of min and max\n * @type Number\n */\nexport function clamp(input = 0, min = 0, max = 255) {\n return Math.min(Math.max(input, min), max);\n}\n\nexport default { clamp };\n","import { createElement } from '../utils/elements';\nimport { once } from '../utils/events';\nimport fetch from '../utils/fetch';\nimport is from '../utils/is';\nimport { clamp } from '../utils/numbers';\nimport { formatTime } from '../utils/time';\n\n// Arg: vttDataString example: \"WEBVTT\\n\\n1\\n00:00:05.000 --> 00:00:10.000\\n1080p-00001.jpg\"\nconst parseVtt = (vttDataString) => {\n const processedList = [];\n const frames = vttDataString.split(/\\r\\n\\r\\n|\\n\\n|\\r\\r/);\n\n frames.forEach((frame) => {\n const result = {};\n const lines = frame.split(/\\r\\n|\\n|\\r/);\n\n lines.forEach((line) => {\n if (!is.number(result.startTime)) {\n // The line with start and end times on it is the first line of interest\n const matchTimes = line.match(\n /([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})( ?--> ?)([0-9]{2})?:?([0-9]{2}):([0-9]{2}).([0-9]{2,3})/,\n ); // Note that this currently ignores caption formatting directives that are optionally on the end of this line - fine for non-captions VTT\n\n if (matchTimes) {\n result.startTime =\n Number(matchTimes[1] || 0) * 60 * 60 +\n Number(matchTimes[2]) * 60 +\n Number(matchTimes[3]) +\n Number(`0.${matchTimes[4]}`);\n result.endTime =\n Number(matchTimes[6] || 0) * 60 * 60 +\n Number(matchTimes[7]) * 60 +\n Number(matchTimes[8]) +\n Number(`0.${matchTimes[9]}`);\n }\n } else if (!is.empty(line.trim()) && is.empty(result.text)) {\n // If we already have the startTime, then we're definitely up to the text line(s)\n const lineSplit = line.trim().split('#xywh=');\n [result.text] = lineSplit;\n\n // If there's content in lineSplit[1], then we have sprites. If not, then it's just one frame per image\n if (lineSplit[1]) {\n [result.x, result.y, result.w, result.h] = lineSplit[1].split(',');\n }\n }\n });\n\n if (result.text) {\n processedList.push(result);\n }\n });\n\n return processedList;\n};\n\n/**\n * Preview thumbnails for seek hover and scrubbing\n * Seeking: Hover over the seek bar (desktop only): shows a small preview container above the seek bar\n * Scrubbing: Click and drag the seek bar (desktop and mobile): shows the preview image over the entire video, as if the video is scrubbing at very high speed\n *\n * Notes:\n * - Thumbs are set via JS settings on Plyr init, not HTML5 'track' property. Using the track property would be a bit gross, because it doesn't support custom 'kinds'. kind=metadata might be used for something else, and we want to allow multiple thumbnails tracks. Tracks must have a unique combination of 'kind' and 'label'. We would have to do something like kind=metadata,label=thumbnails1 / kind=metadata,label=thumbnails2. Square peg, round hole\n * - VTT info: the image URL is relative to the VTT, not the current document. But if the url starts with a slash, it will naturally be relative to the current domain. https://support.jwplayer.com/articles/how-to-add-preview-thumbnails\n * - This implementation uses multiple separate img elements. Other implementations use background-image on one element. This would be nice and simple, but Firefox and Safari have flickering issues with replacing backgrounds of larger images. It seems that YouTube perhaps only avoids this because they don't have the option for high-res previews (even the fullscreen ones, when mousedown/seeking). Images appear over the top of each other, and previous ones are discarded once the new ones have been rendered\n */\n\nconst fitRatio = (ratio, outer) => {\n const targetRatio = outer.width / outer.height;\n const result = {};\n if (ratio > targetRatio) {\n result.width = outer.width;\n result.height = (1 / ratio) * outer.width;\n } else {\n result.height = outer.height;\n result.width = ratio * outer.height;\n }\n\n return result;\n};\n\nclass PreviewThumbnails {\n /**\n * PreviewThumbnails constructor.\n * @param {Plyr} player\n * @return {PreviewThumbnails}\n */\n constructor(player) {\n this.player = player;\n this.thumbnails = [];\n this.loaded = false;\n this.lastMouseMoveTime = Date.now();\n this.mouseDown = false;\n this.loadedImages = [];\n\n this.elements = {\n thumb: {},\n scrubbing: {},\n };\n\n this.load();\n }\n\n get enabled() {\n return this.player.isHTML5 && this.player.isVideo && this.player.config.previewThumbnails.enabled;\n }\n\n load = () => {\n // Toggle the regular seek tooltip\n if (this.player.elements.display.seekTooltip) {\n this.player.elements.display.seekTooltip.hidden = this.enabled;\n }\n\n if (!this.enabled) return;\n\n this.getThumbnails().then(() => {\n if (!this.enabled) {\n return;\n }\n\n // Render DOM elements\n this.render();\n\n // Check to see if thumb container size was specified manually in CSS\n this.determineContainerAutoSizing();\n\n // Set up listeners\n this.listeners();\n\n this.loaded = true;\n });\n };\n\n // Download VTT files and parse them\n getThumbnails = () => {\n return new Promise((resolve) => {\n const { src } = this.player.config.previewThumbnails;\n\n if (is.empty(src)) {\n throw new Error('Missing previewThumbnails.src config attribute');\n }\n\n // Resolve promise\n const sortAndResolve = () => {\n // Sort smallest to biggest (e.g., [120p, 480p, 1080p])\n this.thumbnails.sort((x, y) => x.height - y.height);\n\n this.player.debug.log('Preview thumbnails', this.thumbnails);\n\n resolve();\n };\n\n // Via callback()\n if (is.function(src)) {\n src((thumbnails) => {\n this.thumbnails = thumbnails;\n sortAndResolve();\n });\n }\n // VTT urls\n else {\n // If string, convert into single-element list\n const urls = is.string(src) ? [src] : src;\n // Loop through each src URL. Download and process the VTT file, storing the resulting data in this.thumbnails\n const promises = urls.map((u) => this.getThumbnail(u));\n // Resolve\n Promise.all(promises).then(sortAndResolve);\n }\n });\n };\n\n // Process individual VTT file\n getThumbnail = (url) => {\n return new Promise((resolve) => {\n fetch(url).then((response) => {\n const thumbnail = {\n frames: parseVtt(response),\n height: null,\n urlPrefix: '',\n };\n\n // If the URLs don't start with '/', then we need to set their relative path to be the location of the VTT file\n // If the URLs do start with '/', then they obviously don't need a prefix, so it will remain blank\n // If the thumbnail URLs start with with none of '/', 'http://' or 'https://', then we need to set their relative path to be the location of the VTT file\n if (\n !thumbnail.frames[0].text.startsWith('/') &&\n !thumbnail.frames[0].text.startsWith('http://') &&\n !thumbnail.frames[0].text.startsWith('https://')\n ) {\n thumbnail.urlPrefix = url.substring(0, url.lastIndexOf('/') + 1);\n }\n\n // Download the first frame, so that we can determine/set the height of this thumbnailsDef\n const tempImage = new Image();\n\n tempImage.onload = () => {\n thumbnail.height = tempImage.naturalHeight;\n thumbnail.width = tempImage.naturalWidth;\n\n this.thumbnails.push(thumbnail);\n\n resolve();\n };\n\n tempImage.src = thumbnail.urlPrefix + thumbnail.frames[0].text;\n });\n });\n };\n\n startMove = (event) => {\n if (!this.loaded) return;\n\n if (!is.event(event) || !['touchmove', 'mousemove'].includes(event.type)) return;\n\n // Wait until media has a duration\n if (!this.player.media.duration) return;\n\n if (event.type === 'touchmove') {\n // Calculate seek hover position as approx video seconds\n this.seekTime = this.player.media.duration * (this.player.elements.inputs.seek.value / 100);\n } else {\n // Calculate seek hover position as approx video seconds\n const clientRect = this.player.elements.progress.getBoundingClientRect();\n const percentage = (100 / clientRect.width) * (event.pageX - clientRect.left);\n this.seekTime = this.player.media.duration * (percentage / 100);\n\n if (this.seekTime < 0) {\n // The mousemove fires for 10+px out to the left\n this.seekTime = 0;\n }\n\n if (this.seekTime > this.player.media.duration - 1) {\n // Took 1 second off the duration for safety, because different players can disagree on the real duration of a video\n this.seekTime = this.player.media.duration - 1;\n }\n\n this.mousePosX = event.pageX;\n\n // Set time text inside image container\n this.elements.thumb.time.innerText = formatTime(this.seekTime);\n\n // Get marker point for time\n const point = this.player.config.markers?.points?.find(({ time: t }) => t === Math.round(this.seekTime));\n\n // Append the point label to the tooltip\n if (point) {\n // this.elements.thumb.time.innerText.concat('\\n');\n this.elements.thumb.time.insertAdjacentHTML('afterbegin', `${point.label}<br>`);\n }\n }\n\n // Download and show image\n this.showImageAtCurrentTime();\n };\n\n endMove = () => {\n this.toggleThumbContainer(false, true);\n };\n\n startScrubbing = (event) => {\n // Only act on left mouse button (0), or touch device (event.button does not exist or is false)\n if (is.nullOrUndefined(event.button) || event.button === false || event.button === 0) {\n this.mouseDown = true;\n\n // Wait until media has a duration\n if (this.player.media.duration) {\n this.toggleScrubbingContainer(true);\n this.toggleThumbContainer(false, true);\n\n // Download and show image\n this.showImageAtCurrentTime();\n }\n }\n };\n\n endScrubbing = () => {\n this.mouseDown = false;\n\n // Hide scrubbing preview. But wait until the video has successfully seeked before hiding the scrubbing preview\n if (Math.ceil(this.lastTime) === Math.ceil(this.player.media.currentTime)) {\n // The video was already seeked/loaded at the chosen time - hide immediately\n this.toggleScrubbingContainer(false);\n } else {\n // The video hasn't seeked yet. Wait for that\n once.call(this.player, this.player.media, 'timeupdate', () => {\n // Re-check mousedown - we might have already started scrubbing again\n if (!this.mouseDown) {\n this.toggleScrubbingContainer(false);\n }\n });\n }\n };\n\n /**\n * Setup hooks for Plyr and window events\n */\n listeners = () => {\n // Hide thumbnail preview - on mouse click, mouse leave (in listeners.js for now), and video play/seek. All four are required, e.g., for buffering\n this.player.on('play', () => {\n this.toggleThumbContainer(false, true);\n });\n\n this.player.on('seeked', () => {\n this.toggleThumbContainer(false);\n });\n\n this.player.on('timeupdate', () => {\n this.lastTime = this.player.media.currentTime;\n });\n };\n\n /**\n * Create HTML elements for image containers\n */\n render = () => {\n // Create HTML element: plyr__preview-thumbnail-container\n this.elements.thumb.container = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.thumbContainer,\n });\n\n // Wrapper for the image for styling\n this.elements.thumb.imageContainer = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.imageContainer,\n });\n this.elements.thumb.container.appendChild(this.elements.thumb.imageContainer);\n\n // Create HTML element, parent+span: time text (e.g., 01:32:00)\n const timeContainer = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.timeContainer,\n });\n\n this.elements.thumb.time = createElement('span', {}, '00:00');\n timeContainer.appendChild(this.elements.thumb.time);\n\n this.elements.thumb.imageContainer.appendChild(timeContainer);\n\n // Inject the whole thumb\n if (is.element(this.player.elements.progress)) {\n this.player.elements.progress.appendChild(this.elements.thumb.container);\n }\n\n // Create HTML element: plyr__preview-scrubbing-container\n this.elements.scrubbing.container = createElement('div', {\n class: this.player.config.classNames.previewThumbnails.scrubbingContainer,\n });\n\n this.player.elements.wrapper.appendChild(this.elements.scrubbing.container);\n };\n\n destroy = () => {\n if (this.elements.thumb.container) {\n this.elements.thumb.container.remove();\n }\n if (this.elements.scrubbing.container) {\n this.elements.scrubbing.container.remove();\n }\n };\n\n showImageAtCurrentTime = () => {\n if (this.mouseDown) {\n this.setScrubbingContainerSize();\n } else {\n this.setThumbContainerSizeAndPos();\n }\n\n // Find the desired thumbnail index\n // TODO: Handle a video longer than the thumbs where thumbNum is null\n const thumbNum = this.thumbnails[0].frames.findIndex(\n (frame) => this.seekTime >= frame.startTime && this.seekTime <= frame.endTime,\n );\n const hasThumb = thumbNum >= 0;\n let qualityIndex = 0;\n\n // Show the thumb container if we're not scrubbing\n if (!this.mouseDown) {\n this.toggleThumbContainer(hasThumb);\n }\n\n // No matching thumb found\n if (!hasThumb) {\n return;\n }\n\n // Check to see if we've already downloaded higher quality versions of this image\n this.thumbnails.forEach((thumbnail, index) => {\n if (this.loadedImages.includes(thumbnail.frames[thumbNum].text)) {\n qualityIndex = index;\n }\n });\n\n // Only proceed if either thumb num or thumbfilename has changed\n if (thumbNum !== this.showingThumb) {\n this.showingThumb = thumbNum;\n this.loadImage(qualityIndex);\n }\n };\n\n // Show the image that's currently specified in this.showingThumb\n loadImage = (qualityIndex = 0) => {\n const thumbNum = this.showingThumb;\n const thumbnail = this.thumbnails[qualityIndex];\n const { urlPrefix } = thumbnail;\n const frame = thumbnail.frames[thumbNum];\n const thumbFilename = thumbnail.frames[thumbNum].text;\n const thumbUrl = urlPrefix + thumbFilename;\n\n if (!this.currentImageElement || this.currentImageElement.dataset.filename !== thumbFilename) {\n // If we're already loading a previous image, remove its onload handler - we don't want it to load after this one\n // Only do this if not using sprites. Without sprites we really want to show as many images as possible, as a best-effort\n if (this.loadingImage && this.usingSprites) {\n this.loadingImage.onload = null;\n }\n\n // We're building and adding a new image. In other implementations of similar functionality (YouTube), background image\n // is instead used. But this causes issues with larger images in Firefox and Safari - switching between background\n // images causes a flicker. Putting a new image over the top does not\n const previewImage = new Image();\n previewImage.src = thumbUrl;\n previewImage.dataset.index = thumbNum;\n previewImage.dataset.filename = thumbFilename;\n this.showingThumbFilename = thumbFilename;\n\n this.player.debug.log(`Loading image: ${thumbUrl}`);\n\n // For some reason, passing the named function directly causes it to execute immediately. So I've wrapped it in an anonymous function...\n previewImage.onload = () => this.showImage(previewImage, frame, qualityIndex, thumbNum, thumbFilename, true);\n this.loadingImage = previewImage;\n this.removeOldImages(previewImage);\n } else {\n // Update the existing image\n this.showImage(this.currentImageElement, frame, qualityIndex, thumbNum, thumbFilename, false);\n this.currentImageElement.dataset.index = thumbNum;\n this.removeOldImages(this.currentImageElement);\n }\n };\n\n showImage = (previewImage, frame, qualityIndex, thumbNum, thumbFilename, newImage = true) => {\n this.player.debug.log(\n `Showing thumb: ${thumbFilename}. num: ${thumbNum}. qual: ${qualityIndex}. newimg: ${newImage}`,\n );\n this.setImageSizeAndOffset(previewImage, frame);\n\n if (newImage) {\n this.currentImageContainer.appendChild(previewImage);\n this.currentImageElement = previewImage;\n\n if (!this.loadedImages.includes(thumbFilename)) {\n this.loadedImages.push(thumbFilename);\n }\n }\n\n // Preload images before and after the current one\n // Show higher quality of the same frame\n // Each step here has a short time delay, and only continues if still hovering/seeking the same spot. This is to protect slow connections from overloading\n this.preloadNearby(thumbNum, true)\n .then(this.preloadNearby(thumbNum, false))\n .then(this.getHigherQuality(qualityIndex, previewImage, frame, thumbFilename));\n };\n\n // Remove all preview images that aren't the designated current image\n removeOldImages = (currentImage) => {\n // Get a list of all images, convert it from a DOM list to an array\n Array.from(this.currentImageContainer.children).forEach((image) => {\n if (image.tagName.toLowerCase() !== 'img') {\n return;\n }\n\n const removeDelay = this.usingSprites ? 500 : 1000;\n\n if (image.dataset.index !== currentImage.dataset.index && !image.dataset.deleting) {\n // Wait 200ms, as the new image can take some time to show on certain browsers (even though it was downloaded before showing). This will prevent flicker, and show some generosity towards slower clients\n // First set attribute 'deleting' to prevent multi-handling of this on repeat firing of this function\n // eslint-disable-next-line no-param-reassign\n image.dataset.deleting = true;\n\n // This has to be set before the timeout - to prevent issues switching between hover and scrub\n const { currentImageContainer } = this;\n\n setTimeout(() => {\n currentImageContainer.removeChild(image);\n this.player.debug.log(`Removing thumb: ${image.dataset.filename}`);\n }, removeDelay);\n }\n });\n };\n\n // Preload images before and after the current one. Only if the user is still hovering/seeking the same frame\n // This will only preload the lowest quality\n preloadNearby = (thumbNum, forward = true) => {\n return new Promise((resolve) => {\n setTimeout(() => {\n const oldThumbFilename = this.thumbnails[0].frames[thumbNum].text;\n\n if (this.showingThumbFilename === oldThumbFilename) {\n // Find the nearest thumbs with different filenames. Sometimes it'll be the next index, but in the case of sprites, it might be 100+ away\n let thumbnailsClone;\n if (forward) {\n thumbnailsClone = this.thumbnails[0].frames.slice(thumbNum);\n } else {\n thumbnailsClone = this.thumbnails[0].frames.slice(0, thumbNum).reverse();\n }\n\n let foundOne = false;\n\n thumbnailsClone.forEach((frame) => {\n const newThumbFilename = frame.text;\n\n if (newThumbFilename !== oldThumbFilename) {\n // Found one with a different filename. Make sure it hasn't already been loaded on this page visit\n if (!this.loadedImages.includes(newThumbFilename)) {\n foundOne = true;\n this.player.debug.log(`Preloading thumb filename: ${newThumbFilename}`);\n\n const { urlPrefix } = this.thumbnails[0];\n const thumbURL = urlPrefix + newThumbFilename;\n const previewImage = new Image();\n previewImage.src = thumbURL;\n previewImage.onload = () => {\n this.player.debug.log(`Preloaded thumb filename: ${newThumbFilename}`);\n if (!this.loadedImages.includes(newThumbFilename)) this.loadedImages.push(newThumbFilename);\n\n // We don't resolve until the thumb is loaded\n resolve();\n };\n }\n }\n });\n\n // If there are none to preload then we want to resolve immediately\n if (!foundOne) {\n resolve();\n }\n }\n }, 300);\n });\n };\n\n // If user has been hovering current image for half a second, look for a higher quality one\n getHigherQuality = (currentQualityIndex, previewImage, frame, thumbFilename) => {\n if (currentQualityIndex < this.thumbnails.length - 1) {\n // Only use the higher quality version if it's going to look any better - if the current thumb is of a lower pixel density than the thumbnail container\n let previewImageHeight = previewImage.naturalHeight;\n\n if (this.usingSprites) {\n previewImageHeight = frame.h;\n }\n\n if (previewImageHeight < this.thumbContainerHeight) {\n // Recurse back to the loadImage function - show a higher quality one, but only if the viewer is on this frame for a while\n setTimeout(() => {\n // Make sure the mouse hasn't already moved on and started hovering at another image\n if (this.showingThumbFilename === thumbFilename) {\n this.player.debug.log(`Showing higher quality thumb for: ${thumbFilename}`);\n this.loadImage(currentQualityIndex + 1);\n }\n }, 300);\n }\n }\n };\n\n get currentImageContainer() {\n return this.mouseDown ? this.elements.scrubbing.container : this.elements.thumb.imageContainer;\n }\n\n get usingSprites() {\n return Object.keys(this.thumbnails[0].frames[0]).includes('w');\n }\n\n get thumbAspectRatio() {\n if (this.usingSprites) {\n return this.thumbnails[0].frames[0].w / this.thumbnails[0].frames[0].h;\n }\n\n return this.thumbnails[0].width / this.thumbnails[0].height;\n }\n\n get thumbContainerHeight() {\n if (this.mouseDown) {\n const { height } = fitRatio(this.thumbAspectRatio, {\n width: this.player.media.clientWidth,\n height: this.player.media.clientHeight,\n });\n return height;\n }\n\n // If css is used this needs to return the css height for sprites to work (see setImageSizeAndOffset)\n if (this.sizeSpecifiedInCSS) {\n return this.elements.thumb.imageContainer.clientHeight;\n }\n\n return Math.floor(this.player.media.clientWidth / this.thumbAspectRatio / 4);\n }\n\n get currentImageElement() {\n return this.mouseDown ? this.currentScrubbingImageElement : this.currentThumbnailImageElement;\n }\n\n set currentImageElement(element) {\n if (this.mouseDown) {\n this.currentScrubbingImageElement = element;\n } else {\n this.currentThumbnailImageElement = element;\n }\n }\n\n toggleThumbContainer = (toggle = false, clearShowing = false) => {\n const className = this.player.config.classNames.previewThumbnails.thumbContainerShown;\n this.elements.thumb.container.classList.toggle(className, toggle);\n\n if (!toggle && clearShowing) {\n this.showingThumb = null;\n this.showingThumbFilename = null;\n }\n };\n\n toggleScrubbingContainer = (toggle = false) => {\n const className = this.player.config.classNames.previewThumbnails.scrubbingContainerShown;\n this.elements.scrubbing.container.classList.toggle(className, toggle);\n\n if (!toggle) {\n this.showingThumb = null;\n this.showingThumbFilename = null;\n }\n };\n\n determineContainerAutoSizing = () => {\n if (this.elements.thumb.imageContainer.clientHeight > 20 || this.elements.thumb.imageContainer.clientWidth > 20) {\n // This will prevent auto sizing in this.setThumbContainerSizeAndPos()\n this.sizeSpecifiedInCSS = true;\n }\n };\n\n // Set the size to be about a quarter of the size of video. Unless option dynamicSize === false, in which case it needs to be set in CSS\n setThumbContainerSizeAndPos = () => {\n const { imageContainer } = this.elements.thumb;\n\n if (!this.sizeSpecifiedInCSS) {\n const thumbWidth = Math.floor(this.thumbContainerHeight * this.thumbAspectRatio);\n imageContainer.style.height = `${this.thumbContainerHeight}px`;\n imageContainer.style.width = `${thumbWidth}px`;\n } else if (imageContainer.clientHeight > 20 && imageContainer.clientWidth < 20) {\n const thumbWidth = Math.floor(imageContainer.clientHeight * this.thumbAspectRatio);\n imageContainer.style.width = `${thumbWidth}px`;\n } else if (imageContainer.clientHeight < 20 && imageContainer.clientWidth > 20) {\n const thumbHeight = Math.floor(imageContainer.clientWidth / this.thumbAspectRatio);\n imageContainer.style.height = `${thumbHeight}px`;\n }\n\n this.setThumbContainerPos();\n };\n\n setThumbContainerPos = () => {\n const scrubberRect = this.player.elements.progress.getBoundingClientRect();\n const containerRect = this.player.elements.container.getBoundingClientRect();\n const { container } = this.elements.thumb;\n // Find the lowest and highest desired left-position, so we don't slide out the side of the video container\n const min = containerRect.left - scrubberRect.left + 10;\n const max = containerRect.right - scrubberRect.left - container.clientWidth - 10;\n // Set preview container position to: mousepos, minus seekbar.left, minus half of previewContainer.clientWidth\n const position = this.mousePosX - scrubberRect.left - container.clientWidth / 2;\n const clamped = clamp(position, min, max);\n\n // Move the popover position\n container.style.left = `${clamped}px`;\n\n // The arrow can follow the cursor\n container.style.setProperty('--preview-arrow-offset', `${position - clamped}px`);\n };\n\n // Can't use 100% width, in case the video is a different aspect ratio to the video container\n setScrubbingContainerSize = () => {\n const { width, height } = fitRatio(this.thumbAspectRatio, {\n width: this.player.media.clientWidth,\n height: this.player.media.clientHeight,\n });\n this.elements.scrubbing.container.style.width = `${width}px`;\n this.elements.scrubbing.container.style.height = `${height}px`;\n };\n\n // Sprites need to be offset to the correct location\n setImageSizeAndOffset = (previewImage, frame) => {\n if (!this.usingSprites) return;\n\n // Find difference between height and preview container height\n const multiplier = this.thumbContainerHeight / frame.h;\n\n // eslint-disable-next-line no-param-reassign\n previewImage.style.height = `${previewImage.naturalHeight * multiplier}px`;\n // eslint-disable-next-line no-param-reassign\n previewImage.style.width = `${previewImage.naturalWidth * multiplier}px`;\n // eslint-disable-next-line no-param-reassign\n previewImage.style.left = `-${frame.x * multiplier}px`;\n // eslint-disable-next-line no-param-reassign\n previewImage.style.top = `-${frame.y * multiplier}px`;\n };\n}\n\nexport default PreviewThumbnails;\n","// ==========================================================================\n// Plyr source update\n// ==========================================================================\n\nimport { providers } from './config/types';\nimport html5 from './html5';\nimport media from './media';\nimport PreviewThumbnails from './plugins/preview-thumbnails';\nimport support from './support';\nimport ui from './ui';\nimport { createElement, insertElement, removeElement } from './utils/elements';\nimport is from './utils/is';\nimport { getDeep } from './utils/objects';\n\nconst source = {\n // Add elements to HTML5 media (source, tracks, etc)\n insertElements(type, attributes) {\n if (is.string(attributes)) {\n insertElement(type, this.media, {\n src: attributes,\n });\n } else if (is.array(attributes)) {\n attributes.forEach((attribute) => {\n insertElement(type, this.media, attribute);\n });\n }\n },\n\n // Update source\n // Sources are not checked for support so be careful\n change(input) {\n if (!getDeep(input, 'sources.length')) {\n this.debug.warn('Invalid source format');\n return;\n }\n\n // Cancel current network requests\n html5.cancelRequests.call(this);\n\n // Destroy instance and re-setup\n this.destroy.call(\n this,\n () => {\n // Reset quality options\n this.options.quality = [];\n\n // Remove elements\n removeElement(this.media);\n this.media = null;\n\n // Reset class name\n if (is.element(this.elements.container)) {\n this.elements.container.removeAttribute('class');\n }\n\n // Set the type and provider\n const { sources, type } = input;\n const [{ provider = providers.html5, src }] = sources;\n const tagName = provider === 'html5' ? type : 'div';\n const attributes = provider === 'html5' ? {} : { src };\n\n Object.assign(this, {\n provider,\n type,\n // Check for support\n supported: support.check(type, provider, this.config.playsinline),\n // Create new element\n media: createElement(tagName, attributes),\n });\n\n // Inject the new element\n this.elements.container.appendChild(this.media);\n\n // Autoplay the new source?\n if (is.boolean(input.autoplay)) {\n this.config.autoplay = input.autoplay;\n }\n\n // Set attributes for audio and video\n if (this.isHTML5) {\n if (this.config.crossorigin) {\n this.media.setAttribute('crossorigin', '');\n }\n if (this.config.autoplay) {\n this.media.setAttribute('autoplay', '');\n }\n if (!is.empty(input.poster)) {\n this.poster = input.poster;\n }\n if (this.config.loop.active) {\n this.media.setAttribute('loop', '');\n }\n if (this.config.muted) {\n this.media.setAttribute('muted', '');\n }\n if (this.config.playsinline) {\n this.media.setAttribute('playsinline', '');\n }\n }\n\n // Restore class hook\n ui.addStyleHook.call(this);\n\n // Set new sources for html5\n if (this.isHTML5) {\n source.insertElements.call(this, 'source', sources);\n }\n\n // Set video title\n this.config.title = input.title;\n\n // Set up from scratch\n media.setup.call(this);\n\n // HTML5 stuff\n if (this.isHTML5) {\n // Setup captions\n if (Object.keys(input).includes('tracks')) {\n source.insertElements.call(this, 'track', input.tracks);\n }\n }\n\n // If HTML5 or embed but not fully supported, setupInterface and call ready now\n if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {\n // Setup interface\n ui.build.call(this);\n }\n\n // Load HTML5 sources\n if (this.isHTML5) {\n this.media.load();\n }\n\n // Update previewThumbnails config & reload plugin\n if (!is.empty(input.previewThumbnails)) {\n Object.assign(this.config.previewThumbnails, input.previewThumbnails);\n\n // Cleanup previewThumbnails plugin if it was loaded\n if (this.previewThumbnails && this.previewThumbnails.loaded) {\n this.previewThumbnails.destroy();\n this.previewThumbnails = null;\n }\n\n // Create new instance if it is still enabled\n if (this.config.previewThumbnails.enabled) {\n this.previewThumbnails = new PreviewThumbnails(this);\n }\n }\n\n // Update the fullscreen support\n this.fullscreen.update();\n },\n true,\n );\n },\n};\n\nexport default source;\n","// ==========================================================================\n// Plyr\n// plyr.js v3.7.8\n// https://github.com/sampotts/plyr\n// License: The MIT License (MIT)\n// ==========================================================================\n\nimport captions from './captions';\nimport defaults from './config/defaults';\nimport { pip } from './config/states';\nimport { getProviderByUrl, providers, types } from './config/types';\nimport Console from './console';\nimport controls from './controls';\nimport Fullscreen from './fullscreen';\nimport html5 from './html5';\nimport Listeners from './listeners';\nimport media from './media';\nimport Ads from './plugins/ads';\nimport PreviewThumbnails from './plugins/preview-thumbnails';\nimport source from './source';\nimport Storage from './storage';\nimport support from './support';\nimport ui from './ui';\nimport { closest } from './utils/arrays';\nimport { createElement, hasClass, removeElement, replaceElement, toggleClass, wrap } from './utils/elements';\nimport { off, on, once, triggerEvent, unbindListeners } from './utils/events';\nimport is from './utils/is';\nimport loadSprite from './utils/load-sprite';\nimport { clamp } from './utils/numbers';\nimport { cloneDeep, extend } from './utils/objects';\nimport { silencePromise } from './utils/promise';\nimport { getAspectRatio, reduceAspectRatio, setAspectRatio, validateAspectRatio } from './utils/style';\nimport { parseUrl } from './utils/urls';\n\n// Private properties\n// TODO: Use a WeakMap for private globals\n// const globals = new WeakMap();\n\n// Plyr instance\nclass Plyr {\n constructor(target, options) {\n this.timers = {};\n\n // State\n this.ready = false;\n this.loading = false;\n this.failed = false;\n\n // Touch device\n this.touch = support.touch;\n\n // Set the media element\n this.media = target;\n\n // String selector passed\n if (is.string(this.media)) {\n this.media = document.querySelectorAll(this.media);\n }\n\n // jQuery, NodeList or Array passed, use first element\n if ((window.jQuery && this.media instanceof jQuery) || is.nodeList(this.media) || is.array(this.media)) {\n // eslint-disable-next-line\n this.media = this.media[0];\n }\n\n // Set config\n this.config = extend(\n {},\n defaults,\n Plyr.defaults,\n options || {},\n (() => {\n try {\n return JSON.parse(this.media.getAttribute('data-plyr-config'));\n } catch (_) {\n return {};\n }\n })(),\n );\n\n // Elements cache\n this.elements = {\n container: null,\n fullscreen: null,\n captions: null,\n buttons: {},\n display: {},\n progress: {},\n inputs: {},\n settings: {\n popup: null,\n menu: null,\n panels: {},\n buttons: {},\n },\n };\n\n // Captions\n this.captions = {\n active: null,\n currentTrack: -1,\n meta: new WeakMap(),\n };\n\n // Fullscreen\n this.fullscreen = {\n active: false,\n };\n\n // Options\n this.options = {\n speed: [],\n quality: [],\n };\n\n // Debugging\n // TODO: move to globals\n this.debug = new Console(this.config.debug);\n\n // Log config options and support\n this.debug.log('Config', this.config);\n this.debug.log('Support', support);\n\n // We need an element to setup\n if (is.nullOrUndefined(this.media) || !is.element(this.media)) {\n this.debug.error('Setup failed: no suitable element passed');\n return;\n }\n\n // Bail if the element is initialized\n if (this.media.plyr) {\n this.debug.warn('Target already setup');\n return;\n }\n\n // Bail if not enabled\n if (!this.config.enabled) {\n this.debug.error('Setup failed: disabled by config');\n return;\n }\n\n // Bail if disabled or no basic support\n // You may want to disable certain UAs etc\n if (!support.check().api) {\n this.debug.error('Setup failed: no support');\n return;\n }\n\n // Cache original element state for .destroy()\n const clone = this.media.cloneNode(true);\n clone.autoplay = false;\n this.elements.original = clone;\n\n // Set media type based on tag or data attribute\n // Supported: video, audio, vimeo, youtube\n const type = this.media.tagName.toLowerCase();\n // Embed properties\n let iframe = null;\n let url = null;\n\n // Different setup based on type\n switch (type) {\n case 'div':\n // Find the frame\n iframe = this.media.querySelector('iframe');\n\n // <iframe> type\n if (is.element(iframe)) {\n // Detect provider\n url = parseUrl(iframe.getAttribute('src'));\n this.provider = getProviderByUrl(url.toString());\n\n // Rework elements\n this.elements.container = this.media;\n this.media = iframe;\n\n // Reset classname\n this.elements.container.className = '';\n\n // Get attributes from URL and set config\n if (url.search.length) {\n const truthy = ['1', 'true'];\n\n if (truthy.includes(url.searchParams.get('autoplay'))) {\n this.config.autoplay = true;\n }\n if (truthy.includes(url.searchParams.get('loop'))) {\n this.config.loop.active = true;\n }\n\n // TODO: replace fullscreen.iosNative with this playsinline config option\n // YouTube requires the playsinline in the URL\n if (this.isYouTube) {\n this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));\n this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?\n } else {\n this.config.playsinline = true;\n }\n }\n } else {\n // <div> with attributes\n this.provider = this.media.getAttribute(this.config.attributes.embed.provider);\n\n // Remove attribute\n this.media.removeAttribute(this.config.attributes.embed.provider);\n }\n\n // Unsupported or missing provider\n if (is.empty(this.provider) || !Object.values(providers).includes(this.provider)) {\n this.debug.error('Setup failed: Invalid provider');\n return;\n }\n\n // Audio will come later for external providers\n this.type = types.video;\n\n break;\n\n case 'video':\n case 'audio':\n this.type = type;\n this.provider = providers.html5;\n\n // Get config from attributes\n if (this.media.hasAttribute('crossorigin')) {\n this.config.crossorigin = true;\n }\n if (this.media.hasAttribute('autoplay')) {\n this.config.autoplay = true;\n }\n if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {\n this.config.playsinline = true;\n }\n if (this.media.hasAttribute('muted')) {\n this.config.muted = true;\n }\n if (this.media.hasAttribute('loop')) {\n this.config.loop.active = true;\n }\n\n break;\n\n default:\n this.debug.error('Setup failed: unsupported type');\n return;\n }\n\n // Check for support again but with type\n this.supported = support.check(this.type, this.provider);\n\n // If no support for even API, bail\n if (!this.supported.api) {\n this.debug.error('Setup failed: no support');\n return;\n }\n\n this.eventListeners = [];\n\n // Create listeners\n this.listeners = new Listeners(this);\n\n // Setup local storage for user settings\n this.storage = new Storage(this);\n\n // Store reference\n this.media.plyr = this;\n\n // Wrap media\n if (!is.element(this.elements.container)) {\n this.elements.container = createElement('div');\n wrap(this.media, this.elements.container);\n }\n\n // Migrate custom properties from media to container (so they work 😉)\n ui.migrateStyles.call(this);\n\n // Add style hook\n ui.addStyleHook.call(this);\n\n // Setup media\n media.setup.call(this);\n\n // Listen for events if debugging\n if (this.config.debug) {\n on.call(this, this.elements.container, this.config.events.join(' '), (event) => {\n this.debug.log(`event: ${event.type}`);\n });\n }\n\n // Setup fullscreen\n this.fullscreen = new Fullscreen(this);\n\n // Setup interface\n // If embed but not fully supported, build interface now to avoid flash of controls\n if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {\n ui.build.call(this);\n }\n\n // Container listeners\n this.listeners.container();\n\n // Global listeners\n this.listeners.global();\n\n // Setup ads if provided\n if (this.config.ads.enabled) {\n this.ads = new Ads(this);\n }\n\n // Autoplay if required\n if (this.isHTML5 && this.config.autoplay) {\n this.once('canplay', () => silencePromise(this.play()));\n }\n\n // Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek\n this.lastSeekTime = 0;\n\n // Setup preview thumbnails if enabled\n if (this.config.previewThumbnails.enabled) {\n this.previewThumbnails = new PreviewThumbnails(this);\n }\n }\n\n // ---------------------------------------\n // API\n // ---------------------------------------\n\n /**\n * Types and provider helpers\n */\n get isHTML5() {\n return this.provider === providers.html5;\n }\n\n get isEmbed() {\n return this.isYouTube || this.isVimeo;\n }\n\n get isYouTube() {\n return this.provider === providers.youtube;\n }\n\n get isVimeo() {\n return this.provider === providers.vimeo;\n }\n\n get isVideo() {\n return this.type === types.video;\n }\n\n get isAudio() {\n return this.type === types.audio;\n }\n\n /**\n * Play the media, or play the advertisement (if they are not blocked)\n */\n play = () => {\n if (!is.function(this.media.play)) {\n return null;\n }\n\n // Intecept play with ads\n if (this.ads && this.ads.enabled) {\n this.ads.managerPromise.then(() => this.ads.play()).catch(() => silencePromise(this.media.play()));\n }\n\n // Return the promise (for HTML5)\n return this.media.play();\n };\n\n /**\n * Pause the media\n */\n pause = () => {\n if (!this.playing || !is.function(this.media.pause)) {\n return null;\n }\n\n return this.media.pause();\n };\n\n /**\n * Get playing state\n */\n get playing() {\n return Boolean(this.ready && !this.paused && !this.ended);\n }\n\n /**\n * Get paused state\n */\n get paused() {\n return Boolean(this.media.paused);\n }\n\n /**\n * Get stopped state\n */\n get stopped() {\n return Boolean(this.paused && this.currentTime === 0);\n }\n\n /**\n * Get ended state\n */\n get ended() {\n return Boolean(this.media.ended);\n }\n\n /**\n * Toggle playback based on current status\n * @param {Boolean} input\n */\n togglePlay = (input) => {\n // Toggle based on current state if nothing passed\n const toggle = is.boolean(input) ? input : !this.playing;\n\n if (toggle) {\n return this.play();\n }\n\n return this.pause();\n };\n\n /**\n * Stop playback\n */\n stop = () => {\n if (this.isHTML5) {\n this.pause();\n this.restart();\n } else if (is.function(this.media.stop)) {\n this.media.stop();\n }\n };\n\n /**\n * Restart playback\n */\n restart = () => {\n this.currentTime = 0;\n };\n\n /**\n * Rewind\n * @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime\n */\n rewind = (seekTime) => {\n this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime;\n };\n\n /**\n * Fast forward\n * @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime\n */\n forward = (seekTime) => {\n this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime;\n };\n\n /**\n * Seek to a time\n * @param {Number} input - where to seek to in seconds. Defaults to 0 (the start)\n */\n set currentTime(input) {\n // Bail if media duration isn't available yet\n if (!this.duration) {\n return;\n }\n\n // Validate input\n const inputIsValid = is.number(input) && input > 0;\n\n // Set\n this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;\n\n // Logging\n this.debug.log(`Seeking to ${this.currentTime} seconds`);\n }\n\n /**\n * Get current time\n */\n get currentTime() {\n return Number(this.media.currentTime);\n }\n\n /**\n * Get buffered\n */\n get buffered() {\n const { buffered } = this.media;\n\n // YouTube / Vimeo return a float between 0-1\n if (is.number(buffered)) {\n return buffered;\n }\n\n // HTML5\n // TODO: Handle buffered chunks of the media\n // (i.e. seek to another section buffers only that section)\n if (buffered && buffered.length && this.duration > 0) {\n return buffered.end(0) / this.duration;\n }\n\n return 0;\n }\n\n /**\n * Get seeking status\n */\n get seeking() {\n return Boolean(this.media.seeking);\n }\n\n /**\n * Get the duration of the current media\n */\n get duration() {\n // Faux duration set via config\n const fauxDuration = parseFloat(this.config.duration);\n // Media duration can be NaN or Infinity before the media has loaded\n const realDuration = (this.media || {}).duration;\n const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration;\n\n // If config duration is funky, use regular duration\n return fauxDuration || duration;\n }\n\n /**\n * Set the player volume\n * @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage\n */\n set volume(value) {\n let volume = value;\n const max = 1;\n const min = 0;\n\n if (is.string(volume)) {\n volume = Number(volume);\n }\n\n // Load volume from storage if no value specified\n if (!is.number(volume)) {\n volume = this.storage.get('volume');\n }\n\n // Use config if all else fails\n if (!is.number(volume)) {\n ({ volume } = this.config);\n }\n\n // Maximum is volumeMax\n if (volume > max) {\n volume = max;\n }\n // Minimum is volumeMin\n if (volume < min) {\n volume = min;\n }\n\n // Update config\n this.config.volume = volume;\n\n // Set the player volume\n this.media.volume = volume;\n\n // If muted, and we're increasing volume manually, reset muted state\n if (!is.empty(value) && this.muted && volume > 0) {\n this.muted = false;\n }\n }\n\n /**\n * Get the current player volume\n */\n get volume() {\n return Number(this.media.volume);\n }\n\n /**\n * Increase volume\n * @param {Boolean} step - How much to decrease by (between 0 and 1)\n */\n increaseVolume = (step) => {\n const volume = this.media.muted ? 0 : this.volume;\n this.volume = volume + (is.number(step) ? step : 0);\n };\n\n /**\n * Decrease volume\n * @param {Boolean} step - How much to decrease by (between 0 and 1)\n */\n decreaseVolume = (step) => {\n this.increaseVolume(-step);\n };\n\n /**\n * Set muted state\n * @param {Boolean} mute\n */\n set muted(mute) {\n let toggle = mute;\n\n // Load muted state from storage\n if (!is.boolean(toggle)) {\n toggle = this.storage.get('muted');\n }\n\n // Use config if all else fails\n if (!is.boolean(toggle)) {\n toggle = this.config.muted;\n }\n\n // Update config\n this.config.muted = toggle;\n\n // Set mute on the player\n this.media.muted = toggle;\n }\n\n /**\n * Get current muted state\n */\n get muted() {\n return Boolean(this.media.muted);\n }\n\n /**\n * Check if the media has audio\n */\n get hasAudio() {\n // Assume yes for all non HTML5 (as we can't tell...)\n if (!this.isHTML5) {\n return true;\n }\n\n if (this.isAudio) {\n return true;\n }\n\n // Get audio tracks\n return (\n Boolean(this.media.mozHasAudio) ||\n Boolean(this.media.webkitAudioDecodedByteCount) ||\n Boolean(this.media.audioTracks && this.media.audioTracks.length)\n );\n }\n\n /**\n * Set playback speed\n * @param {Number} input - the speed of playback (0.5-2.0)\n */\n set speed(input) {\n let speed = null;\n\n if (is.number(input)) {\n speed = input;\n }\n\n if (!is.number(speed)) {\n speed = this.storage.get('speed');\n }\n\n if (!is.number(speed)) {\n speed = this.config.speed.selected;\n }\n\n // Clamp to min/max\n const { minimumSpeed: min, maximumSpeed: max } = this;\n speed = clamp(speed, min, max);\n\n // Update config\n this.config.speed.selected = speed;\n\n // Set media speed\n setTimeout(() => {\n if (this.media) {\n this.media.playbackRate = speed;\n }\n }, 0);\n }\n\n /**\n * Get current playback speed\n */\n get speed() {\n return Number(this.media.playbackRate);\n }\n\n /**\n * Get the minimum allowed speed\n */\n get minimumSpeed() {\n if (this.isYouTube) {\n // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate\n return Math.min(...this.options.speed);\n }\n\n if (this.isVimeo) {\n // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror\n return 0.5;\n }\n\n // https://stackoverflow.com/a/32320020/1191319\n return 0.0625;\n }\n\n /**\n * Get the maximum allowed speed\n */\n get maximumSpeed() {\n if (this.isYouTube) {\n // https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate\n return Math.max(...this.options.speed);\n }\n\n if (this.isVimeo) {\n // https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror\n return 2;\n }\n\n // https://stackoverflow.com/a/32320020/1191319\n return 16;\n }\n\n /**\n * Set playback quality\n * Currently HTML5 & YouTube only\n * @param {Number} input - Quality level\n */\n set quality(input) {\n const config = this.config.quality;\n const options = this.options.quality;\n\n if (!options.length) {\n return;\n }\n\n let quality = [\n !is.empty(input) && Number(input),\n this.storage.get('quality'),\n config.selected,\n config.default,\n ].find(is.number);\n\n let updateStorage = true;\n\n if (!options.includes(quality)) {\n const value = closest(options, quality);\n this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);\n quality = value;\n\n // Don't update storage if quality is not supported\n updateStorage = false;\n }\n\n // Update config\n config.selected = quality;\n\n // Set quality\n this.media.quality = quality;\n\n // Save to storage\n if (updateStorage) {\n this.storage.set({ quality });\n }\n }\n\n /**\n * Get current quality level\n */\n get quality() {\n return this.media.quality;\n }\n\n /**\n * Toggle loop\n * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config\n * @param {Boolean} input - Whether to loop or not\n */\n set loop(input) {\n const toggle = is.boolean(input) ? input : this.config.loop.active;\n this.config.loop.active = toggle;\n this.media.loop = toggle;\n\n // Set default to be a true toggle\n /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';\n\n switch (type) {\n case 'start':\n if (this.config.loop.end && this.config.loop.end <= this.currentTime) {\n this.config.loop.end = null;\n }\n this.config.loop.start = this.currentTime;\n // this.config.loop.indicator.start = this.elements.display.played.value;\n break;\n\n case 'end':\n if (this.config.loop.start >= this.currentTime) {\n return this;\n }\n this.config.loop.end = this.currentTime;\n // this.config.loop.indicator.end = this.elements.display.played.value;\n break;\n\n case 'all':\n this.config.loop.start = 0;\n this.config.loop.end = this.duration - 2;\n this.config.loop.indicator.start = 0;\n this.config.loop.indicator.end = 100;\n break;\n\n case 'toggle':\n if (this.config.loop.active) {\n this.config.loop.start = 0;\n this.config.loop.end = null;\n } else {\n this.config.loop.start = 0;\n this.config.loop.end = this.duration - 2;\n }\n break;\n\n default:\n this.config.loop.start = 0;\n this.config.loop.end = null;\n break;\n } */\n }\n\n /**\n * Get current loop state\n */\n get loop() {\n return Boolean(this.media.loop);\n }\n\n /**\n * Set new media source\n * @param {Object} input - The new source object (see docs)\n */\n set source(input) {\n source.change.call(this, input);\n }\n\n /**\n * Get current source\n */\n get source() {\n return this.media.currentSrc;\n }\n\n /**\n * Get a download URL (either source or custom)\n */\n get download() {\n const { download } = this.config.urls;\n\n return is.url(download) ? download : this.source;\n }\n\n /**\n * Set the download URL\n */\n set download(input) {\n if (!is.url(input)) {\n return;\n }\n\n this.config.urls.download = input;\n\n controls.setDownloadUrl.call(this);\n }\n\n /**\n * Set the poster image for a video\n * @param {String} input - the URL for the new poster image\n */\n set poster(input) {\n if (!this.isVideo) {\n this.debug.warn('Poster can only be set for video');\n return;\n }\n\n ui.setPoster.call(this, input, false).catch(() => {});\n }\n\n /**\n * Get the current poster image\n */\n get poster() {\n if (!this.isVideo) {\n return null;\n }\n\n return this.media.getAttribute('poster') || this.media.getAttribute('data-poster');\n }\n\n /**\n * Get the current aspect ratio in use\n */\n get ratio() {\n if (!this.isVideo) {\n return null;\n }\n\n const ratio = reduceAspectRatio(getAspectRatio.call(this));\n\n return is.array(ratio) ? ratio.join(':') : ratio;\n }\n\n /**\n * Set video aspect ratio\n */\n set ratio(input) {\n if (!this.isVideo) {\n this.debug.warn('Aspect ratio can only be set for video');\n return;\n }\n\n if (!is.string(input) || !validateAspectRatio(input)) {\n this.debug.error(`Invalid aspect ratio specified (${input})`);\n return;\n }\n\n this.config.ratio = reduceAspectRatio(input);\n\n setAspectRatio.call(this);\n }\n\n /**\n * Set the autoplay state\n * @param {Boolean} input - Whether to autoplay or not\n */\n set autoplay(input) {\n this.config.autoplay = is.boolean(input) ? input : this.config.autoplay;\n }\n\n /**\n * Get the current autoplay state\n */\n get autoplay() {\n return Boolean(this.config.autoplay);\n }\n\n /**\n * Toggle captions\n * @param {Boolean} input - Whether to enable captions\n */\n toggleCaptions(input) {\n captions.toggle.call(this, input, false);\n }\n\n /**\n * Set the caption track by index\n * @param {Number} input - Caption index\n */\n set currentTrack(input) {\n captions.set.call(this, input, false);\n captions.setup.call(this);\n }\n\n /**\n * Get the current caption track index (-1 if disabled)\n */\n get currentTrack() {\n const { toggled, currentTrack } = this.captions;\n return toggled ? currentTrack : -1;\n }\n\n /**\n * Set the wanted language for captions\n * Since tracks can be added later it won't update the actual caption track until there is a matching track\n * @param {String} input - Two character ISO language code (e.g. EN, FR, PT, etc)\n */\n set language(input) {\n captions.setLanguage.call(this, input, false);\n }\n\n /**\n * Get the current track's language\n */\n get language() {\n return (captions.getCurrentTrack.call(this) || {}).language;\n }\n\n /**\n * Toggle picture-in-picture playback on WebKit/MacOS\n * TODO: update player with state, support, enabled\n * TODO: detect outside changes\n */\n set pip(input) {\n // Bail if no support\n if (!support.pip) {\n return;\n }\n\n // Toggle based on current state if not passed\n const toggle = is.boolean(input) ? input : !this.pip;\n\n // Toggle based on current state\n // Safari\n if (is.function(this.media.webkitSetPresentationMode)) {\n this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive);\n }\n\n // Chrome\n if (is.function(this.media.requestPictureInPicture)) {\n if (!this.pip && toggle) {\n this.media.requestPictureInPicture();\n } else if (this.pip && !toggle) {\n document.exitPictureInPicture();\n }\n }\n }\n\n /**\n * Get the current picture-in-picture state\n */\n get pip() {\n if (!support.pip) {\n return null;\n }\n\n // Safari\n if (!is.empty(this.media.webkitPresentationMode)) {\n return this.media.webkitPresentationMode === pip.active;\n }\n\n // Chrome\n return this.media === document.pictureInPictureElement;\n }\n\n /**\n * Sets the preview thumbnails for the current source\n */\n setPreviewThumbnails(thumbnailSource) {\n if (this.previewThumbnails && this.previewThumbnails.loaded) {\n this.previewThumbnails.destroy();\n this.previewThumbnails = null;\n }\n\n Object.assign(this.config.previewThumbnails, thumbnailSource);\n\n // Create new instance if it is still enabled\n if (this.config.previewThumbnails.enabled) {\n this.previewThumbnails = new PreviewThumbnails(this);\n }\n }\n\n /**\n * Trigger the airplay dialog\n * TODO: update player with state, support, enabled\n */\n airplay = () => {\n // Show dialog if supported\n if (support.airplay) {\n this.media.webkitShowPlaybackTargetPicker();\n }\n };\n\n /**\n * Toggle the player controls\n * @param {Boolean} [toggle] - Whether to show the controls\n */\n toggleControls = (toggle) => {\n // Don't toggle if missing UI support or if it's audio\n if (this.supported.ui && !this.isAudio) {\n // Get state before change\n const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);\n // Negate the argument if not undefined since adding the class to hides the controls\n const force = typeof toggle === 'undefined' ? undefined : !toggle;\n // Apply and get updated state\n const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);\n\n // Close menu\n if (\n hiding &&\n is.array(this.config.controls) &&\n this.config.controls.includes('settings') &&\n !is.empty(this.config.settings)\n ) {\n controls.toggleMenu.call(this, false);\n }\n\n // Trigger event on change\n if (hiding !== isHidden) {\n const eventName = hiding ? 'controlshidden' : 'controlsshown';\n triggerEvent.call(this, this.media, eventName);\n }\n\n return !hiding;\n }\n\n return false;\n };\n\n /**\n * Add event listeners\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n */\n on = (event, callback) => {\n on.call(this, this.elements.container, event, callback);\n };\n\n /**\n * Add event listeners once\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n */\n once = (event, callback) => {\n once.call(this, this.elements.container, event, callback);\n };\n\n /**\n * Remove event listeners\n * @param {String} event - Event type\n * @param {Function} callback - Callback for when event occurs\n */\n off = (event, callback) => {\n off(this.elements.container, event, callback);\n };\n\n /**\n * Destroy an instance\n * Event listeners are removed when elements are removed\n * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory\n * @param {Function} callback - Callback for when destroy is complete\n * @param {Boolean} soft - Whether it's a soft destroy (for source changes etc)\n */\n destroy = (callback, soft = false) => {\n if (!this.ready) {\n return;\n }\n\n const done = () => {\n // Reset overflow (incase destroyed while in fullscreen)\n document.body.style.overflow = '';\n\n // GC for embed\n this.embed = null;\n\n // If it's a soft destroy, make minimal changes\n if (soft) {\n if (Object.keys(this.elements).length) {\n // Remove elements\n removeElement(this.elements.buttons.play);\n removeElement(this.elements.captions);\n removeElement(this.elements.controls);\n removeElement(this.elements.wrapper);\n\n // Clear for GC\n this.elements.buttons.play = null;\n this.elements.captions = null;\n this.elements.controls = null;\n this.elements.wrapper = null;\n }\n\n // Callback\n if (is.function(callback)) {\n callback();\n }\n } else {\n // Unbind listeners\n unbindListeners.call(this);\n\n // Cancel current network requests\n html5.cancelRequests.call(this);\n\n // Replace the container with the original element provided\n replaceElement(this.elements.original, this.elements.container);\n\n // Event\n triggerEvent.call(this, this.elements.original, 'destroyed', true);\n\n // Callback\n if (is.function(callback)) {\n callback.call(this.elements.original);\n }\n\n // Reset state\n this.ready = false;\n\n // Clear for garbage collection\n setTimeout(() => {\n this.elements = null;\n this.media = null;\n }, 200);\n }\n };\n\n // Stop playback\n this.stop();\n\n // Clear timeouts\n clearTimeout(this.timers.loading);\n clearTimeout(this.timers.controls);\n clearTimeout(this.timers.resized);\n\n // Provider specific stuff\n if (this.isHTML5) {\n // Restore native video controls\n ui.toggleNativeControls.call(this, true);\n\n // Clean up\n done();\n } else if (this.isYouTube) {\n // Clear timers\n clearInterval(this.timers.buffering);\n clearInterval(this.timers.playing);\n\n // Destroy YouTube API\n if (this.embed !== null && is.function(this.embed.destroy)) {\n this.embed.destroy();\n }\n\n // Clean up\n done();\n } else if (this.isVimeo) {\n // Destroy Vimeo API\n // then clean up (wait, to prevent postmessage errors)\n if (this.embed !== null) {\n this.embed.unload().then(done);\n }\n\n // Vimeo does not always return\n setTimeout(done, 200);\n }\n };\n\n /**\n * Check for support for a mime type (HTML5 only)\n * @param {String} type - Mime type\n */\n supports = (type) => support.mime.call(this, type);\n\n /**\n * Check for support\n * @param {String} type - Player type (audio/video)\n * @param {String} provider - Provider (html5/youtube/vimeo)\n */\n static supported(type, provider) {\n return support.check(type, provider);\n }\n\n /**\n * Load an SVG sprite into the page\n * @param {String} url - URL for the SVG sprite\n * @param {String} [id] - Unique ID\n */\n static loadSprite(url, id) {\n return loadSprite(url, id);\n }\n\n /**\n * Setup multiple instances\n * @param {*} selector\n * @param {Object} options\n */\n static setup(selector, options = {}) {\n let targets = null;\n\n if (is.string(selector)) {\n targets = Array.from(document.querySelectorAll(selector));\n } else if (is.nodeList(selector)) {\n targets = Array.from(selector);\n } else if (is.array(selector)) {\n targets = selector.filter(is.element);\n }\n\n if (is.empty(targets)) {\n return null;\n }\n\n return targets.map((t) => new Plyr(t, options));\n }\n}\n\nPlyr.defaults = cloneDeep(defaults);\n\nexport default Plyr;\n"]} \ No newline at end of file
diff --git a/youtube/static/modules/plyr/plyr.svg b/youtube/static/modules/plyr/plyr.svg
new file mode 100644
index 0000000..62ab257
--- /dev/null
+++ b/youtube/static/modules/plyr/plyr.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><symbol id="plyr-airplay" viewBox="0 0 18 18"><path d="M16 1H2a1 1 0 00-1 1v10a1 1 0 001 1h3v-2H3V3h12v8h-2v2h3a1 1 0 001-1V2a1 1 0 00-1-1z"/><path d="M4 17h10l-5-6z"/></symbol><symbol id="plyr-captions-off" viewBox="0 0 18 18"><path d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3.3 0 .5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1H1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7 0 2.26 1.65 3.69 3.63 3.69zm7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7 0 2.26 1.65 3.69 3.63 3.69z" fill-rule="evenodd" fill-opacity=".5"/></symbol><symbol id="plyr-captions-on" viewBox="0 0 18 18"><path d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3.3 0 .5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1H1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7 0 2.26 1.65 3.69 3.63 3.69zm7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7 0 2.26 1.65 3.69 3.63 3.69z" fill-rule="evenodd"/></symbol><symbol id="plyr-download" viewBox="0 0 18 18"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zm-7 2h14v2H2z"/></symbol><symbol id="plyr-enter-fullscreen" viewBox="0 0 18 18"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"/></symbol><symbol id="plyr-exit-fullscreen" viewBox="0 0 18 18"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"/></symbol><symbol id="plyr-fast-forward" viewBox="0 0 18 18"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z"/></symbol><symbol id="plyr-logo-vimeo" viewBox="0 0 18 18"><path d="M17 5.3c-.1 1.6-1.2 3.7-3.3 6.4-2.2 2.8-4 4.2-5.5 4.2-.9 0-1.7-.9-2.4-2.6C5 10.9 4.4 6 3 6c-.1 0-.5.3-1.2.8l-.8-1c.8-.7 3.5-3.4 4.7-3.5 1.2-.1 2 .7 2.3 2.5.3 2 .8 6.1 1.8 6.1.9 0 2.5-3.4 2.6-4 .1-.9-.3-1.9-2.3-1.1.8-2.6 2.3-3.8 4.5-3.8 1.7.1 2.5 1.2 2.4 3.3z"/></symbol><symbol id="plyr-logo-youtube" viewBox="0 0 18 18"><path d="M16.8 5.8c-.2-1.3-.8-2.2-2.2-2.4C12.4 3 9 3 9 3s-3.4 0-5.6.4C2 3.6 1.3 4.5 1.2 5.8 1 7.1 1 9 1 9s0 1.9.2 3.2c.2 1.3.8 2.2 2.2 2.4C5.6 15 9 15 9 15s3.4 0 5.6-.4c1.4-.3 2-1.1 2.2-2.4.2-1.3.2-3.2.2-3.2s0-1.9-.2-3.2zM7 12V6l5 3-5 3z"/></symbol><symbol id="plyr-muted" viewBox="0 0 18 18"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol><symbol id="plyr-pause" viewBox="0 0 18 18"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm6 0c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z"/></symbol><symbol id="plyr-pip" viewBox="0 0 18 18"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z"/><path d="M13 15H3V5h5V3H2a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1v-6h-2v5z"/></symbol><symbol id="plyr-play" viewBox="0 0 18 18"><path d="M15.562 8.1L3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"/></symbol><symbol id="plyr-restart" viewBox="0 0 18 18"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z"/></symbol><symbol id="plyr-rewind" viewBox="0 0 18 18"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z"/></symbol><symbol id="plyr-settings" viewBox="0 0 18 18"><path d="M16.135 7.784a2 2 0 01-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 01-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 01-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 01-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 011.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 012.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 012.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 011.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 110-6 3 3 0 010 6z"/></symbol><symbol id="plyr-volume" viewBox="0 0 18 18"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z"/><path d="M11.282 5.282a.909.909 0 000 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 000 1.316c.145.145.636.262 1.018.156a.725.725 0 00.298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 00-1.316 0zm-7.496.726H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol></svg> \ No newline at end of file
diff --git a/youtube/static/normalize.css b/youtube/static/normalize.css
new file mode 100644
index 0000000..192eb9c
--- /dev/null
+++ b/youtube/static/normalize.css
@@ -0,0 +1,349 @@
+/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
+
+/* Document
+ ========================================================================== */
+
+/**
+ * 1. Correct the line height in all browsers.
+ * 2. Prevent adjustments of font size after orientation changes in iOS.
+ */
+
+html {
+ line-height: 1.15; /* 1 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/* Sections
+ ========================================================================== */
+
+/**
+ * Remove the margin in all browsers.
+ */
+
+body {
+ margin: 0;
+}
+
+/**
+ * Render the `main` element consistently in IE.
+ */
+
+main {
+ display: block;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box; /* 1 */
+ height: 0; /* 1 */
+ overflow: visible; /* 2 */
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+pre {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * Remove the gray background on active links in IE 10.
+ */
+
+a {
+ background-color: transparent;
+}
+
+/**
+ * 1. Remove the bottom border in Chrome 57-
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none; /* 1 */
+ text-decoration: underline; /* 2 */
+ text-decoration: underline dotted; /* 2 */
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+samp {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove the border on images inside links in IE 10.
+ */
+
+img {
+ border-style: none;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change the font styles in all browsers.
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit; /* 1 */
+ font-size: 100%; /* 1 */
+ line-height: 1.15; /* 1 */
+ margin: 0; /* 2 */
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input { /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select { /* 1 */
+ text-transform: none;
+}
+
+/**
+ * Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+[type="button"],
+[type="reset"],
+[type="submit"] {
+ -webkit-appearance: button;
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type="button"]:-moz-focusring,
+[type="reset"]:-moz-focusring,
+[type="submit"]:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Correct the padding in Firefox.
+ */
+
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box; /* 1 */
+ color: inherit; /* 2 */
+ display: table; /* 1 */
+ max-width: 100%; /* 1 */
+ padding: 0; /* 3 */
+ white-space: normal; /* 1 */
+}
+
+/**
+ * Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ vertical-align: baseline;
+}
+
+/**
+ * Remove the default vertical scrollbar in IE 10+.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10.
+ * 2. Remove the padding in IE 10.
+ */
+
+[type="checkbox"],
+[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ outline-offset: -2px; /* 2 */
+}
+
+/**
+ * Remove the inner padding in Chrome and Safari on macOS.
+ */
+
+[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button; /* 1 */
+ font: inherit; /* 2 */
+}
+
+/* Interactive
+ ========================================================================== */
+
+/*
+ * Add the correct display in Edge, IE 10+, and Firefox.
+ */
+
+details {
+ display: block;
+}
+
+/*
+ * Add the correct display in all browsers.
+ */
+
+summary {
+ display: list-item;
+}
+
+/* Misc
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 10+.
+ */
+
+template {
+ display: none;
+}
+
+/**
+ * Add the correct display in IE 10.
+ */
+
+[hidden] {
+ display: none;
+}
diff --git a/youtube/static/playlist.css b/youtube/static/playlist.css
new file mode 100644
index 0000000..e6ae0f9
--- /dev/null
+++ b/youtube/static/playlist.css
@@ -0,0 +1,548 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* playlist */
+.playlist {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "play-box"
+ "play-hidden"
+ "play-add"
+ "play-clean";
+ grid-area: playlist;
+}
+.play-box {
+ grid-area: play-box;
+}
+
+.play-hidden {
+ grid-area: play-hidden;
+}
+
+.play-add {
+ grid-area: play-add;
+ cursor: pointer;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-add:hover {
+ background-color: var(--buttom-hover);
+}
+
+.play-clean {
+ display: grid;
+ grid-area: play-clean;
+}
+
+.play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-clean > button:hover {
+ background-color: var(--buttom-hover);
+}
+/* /playlist */
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.playlist-metadata {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+ grid-template-areas:
+ "author"
+ "summary"
+ "playlist-stats";
+}
+.author {
+ grid-area: author;
+ display: grid;
+ grid-template-columns: 100px 1fr;
+ grid-column-gap: 1rem;
+ align-items: center;
+ justify-self: center;
+}
+.summary {
+ grid-area: summary;
+ justify-self: start;
+}
+.playlist-stats {
+ grid-area: playlist-stats;
+ justify-self: start;
+}
+
+/* Video list item */
+.video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.item-box {
+ display: grid;
+ grid-template-columns: 1.9fr 0.1fr;
+ grid-template-rows: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "item-video item-checkbox";
+}
+
+.item-video {
+ grid-area: item-video;
+
+ display: grid;
+ grid-template-columns: auto;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 0.4rem;
+ grid-template-areas:
+ "thumbnail-box"
+ "info-box";
+ align-items: center;
+
+ font-size: 0.7rem;
+}
+
+.item-video a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.thumbnail-box {
+ grid-area: thumbnail-box;
+ position: relative;
+}
+
+.thumbnail {
+ padding: 28.125%;
+ position: relative;
+ box-sizing: border-box;
+}
+
+.thumbnail-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ object-fit: cover;
+ background-color: var(--thumb-background);
+}
+
+.length {
+ position: absolute;
+ background-color: rgba(35, 35, 35, 0.75);
+ color: #fff;
+ border-radius: 2px;
+ padding: 2px;
+ font-size: 16px;
+ right: 0.25em;
+ bottom: -0.75em;
+}
+
+.playlist-item .thumbnail-info {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ height: 100%;
+ width: 50%;
+ text-align: center;
+ white-space: pre-line;
+ opacity: .8;
+ color: var(--text);
+ font-size: 0.8125rem;
+ background: var(--secondary-background);
+ padding: 0;
+}
+
+.playlist-item .thumbnail-info span {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-transform: none;
+}
+
+.info-box {
+ grid-area: info-box;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto auto auto;
+ grid-gap: 1px;
+ grid-template-areas:
+ "."
+ "."
+ "."
+ "."
+ ".";
+}
+
+.title {
+ font-size: 0.8rem;
+ margin: 0px;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info-box address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.thumbnail-info {
+ background-color: var(--time-background);
+ color: #fff;
+ padding: 2px 5px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 12px;
+ position: absolute;
+ right: 0;
+ bottom: .2rem;
+}
+
+.item-checkbox {
+ grid-area: item-checkbox;
+ justify-self: start;
+ align-self: center;
+ min-width: 30px;
+ margin: 0px;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-between;
+}
+
+.horizontal-stats {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.horizontal-stats > li {
+ display: inline;
+}
+
+.horizontal-stats > li:first-child::after {
+ content: " | ";
+}
+
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ margin: 0.2rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+/* /video list item */
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 600px) {
+ .video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ .playlist-metadata {
+ max-width: 50vw;
+ }
+
+ /* playlist */
+ .playlist {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". play-box play-add play-clean";
+ grid-area: playlist;
+ }
+ .play-clean {
+ grid-template-columns: 100px auto;
+ }
+ .play-clean > button {
+ padding-left: 0px;
+ padding-right: 0px;
+ padding-bottom: 6px;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .video-container {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ grid-row-gap: 1rem;
+ grid-column-gap: 1rem;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/search.css b/youtube/static/search.css
new file mode 100644
index 0000000..9b7bf8d
--- /dev/null
+++ b/youtube/static/search.css
@@ -0,0 +1,522 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* playlist */
+.playlist {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "play-box"
+ "play-hidden"
+ "play-add"
+ "play-clean";
+ grid-area: playlist;
+}
+.play-box {
+ grid-area: play-box;
+}
+
+.play-hidden {
+ grid-area: play-hidden;
+}
+
+.play-add {
+ grid-area: play-add;
+ cursor: pointer;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-add:hover {
+ background-color: var(--buttom-hover);
+}
+
+.play-clean {
+ display: grid;
+ grid-area: play-clean;
+}
+
+.play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-clean > button:hover {
+ background-color: var(--buttom-hover);
+}
+/* /playlist */
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.result-info {
+ justify-self: center;
+}
+
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ margin: 0.2rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+/* Video list item */
+.video-container {
+ display: grid;
+ grid-gap: 0.5rem;
+}
+
+.length {
+ position: absolute;
+ background-color: rgba(35, 35, 35, 0.75);
+ color: #fff;
+ border-radius: 2px;
+ padding: 2px;
+ font-size: 16px;
+ right: 0.25em;
+ bottom: -0.75em;
+}
+
+.item-box {
+ display: grid;
+ grid-template-columns: 1.9fr 0.1fr;
+ grid-template-rows: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "item-video item-checkbox";
+}
+
+.item-video {
+ grid-area: item-video;
+
+ display: grid;
+ grid-template-columns: auto;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 0.4rem;
+ grid-template-areas:
+ "thumbnail-box"
+ "info-box";
+ align-items: center;
+
+ font-size: 0.7rem;
+}
+
+.item-video a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.item-video address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.item-video.channel-item .thumbnail.channel {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0px;
+ text-align: center;
+}
+
+.item-video.channel-item .thumbnail-img.channel {
+ width: 56.25%;
+ position: relative;
+}
+
+.thumbnail-box {
+ grid-area: thumbnail-box;
+ position: relative;
+}
+
+.playlist-item .thumbnail-info {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ height: 100%;
+ width: 50%;
+ text-align: center;
+ white-space: pre-line;
+ opacity: .8;
+ color: var(--text);
+ font-size: 0.8125rem;
+ background: var(--secondary-background);
+ padding: 0;
+}
+
+.playlist-item .thumbnail-info span {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-transform: none;
+}
+
+.thumbnail {
+ padding: 28.125%;
+ position: relative;
+ box-sizing: border-box;
+}
+
+.thumbnail-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ object-fit: cover;
+ background-color: var(--thumb-background);
+}
+
+.title {
+ font-size: 0.8rem;
+ margin: 0px;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info-box address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.thumbnail-info {
+ background-color: var(--time-background);
+ color: #fff;
+ padding: 2px 5px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 12px;
+ position: absolute;
+ right: 0;
+ bottom: .2rem;
+}
+
+.item-checkbox {
+ grid-area: item-checkbox;
+ justify-self: start;
+ align-self: center;
+ min-width: 30px;
+ margin: 0px;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-between;
+}
+
+.horizontal-stats > li {
+ display: inline;
+}
+
+
+.horizontal-stats > li:first-child::after {
+ content: " | ";
+}
+
+/* /video list item */
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 600px) {
+ .video-container {
+ display: grid;
+ grid-gap: 0.5rem;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ /* playlist */
+ .playlist {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". play-box play-add play-clean";
+ grid-area: playlist;
+ }
+ .play-clean {
+ grid-template-columns: 100px auto;
+ }
+ .play-clean > button {
+ padding-left: 0px;
+ padding-right: 0px;
+ padding-bottom: 6px;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .video-container {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ grid-gap: 1rem;
+ grid-column-gap: 1rem;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/settings.css b/youtube/static/settings.css
new file mode 100644
index 0000000..33dd923
--- /dev/null
+++ b/youtube/static/settings.css
@@ -0,0 +1,262 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ border-radius: 5px;
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.settings-form {
+ background-color: var(--secondary-background);
+ padding: 1rem;
+}
+
+.settings-form > h2 {
+ border-bottom: 2px solid var(--border-bg-settings);
+ padding-bottom: 0.5rem;
+}
+
+.settings-list {
+ display: grid;
+ grid-row-gap: 1rem;
+ list-style: none;
+ padding: 0px;
+}
+
+.setting-item {
+ display: grid;
+ grid-template-columns: auto auto;
+ align-items: center;
+ padding: 5px;
+}
+
+.setting-item select {
+ max-width: 100%;
+ justify-self: end;
+}
+
+.setting-item input {
+ max-width: 80px;
+ justify-self: end;
+}
+
+.settings-form input[type="submit"] {
+ cursor: pointer;
+}
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 780px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ .main {
+ display: grid;
+ justify-content: center;
+ }
+ .settings-form {
+ width: 50vw;
+ }
+ .setting-item select {
+ max-width: 250px;
+ }
+ .setting-item input {
+ max-width: 250px;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/shared.css b/youtube/static/shared.css
deleted file mode 100644
index 72d290a..0000000
--- a/youtube/static/shared.css
+++ /dev/null
@@ -1,300 +0,0 @@
-* {
- box-sizing: border-box;
-}
-
-h1, h2, h3, h4, h5, h6, div, button{
- margin:0;
- padding:0;
-}
-
-address{
- font-style:normal;
-}
-
-body{
- margin:0;
- padding: 0;
- color:var(--text-color);
-
-
- background-color:var(--background-color);
-
- min-height:100vh;
- display: flex;
- flex-direction: column;
-}
-
- header{
- background-color:#333333;
- height: 50px;
-
- display: flex;
- justify-content: center;
- }
-
- #home-link{
- align-self: center;
- margin-left:10px;
- color: #ffffff;
- }
-
-
- #site-search{
- max-width: 600px;
- margin-left:10px;
- display: flex;
- flex-grow: 1;
- }
-
- #site-search .search-box{
- align-self:center;
- height:25px;
- border:0;
-
- flex-grow: 1;
- }
- #site-search .search-button{
- align-self:center;
- height:25px;
-
- border-style:solid;
- border-width:1px;
- }
- #site-search .dropdown{
- margin-left:5px;
- align-self:center;
- height:25px;
- }
- #site-search .dropdown button{
- align-self:center;
- height:25px;
-
- border-style:solid;
- border-width:1px;
- }
- #site-search .css-sucks{
- width:0px;
- height:0px;
- }
- #site-search .dropdown-content{
- grid-template-columns: auto auto;
- white-space: nowrap;
- }
- #site-search .dropdown-content h3{
- grid-column:1 / span 2;
- }
-
- #playlist-edit{
- margin-left: 10px;
- align-self: center;
- }
- #local-playlists{
- margin-right:5px;
- color: #ffffff;
- }
- #playlist-name-selection{
- }
- #playlist-add-button{
- padding-left: 10px;
- padding-right: 10px;
- }
- #item-selection-reset{
- padding-left: 10px;
- padding-right: 10px;
- }
-
- main{
- flex-grow: 1;
- padding-bottom: 20px;
- }
-
-
-.dropdown{
- z-index:1;
-}
- .dropdown-content{
- display:none;
- background-color: var(--interface-color);
- }
- .dropdown:hover .dropdown-content{
- /* For some reason, if this is just grid, it will insist on being 0px wide just like its 0px by 0px parent */
- /* making it inline-grid happened to fix it */
- display:inline-grid;
- }
-
-.item-list{
- display: grid;
- grid-row-gap: 10px;
-
-}
-
-
-.item-grid{
- display: flex;
- flex-wrap: wrap;
-}
- .item-grid > .playlist-item-box{
- margin-right: 10px;
- }
- .item-grid > * {
- margin-bottom: 10px;
- }
- .item-grid .horizontal-item-box .item{
- width:370px;
- }
- .item-grid .vertical-item-box .item{
- }
-
-.item-box{
- display: inline-flex;
- flex-direction: row;
-}
-.vertical-item-box{
-}
-.horizontal-item-box{
-}
- .item{
- background-color:var(--interface-color);
- text-decoration:none;
- font-size: 12px;
- color: #767676;
- }
-
- .horizontal-item-box .item {
- flex-grow: 1;
- display: grid;
- align-content: start;
- grid-template-columns: auto 1fr;
- grid-template-rows: auto auto auto auto 1fr;
- }
- .vertical-item-box .item{
- width: 168px;
- }
- .thumbnail-box{
- font-size: 0px; /* prevent newlines and blank space from creating gaps */
- position: relative;
- display: block;
- }
- .horizontal-item-box .thumbnail-box{
- grid-row: 1 / span 5;
- margin-right: 4px;
- }
- .no-description .thumbnail-box{
- width: 168px;
- height:94px;
- }
- .has-description .thumbnail-box{
- width: 246px;
- height:138px;
- }
- .video-item .thumbnail-info{
- position: absolute;
- bottom: 2px;
- right: 2px;
- opacity: .8;
- color: #ffffff;
- font-size: 12px;
- background-color: #000000;
- }
- .playlist-item .thumbnail-info{
- position: absolute;
- right: 0px;
- bottom: 0px;
- height: 100%;
- width: 50%;
- text-align:center;
- white-space: pre-line;
- opacity: .8;
- color: #cfcfcf;
- font-size: 12px;
- background-color: #000000;
- }
- .playlist-item .thumbnail-info span{ /* trick to vertically center the text */
- position: absolute;
- top: 50%;
- transform: translate(-50%, -50%);
- }
- .thumbnail-img{ /* center it */
- margin: auto;
- display: block;
- max-height: 100%;
- }
- .horizontal-item-box .thumbnail-img{
- height: 100%;
- }
-
- .item .title{
- min-width: 0;
- max-height:3.6em;
- overflow:hidden;
-
- color: var(--text-color);
- font-size: 16px;
- font-weight: 500;
- text-decoration:initial;
- }
-
- .stats{
- list-style: none;
- padding: 0px;
- margin: 0px;
- }
- .horizontal-stats{
- max-height:2.4em;
- overflow:hidden;
- }
- .horizontal-stats > li{
- display: inline;
- }
-
- .horizontal-stats > li::after{
- content: " | ";
- }
- .horizontal-stats > li:last-child::after{
- content: "";
- }
-
- .vertical-stats{
- display: flex;
- flex-direction: column;
- }
- .stats address{
- display: inline;
- }
- .vertical-stats li{
- max-height: 1.3em;
- overflow: hidden;
- }
-
- .item-checkbox{
- justify-self:start;
- align-self:center;
- height:30px;
- width:30px;
- min-width:30px;
- margin: 0px;
- }
-
-
-.page-button-row{
- margin-top: 10px;
- margin-bottom: 10px;
- justify-self:center;
- justify-content: center;
- display: grid;
- grid-auto-columns: 40px;
- grid-auto-flow: column;
- height: 40px;
-}
- .page-button{
- background-color: var(--interface-color);
- border-style: outset;
- border-width: 2px;
- font-weight: bold;
- text-align: center;
- }
-.sort-button{
- background-color: var(--interface-color);
- padding: 2px;
- justify-self: start;
-}
diff --git a/youtube/static/subscription.css b/youtube/static/subscription.css
new file mode 100644
index 0000000..e4e9c8b
--- /dev/null
+++ b/youtube/static/subscription.css
@@ -0,0 +1,542 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* playlist */
+.playlist {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "play-box"
+ "play-hidden"
+ "play-add"
+ "play-clean";
+ grid-area: playlist;
+}
+.play-box {
+ grid-area: play-box;
+}
+
+.play-hidden {
+ grid-area: play-hidden;
+}
+
+.play-add {
+ grid-area: play-add;
+ cursor: pointer;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-add:hover {
+ background-color: var(--buttom-hover);
+}
+
+.play-clean {
+ display: grid;
+ grid-area: play-clean;
+}
+
+.play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-clean > button:hover {
+ background-color: var(--buttom-hover);
+}
+/* /playlist */
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.sidebar-links {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+ grid-template-areas:
+ "sidebar-title"
+ "sidebar-action";
+ align-items: center;
+ justify-items: center;
+ justify-content: center;
+ grid-row-gap: 1rem;
+}
+.sidebar-title {
+ grid-area: sidebar-title;
+ text-align: center;
+}
+.sidebar-action {
+ grid-area: sidebar-action;
+}
+
+/* Video list item */
+.video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.item-box {
+ display: grid;
+ grid-template-columns: 1.9fr 0.1fr;
+ grid-template-rows: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "item-video item-checkbox";
+}
+
+.item-video {
+ grid-area: item-video;
+
+ display: grid;
+ grid-template-columns: auto;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 0.4rem;
+ grid-template-areas:
+ "thumbnail-box"
+ "info-box";
+ align-items: center;
+
+ font-size: 0.7rem;
+}
+
+.item-video a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.thumbnail-box {
+ grid-area: thumbnail-box;
+ position: relative;
+}
+
+.thumbnail {
+ padding: 28.125%;
+ position: relative;
+ box-sizing: border-box;
+}
+
+.thumbnail-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ object-fit: cover;
+ background-color: var(--thumb-background);
+}
+
+.length {
+ position: absolute;
+ background-color: rgba(35, 35, 35, 0.75);
+ color: #fff;
+ border-radius: 2px;
+ padding: 2px;
+ font-size: 16px;
+ right: 0.25em;
+ bottom: -0.75em;
+}
+
+.playlist-item .thumbnail-info {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ height: 100%;
+ width: 50%;
+ text-align: center;
+ white-space: pre-line;
+ opacity: .8;
+ color: var(--text);
+ font-size: 0.8125rem;
+ background: var(--secondary-background);
+ padding: 0;
+}
+
+.playlist-item .thumbnail-info span {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-transform: none;
+}
+
+.info-box {
+ grid-area: info-box;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto auto auto;
+ grid-gap: 1px;
+ grid-template-areas:
+ "."
+ "."
+ "."
+ "."
+ ".";
+}
+
+.title {
+ font-size: 0.8rem;
+ margin: 0px;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info-box address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.thumbnail-info {
+ background-color: var(--time-background);
+ color: #fff;
+ padding: 2px 5px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 12px;
+ position: absolute;
+ right: 0;
+ bottom: .2rem;
+}
+
+.item-checkbox {
+ grid-area: item-checkbox;
+ justify-self: start;
+ align-self: center;
+ min-width: 30px;
+ margin: 0px;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-between;
+}
+
+.horizontal-stats {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.horizontal-stats > li {
+ display: inline;
+}
+
+.horizontal-stats > li:first-child::after {
+ content: " | ";
+}
+
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+/* /video list item */
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 600px) {
+ .video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ padding-right: 4rem;
+ z-index: 1;
+ position: absolute;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ .sidebar-links {
+ max-width: 50vw;
+ }
+
+ /* playlist */
+ .playlist {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". play-box play-add play-clean";
+ grid-area: playlist;
+ }
+ .play-clean {
+ grid-template-columns: 100px auto;
+ }
+ .play-clean > button {
+ padding-left: 0px;
+ padding-right: 0px;
+ padding-bottom: 6px;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .video-container {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ grid-row-gap: 1rem;
+ grid-column-gap: 1rem;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/subscription_manager.css b/youtube/static/subscription_manager.css
new file mode 100644
index 0000000..7101828
--- /dev/null
+++ b/youtube/static/subscription_manager.css
@@ -0,0 +1,366 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.import-export {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+ grid-template-areas:
+ "subscriptions-import-form"
+ "subscriptions-export-form";
+ align-items: center;
+ justify-items: center;
+ justify-content: center;
+}
+.subscriptions-import-form {
+ grid-area: subscriptions-import-form;
+ text-align: center;
+}
+
+.subscriptions-import-options {
+ display: grid;
+ grid-template-columns: repeat(1, auto);
+ margin: auto;
+}
+
+.subscriptions-export-form {
+ grid-area: subscriptions-export-form;
+ text-align: center;
+}
+
+.subscriptions-export-options {
+ display: grid;
+ grid-template-columns: repeat(1, auto);
+ margin: auto;
+}
+
+.sub-list-controls {
+ display: grid;
+ grid-row-gap: 0.2rem;
+}
+
+.subscriptions-import-form input[type='submit'],
+.subscriptions-export-form input[type='submit'],
+.sub-list-controls button[type='submit'],
+.sub-list-controls input[type='reset'] {
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+
+.tag-group-list {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+}
+
+.sub-list {
+ display: grid;
+ grid-template-columns: 1fr;
+ margin: auto;
+}
+
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+/* /video list item */
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+ .subscriptions-import-options {
+ display: grid;
+ grid-template-columns: repeat(2, auto);
+ margin: auto;
+ align-items: center;
+ justify-items: center;
+ justify-content: center;
+ grid-column-gap: 0.5rem;
+ }
+ .subscriptions-export-options {
+ display: grid;
+ grid-template-columns: repeat(4, auto);
+ margin: auto;
+ align-items: center;
+ justify-items: center;
+ justify-content: center;
+ grid-column-gap: 0.5rem;
+ }
+}
+
+@media (min-width: 600px) {
+ .video-container {
+ display: grid;
+ grid-row-gap: 0.5rem;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ .import-export {
+ max-width: 50vw;
+ }
+
+ .sub-list-controls {
+ grid-template-columns: repeat(8, auto);
+ max-height: 1rem;
+ grid-column-gap: 0.5rem;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/unsubscribe.css b/youtube/static/unsubscribe.css
new file mode 100644
index 0000000..276b335
--- /dev/null
+++ b/youtube/static/unsubscribe.css
@@ -0,0 +1,269 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+/* fix hr when is children of grid */
+hr {
+ width: 100%;
+}
+
+.list-channel {
+ padding: 0;
+}
+
+.list-channel > li {
+ list-style: none;
+}
+/* pagination */
+.main .pagination-container {
+ display: grid;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.main .pagination-container .pagination-list .page-link {
+ border-style: none;
+ font-weight: bold;
+ text-align: center;
+ background: var(--secondary-focus);
+ text-decoration: none;
+ align-self: center;
+ padding: .5rem;
+ width: 1rem;
+}
+
+.main .pagination-container .pagination-list .page-link.is-current {
+ background: var(--secondary-background);
+}
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .info-box {
+ grid-gap: 2px;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ "main main main main"
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ z-index: 1;
+ position: absolute;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/static/watch.css b/youtube/static/watch.css
new file mode 100644
index 0000000..c0bdec6
--- /dev/null
+++ b/youtube/static/watch.css
@@ -0,0 +1,700 @@
+body {
+ display: grid;
+ grid-gap: 20px;
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ /* Fix height */
+ height: 100vh;
+ grid-template-rows: auto 1fr auto;
+ /* fix top and bottom */
+ margin-left: 1rem;
+ margin-right: 1rem;
+}
+
+img {
+ width: 100%;
+ height: auto;
+}
+
+video {
+ width: 100%;
+ height: auto;
+ max-height: calc(100vh/1.5);
+}
+
+a:link {
+ color: var(--link);
+}
+
+a:visited {
+ color: var(--link-visited);
+}
+
+input[type="text"],
+input[type="search"] {
+ background: var(--background);
+ border: 1px solid var(--button-border);
+ border-radius: 5px;
+ padding: 0.4rem 0.4rem;
+ font-size: 15px;
+ color: var(--search-text);
+ outline: none;
+ box-shadow: none;
+}
+
+input[type='search'] {
+ border-bottom: 1px solid var(--button-border);
+ border-top: 0px;
+ border-left: 0px;
+ border-right: 0px;
+ border-radius: 0px;
+}
+
+header {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "home"
+ "form"
+ "playlist";
+ grid-area: header;
+}
+
+.home {
+ grid-area: home;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: 1rem;
+ margin-top: 1rem;
+}
+
+.form {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "search-box"
+ "search-button"
+ "dropdown";
+ grid-area: form;
+}
+
+.search-box {
+ grid-area: search-box;
+}
+.search-button {
+ grid-area: search-button;
+
+ cursor: pointer;
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.search-button:hover {
+ background-color: var(--buttom-hover);
+}
+
+.dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+}
+.dropdown-label {
+ grid-area: dropdown-label;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.dropdown-label:hover {
+ background-color: var(--buttom-hover);
+}
+
+.live-url-choices {
+ background-color: var(--thumb-background);
+ margin: 1rem 0;
+ padding: 1rem;
+}
+
+.playability-error {
+ position: relative;
+ box-sizing: border-box;
+ height: 30vh;
+ margin: 1rem 0;
+}
+
+.playability-error > span {
+ display: flex;
+ background-color: var(--thumb-background);
+ height: 100%;
+ object-fit: cover;
+ justify-content: center;
+ align-items: center;
+ text-align: center;
+}
+
+.playlist {
+ display: grid;
+ grid-gap: 4px;
+ grid-template-areas:
+ "play-box"
+ "play-hidden"
+ "play-add"
+ "play-clean";
+ grid-area: playlist;
+}
+.play-box {
+ grid-area: play-box;
+}
+
+.play-hidden {
+ grid-area: play-hidden;
+}
+
+.play-add {
+ grid-area: play-add;
+ cursor: pointer;
+
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-add:hover {
+ background-color: var(--buttom-hover);
+}
+
+.play-clean {
+ display: grid;
+ grid-area: play-clean;
+}
+
+.play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ border: 1px solid var(--button-border);
+ color: var(--buttom-text);
+ border-radius: 5px;
+}
+.play-clean > button:hover {
+ background-color: var(--buttom-hover);
+}
+
+/* ------------- Menu Mobile sin JS ---------------- */
+/* input hidden */
+.opt-box {
+ display: none;
+}
+.dropdown-content {
+ display: none;
+ grid-area: dropdown-content;
+}
+label[for=options-toggle-cbox] {
+ cursor: pointer;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#options-toggle-cbox:checked ~ .dropdown-content {
+ display: block;
+ white-space: nowrap;
+ background: var(--secondary-background);
+ padding: 0.5rem 1rem;
+}
+/*- ----------- End Menu Mobile sin JS ------------- */
+
+.main {
+ grid-area: main;
+ margin: 0 auto;
+
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "sc-video"
+ "sc-info";
+}
+figure.sc-video {
+ margin: 1rem 0px;
+}
+.sc-video { grid-area: sc-video; }
+.sc-info {
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "video-info"
+ "side-videos"
+ "comments-area-outer";
+ grid-area: sc-info;
+}
+.video-info {
+ grid-area: video-info;
+
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-areas:
+ "v-title v-title"
+ "v-uploaded v-views"
+ "v-published v-likes-dislikes"
+ "external-player-controls v-checkbox"
+ "v-direct-link v-direct-link"
+ "v-download v-download"
+ "v-description v-description"
+ "v-music-list v-music-list"
+ "v-more-info v-more-info";
+}
+.v-title {
+ grid-area: v-title;
+ margin: 0px;
+}
+.v-uploaded { grid-area: v-uploaded; }
+.v-views {
+ grid-area: v-views;
+ justify-self: end;
+}
+.v-published { grid-area: v-published; }
+.v-likes-dislikes {
+ grid-area: v-likes-dislikes;
+ justify-self: end;
+}
+.external-player-controls {
+ grid-area: external-player-controls;
+}
+.external-player-controls input.speed {
+ width: 65px;
+ text-align: center;
+}
+.v-checkbox {
+ grid-area: v-checkbox;
+ justify-self: end;
+}
+.v-direct-link {
+ grid-area: v-direct-link;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+.v-download { grid-area: v-download; }
+.v-download > ul.download-dropdown-content {
+ background: var(--secondary-background);
+ padding-left: 0px;
+}
+.v-download > ul.download-dropdown-content > li.download-format {
+ list-style: none;
+ padding: 0.4rem 0;
+ padding-left: 1rem;
+}
+.v-download > ul.download-dropdown-content > li.download-format a.download-link {
+ text-decoration: none;
+}
+
+.v-description {
+ grid-area: v-description;
+ background-color: var(--secondary-background);
+ margin-top: 0.4rem;
+ white-space: pre-wrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+ padding: 5px;
+}
+
+.v-music-list {
+ grid-area: v-music-list;
+ padding-bottom: 1rem;
+}
+.v-music-list table,th,td{
+ border: 1px solid;
+}
+.v-music-list th,td{
+ padding-left:4px;
+ padding-right:5px;
+}
+.v-music-list caption{
+ text-align:left;
+ font-weight:bold;
+ margin-bottom:5px;
+}
+
+.v-more-info {
+ grid-area: v-more-info;
+}
+.v-more-info > .more-info-content {
+ background-color: var(--secondary-background);
+ padding: 5px;
+}
+
+.side-videos { grid-area: side-videos; }
+
+.side-videos .related-autoplay {
+ display: grid;
+ grid-template-columns: repeat(2, auto);
+ justify-content: start;
+ grid-column-gap: 0.5rem;
+ align-items: center;
+}
+
+/* playlist items */
+.side-videos .site-playlist {
+ border-style: solid;
+ border-width: 2px;
+ border-color: var(--secondary-focus);
+ margin-bottom: 1rem;
+}
+
+.side-videos .site-playlist .playlist-header {
+ background-color: var(--secondary-background);
+ padding: 1rem;
+ border-bottom-style: solid;
+ border-bottom-width: 2px;
+ border-bottom-color: var(--secondary-focus);
+}
+
+.side-videos .site-playlist .playlist-header h3 {
+ margin: 0px;
+ padding: 0px;
+}
+
+.side-videos .site-playlist .playlist-header .playlist-metadata {
+ list-style: none;
+ display: grid;
+ justify-content: start;
+ padding: 0;
+ margin: 0px;
+ grid-template-columns: repeat(3, auto);
+ grid-column-gap: 1rem;
+}
+
+.side-videos .site-playlist .playlist-videos {
+ display: grid;
+ grid-row-gap: 1rem;
+ height: 300px;
+ overflow-y: scroll;
+ padding-top: 1rem;
+}
+
+.side-videos .site-playlist .playlist-videos article.item-box {
+ padding-left: 1rem;
+}
+/* /playlist items */
+
+.comments-area-outer { grid-area: comments-area-outer; }
+
+.related-videos-inner {
+ padding-top: 10px;
+ display: grid;
+ grid-row-gap: 1rem;
+}
+
+.item-box {
+ display: grid;
+ grid-template-columns: 1.9fr 0.1fr;
+ grid-template-rows: 1fr;
+ grid-gap: 1px;
+ grid-template-areas:
+ "item-video item-checkbox";
+}
+
+.item-video {
+ grid-area: item-video;
+
+ display: grid;
+ grid-template-columns: auto;
+ grid-template-rows: repeat(4, auto);
+ grid-row-gap: 0.4rem;
+ grid-template-areas:
+ "thumbnail-box"
+ "info-box";
+ align-items: center;
+
+ font-size: 0.7rem;
+}
+
+.item-video a {
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.thumbnail-box {
+ grid-area: thumbnail-box;
+ position: relative;
+}
+
+.thumbnail {
+ padding: 28.125%;
+ position: relative;
+ box-sizing: border-box;
+}
+
+.thumbnail-img {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ object-fit: cover;
+ background-color: var(--thumb-background);
+}
+
+.length {
+ position: absolute;
+ background-color: rgba(35, 35, 35, 0.75);
+ color: #fff;
+ border-radius: 2px;
+ padding: 2px;
+ font-size: 16px;
+ right: 0.25em;
+ bottom: -0.75em;
+}
+
+.info-box {
+ grid-area: info-box;
+ display: grid;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto auto auto;
+ grid-gap: 1px;
+ grid-template-areas:
+ "."
+ "."
+ "."
+ "."
+ ".";
+}
+
+.title {
+ font-size: 0.8rem;
+ margin: 0px;
+ font-weight: normal;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.info-box address {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.thumbnail-info {
+ background-color: var(--time-background);
+ color: #fff;
+ padding: 2px 5px;
+ text-transform: uppercase;
+ font-weight: 700;
+ font-size: 12px;
+ position: absolute;
+ right: 0;
+ bottom: .2rem;
+}
+
+.item-checkbox {
+ grid-area: item-checkbox;
+ justify-self: start;
+ align-self: center;
+ min-width: 30px;
+ margin: 0px;
+}
+
+.stats {
+ display: flex;
+ justify-content: space-between;
+}
+
+/* comments */
+.comments-area {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.comments {
+ display: grid;
+ grid-row-gap: 0.5rem;
+}
+
+.comment {
+ display: grid;
+ grid-template-columns: repeat(3, auto) 3fr;
+ grid-template-rows: repeat(4, auto);
+ grid-column-gap: 0.4rem;
+ grid-template-areas:
+ "author-avatar author-name permalink ."
+ "author-avatar comment-text comment-text comment-text"
+ ". comment-likes comment-likes comment-likes"
+ ". button-row button-row button-row";
+ background: var(--secondary-background);
+}
+
+.author-avatar { grid-area: author-avatar; }
+.author-name {
+ grid-area: author-name;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.permalink { grid-area: permalink; }
+.comment-text {
+ grid-area: comment-text;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.comment-likes { grid-area: comment-likes; }
+.button-row { grid-area: button-row; }
+
+.more-comments {
+ justify-self: center;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ background: var(--secondary-background);
+ padding: 5px;
+
+ /* disable text selection */
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+.footer {
+ grid-area: footer;
+ display: grid;
+ grid-template-columns: auto;
+ align-items: center;
+ justify-content: center;
+ margin: auto;
+ text-align: center;
+}
+
+.footer > p {
+ text-align: center;
+}
+
+@media (min-width: 480px) {
+ .item-video {
+ font-size: 0.85rem;
+ }
+ .title {
+ font-size: 1rem;
+ }
+}
+
+@media (min-width: 992px) {
+ body {
+ display: grid;
+ grid-template-columns: 0.3fr 2fr 1fr 0.3fr;
+ grid-template-rows: auto 1fr auto;
+ grid-template-areas:
+ "header header header header"
+ ". main main ."
+ "footer footer footer footer";
+ }
+ .form {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". search-box search-button dropdown";
+ grid-area: form;
+ position: relative;
+ }
+ .dropdown {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 100px auto;
+ grid-template-areas:
+ "dropdown-label"
+ "dropdown-content";
+ grid-area: dropdown;
+ position: absolute;
+ z-index: 1;
+ }
+ #options-toggle-cbox:checked ~ .dropdown-content {
+ width: calc(100% + 100px);
+ max-height: 80vh;
+ overflow-y: scroll;
+ }
+ .playability-error {
+ height: 60vh;
+ }
+ .playlist {
+ display: grid;
+ grid-gap: 1px;
+ grid-template-columns: 1fr 1.4fr 0.3fr 1.3fr;
+ grid-template-areas: ". play-box play-add play-clean";
+ grid-area: playlist;
+ }
+ .play-clean {
+ grid-template-columns: 100px auto;
+ }
+ .play-clean > button {
+ padding-bottom: 6px;
+ padding-left: .75em;
+ padding-right: .75em;
+ padding-top: 6px;
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--buttom);
+ color: var(--buttom-text);
+ border-radius: 5px;
+ cursor: pointer;
+ }
+
+ .main {
+ grid-area: main;
+ margin: 0px;
+ }
+
+ .sc-info {
+ display: grid;
+ grid-template-columns: 3fr 1fr;
+ grid-gap: 1px 40px;
+ grid-template-areas:
+ "video-info side-videos"
+ "comments-area-outer side-videos"
+ ". side-videos";
+ grid-area: sc-info;
+ }
+
+ .footer {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ grid-column-gap: 2rem;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+ }
+}
diff --git a/youtube/subscriptions.py b/youtube/subscriptions.py
index 87e1659..04d3c5a 100644
--- a/youtube/subscriptions.py
+++ b/youtube/subscriptions.py
@@ -1,4 +1,4 @@
-from youtube import util, yt_data_extract, channel
+from youtube import util, yt_data_extract, channel, local_playlist, playlist
from youtube import yt_app
import settings
@@ -15,6 +15,8 @@ import math
import secrets
import collections
import calendar # bullshit! https://bugs.python.org/issue6280
+import csv
+import re
import flask
from flask import request
@@ -26,6 +28,7 @@ thumbnails_directory = os.path.join(settings.data_dir, "subscription_thumbnails"
database_path = os.path.join(settings.data_dir, "subscriptions.sqlite")
+
def open_database():
if not os.path.exists(settings.data_dir):
os.makedirs(settings.data_dir)
@@ -74,11 +77,13 @@ def open_database():
# https://stackoverflow.com/questions/19522505/using-sqlite3-in-python-with-with-keyword
return contextlib.closing(connection)
+
def with_open_db(function, *args, **kwargs):
with open_database() as connection:
with connection as cursor:
return function(cursor, *args, **kwargs)
+
def _is_subscribed(cursor, channel_id):
result = cursor.execute('''SELECT EXISTS(
SELECT 1
@@ -88,12 +93,14 @@ def _is_subscribed(cursor, channel_id):
)''', [channel_id]).fetchone()
return bool(result[0])
+
def is_subscribed(channel_id):
if not os.path.exists(database_path):
return False
return with_open_db(_is_subscribed, channel_id)
+
def _subscribe(channels):
''' channels is a list of (channel_id, channel_name) '''
channels = list(channels)
@@ -101,7 +108,7 @@ def _subscribe(channels):
with connection as cursor:
channel_ids_to_check = [channel[0] for channel in channels if not _is_subscribed(cursor, channel[0])]
- rows = ( (channel_id, channel_name, 0, 0) for channel_id, channel_name in channels)
+ rows = ((channel_id, channel_name, 0, 0) for channel_id, channel_name in channels)
cursor.executemany('''INSERT OR IGNORE INTO subscribed_channels (yt_channel_id, channel_name, time_last_checked, next_check_time)
VALUES (?, ?, ?, ?)''', rows)
@@ -111,6 +118,7 @@ def _subscribe(channels):
channel_names.update(channels)
check_channels_if_necessary(channel_ids_to_check)
+
def delete_thumbnails(to_delete):
for thumbnail in to_delete:
try:
@@ -122,6 +130,7 @@ def delete_thumbnails(to_delete):
print('Failed to delete thumbnail: ' + thumbnail)
traceback.print_exc()
+
def _unsubscribe(cursor, channel_ids):
''' channel_ids is a list of channel_ids '''
to_delete = []
@@ -138,7 +147,8 @@ def _unsubscribe(cursor, channel_ids):
gevent.spawn(delete_thumbnails, to_delete)
cursor.executemany("DELETE FROM subscribed_channels WHERE yt_channel_id=?", ((channel_id, ) for channel_id in channel_ids))
-def _get_videos(cursor, number_per_page, offset, tag = None):
+
+def _get_videos(cursor, number_per_page, offset, tag=None):
'''Returns a full page of videos with an offset, and a value good enough to be used as the total number of videos'''
# We ask for the next 9 pages from the database
# Then the actual length of the results tell us if there are more than 9 pages left, and if not, how many there actually are
@@ -149,7 +159,7 @@ def _get_videos(cursor, number_per_page, offset, tag = None):
# We cannot use tricks with the sql id for the video since we frequently have filters and other restrictions in place on the results anyway
# TODO: This is probably not the ideal solution
if tag is not None:
- db_videos = cursor.execute('''SELECT video_id, title, duration, time_published, is_time_published_exact, channel_name
+ db_videos = cursor.execute('''SELECT video_id, title, duration, time_published, is_time_published_exact, channel_name, yt_channel_id
FROM videos
INNER JOIN subscribed_channels on videos.sql_channel_id = subscribed_channels.id
INNER JOIN tag_associations on videos.sql_channel_id = tag_associations.sql_channel_id
@@ -157,7 +167,7 @@ def _get_videos(cursor, number_per_page, offset, tag = None):
ORDER BY time_noticed DESC, time_published DESC
LIMIT ? OFFSET ?''', (tag, number_per_page*9, offset)).fetchall()
else:
- db_videos = cursor.execute('''SELECT video_id, title, duration, time_published, is_time_published_exact, channel_name
+ db_videos = cursor.execute('''SELECT video_id, title, duration, time_published, is_time_published_exact, channel_name, yt_channel_id
FROM videos
INNER JOIN subscribed_channels on videos.sql_channel_id = subscribed_channels.id
WHERE muted = 0
@@ -172,15 +182,15 @@ def _get_videos(cursor, number_per_page, offset, tag = None):
'id': db_video[0],
'title': db_video[1],
'duration': db_video[2],
- 'published': exact_timestamp(db_video[3]) if db_video[4] else posix_to_dumbed_down(db_video[3]),
+ 'time_published': exact_timestamp(db_video[3]) if db_video[4] else posix_to_dumbed_down(db_video[3]),
'author': db_video[5],
+ 'author_id': db_video[6],
+ 'author_url': '/https://www.youtube.com/channel/' + db_video[6],
})
return videos, pseudo_number_of_videos
-
-
def _get_subscribed_channels(cursor):
for item in cursor.execute('''SELECT channel_name, yt_channel_id, muted
FROM subscribed_channels
@@ -202,7 +212,6 @@ def _remove_tags(cursor, channel_ids, tags):
)''', pairs)
-
def _get_tags(cursor, channel_id):
return [row[0] for row in cursor.execute('''SELECT tag
FROM tag_associations
@@ -210,9 +219,11 @@ def _get_tags(cursor, channel_id):
SELECT id FROM subscribed_channels WHERE yt_channel_id = ?
)''', (channel_id,))]
+
def _get_all_tags(cursor):
return [row[0] for row in cursor.execute('''SELECT DISTINCT tag FROM tag_associations''')]
+
def _get_channel_names(cursor, channel_ids):
''' returns list of (channel_id, channel_name) '''
result = []
@@ -220,7 +231,7 @@ def _get_channel_names(cursor, channel_ids):
row = cursor.execute('''SELECT channel_name
FROM subscribed_channels
WHERE yt_channel_id = ?''', (channel_id,)).fetchone()
- result.append( (channel_id, row[0]) )
+ result.append((channel_id, row[0]))
return result
@@ -245,12 +256,15 @@ def _channels_with_tag(cursor, tag, order=False, exclude_muted=False, include_mu
return cursor.execute(statement, [tag]).fetchall()
+
def _schedule_checking(cursor, channel_id, next_check_time):
cursor.execute('''UPDATE subscribed_channels SET next_check_time = ? WHERE yt_channel_id = ?''', [int(next_check_time), channel_id])
+
def _is_muted(cursor, channel_id):
return bool(cursor.execute('''SELECT muted FROM subscribed_channels WHERE yt_channel_id=?''', [channel_id]).fetchone()[0])
+
units = collections.OrderedDict([
('year', 31536000), # 365*24*3600
('month', 2592000), # 30*24*3600
@@ -260,6 +274,8 @@ units = collections.OrderedDict([
('minute', 60),
('second', 1),
])
+
+
def youtube_timestamp_to_posix(dumb_timestamp):
''' Given a dumbed down timestamp such as 1 year ago, 3 hours ago,
approximates the unix time (seconds since 1/1/1970) '''
@@ -273,6 +289,7 @@ def youtube_timestamp_to_posix(dumb_timestamp):
unit = unit[:-1] # remove s from end
return now - quantifier*units[unit]
+
def posix_to_dumbed_down(posix_time):
'''Inverse of youtube_timestamp_to_posix.'''
delta = int(time.time() - posix_time)
@@ -291,12 +308,14 @@ def posix_to_dumbed_down(posix_time):
else:
raise Exception()
+
def exact_timestamp(posix_time):
result = time.strftime('%I:%M %p %m/%d/%y', time.localtime(posix_time))
if result[0] == '0': # remove 0 infront of hour (like 01:00 PM)
return result[1:]
return result
+
try:
existing_thumbnails = set(os.path.splitext(name)[0] for name in os.listdir(thumbnails_directory))
except FileNotFoundError:
@@ -312,23 +331,71 @@ checking_channels = set()
# Just to use for printing channel checking status to console without opening database
channel_names = dict()
+
def check_channel_worker():
while True:
channel_id = check_channels_queue.get()
try:
_get_upstream_videos(channel_id)
+ except Exception:
+ traceback.print_exc()
finally:
checking_channels.remove(channel_id)
-for i in range(0,5):
+
+for i in range(0, 5):
gevent.spawn(check_channel_worker)
# ----------------------------
-
# --- Auto checking system - Spaghetti code ---
+def autocheck_dispatcher():
+ '''Scans the auto_check_list. Sleeps until the earliest job is due, then adds that channel to the checking queue above. Can be sent a new job through autocheck_job_application'''
+ while True:
+ if len(autocheck_jobs) == 0:
+ new_job = autocheck_job_application.get()
+ autocheck_jobs.append(new_job)
+ else:
+ earliest_job_index = min(range(0, len(autocheck_jobs)), key=lambda index: autocheck_jobs[index]['next_check_time']) # https://stackoverflow.com/a/11825864
+ earliest_job = autocheck_jobs[earliest_job_index]
+ time_until_earliest_job = earliest_job['next_check_time'] - time.time()
+
+ if time_until_earliest_job <= -5: # should not happen unless we're running extremely slow
+ print('ERROR: autocheck_dispatcher got job scheduled in the past, skipping and rescheduling: ' + earliest_job['channel_id'] + ', ' + earliest_job['channel_name'] + ', ' + str(earliest_job['next_check_time']))
+ next_check_time = time.time() + 3600*secrets.randbelow(60)/60
+ with_open_db(_schedule_checking, earliest_job['channel_id'], next_check_time)
+ autocheck_jobs[earliest_job_index]['next_check_time'] = next_check_time
+ continue
+
+ # make sure it's not muted
+ if with_open_db(_is_muted, earliest_job['channel_id']):
+ del autocheck_jobs[earliest_job_index]
+ continue
+
+ if time_until_earliest_job > 0: # it can become less than zero (in the past) when it's set to go off while the dispatcher is doing something else at that moment
+ try:
+ new_job = autocheck_job_application.get(timeout=time_until_earliest_job) # sleep for time_until_earliest_job time, but allow to be interrupted by new jobs
+ except gevent.queue.Empty: # no new jobs
+ pass
+ else: # new job, add it to the list
+ autocheck_jobs.append(new_job)
+ continue
+
+ # no new jobs, time to execute the earliest job
+ channel_names[earliest_job['channel_id']] = earliest_job['channel_name']
+ checking_channels.add(earliest_job['channel_id'])
+ check_channels_queue.put(earliest_job['channel_id'])
+ del autocheck_jobs[earliest_job_index]
+
+
+dispatcher_greenlet = None
+
+
+def start_autocheck_system():
+ global autocheck_job_application
+ global autocheck_jobs
+ global dispatcher_greenlet
-if settings.autocheck_subscriptions:
# job application format: dict with keys (channel_id, channel_name, next_check_time)
autocheck_job_application = gevent.queue.Queue() # only really meant to hold 1 item, just reusing gevent's wait and timeout machinery
@@ -350,53 +417,30 @@ if settings.autocheck_subscriptions:
row = (row[0], row[1], next_check_time)
_schedule_checking(cursor, row[0], next_check_time)
autocheck_jobs.append({'channel_id': row[0], 'channel_name': row[1], 'next_check_time': next_check_time})
+ dispatcher_greenlet = gevent.spawn(autocheck_dispatcher)
+def stop_autocheck_system():
+ if dispatcher_greenlet is not None:
+ dispatcher_greenlet.kill()
- def autocheck_dispatcher():
- '''Scans the auto_check_list. Sleeps until the earliest job is due, then adds that channel to the checking queue above. Can be sent a new job through autocheck_job_application'''
- while True:
- if len(autocheck_jobs) == 0:
- new_job = autocheck_job_application.get()
- autocheck_jobs.append(new_job)
- else:
- earliest_job_index = min(range(0, len(autocheck_jobs)), key=lambda index: autocheck_jobs[index]['next_check_time']) # https://stackoverflow.com/a/11825864
- earliest_job = autocheck_jobs[earliest_job_index]
- time_until_earliest_job = earliest_job['next_check_time'] - time.time()
-
- if time_until_earliest_job <= -5: # should not happen unless we're running extremely slow
- print('ERROR: autocheck_dispatcher got job scheduled in the past, skipping and rescheduling: ' + earliest_job['channel_id'] + ', ' + earliest_job['channel_name'] + ', ' + str(earliest_job['next_check_time']))
- next_check_time = time.time() + 3600*secrets.randbelow(60)/60
- with_open_db(_schedule_checking, earliest_job['channel_id'], next_check_time)
- autocheck_jobs[earliest_job_index]['next_check_time'] = next_check_time
- continue
-
- # make sure it's not muted
- if with_open_db(_is_muted, earliest_job['channel_id']):
- del autocheck_jobs[earliest_job_index]
- continue
- if time_until_earliest_job > 0: # it can become less than zero (in the past) when it's set to go off while the dispatcher is doing something else at that moment
- try:
- new_job = autocheck_job_application.get(timeout = time_until_earliest_job) # sleep for time_until_earliest_job time, but allow to be interrupted by new jobs
- except gevent.queue.Empty: # no new jobs
- pass
- else: # new job, add it to the list
- autocheck_jobs.append(new_job)
- continue
-
- # no new jobs, time to execute the earliest job
- channel_names[earliest_job['channel_id']] = earliest_job['channel_name']
- checking_channels.add(earliest_job['channel_id'])
- check_channels_queue.put(earliest_job['channel_id'])
- del autocheck_jobs[earliest_job_index]
+def autocheck_setting_changed(old_value, new_value):
+ if new_value:
+ start_autocheck_system()
+ else:
+ stop_autocheck_system()
- gevent.spawn(autocheck_dispatcher)
+settings.add_setting_changed_hook(
+ 'autocheck_subscriptions',
+ autocheck_setting_changed
+)
+if settings.autocheck_subscriptions:
+ start_autocheck_system()
# ----------------------------
-
def check_channels_if_necessary(channel_ids):
for channel_id in channel_ids:
if channel_id not in checking_channels:
@@ -404,6 +448,54 @@ def check_channels_if_necessary(channel_ids):
check_channels_queue.put(channel_id)
+def _get_atoma_feed(channel_id):
+ url = 'https://www.youtube.com/feeds/videos.xml?channel_id=' + channel_id
+ try:
+ return util.fetch_url(url).decode('utf-8')
+ except util.FetchError as e:
+ # 404 is expected for terminated channels
+ if e.code in ('404', '429'):
+ return ''
+ if e.code == '502':
+ return str(e)
+ raise
+
+
+def _get_channel_videos_first_page(channel_id, channel_status_name):
+ try:
+ # First try the playlist method
+ pl_json = playlist.get_videos(
+ 'UU' + channel_id[2:],
+ 1,
+ include_shorts=settings.include_shorts_in_subscriptions,
+ report_text=None
+ )
+ pl_info = yt_data_extract.extract_playlist_info(pl_json)
+ if pl_info.get('items'):
+ pl_info['items'] = pl_info['items'][0:30]
+ return pl_info
+
+ # Try the channel api method
+ channel_json = channel.get_channel_first_page(channel_id=channel_id)
+ channel_info = yt_data_extract.extract_channel_info(
+ json.loads(channel_json), 'videos'
+ )
+ return channel_info
+ except util.FetchError as e:
+ if e.code == '429' and settings.route_tor:
+ error_message = ('Error checking channel ' + channel_status_name
+ + ': YouTube blocked the request because the'
+ + ' Tor exit node is overutilized. Try getting a new exit node'
+ + ' by using the New Identity button in the Tor Browser.')
+ if e.ip:
+ error_message += ' Exit node IP address: ' + e.ip
+ print(error_message)
+ return None
+ elif e.code == '502':
+ print('Error checking channel', channel_status_name + ':', str(e))
+ return None
+ raise
+
def _get_upstream_videos(channel_id):
try:
@@ -414,12 +506,15 @@ def _get_upstream_videos(channel_id):
print("Checking channel: " + channel_status_name)
tasks = (
- gevent.spawn(channel.get_channel_tab, channel_id, print_status=False), # channel page, need for video duration
- gevent.spawn(util.fetch_url, 'https://www.youtube.com/feeds/videos.xml?channel_id=' + channel_id) # atoma feed, need for exact published time
+ # channel page, need for video duration
+ gevent.spawn(_get_channel_videos_first_page, channel_id,
+ channel_status_name),
+ # need atoma feed for exact published time
+ gevent.spawn(_get_atoma_feed, channel_id)
)
gevent.joinall(tasks)
- channel_tab, feed = tasks[0].value, tasks[1].value
+ channel_info, feed = tasks[0].value, tasks[1].value
# extract published times from atoma feed
times_published = {}
@@ -436,7 +531,7 @@ def _get_upstream_videos(channel_id):
return element
return None
- root = defusedxml.ElementTree.fromstring(feed.decode('utf-8'))
+ root = defusedxml.ElementTree.fromstring(feed)
assert remove_bullshit(root.tag) == 'feed'
for entry in root:
if (remove_bullshit(entry.tag) != 'entry'):
@@ -451,42 +546,70 @@ def _get_upstream_videos(channel_id):
time_published = int(calendar.timegm(time.strptime(time_published_element.text, '%Y-%m-%dT%H:%M:%S+00:00')))
times_published[video_id_element.text] = time_published
- except (AssertionError, defusedxml.ElementTree.ParseError) as e:
+ except AssertionError:
print('Failed to read atoma feed for ' + channel_status_name)
traceback.print_exc()
+ except defusedxml.ElementTree.ParseError:
+ print('Failed to read atoma feed for ' + channel_status_name)
- channel_info = yt_data_extract.extract_channel_info(json.loads(channel_tab), 'videos')
- if channel_info['errors']:
- print('Error checking channel ' + channel_status_name + ': ' + ', '.join(channel_info['errors']))
+ if channel_info is None: # there was an error
+ return
+ if channel_info['error']:
+ print('Error checking channel ' + channel_status_name + ': ' + channel_info['error'])
return
videos = channel_info['items']
for i, video_item in enumerate(videos):
- if 'description' not in video_item:
+ if not video_item.get('description'):
video_item['description'] = ''
+ else:
+ video_item['description'] = ''.join(run.get('text', '') for run in video_item['description'])
if video_item['id'] in times_published:
video_item['time_published'] = times_published[video_item['id']]
video_item['is_time_published_exact'] = True
- else:
+ elif video_item.get('time_published'):
video_item['is_time_published_exact'] = False
try:
- video_item['time_published'] = youtube_timestamp_to_posix(video_item['published']) - i # subtract a few seconds off the videos so they will be in the right order
- except KeyError:
+ video_item['time_published'] = youtube_timestamp_to_posix(video_item['time_published']) - i # subtract a few seconds off the videos so they will be in the right order
+ except Exception:
print(video_item)
-
+ else:
+ video_item['is_time_published_exact'] = False
+ video_item['time_published'] = None
video_item['channel_id'] = channel_id
+ if len(videos) > 1:
+ # Go back and fill in any videos that don't have a time published
+ # using the time published of the surrounding ones
+ for i in range(len(videos)-1):
+ if (videos[i+1]['time_published'] is None
+ and videos[i]['time_published'] is not None
+ ):
+ videos[i+1]['time_published'] = videos[i]['time_published'] - 1
+ for i in reversed(range(1,len(videos))):
+ if (videos[i-1]['time_published'] is None
+ and videos[i]['time_published'] is not None
+ ):
+ videos[i-1]['time_published'] = videos[i]['time_published'] + 1
+ # Special case: none of the videos have a time published.
+ # In this case, make something up
+ if videos and videos[0]['time_published'] is None:
+ assert all(v['time_published'] is None for v in videos)
+ now = time.time()
+ for i in range(len(videos)):
+ # 1 month between videos
+ videos[i]['time_published'] = now - i*3600*24*30
if len(videos) == 0:
- average_upload_period = 4*7*24*3600 # assume 1 month for channel with no videos
+ average_upload_period = 4*7*24*3600 # assume 1 month for channel with no videos
elif len(videos) < 5:
average_upload_period = int((time.time() - videos[len(videos)-1]['time_published'])/len(videos))
else:
average_upload_period = int((time.time() - videos[4]['time_published'])/5) # equivalent to averaging the time between videos for the last 5 videos
# calculate when to check next for auto checking
- # add some quantization and randomness to make pattern analysis by Youtube slightly harder
+ # add some quantization and randomness to make pattern analysis by YouTube slightly harder
quantized_upload_period = average_upload_period - (average_upload_period % (4*3600)) + 4*3600 # round up to nearest 4 hours
randomized_upload_period = quantized_upload_period*(1 + secrets.randbelow(50)/50*0.5) # randomly between 1x and 1.5x
next_check_delay = randomized_upload_period/10 # check at 10x the channel posting rate. might want to fine tune this number
@@ -495,44 +618,74 @@ def _get_upstream_videos(channel_id):
with open_database() as connection:
with connection as cursor:
- # calculate how many new videos there are
- row = cursor.execute('''SELECT video_id
- FROM videos
- INNER JOIN subscribed_channels ON videos.sql_channel_id = subscribed_channels.id
- WHERE yt_channel_id=?
- ORDER BY time_published DESC
- LIMIT 1''', [channel_id]).fetchone()
- if row is None:
- number_of_new_videos = len(videos)
- else:
- latest_video_id = row[0]
- index = 0
- for video in videos:
- if video['id'] == latest_video_id:
- break
- index += 1
- number_of_new_videos = index
+ # Get video ids and duration of existing vids so we
+ # can see how many new ones there are and update
+ # livestreams/premiers
+ existing_vids = list(cursor.execute(
+ '''SELECT video_id, duration
+ FROM videos
+ INNER JOIN subscribed_channels
+ ON videos.sql_channel_id = subscribed_channels.id
+ WHERE yt_channel_id=?
+ ORDER BY time_published DESC
+ LIMIT 30''', [channel_id]).fetchall())
+ existing_vid_ids = set(row[0] for row in existing_vids)
+ existing_durs = dict(existing_vids)
+
+ # new videos the channel has uploaded since last time we checked
+ number_of_new_videos = 0
+ for video in videos:
+ if video['id'] in existing_vid_ids:
+ break
+ number_of_new_videos += 1
is_first_check = cursor.execute('''SELECT time_last_checked FROM subscribed_channels WHERE yt_channel_id=?''', [channel_id]).fetchone()[0] in (None, 0)
time_videos_retrieved = int(time.time())
rows = []
- for video_item in videos:
- if is_first_check or number_of_new_videos > 6:
+ update_rows = []
+ for i, video_item in enumerate(videos):
+ if (is_first_check
+ or number_of_new_videos > 6
+ or i >= number_of_new_videos):
# don't want a crazy ordering on first check or check in a long time, since we're ordering by time_noticed
+ # Last condition is for when the channel deleting videos
+ # causes new videos to appear at the end of the backlog.
+ # For instance, if we have 30 vids in the DB, and 1 vid
+ # that we previously saw has since been deleted,
+ # then a video we haven't seen before will appear as the
+ # 30th. Don't want this to be considered a newly noticed
+ # vid which would appear at top of subscriptions feed
time_noticed = video_item['time_published']
else:
time_noticed = time_videos_retrieved
- rows.append((
- video_item['channel_id'],
- video_item['id'],
- video_item['title'],
- video_item['duration'],
- video_item['time_published'],
- video_item['is_time_published_exact'],
- time_noticed,
- video_item['description'],
- ))
+ # videos which need durations updated
+ non_durations = ('upcoming', 'none', 'live', '')
+ v_id = video_item['id']
+ if (existing_durs.get(v_id) is not None
+ and existing_durs[v_id].lower() in non_durations
+ and video_item['duration'] not in non_durations
+ ):
+ update_rows.append((
+ video_item['title'],
+ video_item['duration'],
+ video_item['time_published'],
+ video_item['is_time_published_exact'],
+ video_item['description'],
+ video_item['id'],
+ ))
+ # all other videos
+ else:
+ rows.append((
+ video_item['channel_id'],
+ video_item['id'],
+ video_item['title'],
+ video_item['duration'],
+ video_item['time_published'],
+ video_item['is_time_published_exact'],
+ time_noticed,
+ video_item['description'],
+ ))
cursor.executemany('''INSERT OR IGNORE INTO videos (
sql_channel_id,
@@ -545,6 +698,13 @@ def _get_upstream_videos(channel_id):
description
)
VALUES ((SELECT id FROM subscribed_channels WHERE yt_channel_id=?), ?, ?, ?, ?, ?, ?, ?)''', rows)
+ cursor.executemany('''UPDATE videos SET
+ title=?,
+ duration=?,
+ time_published=?,
+ is_time_published_exact=?,
+ description=?
+ WHERE video_id=?''', update_rows)
cursor.execute('''UPDATE subscribed_channels
SET time_last_checked = ?, next_check_time = ?
WHERE yt_channel_id=?''', [int(time.time()), next_check_time, channel_id])
@@ -561,7 +721,6 @@ def _get_upstream_videos(channel_id):
print(str(number_of_new_videos) + ' new videos from ' + channel_status_name)
-
def check_all_channels():
with open_database() as connection:
with connection as cursor:
@@ -595,35 +754,54 @@ def check_specific_channels(channel_ids):
channel_names.update(channel_id_name_list)
check_channels_if_necessary(channel_ids)
-
-
+CHANNEL_ID_RE = re.compile(r'UC[-_\w]{22}')
@yt_app.route('/import_subscriptions', methods=['POST'])
def import_subscriptions():
# check if the post request has the file part
if 'subscriptions_file' not in request.files:
- #flash('No file part')
+ # flash('No file part')
return flask.redirect(util.URL_ORIGIN + request.full_path)
file = request.files['subscriptions_file']
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
- #flash('No selected file')
+ # flash('No selected file')
return flask.redirect(util.URL_ORIGIN + request.full_path)
-
mime_type = file.mimetype
if mime_type == 'application/json':
- file = file.read().decode('utf-8')
+ info = file.read().decode('utf-8')
+ if info == '':
+ return '400 Bad Request: File is empty', 400
try:
- file = json.loads(file)
+ info = json.loads(info)
except json.decoder.JSONDecodeError:
traceback.print_exc()
return '400 Bad Request: Invalid json file', 400
+ channels = []
try:
- channels = ( (item['snippet']['resourceId']['channelId'], item['snippet']['title']) for item in file)
+ if 'app_version_int' in info: # NewPipe Format
+ for item in info['subscriptions']:
+ # Other service, such as SoundCloud
+ if item.get('service_id', 0) != 0:
+ continue
+ channel_url = item['url']
+ channel_id_match = CHANNEL_ID_RE.search(channel_url)
+ if channel_id_match:
+ channel_id = channel_id_match.group(0)
+ else:
+ print('WARNING: Could not find channel id in url',
+ channel_url)
+ continue
+ channels.append((channel_id, item['name']))
+ else: # Old Google Takeout format
+ for item in info:
+ snippet = item['snippet']
+ channel_id = snippet['resourceId']['channelId']
+ channels.append((channel_id, snippet['title']))
except (KeyError, IndexError):
traceback.print_exc()
return '400 Bad Request: Unknown json structure', 400
@@ -637,22 +815,98 @@ def import_subscriptions():
if (outline_element.tag != 'outline') or ('xmlUrl' not in outline_element.attrib):
continue
-
channel_name = outline_element.attrib['text']
channel_rss_url = outline_element.attrib['xmlUrl']
channel_id = channel_rss_url[channel_rss_url.find('channel_id=')+11:].strip()
- channels.append( (channel_id, channel_name) )
+ channels.append((channel_id, channel_name))
except (AssertionError, IndexError, defusedxml.ElementTree.ParseError) as e:
return '400 Bad Request: Unable to read opml xml file, or the file is not the expected format', 400
+ elif mime_type in ('text/csv', 'application/vnd.ms-excel'):
+ content = file.read().decode('utf-8')
+ reader = csv.reader(content.splitlines())
+ channels = []
+ for row in reader:
+ if not row or row[0].lower().strip() == 'channel id':
+ continue
+ elif len(row) > 1 and CHANNEL_ID_RE.fullmatch(row[0].strip()):
+ channels.append( (row[0], row[-1]) )
+ else:
+ print('WARNING: Unknown row format:', row)
else:
- return '400 Bad Request: Unsupported file format: ' + mime_type + '. Only subscription.json files (from Google Takeouts) and XML OPML files exported from Youtube\'s subscription manager page are supported', 400
+ error = 'Unsupported file format: ' + mime_type
+ error += (' . Only subscription.json, subscriptions.csv files'
+ ' (from Google Takeouts)'
+ ' and XML OPML files exported from YouTube\'s'
+ ' subscription manager page are supported')
+ return (flask.render_template('error.html', error_message=error),
+ 400)
_subscribe(channels)
return flask.redirect(util.URL_ORIGIN + '/subscription_manager', 303)
+@yt_app.route('/export_subscriptions', methods=['POST'])
+def export_subscriptions():
+ include_muted = request.values.get('include_muted') == 'on'
+ with open_database() as connection:
+ with connection as cursor:
+ sub_list = []
+ for channel_name, channel_id, muted in (
+ _get_subscribed_channels(cursor)):
+ if muted and not include_muted:
+ continue
+ if request.values['export_format'] == 'json_google_takeout':
+ sub_list.append({
+ 'kind': 'youtube#subscription',
+ 'snippet': {
+ 'muted': bool(muted),
+ 'resourceId': {
+ 'channelId': channel_id,
+ 'kind': 'youtube#channel',
+ },
+ 'tags': _get_tags(cursor, channel_id),
+ 'title': channel_name,
+ },
+ })
+ elif request.values['export_format'] == 'json_newpipe':
+ sub_list.append({
+ 'service_id': 0,
+ 'url': 'https://www.youtube.com/channel/' + channel_id,
+ 'name': channel_name,
+ })
+ elif request.values['export_format'] == 'opml':
+ sub_list.append({
+ 'channel_name': channel_name,
+ 'channel_id': channel_id,
+ })
+ date_time = time.strftime('%Y%m%d%H%M', time.localtime())
+ if request.values['export_format'] == 'json_google_takeout':
+ r = flask.Response(json.dumps(sub_list), mimetype='text/json')
+ cd = 'attachment; filename="subscriptions_%s.json"' % date_time
+ r.headers['Content-Disposition'] = cd
+ return r
+ elif request.values['export_format'] == 'json_newpipe':
+ r = flask.Response(json.dumps({
+ 'app_version': '0.21.9',
+ 'app_version_int': 975,
+ 'subscriptions': sub_list,
+ }), mimetype='text/json')
+ file_name = 'newpipe_subscriptions_%s_youtube-local.json' % date_time
+ cd = 'attachment; filename="%s"' % file_name
+ r.headers['Content-Disposition'] = cd
+ return r
+ elif request.values['export_format'] == 'opml':
+ r = flask.Response(
+ flask.render_template('subscriptions.xml', sub_list=sub_list),
+ mimetype='text/xml')
+ cd = 'attachment; filename="subscriptions_%s.xml"' % date_time
+ r.headers['Content-Disposition'] = cd
+ return r
+ else:
+ return '400 Bad Request', 400
+
@yt_app.route('/subscription_manager', methods=['GET'])
def get_subscription_manager_page():
@@ -673,7 +927,7 @@ def get_subscription_manager_page():
'tags': [t for t in _get_tags(cursor, channel_id) if t != tag],
})
- tag_groups.append( (tag, sub_list) )
+ tag_groups.append((tag, sub_list))
# Channels with no tags
channel_list = cursor.execute('''SELECT yt_channel_id, channel_name, muted
@@ -693,7 +947,7 @@ def get_subscription_manager_page():
'tags': [],
})
- tag_groups.append( ('No tags', sub_list) )
+ tag_groups.append(('No tags', sub_list))
else:
sub_list = []
for channel_name, channel_id, muted in _get_subscribed_channels(cursor):
@@ -705,20 +959,20 @@ def get_subscription_manager_page():
'tags': _get_tags(cursor, channel_id),
})
-
-
-
if group_by_tags:
- return flask.render_template('subscription_manager.html',
- group_by_tags = True,
- tag_groups = tag_groups,
+ return flask.render_template(
+ 'subscription_manager.html',
+ group_by_tags=True,
+ tag_groups=tag_groups,
)
else:
- return flask.render_template('subscription_manager.html',
- group_by_tags = False,
- sub_list = sub_list,
+ return flask.render_template(
+ 'subscription_manager.html',
+ group_by_tags=False,
+ sub_list=sub_list,
)
+
def list_from_comma_separated_tags(string):
return [tag.strip() for tag in string.split(',') if tag.strip()]
@@ -737,7 +991,7 @@ def post_subscription_manager_page():
_unsubscribe(cursor, request.values.getlist('channel_ids'))
elif action == 'unsubscribe_verify':
unsubscribe_list = _get_channel_names(cursor, request.values.getlist('channel_ids'))
- return flask.render_template('unsubscribe_verify.html', unsubscribe_list = unsubscribe_list)
+ return flask.render_template('unsubscribe_verify.html', unsubscribe_list=unsubscribe_list)
elif action == 'mute':
cursor.executemany('''UPDATE subscribed_channels
@@ -752,6 +1006,7 @@ def post_subscription_manager_page():
return flask.redirect(util.URL_ORIGIN + request.full_path, 303)
+
@yt_app.route('/subscriptions', methods=['GET'])
@yt_app.route('/feed/subscriptions', methods=['GET'])
def get_subscriptions_page():
@@ -764,11 +1019,10 @@ def get_subscriptions_page():
video['thumbnail'] = util.URL_ORIGIN + '/data/subscription_thumbnails/' + video['id'] + '.jpg'
video['type'] = 'video'
video['item_size'] = 'small'
- yt_data_extract.add_extra_html_info(video)
+ util.add_extra_html_info(video)
tags = _get_all_tags(cursor)
-
subscription_list = []
for channel_name, channel_id, muted in _get_subscribed_channels(cursor):
subscription_list.append({
@@ -778,15 +1032,18 @@ def get_subscriptions_page():
'muted': muted,
})
- return flask.render_template('subscriptions.html',
- videos = videos,
- num_pages = math.ceil(number_of_videos_in_db/60),
- parameters_dictionary = request.args,
- tags = tags,
- current_tag = tag,
- subscription_list = subscription_list,
+ return flask.render_template(
+ 'subscriptions.html',
+ header_playlist_names=local_playlist.get_playlist_names(),
+ videos=videos,
+ num_pages=math.ceil(number_of_videos_in_db/60),
+ parameters_dictionary=request.args,
+ tags=tags,
+ current_tag=tag,
+ subscription_list=subscription_list,
)
+
@yt_app.route('/subscriptions', methods=['POST'])
@yt_app.route('/feed/subscriptions', methods=['POST'])
def post_subscriptions_page():
@@ -832,7 +1089,7 @@ def serve_subscription_thumbnail(thumbnail):
f.close()
return flask.Response(image, mimetype='image/jpeg')
- url = "https://i.ytimg.com/vi/" + video_id + "/mqdefault.jpg"
+ url = f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
try:
image = util.fetch_url(url, report_text="Saved thumbnail: " + video_id)
except urllib.error.HTTPError as e:
@@ -841,17 +1098,10 @@ def serve_subscription_thumbnail(thumbnail):
try:
f = open(thumbnail_path, 'wb')
except FileNotFoundError:
- os.makedirs(thumbnails_directory, exist_ok = True)
+ os.makedirs(thumbnails_directory, exist_ok=True)
f = open(thumbnail_path, 'wb')
f.write(image)
f.close()
existing_thumbnails.add(video_id)
return flask.Response(image, mimetype='image/jpeg')
-
-
-
-
-
-
-
diff --git a/youtube/templates/base.html b/youtube/templates/base.html
index 9127efa..393cc52 100644
--- a/youtube/templates/base.html
+++ b/youtube/templates/base.html
@@ -1,113 +1,179 @@
+{% if settings.app_public %}
+ {% set app_url = settings.app_url|string %}
+{% else %}
+ {% set app_url = settings.app_url|string + ':' + settings.port_number|string %}
+{% endif %}
<!DOCTYPE html>
-<html>
+<html lang="en">
<head>
- <meta charset="utf-8">
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' blob: {{ app_url }}/* data: https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}">
<title>{{ page_title }}</title>
- <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; script-src 'none'; media-src 'self' https://*.googlevideo.com">
- <link href="{{ theme_path }}" type="text/css" rel="stylesheet">
- <link href="/youtube.com/static/shared.css" type="text/css" rel="stylesheet">
- <link href="/youtube.com/static/comments.css" type="text/css" rel="stylesheet">
+ <link title="YT Local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml">
<link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
- <link title="Youtube local" href="/youtube.com/opensearch.xml" rel="search" type="application/opensearchdescription+xml">
- <style type="text/css">
-{% block style %}
-{{ style }}
-{% endblock %}
- </style>
+ <link href="/youtube.com/static/normalize.css" rel="stylesheet">
+ <link href="{{ theme_path }}" rel="stylesheet">
+ <link href="/youtube.com/shared.css" rel="stylesheet">
+ {% block style %}
+ {{ style }}
+ {% endblock %}
+
+ {% if js_data %}
+ <script>
+ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+ data = {{ js_data|tojson }};
+ // @license-end
+ </script>
+ {% endif %}
</head>
- <body>
- <header>
- <a href="/youtube.com" id="home-link">Home</a>
- <form id="site-search" action="/youtube.com/search">
- <input type="search" name="query" class="search-box" value="{{ search_box_value }}">
- <button type="submit" value="Search" class="search-button">Search</button>
- <div class="dropdown">
- <button class="dropdown-label">Options</button>
- <div class="css-sucks">
- <div class="dropdown-content">
- <h3>Sort by</h3>
- <input type="radio" id="sort_relevance" name="sort" value="0">
- <label for="sort_relevance">Relevance</label>
-
- <input type="radio" id="sort_upload_date" name="sort" value="2">
- <label for="sort_upload_date">Upload date</label>
-
- <input type="radio" id="sort_view_count" name="sort" value="3">
- <label for="sort_view_count">View count</label>
-
- <input type="radio" id="sort_rating" name="sort" value="1">
- <label for="sort_rating">Rating</label>
-
-
- <h3>Upload date</h3>
- <input type="radio" id="time_any" name="time" value="0">
- <label for="time_any">Any</label>
-
- <input type="radio" id="time_last_hour" name="time" value="1">
- <label for="time_last_hour">Last hour</label>
-
- <input type="radio" id="time_today" name="time" value="2">
- <label for="time_today">Today</label>
-
- <input type="radio" id="time_this_week" name="time" value="3">
- <label for="time_this_week">This week</label>
-
- <input type="radio" id="time_this_month" name="time" value="4">
- <label for="time_this_month">This month</label>
-
- <input type="radio" id="time_this_year" name="time" value="5">
- <label for="time_this_year">This year</label>
- <h3>Type</h3>
- <input type="radio" id="type_any" name="type" value="0">
- <label for="type_any">Any</label>
-
- <input type="radio" id="type_video" name="type" value="1">
- <label for="type_video">Video</label>
-
- <input type="radio" id="type_channel" name="type" value="2">
- <label for="type_channel">Channel</label>
-
- <input type="radio" id="type_playlist" name="type" value="3">
- <label for="type_playlist">Playlist</label>
-
- <input type="radio" id="type_movie" name="type" value="4">
- <label for="type_movie">Movie</label>
-
- <input type="radio" id="type_show" name="type" value="5">
- <label for="type_show">Show</label>
-
-
- <h3>Duration</h3>
- <input type="radio" id="duration_any" name="duration" value="0">
- <label for="duration_any">Any</label>
+ <body>
+ <header class="header">
+ <nav class="home">
+ <a href="/youtube.com" id="home-link">YT Local</a>
+ </nav>
+ <form class="form" id="site-search" action="/youtube.com/results">
+ <input type="search" name="search_query" class="search-box" value="{{ search_box_value }}"
+ {{ "autofocus" if (request.path in ("/", "/results") or error_message) else "" }} required placeholder="Type to search...">
+ <button type="submit" value="Search" class="search-button">Search</button>
+ <!-- options -->
+ <div class="dropdown">
+ <!-- hidden box -->
+ <input id="options-toggle-cbox" class="opt-box" type="checkbox">
+ <!-- end hidden box -->
+ <label class="dropdown-label" for="options-toggle-cbox">Options</label>
+ <div class="dropdown-content">
+ <h3>Sort by</h3>
+ <div class="option">
+ <input type="radio" id="sort_relevance" name="sort" value="0">
+ <label for="sort_relevance">Relevance</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="sort_upload_date" name="sort" value="2">
+ <label for="sort_upload_date">Upload date</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="sort_view_count" name="sort" value="3">
+ <label for="sort_view_count">View count</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="sort_rating" name="sort" value="1">
+ <label for="sort_rating">Rating</label>
+ </div>
- <input type="radio" id="duration_short" name="duration" value="1">
- <label for="duration_short">Short (&lt; 4 minutes)</label>
+ <h3>Upload date</h3>
+ <div class="option">
+ <input type="radio" id="time_any" name="time" value="0">
+ <label for="time_any">Any</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="time_last_hour" name="time" value="1">
+ <label for="time_last_hour">Last hour</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="time_today" name="time" value="2">
+ <label for="time_today">Today</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="time_this_week" name="time" value="3">
+ <label for="time_this_week">This week</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="time_this_month" name="time" value="4">
+ <label for="time_this_month">This month</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="time_this_year" name="time" value="5">
+ <label for="time_this_year">This year</label>
+ </div>
- <input type="radio" id="duration_long" name="duration" value="2">
- <label for="duration_long">Long (&gt; 20 minutes)</label>
+ <h3>Type</h3>
+ <div class="option">
+ <input type="radio" id="type_any" name="type" value="0">
+ <label for="type_any">Any</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="type_video" name="type" value="1">
+ <label for="type_video">Video</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="type_channel" name="type" value="2">
+ <label for="type_channel">Channel</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="type_playlist" name="type" value="3">
+ <label for="type_playlist">Playlist</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="type_movie" name="type" value="4">
+ <label for="type_movie">Movie</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="type_show" name="type" value="5">
+ <label for="type_show">Show</label>
+ </div>
- </div>
+ <h3>Duration</h3>
+ <div class="option">
+ <input type="radio" id="duration_any" name="duration" value="0">
+ <label for="duration_any">Any</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="duration_short" name="duration" value="1">
+ <label for="duration_short">Short (&lt; 4 minutes)</label>
+ </div>
+ <div class="option">
+ <input type="radio" id="duration_long" name="duration" value="2">
+ <label for="duration_long">Long (&gt; 20 minutes)</label>
</div>
</div>
+ </div>
+ </form>
+
+ {% if header_playlist_names is defined %}
+ <form class="playlist" id="playlist-edit" action="/youtube.com/edit_playlist" method="post" target="_self">
+ <input class="play-box" name="playlist_name" id="playlist-name-selection" list="playlist-options" type="search" placeholder="Add name of your playlist...">
+ <datalist class="play-hidden" id="playlist-options">
+ {% for playlist_name in header_playlist_names %}
+ <option value="{{ playlist_name }}">{{ playlist_name }}</option>
+ {% endfor %}
+ </datalist>
+ <button class="play-add" type="submit" id="playlist-add-button" name="action" value="add">+List</button>
+ <div class="play-clean">
+ <button type="reset" id="item-selection-reset">Clear</button>
+ </div>
</form>
+ <script src="/youtube.com/static/js/playlistadd.js"></script>
+ {% endif %}
- <form id="playlist-edit" action="/youtube.com/edit_playlist" method="post" target="_self">
- <input name="playlist_name" id="playlist-name-selection" list="playlist-options" type="text">
- <datalist id="playlist-options">
- {% for playlist_name in header_playlist_names %}
- <option value="{{ playlist_name }}">{{ playlist_name }}</option>
- {% endfor %}
- </datalist>
- <button type="submit" id="playlist-add-button" name="action" value="add">Add to playlist</button>
- <button type="reset" id="item-selection-reset">Clear selection</button>
- </form>
</header>
- <main>
-{% block main %}
-{{ main }}
-{% endblock %}
+ <main class="main">
+
+ {% block main %}
+ {{ main }}
+ {% endblock %}
+
</main>
+ <footer class="footer">
+ <div>
+ <a href="https://git.sr.ht/~heckyel/yt-local"
+ rel="noopener noreferrer" target="_blank">
+ Released under the AGPLv3 or later
+ </a>
+ </div>
+ <div>
+ <p>This site is Free/Libre Software</p>
+ {% if current_commit != None %}
+ <p>Current version: {{ current_commit }} @ {{ current_branch }}</p>
+ {% else %}
+ <p>Current version: {{ current_version }}</p>
+ {% endif %}
+ </div>
+ <div>
+ <a href="/youtube.com/licenses" data-jslicense="1" rel="noopener noreferrer" target="_blank">JavaScript licenses</a>
+ </div>
+ </footer>
</body>
+
</html>
diff --git a/youtube/templates/channel.html b/youtube/templates/channel.html
index 48041a0..c43f488 100644
--- a/youtube/templates/channel.html
+++ b/youtube/templates/channel.html
@@ -1,106 +1,39 @@
-{% set page_title = channel_name + ' - Channel' %}
+{% if current_tab == 'search' %}
+ {% set page_title = search_box_value + ' - Page ' + page_number|string %}
+{% else %}
+ {% set page_title = channel_name|string + ' - Channel' %}
+{% endif %}
+
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
- main{
- display:grid;
-{% if current_tab == 'about' %}
- grid-template-rows: 0fr 0fr 1fr;
- grid-template-columns: 0fr 1fr;
-{% else %}
- grid-template-rows: repeat(5, 0fr);
- grid-template-columns: auto 1fr;
-{% endif %}
- }
- main .avatar{
- grid-row:1;
- grid-column:1;
- height:200px;
- width:200px;
- }
- main .summary{
- grid-row:1;
- grid-column:2;
- margin-left: 5px;
- }
- .summary subscribe-unsubscribe, .summary short-description{
- margin-top: 10px;
- }
- main .channel-tabs{
- grid-row:2;
- grid-column: 1 / span 2;
-
- display:grid;
- grid-auto-flow: column;
- justify-content:start;
-
- background-color: var(--interface-color);
- padding: 3px;
- padding-left: 6px;
- }
- #links-metadata{
- display: grid;
- grid-auto-flow: column;
- grid-column-gap: 10px;
- grid-column: 1/span 2;
- justify-content: start;
- padding-top: 8px;
- padding-bottom: 8px;
- padding-left: 6px;
- margin-bottom: 10px;
- }
- #number-of-results{
- font-weight:bold;
- }
- .item-grid{
- padding-left: 20px;
- grid-row:4;
- grid-column: 1 / span 2;
- }
- .item-list{
- width:1000px;
- grid-column: 1 / span 2;
- }
- .page-button-row{
- grid-column: 1 / span 2;
- }
- .tab{
- padding: 5px 75px;
- }
- .channel-info{
- grid-row: 3;
- grid-column: 1 / span 3;
- }
- .channel-info ul{
- padding-left: 40px;
- }
- .channel-info h3{
- margin-left: 40px;
- }
- .channel-info .description{
- white-space: pre-wrap;
- min-width: 0;
- margin-left: 40px;
- }
- .medium-item img{
- max-width: 168px;
- }
+ <link href="/youtube.com/static/message_box.css" rel="stylesheet">
+ <link href="/youtube.com/static/channel.css" rel="stylesheet">
{% endblock style %}
-{% block main %}
- <img class="avatar" src="{{ avatar }}">
- <div class="summary">
- <h2 class="title">{{ channel_name }}</h2>
- <p class="short-description">{{ short_description }}</p>
- <form method="POST" action="/youtube.com/subscriptions" class="subscribe-unsubscribe">
- <input type="submit" value="{{ 'Unsubscribe' if subscribed else 'Subscribe' }}">
- <input type="hidden" name="channel_id" value="{{ channel_id }}">
- <input type="hidden" name="channel_name" value="{{ channel_name }}">
- <input type="hidden" name="action" value="{{ 'unsubscribe' if subscribed else 'subscribe' }}">
- </form>
+{% block main %}
+
+ <div class="author-container">
+ <div class="author">
+ <img alt="{{ channel_name }}" src="{{ avatar }}">
+ <h2>{{ channel_name }}</h2>
+ </div>
+ <div class="summary">
+ <p>{{ short_description }}</p>
+ </div>
+ <div class="subscribe">
+ <form method="POST" action="/youtube.com/subscriptions" class="subscribe-unsubscribe">
+ <input class="btn-subscribe" type="submit" value="{{ 'Unsubscribe' if subscribed else 'Subscribe' }}">
+ <input type="hidden" name="channel_id" value="{{ channel_id }}">
+ <input type="hidden" name="channel_name" value="{{ channel_name }}">
+ <input type="hidden" name="action" value="{{ 'unsubscribe' if subscribed else 'subscribe' }}">
+ </form>
+ </div>
</div>
+ <hr/>
+
<nav class="channel-tabs">
- {% for tab_name in ('Videos', 'Playlists', 'About') %}
+ {% for tab_name in ('Videos', 'Shorts', 'Streams', 'Playlists', 'About') %}
{% if tab_name.lower() == current_tab %}
<a class="tab page-button">{{ tab_name }}</a>
{% else %}
@@ -116,8 +49,17 @@
{% if current_tab == 'about' %}
<div class="channel-info">
<ul>
- {% for stat in stats %}
- <li>{{ stat }}</li>
+ {% for (before_text, stat, after_text) in [
+ ('Joined ', date_joined, ''),
+ ('', approx_view_count, ' views'),
+ ('', approx_subscriber_count, ' subscribers'),
+ ('', approx_video_count, ' videos'),
+ ('Country: ', country, ''),
+ ('Canonical Url: ', canonical_url, ''),
+ ] %}
+ {% if stat %}
+ <li>{{ before_text + stat|string + after_text }}</li>
+ {% endif %}
{% endfor %}
</ul>
<hr>
@@ -126,43 +68,66 @@
<hr>
<ul>
{% for text, url in links %}
- <li><a href="{{ url }}">{{ text }}</a></li>
+ {% if url %}
+ <li><a href="{{ url }}">{{ text }}</a></li>
+ {% else %}
+ <li>{{ text }}</li>
+ {% endif %}
{% endfor %}
</ul>
</div>
{% else %}
- <div id="links-metadata">
- {% if current_tab == 'videos' %}
- {% set sorts = [('1', 'views'), ('2', 'oldest'), ('3', 'newest')] %}
- <div id="number-of-results">{{ number_of_videos }} videos</div>
- {% elif current_tab == 'playlists' %}
- {% set sorts = [('2', 'oldest'), ('3', 'newest'), ('4', 'last video added')] %}
- {% else %}
- {% set sorts = [] %}
- {% endif %}
- {% for sort_number, sort_name in sorts %}
- {% if sort_number == current_sort.__str__() %}
- <a class="sort-button">{{ 'Sorted by ' + sort_name }}</a>
+ <!-- new-->
+ <div id="links-metadata">
+ {% if current_tab in ('videos', 'shorts', 'streams') %}
+ {% set sorts = [('1', 'views'), ('2', 'oldest'), ('3', 'newest'), ('4', 'newest - no shorts'),] %}
+ <div id="number-of-results">{{ number_of_videos }} videos</div>
+ {% elif current_tab == 'playlists' %}
+ {% set sorts = [('2', 'oldest'), ('3', 'newest'), ('4', 'last video added')] %}
+ {% if items %}
+ <h2 class="page-number">Page {{ page_number }}</h2>
+ {% else %}
+ <h2 class="page-number">No items</h2>
+ {% endif %}
+ {% elif current_tab == 'search' %}
+ {% if items %}
+ <h2 class="page-number">Page {{ page_number }}</h2>
+ {% else %}
+ <h2 class="page-number">No results</h2>
+ {% endif %}
{% else %}
- <a class="sort-button" href="{{ channel_url + '/' + current_tab + '?sort=' + sort_number }}">{{ 'Sort by ' + sort_name }}</a>
+ {% set sorts = [] %}
{% endif %}
- {% endfor %}
+
+ {% for sort_number, sort_name in sorts %}
+ {% if sort_number == current_sort.__str__() %}
+ <a class="sort-button">{{ 'Sorted by ' + sort_name }}</a>
+ {% else %}
+ <a class="sort-button" href="{{ channel_url + '/' + current_tab + '?sort=' + sort_number }}">{{ 'Sort by ' + sort_name }}</a>
+ {% endif %}
+ {% endfor %}
</div>
- {% if current_tab != 'about' %}
- <nav class="{{ 'item-list' if current_tab == 'search' else 'item-grid' }}">
+ <div class="video-container {{ current_tab + '-content'}}">
{% for item_info in items %}
{{ common_elements.item(item_info, include_author=false) }}
{% endfor %}
- </nav>
+ </div>
+ <hr/>
- {% if current_tab != 'playlists' %}
- <nav class="page-button-row">
- {{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary) }}
+ <footer class="pagination-container">
+ {% if current_tab in ('videos', 'shorts', 'streams') %}
+ <nav class="pagination-list">
+ {{ common_elements.page_buttons(number_of_pages, channel_url + '/' + current_tab, parameters_dictionary, include_ends=(current_sort.__str__() in '34')) }}
+ </nav>
+ {% elif current_tab == 'playlists' or current_tab == 'search' %}
+ <nav class="next-previous-button-row">
+ {{ common_elements.next_previous_buttons(is_last_page, channel_url + '/' + current_tab, parameters_dictionary) }}
</nav>
{% endif %}
- {% endif %}
-
+ </footer>
+ <!-- /new-->
{% endif %}
+
{% endblock main %}
diff --git a/youtube/templates/comments.html b/youtube/templates/comments.html
index 20cde4e..7bd75e5 100644
--- a/youtube/templates/comments.html
+++ b/youtube/templates/comments.html
@@ -1,30 +1,43 @@
{% import "common_elements.html" as common_elements %}
-{% macro render_comment(comment, include_avatar) %}
+{% macro render_comment(comment, include_avatar, timestamp_links=False) %}
<div class="comment-container">
<div class="comment">
<a class="author-avatar" href="{{ comment['author_url'] }}" title="{{ comment['author'] }}">
{% if include_avatar %}
- <img class="author-avatar-img" src="{{ comment['author_avatar'] }}">
+ <img class="author-avatar-img" alt="{{ comment['author'] }}" src="{{ comment['author_avatar'] }}">
{% endif %}
</a>
- <address>
+ <address class="author-name">
<a class="author" href="{{ comment['author_url'] }}" title="{{ comment['author'] }}">{{ comment['author'] }}</a>
</address>
<a class="permalink" href="{{ comment['permalink'] }}" title="permalink">
- <time datetime="">{{ comment['published'] }}</time>
+ <span>{{ comment['time_published'] }}</span>
</a>
- <span class="text">{{ common_elements.text_runs(comment['text']) }}</span>
- <span class="likes">{{ comment['likes_text'] if comment['likes'] else ''}}</span>
- <div class="bottom-row">
- <a href="{{ comment['replies_url'] }}" class="replies">{{ comment['view_replies_text'] }}</a>
- {% if 'delete_url' is in comment %}
- <a href="{{ comment['delete_url'] }}" target="_blank">Delete</a>
+ {% if timestamp_links %}
+ <span class="comment-text">{{ common_elements.text_runs(comment['text'])|timestamps|safe }}</span>
+ {% else %}
+ <span class="comment-text">{{ common_elements.text_runs(comment['text']) }}</span>
+ {% endif %}
+
+ <span class="comment-likes">{{ comment['likes_text'] if comment['approx_like_count'] else ''}}</span>
+ <div class="button-row">
+ {% if comment['reply_count'] %}
+ {% if settings.use_comments_js and comment['replies_url'] %}
+ <details class="replies" data-src="{{ comment['replies_url'] }}">
+ <summary>{{ comment['view_replies_text'] }}</summary>
+ <a href="{{ comment['replies_url'] }}" class="replies-open-new-tab" target="_blank">Open in new tab</a>
+ <div class="comment_page">loading...</div>
+ </details>
+ {% elif comment['replies_url'] %}
+ <a href="{{ comment['replies_url'] }}" class="replies">{{ comment['view_replies_text'] }}</a>
+ {% else %}
+ <a class="replies">{{ comment['view_replies_text'] }} (error constructing url)</a>
+ {% endif %}
{% endif %}
</div>
</div>
-
</div>
{% endmacro %}
@@ -34,35 +47,19 @@
<a class="sort-button" href="{{ link_url }}">{{ link_text }}</a>
{% endfor %}
</div>
- <div class="comments">
- {% for comment in comments_info['comments'] %}
- {{ render_comment(comment, comments_info['include_avatars']) }}
- {% endfor %}
- </div>
- {% if 'more_comments_url' is in comments_info %}
- <a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a>
- {% endif %}
-{% endmacro %}
-
-{% macro comment_posting_box(info) %}
- <form action="{{ info['form_action'] }}" method="post" class="comment-form">
- <div id="comment-account-options">
- <label for="account-selection">Account:</label>
- <select id="account-selection" name="channel_id">
- {% for account in info['accounts'] %}
- <option value="{{ account[0] }}">{{ account[1] }}</option>
- {% endfor %}
- </select>
- <a href="/https://youtube.com/login" target="_blank">Add account</a>
+ {% if comments_info['error'] %}
+ <div class="comments">
+ <div class="code-box"><code>{{ comments_info['error'] }}</code></div>
</div>
- <textarea name="comment_text"></textarea>
- {% if info['include_video_id_input'] %}
- <input type="hidden" name="video_id" value="{{ info['video_id'] }}">
+ {% else %}
+ <div class="comments">
+ {% for comment in comments_info['comments'] %}
+ {{ render_comment(comment, comments_info['include_avatars'], True) }}
+ {% endfor %}
+ </div>
+ {% if 'more_comments_url' is in comments_info %}
+ <a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a>
{% endif %}
- <button type="submit" class="post-comment-button">{{ 'Post reply' if info['replying'] else 'Post comment' }}</button>
- </form>
-{% endmacro %}
-
-
-
+ {% endif %}
+{% endmacro %}
diff --git a/youtube/templates/comments_page.html b/youtube/templates/comments_page.html
index 047404a..3764b10 100644
--- a/youtube/templates/comments_page.html
+++ b/youtube/templates/comments_page.html
@@ -1,14 +1,12 @@
-{% set page_title = ('Replies' if comments_info['is_replies'] else 'Comments page ' + comments_info['page_number']) %}
-{% extends "base.html" %}
-{% import "comments.html" as comments %}
-
-{% block style %}
- .comments-area{
- margin: auto;
- width:640px;
- }
-{% endblock style %}
+{% set page_title = ('Replies' if comments_info['is_replies'] else 'Comments page ' + comments_info['page_number']|string) %}
+{% import "comments.html" as comments with context %}
+{% if not slim %}
+ {% extends "base.html" %}
+ {% block style %}
+ <link href="/youtube.com/static/comments.css" rel="stylesheet">
+ {% endblock style %}
+{% endif %}
{% block main %}
<section class="comments-area">
@@ -24,8 +22,6 @@
</section>
{% endif %}
- {{ comments.comment_posting_box(comment_posting_box_info) }}
-
{% if not comments_info['is_replies'] %}
<div class="comment-links">
{% for link_text, link_url in comments_info['comment_links'] %}
@@ -36,13 +32,16 @@
<div class="comments">
{% for comment in comments_info['comments'] %}
- {{ comments.render_comment(comment, comments_info['include_avatars']) }}
+ {{ comments.render_comment(comment, comments_info['include_avatars'], slim) }}
{% endfor %}
</div>
{% if 'more_comments_url' is in comments_info %}
<a class="page-button more-comments" href="{{ comments_info['more_comments_url'] }}">More comments</a>
{% endif %}
</section>
-{% endblock main %}
-
+ {% if settings.use_comments_js %}
+ <script src="/youtube.com/static/js/common.js"></script>
+ <script src="/youtube.com/static/js/comments.js"></script>
+ {% endif %}
+{% endblock main %}
diff --git a/youtube/templates/common_elements.html b/youtube/templates/common_elements.html
index 67655b3..bacc513 100644
--- a/youtube/templates/common_elements.html
+++ b/youtube/templates/common_elements.html
@@ -9,59 +9,75 @@
{{ text_run["text"] }}
{%- endif -%}
{%- endfor -%}
- {%- else -%}
+ {%- elif runs -%}
{{ runs }}
{%- endif -%}
{% endmacro %}
-{% macro item(info, description=false, horizontal=true, include_author=true) %}
- <div class="item-box {{ info['type'] + '-item-box' }} {{'horizontal-item-box' if horizontal else 'vertical-item-box'}} {{'has-description' if description else 'no-description'}}">
- <div class="item {{ info['type'] + '-item' }}">
- <a class="thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
- <img class="thumbnail-img" src="{{ info['thumbnail'] }}">
- {% if info['type'] != 'channel' %}
- <div class="thumbnail-info">
- <span>{{ info['size'] if info['type'] == 'playlist' else info['duration'] }}</span>
- </div>
- {% endif %}
- </a>
+{% macro item(info, description=false, horizontal=true, include_author=true, include_badges=true, lazy_load=false) %}
+ <article class="item-box">
+ {% if info['error'] %}
+ {{ info['error'] }}
+ {% else %}
+ <div class="item-video {{ info['type'] + '-item' }}">
+ <a class="thumbnail-box" href="{{ info['url'] }}" title="{{ info['title'] }}">
+ <div class="thumbnail {% if info['type'] == 'channel' %} channel {% endif %}">
+ {% if lazy_load %}
+ <img class="thumbnail-img lazy" alt="&#x20;" data-src="{{ info['thumbnail'] }}">
+ {% elif info['type'] == 'channel' %}
+ <img class="thumbnail-img channel" alt="&#x20;" src="{{ info['thumbnail'] }}">
+ {% else %}
+ <img class="thumbnail-img" alt="&#x20;" src="{{ info['thumbnail'] }}">
+ {% endif %}
- <div class="title"><a class="title" href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a></div>
+ {% if info['type'] != 'channel' %}
+ <p class="length">{{ (info['video_count']|commatize + ' videos') if info['type'] == 'playlist' else info['duration'] }}</p>
+ {% endif %}
+ </div>
+ </a>
+ <h4 class="title"><a href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a></h4>
- <ul class="stats {{'vertical-stats' if horizontal and not description and include_author else 'horizontal-stats'}}">
- {% if info['type'] == 'channel' %}
- <li><span>{{ info['subscriber_count'] }} subscribers</span></li>
- <li><span>{{ info['size'] }} videos</span></li>
- {% else %}
- {% if include_author %}
- {% if 'author_url' is in(info) %}
- <li><address title="{{ info['author'] }}">By <a href="{{ info['author_url'] }}">{{ info['author'] }}</a></address></li>
- {% else %}
- <li><address title="{{ info['author'] }}"><b>{{ info['author'] }}</b></address></li>
+ {% if include_author %}
+ {% set author_description = info['author'] %}
+ {% set AUTHOR_DESC_LENGTH = 35 %}
+ {% if author_description != None %}
+ {% if author_description|length >= AUTHOR_DESC_LENGTH %}
+ {% set author_description = author_description[:AUTHOR_DESC_LENGTH].split(' ')[:-1]|join(' ') %}
+ {% if not author_description[-1] in ['.', '?', ':', '!'] %}
+ {% set author_more = author_description + '…' %}
+ {% set author_description = author_more|replace('"','') %}
+ {% endif %}
{% endif %}
{% endif %}
- {% if 'views' is in(info) %}
- <li><span class="views">{{ info['views'] }}</span></li>
- {% endif %}
- {% if 'published' is in(info) %}
- <li><time>{{ info['published'] }}</time></li>
+ {% if info.get('author_url') %}
+ <address title="{{ info['author'] }}"><b><a href="{{ info['author_url'] }}">{{ author_description }}</a></b></address>
+ {% else %}
+ <address title="{{ info['author'] }}"><b>{{ author_description }}</b></address>
{% endif %}
{% endif %}
- </ul>
- {% if description %}
- <span class="description">{{ text_runs(info.get('description', '')) }}</span>
+ <div class="stats {{'horizontal-stats' if horizontal else 'vertical-stats'}}">
+ {% if info['type'] == 'channel' %}
+ <div>{{ info['approx_subscriber_count'] }} subscribers</div>
+ <div>{{ info['video_count']|commatize }} videos</div>
+ {% else %}
+ {% if info.get('time_published') %}
+ <span>{{ info['time_published'] }}</span>
+ {% endif %}
+ {% if info.get('approx_view_count') %}
+ <div class="views">{{ info['approx_view_count'] }} views</div>
+ {% endif %}
+ {% endif %}
+ </div>
+ </div>
+ {% if info['type'] == 'video' %}
+ <input class="item-checkbox" type="checkbox" name="video_info_list" value="{{ info['video_info'] }}" form="playlist-edit">
{% endif %}
- <span class="badges">{{ info['badges']|join(' | ') }}</span>
- </div>
- {% if info['type'] == 'video' %}
- <input class="item-checkbox" type="checkbox" name="video_info_list" value="{{ info['video_info'] }}" form="playlist-edit">
{% endif %}
- </div>
-
+ </article>
{% endmacro %}
-{% macro page_buttons(estimated_pages, url, parameters_dictionary) %}
+{% macro page_buttons(estimated_pages, url, parameters_dictionary, include_ends=false) %}
{% set current_page = parameters_dictionary.get('page', 1)|int %}
{% set parameters_dictionary = parameters_dictionary.to_dict() %}
{% if current_page is le(5) %}
@@ -72,15 +88,53 @@
{% set page_end = [current_page + 4, estimated_pages]|min %}
{% endif %}
+ {% if include_ends and page_start is gt(1) %}
+ {% set _ = parameters_dictionary.__setitem__('page', 1) %}
+ <a class="page-link first-page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ 1 }}</a>
+ {% endif %}
+
{% for page in range(page_start, page_end+1) %}
{% if page == current_page %}
- <div class="page-button">{{ page }}</div>
+ <a class="page-link is-current">{{ page }}</a>
{% else %}
- {# IMPORTANT: Jinja SUCKS #}
{# https://stackoverflow.com/questions/36886650/how-to-add-a-new-entry-into-a-dictionary-object-while-using-jinja2 #}
{% set _ = parameters_dictionary.__setitem__('page', page) %}
- <a class="page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ page }}</a>
+ <a class="page-link" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ page }}</a>
{% endif %}
{% endfor %}
+ {% if include_ends and page_end is lt(estimated_pages) %}
+ {% set _ = parameters_dictionary.__setitem__('page', estimated_pages) %}
+ <a class="page-link last-page-button" href="{{ url + '?' + parameters_dictionary|urlencode }}">{{ estimated_pages }}</a>
+ {% endif %}
+
+{% endmacro %}
+
+{% macro next_previous_buttons(is_last_page, url, parameters_dictionary) %}
+ {% set current_page = parameters_dictionary.get('page', 1)|int %}
+ {% set parameters_dictionary = parameters_dictionary.to_dict() %}
+
+ {% if current_page != 1 %}
+ {% set _ = parameters_dictionary.__setitem__('page', current_page - 1) %}
+ <a class="page-link previous-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Previous page</a>
+ {% endif %}
+
+ {% if not is_last_page %}
+ {% set _ = parameters_dictionary.__setitem__('page', current_page + 1) %}
+ <a class="page-link next-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Next page</a>
+ {% endif %}
+{% endmacro %}
+
+{% macro next_previous_ctoken_buttons(prev_ctoken, next_ctoken, url, parameters_dictionary) %}
+ {% set parameters_dictionary = parameters_dictionary.to_dict() %}
+
+ {% if prev_ctoken %}
+ {% set _ = parameters_dictionary.__setitem__('ctoken', prev_ctoken) %}
+ <a class="page-link previous-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Previous page</a>
+ {% endif %}
+
+ {% if next_ctoken %}
+ {% set _ = parameters_dictionary.__setitem__('ctoken', next_ctoken) %}
+ <a class="page-link next-page" href="{{ url + '?' + parameters_dictionary|urlencode }}">Next page</a>
+ {% endif %}
{% endmacro %}
diff --git a/youtube/templates/delete_comment.html b/youtube/templates/delete_comment.html
deleted file mode 100644
index 28c8f2a..0000000
--- a/youtube/templates/delete_comment.html
+++ /dev/null
@@ -1,21 +0,0 @@
-{% set page_title = 'Delete comment?' %}
-{% extends "base.html" %}
-
-{% block style %}
- main > div, main > form{
- margin: auto;
- margin-top:20px;
- width: 640px;
- }
-{% endblock style %}
-
-{% block main %}
- <div>Are you sure you want to delete this comment?</div>
- <form action="" method="POST">
- {% for parameter_name, parameter_value in parameters %}
- <input type="hidden" name="{{ parameter_name }}" value="{{ parameter_value }}">
- {% endfor %}
- <input type="submit" value="Yes, delete it">
- </form>
-{% endblock %}
-
diff --git a/youtube/templates/embed.html b/youtube/templates/embed.html
new file mode 100644
index 0000000..85d2d78
--- /dev/null
+++ b/youtube/templates/embed.html
@@ -0,0 +1,74 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline'; media-src 'self' https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}">
+ <title>{{ title }}</title>
+ <link href="/youtube.com/static/favicon.ico" type="image/x-icon" rel="icon">
+ {% if settings.use_video_player == 2 %}
+ <!-- plyr -->
+ <link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet">
+ <!--/ plyr -->
+ {% endif %}
+ <style>
+ body {
+ margin: 0rem;
+ padding: 0rem;
+ }
+ video {
+ width: 100%;
+ height: auto;
+ }
+ /* Prevent this div from blocking right-click menu for video
+ e.g. Firefox playback speed options */
+ .plyr__poster {
+ display: none !important;
+ }
+ </style>
+ {% if js_data %}
+ <script>
+ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+ data = {{ js_data|tojson }};
+ // @license-end
+ </script>
+ {% endif %}
+ </head>
+ <body>
+ <video id="js-video-player" controls autofocus onmouseleave="{{ title }}"
+ oncontextmenu="{{ title }}" onmouseenter="{{ title }}" title="{{ title }}">
+ {% if uni_sources %}
+ <source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
+ {% endif %}
+ {% for source in subtitle_sources %}
+ {% if source['on'] %}
+ <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
+ {% else %}
+ <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
+ {% endif %}
+ {% endfor %}
+ </video>
+ {% if js_data %}
+ <script>
+ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+ data = {{ js_data|tojson }};
+ // @license-end
+ </script>
+ {% endif %}
+ <script>
+ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+ let storyboard_url = {{ storyboard_url | tojson }};
+ // @license-end
+ </script>
+ {% if settings.use_video_player == 2 %}
+ <!-- plyr -->
+ <script src="/youtube.com/static/modules/plyr/plyr.min.js"
+ integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
+ crossorigin="anonymous"></script>
+ <script src="/youtube.com/static/js/plyr-start.js"></script>
+ <!-- /plyr -->
+ {% elif settings.use_video_player == 1 %}
+ <script src="/youtube.com/static/js/hotkeys.js"></script>
+ {% endif %}
+ </body>
+</html>
diff --git a/youtube/templates/error.html b/youtube/templates/error.html
index e77c92c..97f8ca9 100644
--- a/youtube/templates/error.html
+++ b/youtube/templates/error.html
@@ -1,7 +1,36 @@
-{% set page_title = 'Error' %}
-{% extends "base.html" %}
+{% if error_code %}
+ {% set page_title = 'Error: ' ~ error_code %}
+{% else %}
+ {% set page_title = 'Error' %}
+{% endif %}
+
+{% if not slim %}
+ {% extends "base.html" %}
+{% endif %}
+
+{% if traceback %}
+ {% block style %}
+ <link href="/youtube.com/static/home.css" rel="stylesheet">
+ {% endblock style %}
+{% endif %}
{% block main %}
- {{ error_message }}
+ {% if traceback %}
+ <div class="code-error" id="error-box">
+ <h1>500 Uncaught exception:</h1>
+ <div class="code-box"><code>{{ traceback }}</code></div>
+ <p>Please report this issue at <a href="https://todo.sr.ht/~heckyel/yt-local" target="_blank" rel="noopener noreferrer">https://todo.sr.ht/~heckyel/yt-local</a></p>
+ <p>Remember to include the traceback in your issue and redact any information in it you do not want to share</p>
+ </div>
+ {% else %}
+ <section id="error-message" class="comments-area">
+ <div class="comments">
+ <div class="comment-container">
+ <div class="comment">
+ <span class="comment-text">{{ error_message }}</span>
+ </div>
+ </div>
+ </div>
+ </section>
+ {% endif %}
{% endblock %}
-
diff --git a/youtube/templates/home.html b/youtube/templates/home.html
index 9890f5e..0adac56 100644
--- a/youtube/templates/home.html
+++ b/youtube/templates/home.html
@@ -1,16 +1,7 @@
{% set page_title = title %}
{% extends "base.html" %}
{% block style %}
- ul {
- background-color: var(--interface-color);
- padding: 20px;
- width: 400px;
- margin: auto;
- margin-top: 20px;
- }
- li {
- margin-bottom: 10px;
- }
+ <link href="/youtube.com/static/home.css" rel="stylesheet">
{% endblock style %}
{% block main %}
<ul>
diff --git a/youtube/templates/licenses.html b/youtube/templates/licenses.html
new file mode 100644
index 0000000..dc73bfb
--- /dev/null
+++ b/youtube/templates/licenses.html
@@ -0,0 +1,64 @@
+{% set page_title = title %}
+{% extends "base.html" %}
+{% block style %}
+ <link href="/youtube.com/static/license.css" rel="stylesheet">
+{% endblock style %}
+{% block main %}
+ <table id="jslicense-labels1" class="table">
+ <caption>JavaScript Licensing Table</caption>
+ <thead>
+ <tr>
+ <th>File</th>
+ <th>License</th>
+ <th>Source</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/av-merge.js">av-merge.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/av-merge.js">av-merge.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/comments.js">comments.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/comments.js">comments.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/common.js">common.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/common.js">common.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/hotkeys.js">hotkeys.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/hotkeys.js">hotkeys.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/playlistadd.js">playlistadd.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/playlistadd.js">playlistadd.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/plyr-start.js">plyr-start.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/plyr-start.js">plyr-start.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/modules/plyr/plyr.min.js">plyr.min.js</a></td>
+ <td data-label="License"><a href="https://spdx.org/licenses/MIT.html">Expat</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/modules/plyr/plyr.js">plyr.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/transcript-table.js">transcript-table.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/transcript-table.js">transcript-table.js</a></td>
+ </tr>
+ <tr>
+ <td data-label="File"><a href="/youtube.com/static/js/watch.js">watch.js</a></td>
+ <td data-label="License"><a href="http://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0 or later</a></td>
+ <td data-label="Source"><a href="/youtube.com/static/js/watch.js">watch.js</a></td>
+ </tr>
+ </tbody>
+ </table>
+{% endblock main %}
diff --git a/youtube/templates/local_playlist.html b/youtube/templates/local_playlist.html
index 7ba0642..3286f67 100644
--- a/youtube/templates/local_playlist.html
+++ b/youtube/templates/local_playlist.html
@@ -2,41 +2,48 @@
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
- main > *{
- width: 800px;
- margin: auto;
- }
-
- .playlist-metadata{
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- }
- .playlist-title{
- }
- #playlist-remove-button{
- align-self: center;
- white-space: nowrap;
- }
- #results{
- display: grid;
- grid-auto-rows: 0fr;
- grid-row-gap: 10px;
- }
+ <link href="/youtube.com/static/message_box.css" rel="stylesheet">
+ <link href="/youtube.com/static/local_playlist.css" rel="stylesheet">
{% endblock style %}
-{% block main %}
+{% block main %}
<div class="playlist-metadata">
- <h2 class="playlist-title">{{ playlist_name }}</h2>
+ <h2 class="play-title">{{ playlist_name }}</h2>
+
+ <div id="export-options">
+ <form id="playlist-export" method="post">
+ <select id="export-type" name="export_format">
+ <option value="json">JSON</option>
+ <option value="ids">Video id list (txt)</option>
+ <option value="urls">Video url list (txt)</option>
+ </select>
+ <button type="submit" id="playlist-export-button" name="action" value="export">Export</button>
+ </form>
+ </div>
+ </div>
+
+ <form id="playlist-remove" action="/youtube.com/edit_playlist" method="post" target="_self"></form>
+ <div class="playlist-metadata" id="video-remove-container">
+ <button id="removePlayList" type="submit" name="action" value="remove_playlist" form="playlist-remove" formaction="">Remove playlist</button>
<input type="hidden" name="playlist_page" value="{{ playlist_name }}" form="playlist-edit">
- <button type="submit" id="playlist-remove-button" name="action" value="remove" form="playlist-edit" formaction="">Remove from playlist</button>
+ <button class="play-action" type="submit" id="playlist-remove-button" name="action" value="remove" form="playlist-edit" formaction="">Remove from playlist</button>
</div>
- <div id="results">
+ <div id="results" class="video-container">
{% for video_info in videos %}
{{ common_elements.item(video_info) }}
{% endfor %}
</div>
- <nav class="page-button-row">
- {{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlists/' + playlist_name, parameters_dictionary) }}
- </nav>
+ <script>
+ // @license magnet:?xt=urn:btih:1f739d935676111cfff4b4693e3816e664797050&dn=gpl-3.0.txt GPL-v3-or-Later
+ const deletePlayList = document.getElementById('removePlayList');
+ deletePlayList.addEventListener('click', (event) => {
+ return confirm('You are about to permanently delete {{ playlist_name }}\n\nOnce a playlist is permanently deleted, it cannot be recovered.')
+ });
+ // @license-end
+ </script>
+ <footer class="pagination-container">
+ <nav class="pagination-list">
+ {{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlists/' + playlist_name, parameters_dictionary) }}
+ </nav>
+ </footer>
{% endblock main %}
diff --git a/youtube/templates/local_playlists_list.html b/youtube/templates/local_playlists_list.html
index 9b5f510..61a6888 100644
--- a/youtube/templates/local_playlists_list.html
+++ b/youtube/templates/local_playlists_list.html
@@ -1,6 +1,10 @@
{% set page_title = 'Local playlists' %}
{% extends "base.html" %}
+{% block style %}
+ <link href="/youtube.com/static/home.css" rel="stylesheet"/>
+{% endblock style %}
+
{% block main %}
<ul>
{% for playlist_name, playlist_url in playlists %}
@@ -8,9 +12,3 @@
{% endfor %}
</ul>
{% endblock main %}
-
-
-
-
-
-
diff --git a/youtube/templates/login.html b/youtube/templates/login.html
deleted file mode 100644
index 384f1ac..0000000
--- a/youtube/templates/login.html
+++ /dev/null
@@ -1,57 +0,0 @@
-{% set page_title = 'Login' %}
-{% extends "base.html" %}
-
-{% block style %}
- main > * {
- width: 640px;
- margin: auto;
- }
- main form{
- background-color: var(--interface-color);
- padding: 10px;
- margin-top:20px;
- display:grid;
- justify-items: start;
- align-content: start;
- grid-row-gap: 10px;
- }
-
- #username, #password{
- grid-column:2;
- width: 250px;
- }
- #add-account-button{
- margin-top:20px;
- }
- #tor-note{
- background-color: var(--interface-color);
- padding: 10px;
- margin-top: 40px;
- }
-{% endblock style %}
-
-{% block main %}
- <form action="" method="POST">
- <div class="form-field">
- <label for="username">Username:</label>
- <input type="text" id="username" name="username">
- </div>
- <div class="form-field">
- <label for="password">Password:</label>
- <input type="password" id="password" name="password">
- </div>
- <div id="save-account-checkbox">
- <input type="checkbox" id="save-account" name="save" checked>
- <label for="save-account">Save account info to disk (password will not be saved, only the login cookie)</label>
- </div>
- <div>
- <input type="checkbox" id="use-tor" name="use_tor">
- <label for="use-tor">Use Tor when logging in (WARNING: This will lock your Google account under normal circumstances, see note below)</label>
- </div>
- <input type="submit" value="Add account" id="add-account-button">
- </form>
- <div id="tor-note"><b>Note on using Tor to log in</b><br>
-Using Tor to log in should only be done if the account was created using a proxy/VPN/Tor to begin with and hasn't been logged in using your IP. Otherwise, it's pointless since Google already knows who the account belongs to. When logging into a google account, it must be logged in using an IP address geographically close to the area where the account was created or where it is logged into regularly. If the account was created using an IP address in America and is logged into from an IP in Russia, Google will block the Russian IP from logging in, assume someone knows your password, lock the account, and make you change your password. If creating an account using Tor, you must remember the IP (or geographic region) it was created in, and only log in using that geographic region for the exit node. This can be accomplished by <a href="https://tor.stackexchange.com/questions/733/can-i-exit-from-a-specific-country-or-node">putting the desired IP in the torrc file</a> to force Tor to use that exit node. Using the login cookie to post comments through Tor is perfectly safe, however.
- </div>
-{% endblock main %}
-
diff --git a/youtube/templates/playlist.html b/youtube/templates/playlist.html
index ab2640f..994523e 100644
--- a/youtube/templates/playlist.html
+++ b/youtube/templates/playlist.html
@@ -1,77 +1,41 @@
-{% set page_title = title + ' - Page ' + parameters_dictionary.get('page', '1') %}
+{% set page_title = title|string + ' - Page ' + parameters_dictionary.get('page', '1') %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
- main > * {
- width: 800px;
- margin:auto;
- }
-
- .playlist-metadata{
- display:grid;
- grid-template-columns: 0fr 1fr;
- }
- .playlist-thumbnail{
- grid-row: 1 / span 5;
- grid-column:1;
- justify-self:start;
- width:250px;
- margin-right: 10px;
- }
- .playlist-title{
- grid-row: 1;
- grid-column:2;
- }
- .playlist-author{
- grid-row:2;
- grid-column:2;
- }
- .playlist-stats{
- grid-row:3;
- grid-column:2;
- }
-
- .playlist-description{
- grid-row:4;
- grid-column:2;
- min-width:0px;
- white-space: pre-line;
- }
-
- #results{
- margin-top:10px;
-
- display: grid;
- grid-auto-rows: 0fr;
- grid-row-gap: 10px;
-
- }
+ <link href="/youtube.com/static/message_box.css" rel="stylesheet">
+ <link href="/youtube.com/static/playlist.css" rel="stylesheet">
{% endblock style %}
{% block main %}
+
<div class="playlist-metadata">
- <img class="playlist-thumbnail" src="{{ thumbnail }}">
- <h2 class="playlist-title">{{ title }}</h2>
- <a class="playlist-author" href="{{ author_url }}">{{ author }}</a>
+ <div class="author">
+ <img alt="{{ title }}" src="{{ thumbnail }}">
+ <h2>{{ title }}</h2>
+ </div>
+ <div class="summary">
+ <a class="playlist-author" href="{{ author_url }}">{{ author }}</a>
+ </div>
<div class="playlist-stats">
- <div>{{ views }}</div>
- <div>{{ size }}</div>
+ <div>{{ video_count|commatize }} videos</div>
+ <div>{{ view_count|commatize }} views</div>
+ <div>Last updated {{ time_published }}</div>
</div>
- <div class="playlist-description">{{ common_elements.text_runs(description) }}</div>
</div>
+ <hr/>
+
- <div id="results">
+ <div id="results" class="video-container">
{% for info in video_list %}
{{ common_elements.item(info) }}
{% endfor %}
</div>
- <nav class="page-button-row">
- {{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlist', parameters_dictionary) }}
- </nav>
-{% endblock main %}
-
-
-
-
+ <hr/>
+ <footer class="pagination-container">
+ <nav class="pagination-list">
+ {{ common_elements.page_buttons(num_pages, '/https://www.youtube.com/playlist', parameters_dictionary) }}
+ </nav>
+ </footer>
+{% endblock main %}
diff --git a/youtube/templates/post_comment.html b/youtube/templates/post_comment.html
deleted file mode 100644
index ba6a22c..0000000
--- a/youtube/templates/post_comment.html
+++ /dev/null
@@ -1,20 +0,0 @@
-{% set page_title = 'Post reply' if replying else 'Post comment' %}
-{% extends "base.html" %}
-{% import "comments.html" as comments %}
-
-{% block style %}
- .comment-form{
- width: 640px;
- margin: auto;
- justify-content:start;
- }
- textarea{
- width: 460px;
- height: 85px;
- }
-{% endblock style %}
-
-{% block main %}
- {{ comments.comment_posting_box(comment_posting_box_info) }}
-{% endblock %}
-
diff --git a/youtube/templates/search.html b/youtube/templates/search.html
index aef914a..af87c90 100644
--- a/youtube/templates/search.html
+++ b/youtube/templates/search.html
@@ -3,44 +3,32 @@
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
- main > * {
- max-width: 800px;
- margin: auto;
- }
- #result-info{
- margin-top: 10px;
- margin-bottom: 10px;
- padding-left: 10px;
- padding-right: 10px;
- }
- #number-of-results{
- font-weight:bold;
- }
- .item-list{
- padding-left: 10px;
- padding-right: 10px;
- }
- .badge{
- background-color:#cccccc;
- }
+ <link href="/youtube.com/static/message_box.css" rel="stylesheet">
+ <link href="/youtube.com/static/search.css" rel="stylesheet">
{% endblock style %}
{% block main %}
- <div id="result-info">
- <div id="number-of-results">Approximately {{ '{:,}'.format(estimated_results) }} results ({{ '{:,}'.format(estimated_pages) }} pages)</div>
-{% if corrections['type'] == 'showing_results_for' %}
- <div>Showing results for <a>{{ corrections['corrected_query']|safe }}</a></div>
- <div>Search instead for <a href="{{ corrections['original_query_url'] }}">{{ corrections['original_query'] }}</a></div>
-{% elif corrections['type'] == 'did_you_mean' %}
- <div>Did you mean <a href="{{ corrections['corrected_query_url'] }}">{{ corrections['corrected_query']|safe }}</a></div>
-{% endif %}
- </div>
- <div class="item-list">
- {% for info in results %}
- {{ common_elements.item(info, description=true) }}
- {% endfor %}
- </div>
- <nav class="page-button-row">
- {{ common_elements.page_buttons(estimated_pages, '/https://www.youtube.com/search', parameters_dictionary) }}
- </nav>
+ <div class="result-info" id="result-info">
+ <div id="number-of-results">Approximately {{ '{:,}'.format(estimated_results) }} results ({{ '{:,}'.format(estimated_pages) }} pages)</div>
+ {% if corrections['type'] == 'showing_results_for' %}
+ <div>Showing results for <a>{{ common_elements.text_runs(corrections['corrected_query_text']) }}</a></div>
+ <div>Search instead for <a href="{{ corrections['original_query_url'] }}">{{ corrections['original_query_text'] }}</a></div>
+ {% elif corrections['type'] == 'did_you_mean' %}
+ <div>Did you mean <a href="{{ corrections['corrected_query_url'] }}">{{ common_elements.text_runs(corrections['corrected_query_text']) }}</a></div>
+ {% endif %}
+ </div>
+
+ <!-- video item -->
+ <div class="video-container">
+ {% for info in results %}
+ {{ common_elements.item(info, description=true) }}
+ {% endfor %}
+ </div>
+ <hr/>
+ <!-- /video item -->
+ <footer class="pagination-container">
+ <nav class="pagination-list">
+ {{ common_elements.page_buttons(estimated_pages, '/https://www.youtube.com/results', parameters_dictionary) }}
+ </nav>
+ </footer>
{% endblock main %}
diff --git a/youtube/templates/settings.html b/youtube/templates/settings.html
index 19a2461..a4ebabf 100644
--- a/youtube/templates/settings.html
+++ b/youtube/templates/settings.html
@@ -1,65 +1,47 @@
{% set page_title = 'Settings' %}
{% extends "base.html" %}
-{% import "common_elements.html" as common_elements %}
{% block style %}
- .settings-form {
- margin: auto;
- width: 500px;
- margin-top:10px;
- padding: 10px;
- display: block;
- background-color: var(--interface-color);
- }
- .settings-list{
- list-style: none;
- padding: 0px;
- }
- .setting-item{
- margin-bottom: 10px;
- padding: 5px;
- }
- .setting-item label{
- display: inline-block;
- width: 250px;
- }
-
+ <link href="/youtube.com/static/settings.css" rel="stylesheet">
{% endblock style %}
{% block main %}
<form method="POST" class="settings-form">
- <ul class="settings-list">
- {% for setting_name, setting_info, value in settings %}
- {% if not setting_info.get('hidden', false) %}
- <li class="setting-item">
- {% if 'label' is in(setting_info) %}
- <label for="{{ 'setting_' + setting_name }}">{{ setting_info['label'] }}</label>
- {% else %}
- <label for="{{ 'setting_' + setting_name }}">{{ setting_name.replace('_', ' ')|capitalize }}</label>
- {% endif %}
-
- {% if setting_info['type'].__name__ == 'bool' %}
- <input type="checkbox" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" {{ 'checked' if value else '' }}>
- {% elif setting_info['type'].__name__ == 'int' %}
- {% if 'options' is in(setting_info) %}
- <select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
- {% for option in setting_info['options'] %}
- <option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option>
- {% endfor %}
- </select>
+ {% for categ in categories %}
+ <h2>{{ categ|capitalize }}</h2>
+ <ul class="settings-list">
+ {% for setting_name, setting_info, value in settings_by_category[categ] %}
+ {% if not setting_info.get('hidden', false) %}
+ <li class="setting-item">
+ {% if 'label' is in(setting_info) %}
+ <label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ setting_info['label'] }}</label>
{% else %}
- <input type="number" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}" step="1">
+ <label for="{{ 'setting_' + setting_name }}" {% if 'comment' is in(setting_info) %}title="{{ setting_info['comment'] }}" {% endif %}>{{ setting_name.replace('_', ' ')|capitalize }}</label>
{% endif %}
- {% elif setting_info['type'].__name__ == 'float' %}
- {% elif setting_info['type'].__name__ == 'str' %}
- <input type="text" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}">
- {% else %}
- <span>Error: Unknown setting type: setting_info['type'].__name__</span>
- {% endif %}
- </li>
- {% endif %}
- {% endfor %}
- </ul>
+ {% if setting_info['type'].__name__ == 'bool' %}
+ <input type="checkbox" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" {{ 'checked' if value else '' }}>
+ {% elif setting_info['type'].__name__ == 'int' %}
+ {% if 'options' is in(setting_info) %}
+ <select id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}">
+ {% for option in setting_info['options'] %}
+ <option value="{{ option[0] }}" {{ 'selected' if option[0] == value else '' }}>{{ option[1] }}</option>
+ {% endfor %}
+ </select>
+ {% else %}
+ <input type="number" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}" step="1">
+ {% endif %}
+ {% elif setting_info['type'].__name__ == 'float' %}
+
+ {% elif setting_info['type'].__name__ == 'str' %}
+ <input type="text" id="{{ 'setting_' + setting_name }}" name="{{ setting_name }}" value="{{ value }}">
+ {% else %}
+ <span>Error: Unknown setting type: setting_info['type'].__name__</span>
+ {% endif %}
+ </li>
+ {% endif %}
+ {% endfor %}
+ </ul>
+ {% endfor %}
<input type="submit" value="Save settings">
</form>
{% endblock main %}
diff --git a/youtube/templates/shared.css b/youtube/templates/shared.css
new file mode 100644
index 0000000..8f12651
--- /dev/null
+++ b/youtube/templates/shared.css
@@ -0,0 +1,5 @@
+html {
+ font-family: {{ font_family }};
+ background: var(--background);
+ color: var(--text);
+}
diff --git a/youtube/templates/status.html b/youtube/templates/status.html
index 901aa5b..97e2ed4 100644
--- a/youtube/templates/status.html
+++ b/youtube/templates/status.html
@@ -4,4 +4,3 @@
{% block main %}
{{ message }}
{% endblock %}
-
diff --git a/youtube/templates/subscription_manager.html b/youtube/templates/subscription_manager.html
index c9683ce..96082c3 100644
--- a/youtube/templates/subscription_manager.html
+++ b/youtube/templates/subscription_manager.html
@@ -1,79 +1,7 @@
{% set page_title = 'Subscription Manager' %}
{% extends "base.html" %}
{% block style %}
- .import-export{
- display: flex;
- flex-direction: row;
- padding-left: 10px;
- padding-top: 10px;
- }
- .subscriptions-import-form{
- background-color: var(--interface-color);
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- max-width: 300px;
- padding:10px;
- }
- .subscriptions-import-form h2{
- font-size: 20px;
- margin-bottom: 10px;
- }
-
- .import-submit-button{
- margin-top:15px;
- align-self: flex-end;
- }
-
-
- .subscriptions-export-links{
- margin: 0px 0px 0px 20px;
- background-color: var(--interface-color);
- list-style: none;
- max-width: 300px;
- padding:10px;
- }
-
- .sub-list-controls{
- background-color: var(--interface-color);
- padding:10px;
- }
-
-
- .tag-group-list{
- list-style: none;
- margin-left: 10px;
- margin-right: 10px;
- padding: 0px;
- }
- .tag-group{
- border-style: solid;
- margin-bottom: 10px;
- }
-
- .sub-list{
- list-style: none;
- padding:10px;
- column-width: 300px;
- column-gap: 40px;
- }
- .sub-list-item{
- display:flex;
- margin-bottom: 10px;
- break-inside:avoid;
- background-color: var(--interface-color);
- }
- .tag-list{
- margin-left:15px;
- font-weight:bold;
- }
- .sub-list-item-name{
- margin-left:15px;
- }
- .sub-list-checkbox{
- height: 1.5em;
- min-width: 1.5em; // need min-width otherwise browser doesn't respect the width and squishes the checkbox down when there's too many tags
- }
+ <link href="/youtube.com/static/subscription_manager.css" rel="stylesheet">
{% endblock style %}
@@ -87,20 +15,29 @@
{% endfor %}
{% endmacro %}
-
-
{% block main %}
<div class="import-export">
<form class="subscriptions-import-form" enctype="multipart/form-data" action="/youtube.com/import_subscriptions" method="POST">
<h2>Import subscriptions</h2>
- <input type="file" id="subscriptions-import" accept="application/json, application/xml, text/x-opml" name="subscriptions_file">
- <input type="submit" value="Import" class="import-submit-button">
+ <div class="subscriptions-import-options">
+ <input type="file" id="subscriptions-import" accept="application/json, application/xml, text/x-opml, text/csv" name="subscriptions_file" required>
+ <input type="submit" value="Import">
+ </div>
</form>
- <!--<ul class="subscriptions-export-links">
- <li><a href="/youtube.com/subscriptions.opml">Export subscriptions (OPML)</a></li>
- <li><a href="/youtube.com/subscriptions.xml">Export subscriptions (RSS)</a></li>
- </ul>-->
+ <form class="subscriptions-export-form" action="/youtube.com/export_subscriptions" method="POST">
+ <h2>Export subscriptions</h2>
+ <div class="subscriptions-export-options">
+ <select id="export-type" name="export_format" title="Export format">
+ <option value="json_newpipe">JSON (NewPipe)</option>
+ <option value="json_google_takeout">JSON (Old Google Takeout Format)</option>
+ <option value="opml">OPML (RSS, no tags)</option>
+ </select>
+ <label for="include-muted">Include muted</label>
+ <input id="include-muted" type="checkbox" name="include_muted" checked>
+ <input type="submit" value="Export">
+ </div>
+ </form>
</div>
<hr>
diff --git a/youtube/templates/subscriptions.html b/youtube/templates/subscriptions.html
index 12430a5..2823e8d 100644
--- a/youtube/templates/subscriptions.html
+++ b/youtube/templates/subscriptions.html
@@ -1,75 +1,28 @@
-{% set page_title = 'Subscriptions' %}
+{% if current_tag %}
+ {% set page_title = 'Subscriptions - ' + current_tag %}
+{% else %}
+ {% set page_title = 'Subscriptions' %}
+{% endif %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
{% block style %}
- main{
- display:flex;
- flex-direction: row;
- }
- .video-section{
- flex-grow: 1;
- padding-left: 10px;
- padding-top: 10px;
- }
- .video-section .page-button-row{
- justify-content: center;
- }
- .subscriptions-sidebar{
- flex-basis: 300px;
- background-color: var(--interface-color);
- border-left: 2px;
- }
- .sidebar-links{
- display:flex;
- justify-content: space-between;
- padding-left:10px;
- padding-right: 10px;
- }
-
- .sidebar-list{
- list-style: none;
- padding-left:10px;
- padding-right: 10px;
- }
- .sidebar-list-item{
- display:flex;
- justify-content: space-between;
- margin-bottom: 5px;
- }
- .sub-refresh-list .sidebar-item-name{
- text-overflow: clip;
- white-space: nowrap;
- overflow: hidden;
- max-width: 200px;
- }
+ <link href="/youtube.com/static/message_box.css" rel="stylesheet">
+ <link href="/youtube.com/static/subscription.css" rel="stylesheet">
{% endblock style %}
{% block main %}
- <div class="video-section">
- <nav class="item-grid">
- {% for video_info in videos %}
- {{ common_elements.item(video_info) }}
- {% endfor %}
- </nav>
-
- <nav class="page-button-row">
- {{ common_elements.page_buttons(num_pages, '/youtube.com/subscriptions', parameters_dictionary) }}
- </nav>
- </div>
<div class="subscriptions-sidebar">
<div class="sidebar-links">
- <a href="/youtube.com/subscription_manager" class="sub-manager-link">Subscription Manager</a>
- <form method="POST" class="refresh-all">
+ <a class="sidebar-title" href="/youtube.com/subscription_manager" class="sub-manager-link">Subscription Manager</a>
+ <form class="sidebar-action" method="POST" class="refresh-all">
<input type="submit" value="Check All">
<input type="hidden" name="action" value="refresh">
<input type="hidden" name="type" value="all">
</form>
</div>
- <hr>
-
<ol class="sidebar-list tags">
{% if current_tag %}
<li class="sidebar-list-item">
@@ -95,7 +48,6 @@
</ol>
<hr>
-
<ol class="sidebar-list sub-refresh-list">
{% for subscription in subscription_list %}
<li class="sidebar-list-item {{ 'muted' if subscription['muted'] else '' }}">
@@ -109,7 +61,23 @@
</li>
{% endfor %}
</ol>
+ </div>
+
+ {% if current_tag %}
+ <h2 class="current-tag">{{ current_tag }}</h2>
+ {% endif %}
+ <div class="video-container">
+ {% for video_info in videos %}
+ {{ common_elements.item(video_info) }}
+ {% endfor %}
</div>
+ <hr/>
+
+ <footer class="pagination-container">
+ <nav class="pagination-list">
+ {{ common_elements.page_buttons(num_pages, '/youtube.com/subscriptions', parameters_dictionary) }}
+ </nav>
+ </footer>
{% endblock main %}
diff --git a/youtube/templates/subscriptions.xml b/youtube/templates/subscriptions.xml
new file mode 100644
index 0000000..5365da1
--- /dev/null
+++ b/youtube/templates/subscriptions.xml
@@ -0,0 +1,9 @@
+<opml version="1.1">
+ <body>
+ <outline text="YouTube Subscriptions" title="YouTube Subscriptions">
+ {% for sub in sub_list %}
+ <outline text="{{sub['channel_name']}}" title="{{sub['channel_name']}}" type="rss" xmlUrl="https://www.youtube.com/feeds/videos.xml?channel_id={{sub['channel_id']}}" />
+ {%- endfor %}
+ </outline>
+ </body>
+</opml>
diff --git a/youtube/templates/unsubscribe_verify.html b/youtube/templates/unsubscribe_verify.html
index 98581c0..e899783 100644
--- a/youtube/templates/unsubscribe_verify.html
+++ b/youtube/templates/unsubscribe_verify.html
@@ -1,17 +1,19 @@
{% set page_title = 'Unsubscribe?' %}
{% extends "base.html" %}
+{% block style %}
+ <link href="/youtube.com/static/unsubscribe.css" rel="stylesheet"/>
+{% endblock style %}
{% block main %}
- <span>Are you sure you want to unsubscribe from these channels?</span>
+ <p>Are you sure you want to unsubscribe from these channels?</p>
<form class="subscriptions-import-form" action="/youtube.com/subscription_manager" method="POST">
{% for channel_id, channel_name in unsubscribe_list %}
<input type="hidden" name="channel_ids" value="{{ channel_id }}">
{% endfor %}
-
<input type="hidden" name="action" value="unsubscribe">
<input type="submit" value="Yes, unsubscribe">
</form>
- <ul>
+ <ul class="list-channel">
{% for channel_id, channel_name in unsubscribe_list %}
<li><a href="{{ '/https://www.youtube.com/channel/' + channel_id }}" title="{{ channel_name }}">{{ channel_name }}</a></li>
{% endfor %}
diff --git a/youtube/templates/watch.html b/youtube/templates/watch.html
index 14e953b..0991457 100644
--- a/youtube/templates/watch.html
+++ b/youtube/templates/watch.html
@@ -1,285 +1,263 @@
{% set page_title = title %}
{% extends "base.html" %}
{% import "common_elements.html" as common_elements %}
-{% import "comments.html" as comments %}
+{% import "comments.html" as comments with context %}
{% block style %}
- details > summary{
- background-color: var(--interface-color);
- border-style: outset;
- border-width: 2px;
- font-weight: bold;
- padding-bottom: 2px;
- }
- details > summary:hover{
- text-decoration: underline;
- }
-
- {% if theater_mode %}
- video{
- grid-column: 1 / span 5;
- justify-self: center;
- max-width: 100%;
- width: {{ theater_video_target_width }}px;
- max-height: {{ video_height }}px;
- margin-bottom: 10px;
- background-color: var(--background-color);
- }
- .related-videos-outer{
- grid-row: 2 /span 3;
- width: 400px;
- }
- .video-info{
- width: 640px;
- }
- {% else %}
- video{
- height: 360px;
- width: 640px;
- grid-column: 2;
- }
- .related-videos-outer{
- grid-row: 1 /span 4;
- }
+ <link href="/youtube.com/static/message_box.css" rel="stylesheet">
+ <link href="/youtube.com/static/watch.css" rel="stylesheet">
+ {% if settings.use_video_player == 2 %}
+ <!-- plyr -->
+ <link href="/youtube.com/static/modules/plyr/plyr.css" rel="stylesheet">
+ <link href="/youtube.com/static/modules/plyr/custom_plyr.css" rel="stylesheet">
+ <!--/ plyr -->
{% endif %}
+{% endblock style %}
- main{
- display:grid;
- grid-template-columns: 1fr 640px 40px 400px 1fr;
- grid-template-rows: auto auto auto auto;
- align-content: start;
- }
+{% block main %}
+ {% if playability_error %}
+ <div class="playability-error">
+ <span>{{ 'Error: ' + playability_error }}
+ {% if invidious_reload_button %}
+ <a href="{{ video_url }}&use_invidious=0"><br>
+ Reload without invidious (for usage of new identity button).</a>
+ {% endif %}
+ </span>
+ </div>
+ {% elif (uni_sources.__len__() == 0 or live) and hls_formats.__len__() != 0 %}
+ <div class="live-url-choices">
+ <span>Copy a url into your video player:</span>
+ <ol>
+ {% for fmt in hls_formats %}
+ <li class="url-choice"><div class="url-choice-label">{{ fmt['video_quality'] }}: </div><input class="url-choice-copy" value="{{ fmt['url'] }}" readonly onclick="this.select();"></li>
+ {% endfor %}
+ </ol>
+ </div>
+ {% else %}
+ <figure class="sc-video">
+ <video id="js-video-player" playsinline controls {{ 'autoplay' if settings.autoplay_videos }}>
+ {% if uni_sources %}
+ <source src="{{ uni_sources[uni_idx]['url'] }}" type="{{ uni_sources[uni_idx]['type'] }}" data-res="{{ uni_sources[uni_idx]['quality'] }}">
+ {% endif %}
- .video-info{
- grid-column: 2;
- grid-row: 2;
- display: grid;
- grid-template-rows: 0fr 0fr 0fr 20px 0fr 0fr;
- grid-template-columns: 1fr 1fr;
- align-content: start;
- }
- .video-info > .title{
- grid-column: 1 / span 2;
- min-width: 0;
- }
- .video-info > .is-unlisted{
- background-color: var(--interface-color);
- justify-self:start;
- padding-left:2px;
- padding-right:2px;
- }
- .video-info > address{
- grid-column: 1;
- grid-row: 3;
- justify-self: start;
- }
- .video-info > .views{
- grid-column: 2;
- grid-row: 3;
- justify-self:end;
- }
- .video-info > time{
- grid-column: 1;
- grid-row: 4;
- justify-self:start;
- }
- .video-info > .likes-dislikes{
- grid-column: 2;
- grid-row: 4;
- justify-self:end;
- }
- .video-info > .download-dropdown{
- grid-column:1 / span 2;
- grid-row: 6;
- }
- .video-info > .checkbox{
- justify-self:end;
- align-self: start;
+ {% for source in subtitle_sources %}
+ {% if source['on'] %}
+ <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
+ {% else %}
+ <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
+ {% endif %}
+ {% endfor %}
+ </video>
+ </figure>
+ {% endif %}
- grid-row: 5;
- grid-column: 2;
- }
- .video-info > .description{
- background-color:var(--interface-color);
- margin-top:8px;
- white-space: pre-wrap;
- min-width: 0;
- word-wrap: break-word;
- grid-column: 1 / span 2;
- grid-row: 7;
- }
+ <div class="sc-info">
+ <div class="video-info">
+ <h1 class="v-title">{{ title }}</h1>
- .music-list{
- grid-row:8;
- grid-column: 1 / span 2;
- background-color: var(--interface-color);
- }
- .music-list table,th,td{
- border: 1px solid;
- }
- .music-list th,td{
- padding-left:4px;
- padding-right:5px;
- }
- .music-list caption{
- text-align:left;
- font-weight:bold;
- margin-bottom:5px;
- }
- .comments-area-outer{
- grid-column: 2;
- grid-row: 3;
- margin-top:10px;
- }
- .comments-area-inner{
- padding-top: 10px;
- }
- .comment{
- width:640px;
- }
- .related-videos-outer{
- grid-column: 4;
- max-width: 640px;
- }
- .related-videos-inner{
- padding-top: 10px;
- display: grid;
- grid-auto-rows: 94px;
- grid-row-gap: 10px;
- }
+ <ul class="labels">
+ {%- if unlisted -%}
+ <li class="is-unlisted">Unlisted</li>
+ {%- endif -%}
+ {%- if age_restricted -%}
+ <li class="age-restricted">Age-restricted</li>
+ {%- endif -%}
+ {%- if limited_state -%}
+ <li>Limited state</li>
+ {%- endif -%}
+ {%- if live -%}
+ <li>Live</li>
+ {%- endif -%}
+ </ul>
- /* Put related vids below videos when window is too small */
- /* 1100px instead of 1080 because W3C is full of idiots who include scrollbar width */
- @media (max-width:1100px){
- main{
- grid-template-columns: 1fr 640px 40px 1fr;
- }
- .related-videos-outer{
- margin-top: 10px;
- grid-column: 2;
- grid-row: 3;
- width: initial;
- }
- .comments-area-outer{
- grid-row: 4;
- }
- }
+ <address class="v-uploaded">Uploaded by <a href="{{ uploader_channel_url }}">{{ uploader }}</a></address>
+ <span class="v-views">{{ view_count }} views</span>
+ <time class="v-published" datetime="{{ time_published_utc }}">Published on {{ time_published }}</time>
+ <span class="v-likes-dislikes">{{ like_count }} likes</span>
- .download-dropdown-content{
- background-color: var(--interface-color);
- padding: 10px;
- list-style: none;
- margin: 0px;
- }
- li.download-format{
- margin-bottom: 7px;
- }
- .format-attributes{
- list-style: none;
- padding: 0px;
- margin: 0px;
- display: flex;
- flex-direction: row;
- }
- .format-attributes li{
- white-space: nowrap;
- max-height: 1.2em;
- }
- .format-ext{
- width: 60px;
- }
- .format-res{
- width:90px;
- }
-{% endblock style %}
+ <div class="external-player-controls">
+ <input class="speed" id="speed-control" type="text" title="Video speed">
+ {% if settings.use_video_player != 2 %}
+ <select id="quality-select" autocomplete="off">
+ {% for src in uni_sources %}
+ <option value='{"type": "uni", "index": {{ loop.index0 }}}' {{ 'selected' if loop.index0 == uni_idx and not using_pair_sources else '' }} >{{ src['quality_string'] }}</option>
+ {% endfor %}
+ {% for src_pair in pair_sources %}
+ <option value='{"type": "pair", "index": {{ loop.index0}}}' {{ 'selected' if loop.index0 == pair_idx and using_pair_sources else '' }} >{{ src_pair['quality_string'] }}</option>
+ {% endfor %}
+ </select>
+ {% endif %}
+ </div>
+ <input class="v-checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox">
-{% block main %}
- <video controls autofocus>
- {% for video_source in video_sources %}
- <source src="{{ video_source['src'] }}" type="{{ video_source['type'] }}">
- {% endfor %}
+ <span class="v-direct-link"><a href="https://youtu.be/{{ video_id }}" rel="noopener noreferrer" target="_blank">Direct Link</a></span>
- {% for source in subtitle_sources %}
- {% if source['on'] %}
- <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}" default>
+ {% if settings.use_video_download != 0 %}
+ <details class="v-download">
+ <summary class="download-dropdown-label">Download</summary>
+ <ul class="download-dropdown-content">
+ {% for format in download_formats %}
+ <li class="download-format">
+ <a class="download-link" href="{{ format['url'] }}" download="{{ title }}.{{ format['ext'] }}">
+ {{ format['ext'] }} {{ format['video_quality'] }} {{ format['audio_quality'] }} {{ format['file_size'] }} {{ format['codecs'] }}
+ </a>
+ </li>
+ {% endfor %}
+ {% for download in other_downloads %}
+ <li class="download-format">
+ <a href="{{ download['url'] }}" download>
+ {{ download['ext'] }} {{ download['label'] }}
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+ </details>
{% else %}
- <track label="{{ source['label'] }}" src="{{ source['url'] }}" kind="subtitles" srclang="{{ source['srclang'] }}">
+ <span class="v-download"></span>
{% endif %}
- {% endfor %}
+ <span class="v-description">{{ common_elements.text_runs(description)|escape|urlize|timestamps|safe }}</span>
- </video>
+ <div class="v-music-list">
+ {% if music_list.__len__() != 0 %}
+ <hr>
+ <table>
+ <caption>Music</caption>
+ <tr>
+ {% for attribute in music_attributes %}
+ <th>{{ attribute }}</th>
+ {% endfor %}
+ </tr>
+ {% for track in music_list %}
+ <tr>
+ {% for attribute in music_attributes %}
+ {% if attribute.lower() == 'title' and track['url'] is not none %}
+ <td><a href="{{ track['url'] }}">{{ track.get(attribute.lower(), '') }}</a></td>
+ {% else %}
+ <td>{{ track.get(attribute.lower(), '') }}</td>
+ {% endif %}
+ {% endfor %}
+ </tr>
+ {% endfor %}
+ </table>
+ {% endif %}
+ </div>
+ <details class="v-more-info">
+ <summary>More info</summary>
+ <div class="more-info-content">
+ <p>Tor exit node: {{ ip_address }}</p>
+ {% if invidious_used %}
+ <p>Used Invidious as fallback.</p>
+ {% endif %}
+ <p class="allowed-countries">Allowed countries: {{ allowed_countries|join(', ') }}</p>
+ {% if settings.use_sponsorblock_js %}
+ <ul class="more-actions">
+ <li><label><input type=checkbox id=skip_sponsors checked>skip sponsors</label> <span id=skip_n></span>
+ </ul>
+ {% endif %}
+ </div>
+ </details>
+ </div>
- <div class="video-info">
- <h2 class="title">{{ title }}</h2>
- {% if unlisted %}
- <span class="is-unlisted">Unlisted</span>
- {% endif %}
- <address>Uploaded by <a href="{{ uploader_channel_url }}">{{ uploader }}</a></address>
- <span class="views">{{ views }} views</span>
+ <div class="side-videos">
+ <!-- playlist -->
+ {% if playlist %}
+ <div class="site-playlist">
+ <div class="playlist-header">
+ <a href="{{ playlist['url'] }}" title="{{ playlist['title'] }}"><h3>{{ playlist['title'] }}</h3></a>
+ <ul class="playlist-metadata">
+ <li><label for="playlist-autoplay-toggle">Autoplay: </label><input id="playlist-autoplay-toggle" type="checkbox" class="autoplay-toggle"></li>
+ {% if playlist['current_index'] is none %}
+ <li>[Error!]/{{ playlist['video_count'] }}</li>
+ {% else %}
+ <li>{{ playlist['current_index']+1 }}/{{ playlist['video_count'] }}</li>
+ {% endif %}
+ <li><a href="{{ playlist['author_url'] }}" title="{{ playlist['author'] }}">{{ playlist['author'] }}</a></li>
+ </ul>
+ </div>
+ <nav class="playlist-videos">
+ {% for info in playlist['items'] %}
+ {# non-lazy load for 5 videos surrounding current video #}
+ {# for non-js browsers or old such that IntersectionObserver doesn't work #}
+ {# -10 is sentinel to not load anything if there's no current_index for some reason #}
+ {% if (playlist.get('current_index', -10) - loop.index0)|abs is lt(5) %}
+ {{ common_elements.item(info, include_badges=false, lazy_load=false) }}
+ {% else %}
+ {{ common_elements.item(info, include_badges=false, lazy_load=true) }}
+ {% endif %}
+ {% endfor %}
+ </nav>
+ </div>
+ {% elif settings.related_videos_mode != 0 %}
+ <div class="related-autoplay"><label for="related-autoplay-toggle">Autoplay: </label><input id="related-autoplay-toggle" type="checkbox" class="autoplay-toggle"></div>
+ {% endif %}
- <time datetime="$upload_date">Published on {{ upload_date }}</time>
- <span class="likes-dislikes">{{ likes }} likes {{ dislikes }} dislikes</span>
- <details class="download-dropdown">
- <summary class="download-dropdown-label">Download</summary>
- <ul class="download-dropdown-content">
- {% for format in download_formats %}
- <li class="download-format">
- <a class="download-link" href="{{ format['url'] }}">
- <ol class="format-attributes">
- <li class="format-ext">{{ format['ext'] }}</li>
- <li class="format-res">{{ format['resolution'] }}</li>
- <li class="format-note">{{ format['note'] }}</li>
- </ol>
- </a>
- </li>
- {% endfor %}
- </ul>
- </details>
- <input class="checkbox" name="video_info_list" value="{{ video_info }}" form="playlist-edit" type="checkbox">
+ {% if subtitle_sources %}
+ <details id="transcript-details">
+ <summary>Transcript</summary>
+ <div id="transcript-div">
+ <select id="select-tt">
+ {% for source in subtitle_sources %}
+ <option>{{ source['label'] }}</option>
+ {% endfor %}
+ </select>
+ <label for="transcript-use-table">Table view</label>
+ <input id="transcript-use-table" type="checkbox">
+ <table id="transcript-table"></table>
+ </div>
+ </details>
+ {% endif %}
- <span class="description">{{ description }}</span>
- <div class="music-list">
- {% if music_list.__len__() != 0 %}
- <hr>
- <table>
- <caption>Music</caption>
- <tr>
- {% for attribute in music_attributes %}
- <th>{{ attribute }}</th>
+ {% if settings.related_videos_mode != 0 %}
+ <details class="related-videos-outer" {{'open' if settings.related_videos_mode == 1 else ''}}>
+ <summary>Related Videos</summary>
+ <nav class="related-videos-inner">
+ {% for info in related %}
+ {{ common_elements.item(info, include_badges=false) }}
{% endfor %}
- </tr>
- {% for track in music_list %}
- <tr>
- {% for attribute in music_attributes %}
- <td>{{ track.get(attribute.lower(), '') }}</td>
- {% endfor %}
- </tr>
- {% endfor %}
- </table>
+ </nav>
+ </details>
{% endif %}
+
</div>
- </div>
- {% if related_videos_mode != 0 %}
- <details class="related-videos-outer" {{'open' if related_videos_mode == 1 else ''}}>
- <summary>Related Videos</summary>
- <nav class="related-videos-inner">
- {% for info in related %}
- {{ common_elements.item(info) }}
- {% endfor %}
- </nav>
- </details>
- {% endif %}
+ <!-- comments -->
+ {% if settings.comments_mode != 0 %}
+ {% if comments_disabled %}
+ <div class="comments-area-outer comments-disabled">Comments disabled</div>
+ {% else %}
+ <details class="comments-area-outer" {{'open' if settings.comments_mode == 1 else ''}}>
+ <summary>{{ comment_count|commatize }} comment{{'s' if comment_count != '1' else ''}}</summary>
+ <div class="comments-area-inner comments-area">
+ {% if comments_info %}
+ {{ comments.video_comments(comments_info) }}
+ {% endif %}
+ </div>
+ </details>
+ {% endif %}
+ {% endif %}
- {% if comments_mode != 0 %}
- <details class="comments-area-outer" {{'open' if comments_mode == 1 else ''}}>
- <summary>Comments</summary>
- <section class="comments-area-inner comments-area">
- {% if comments_info %}
- {{ comments.video_comments(comments_info) }}
- {% endif %}
- </section>
- </details>
+ </div>
+
+ <script src="/youtube.com/static/js/av-merge.js"></script>
+ <script src="/youtube.com/static/js/watch.js"></script>
+ <script>
+ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
+ let storyboard_url = {{ storyboard_url | tojson }};
+ // @license-end
+ </script>
+ <script src="/youtube.com/static/js/common.js"></script>
+ <script src="/youtube.com/static/js/transcript-table.js"></script>
+ {% if settings.use_video_player == 2 %}
+ <!-- plyr -->
+ <script src="/youtube.com/static/modules/plyr/plyr.min.js"
+ integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
+ crossorigin="anonymous"></script>
+ <script src="/youtube.com/static/js/plyr-start.js"></script>
+ <!-- /plyr -->
+ {% elif settings.use_video_player == 1 %}
+ <script src="/youtube.com/static/js/hotkeys.js"></script>
{% endif %}
+ {% if settings.use_comments_js %} <script src="/youtube.com/static/js/comments.js"></script> {% endif %}
+ {% if settings.use_sponsorblock_js %} <script src="/youtube.com/static/js/sponsorblock.js"></script> {% endif %}
{% endblock main %}
diff --git a/youtube/util.py b/youtube/util.py
index a81ae83..c59fae8 100644
--- a/youtube/util.py
+++ b/youtube/util.py
@@ -1,14 +1,25 @@
+from datetime import datetime
import settings
-import socks, sockshandler
+import socks
+import sockshandler
import gzip
-import brotli
+try:
+ import brotli
+ have_brotli = True
+except ImportError:
+ have_brotli = False
import urllib.parse
import re
import time
import os
+import json
import gevent
import gevent.queue
import gevent.lock
+import collections
+import stem
+import stem.control
+import traceback
# The trouble with the requests library: It ships its own certificate bundle via certifi
# instead of using the system certificate store, meaning self-signed certificates
@@ -45,34 +56,119 @@ import urllib3.contrib.socks
URL_ORIGIN = "/https://www.youtube.com"
-connection_pool = urllib3.PoolManager(cert_reqs = 'CERT_REQUIRED')
+connection_pool = urllib3.PoolManager(cert_reqs='CERT_REQUIRED')
+
+
+class TorManager:
+ MAX_TRIES = 3
+ # Remember the 7-sec wait times, so make cooldown be two of those
+ # (otherwise it will retry forever if 429s never end)
+ COOLDOWN_TIME = 14
+
+ def __init__(self):
+ self.old_tor_connection_pool = None
+ self.tor_connection_pool = urllib3.contrib.socks.SOCKSProxyManager(
+ 'socks5h://127.0.0.1:' + str(settings.tor_port) + '/',
+ cert_reqs='CERT_REQUIRED')
+ self.tor_pool_refresh_time = time.monotonic()
+ settings.add_setting_changed_hook(
+ 'tor_port',
+ lambda old_val, new_val: self.refresh_tor_connection_pool(),
+ )
+
+ self.new_identity_lock = gevent.lock.BoundedSemaphore(1)
+ self.last_new_identity_time = time.monotonic() - 20
+ self.try_num = 1
+
+ def refresh_tor_connection_pool(self):
+ self.tor_connection_pool.clear()
+
+ # Keep a reference for 5 min to avoid it getting garbage collected
+ # while sockets still in use
+ self.old_tor_connection_pool = self.tor_connection_pool
+
+ self.tor_connection_pool = urllib3.contrib.socks.SOCKSProxyManager(
+ 'socks5h://127.0.0.1:' + str(settings.tor_port) + '/',
+ cert_reqs='CERT_REQUIRED')
+ self.tor_pool_refresh_time = time.monotonic()
+
+ def get_tor_connection_pool(self):
+ # Tor changes circuits after 10 minutes:
+ # https://tor.stackexchange.com/questions/262/for-how-long-does-a-circuit-stay-alive
+ current_time = time.monotonic()
+
+ # close pool after 5 minutes
+ if current_time - self.tor_pool_refresh_time > 300:
+ self.refresh_tor_connection_pool()
+
+ return self.tor_connection_pool
+
+ def new_identity(self, time_failed_request_started):
+ '''return error, or None if no error and the identity is fresh'''
+
+ # The overall pattern at maximum (always returning 429) will be
+ # R N (0) R N (6) R N (6) R | (12) R N (0) R N (6) ...
+ # where R is a request, N is a new identity, (x) is a wait time of
+ # x sec, and | is where we give up and display an error to the user.
+
+ print('new_identity: new_identity called')
+ # blocks if another greenlet currently has the lock
+ self.new_identity_lock.acquire()
+ print('new_identity: New identity lock acquired')
+
+ try:
+ # This was caused by a request that failed within a previous,
+ # stale identity
+ if time_failed_request_started <= self.last_new_identity_time:
+ print('new_identity: Cancelling; request was from stale identity')
+ return None
+
+ delta = time.monotonic() - self.last_new_identity_time
+ if delta < self.COOLDOWN_TIME and self.try_num == 1:
+ err = ('Retried with new circuit %d times (max) within last '
+ '%d seconds.' % (self.MAX_TRIES, self.COOLDOWN_TIME))
+ print('new_identity:', err)
+ return err
+ elif delta >= self.COOLDOWN_TIME:
+ self.try_num = 1
+
+ try:
+ port = settings.tor_control_port
+ with stem.control.Controller.from_port(port=port) as controller:
+ controller.authenticate('')
+ print('new_identity: Getting new identity')
+ controller.signal(stem.Signal.NEWNYM)
+ print('new_identity: NEWNYM signal sent')
+ self.last_new_identity_time = time.monotonic()
+ self.refresh_tor_connection_pool()
+ except stem.SocketError:
+ traceback.print_exc()
+ return 'Failed to connect to Tor control port.'
+ finally:
+ original_try_num = self.try_num
+ self.try_num += 1
+ if self.try_num > self.MAX_TRIES:
+ self.try_num = 1
+
+ # If we do the request right after second new identity it won't
+ # be a new IP, based on experiments.
+ # Not necessary after first new identity
+ if original_try_num > 1:
+ print('Sleeping for 7 seconds before retrying request')
+ time.sleep(7) # experimentally determined minimum
+
+ return None
+ finally:
+ self.new_identity_lock.release()
+
+
+tor_manager = TorManager()
-old_tor_connection_pool = None
-tor_connection_pool = urllib3.contrib.socks.SOCKSProxyManager('socks5://127.0.0.1:9150/', cert_reqs = 'CERT_REQUIRED')
-
-tor_pool_refresh_time = time.monotonic() # prevent problems due to clock changes
def get_pool(use_tor):
- global old_tor_connection_pool
- global tor_connection_pool
- global tor_pool_refresh_time
-
if not use_tor:
return connection_pool
-
- # Tor changes circuits after 10 minutes: https://tor.stackexchange.com/questions/262/for-how-long-does-a-circuit-stay-alive
- current_time = time.monotonic()
- if current_time - tor_pool_refresh_time > 300: # close pool after 5 minutes
- tor_connection_pool.clear()
-
- # Keep a reference for 5 min to avoid it getting garbage collected while sockets still in use
- old_tor_connection_pool = tor_connection_pool
-
- tor_connection_pool = urllib3.contrib.socks.SOCKSProxyManager('socks5://127.0.0.1:9150/', cert_reqs = 'CERT_REQUIRED')
- tor_pool_refresh_time = current_time
-
- return tor_connection_pool
-
+ return tor_manager.get_tor_connection_pool()
class HTTPAsymmetricCookieProcessor(urllib.request.BaseHandler):
@@ -96,6 +192,19 @@ class HTTPAsymmetricCookieProcessor(urllib.request.BaseHandler):
https_response = http_response
+class FetchError(Exception):
+ def __init__(self, code, reason='', ip=None, error_message=None):
+ if error_message:
+ string = code + ' ' + reason + ': ' + error_message
+ else:
+ string = 'HTTP error during request: ' + code + ' ' + reason
+ Exception.__init__(self, string)
+ self.code = code
+ self.reason = reason
+ self.ip = ip
+ self.error_message = error_message
+
+
def decode_content(content, encoding_header):
encodings = encoding_header.replace(' ', '').split(',')
for encoding in reversed(encodings):
@@ -107,8 +216,12 @@ def decode_content(content, encoding_header):
content = gzip.decompress(content)
return content
-def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookiejar_send=None, cookiejar_receive=None, use_tor=True, return_response=False, debug_name=None):
+
+def fetch_url_response(url, headers=(), timeout=15, data=None,
+ cookiejar_send=None, cookiejar_receive=None,
+ use_tor=True, max_redirects=None):
'''
+ returns response, cleanup_function
When cookiejar_send is set to a CookieJar object,
those cookies will be sent in the request (but cookies in response will not be merged into it)
When cookiejar_receive is set to a CookieJar object,
@@ -117,7 +230,10 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookieja
and response cookies will be merged into it.
'''
headers = dict(headers) # Note: Calling dict() on a dict will make a copy
- headers['Accept-Encoding'] = 'gzip, br'
+ if have_brotli:
+ headers['Accept-Encoding'] = 'gzip, br'
+ else:
+ headers['Accept-Encoding'] = 'gzip'
# prevent python version being leaked by urllib if User-Agent isn't provided
# (urllib will use ex. Python-urllib/3.6 otherwise)
@@ -128,11 +244,10 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookieja
if data is not None:
method = "POST"
if isinstance(data, str):
- data = data.encode('ascii')
+ data = data.encode('utf-8')
elif not isinstance(data, bytes):
- data = urllib.parse.urlencode(data).encode('ascii')
+ data = urllib.parse.urlencode(data).encode('utf-8')
- start_time = time.time()
if cookiejar_send is not None or cookiejar_receive is not None: # Use urllib
req = urllib.request.Request(url, data=data, headers=headers)
@@ -140,50 +255,163 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None, cookieja
cookie_processor = HTTPAsymmetricCookieProcessor(cookiejar_send=cookiejar_send, cookiejar_receive=cookiejar_receive)
if use_tor and settings.route_tor:
- opener = urllib.request.build_opener(sockshandler.SocksiPyHandler(socks.PROXY_TYPE_SOCKS5, "127.0.0.1", 9150), cookie_processor)
+ opener = urllib.request.build_opener(sockshandler.SocksiPyHandler(socks.PROXY_TYPE_SOCKS5, "127.0.0.1", settings.tor_port), cookie_processor)
else:
opener = urllib.request.build_opener(cookie_processor)
response = opener.open(req, timeout=timeout)
- response_time = time.time()
-
-
- content = response.read()
+ cleanup_func = (lambda r: None)
else: # Use a urllib3 pool. Cookies can't be used since urllib3 doesn't have easy support for them.
+ # default: Retry.DEFAULT = Retry(3)
+ # (in connectionpool.py in urllib3)
+ # According to the documentation for urlopen, a redirect counts as a
+ # retry. So there are 3 redirects max by default.
+ if max_redirects:
+ retries = urllib3.Retry(3+max_redirects, redirect=max_redirects, raise_on_redirect=False)
+ else:
+ retries = urllib3.Retry(3, raise_on_redirect=False)
pool = get_pool(use_tor and settings.route_tor)
-
- response = pool.request(method, url, headers=headers, timeout=timeout, preload_content=False, decode_content=False)
- response_time = time.time()
+ try:
+ response = pool.request(method, url, headers=headers, body=data,
+ timeout=timeout, preload_content=False,
+ decode_content=False, retries=retries)
+ response.retries = retries
+ except urllib3.exceptions.MaxRetryError as e:
+ exception_cause = e.__context__.__context__
+ if (isinstance(exception_cause, socks.ProxyConnectionError)
+ and settings.route_tor):
+ msg = ('Failed to connect to Tor. Check that Tor is open and '
+ 'that your internet connection is working.\n\n'
+ + str(e))
+ raise FetchError('502', reason='Bad Gateway',
+ error_message=msg)
+ elif isinstance(e.__context__,
+ urllib3.exceptions.NewConnectionError):
+ msg = 'Failed to establish a connection.\n\n' + str(e)
+ raise FetchError(
+ '502', reason='Bad Gateway',
+ error_message=msg)
+ else:
+ raise
+ cleanup_func = (lambda r: r.release_conn())
+
+ return response, cleanup_func
+
+
+def fetch_url(url, headers=(), timeout=15, report_text=None, data=None,
+ cookiejar_send=None, cookiejar_receive=None, use_tor=True,
+ debug_name=None):
+ while True:
+ start_time = time.monotonic()
+
+ response, cleanup_func = fetch_url_response(
+ url, headers, timeout=timeout, data=data,
+ cookiejar_send=cookiejar_send, cookiejar_receive=cookiejar_receive,
+ use_tor=use_tor)
+ response_time = time.monotonic()
content = response.read()
- response.release_conn()
- read_finish = time.time()
+ read_finish = time.monotonic()
+
+ cleanup_func(response) # release_connection for urllib3
+ content = decode_content(
+ content,
+ response.headers.get('Content-Encoding', default='identity'))
+
+ if (settings.debugging_save_responses
+ and debug_name is not None
+ and content):
+ save_dir = os.path.join(settings.data_dir, 'debug')
+ if not os.path.exists(save_dir):
+ os.makedirs(save_dir)
+
+ with open(os.path.join(save_dir, debug_name), 'wb') as f:
+ f.write(content)
+
+ if response.status == 429 or (
+ response.status == 302 and (response.getheader('Location') == url
+ or response.getheader('Location').startswith(
+ 'https://www.google.com/sorry/index'
+ )
+ )
+ ):
+ print(response.status, response.reason, response.headers)
+ ip = re.search(
+ br'IP address: ((?:[\da-f]*:)+[\da-f]+|(?:\d+\.)+\d+)',
+ content)
+ ip = ip.group(1).decode('ascii') if ip else None
+ if not ip:
+ ip = re.search(r'IP=((?:\d+\.)+\d+)',
+ response.getheader('Set-Cookie') or '')
+ ip = ip.group(1) if ip else None
+
+ # don't get new identity if we're not using Tor
+ if not use_tor:
+ raise FetchError('429', reason=response.reason, ip=ip)
+
+ print('Error: YouTube blocked the request because the Tor exit node is overutilized. Exit node IP address: %s' % ip)
+
+ # get new identity
+ error = tor_manager.new_identity(start_time)
+ if error:
+ raise FetchError(
+ '429', reason=response.reason, ip=ip,
+ error_message='Automatic circuit change: ' + error)
+ else:
+ continue # retry now that we have new identity
+
+ elif response.status >= 400:
+ raise FetchError(str(response.status), reason=response.reason,
+ ip=None)
+ break
+
if report_text:
- print(report_text, ' Latency:', round(response_time - start_time,3), ' Read time:', round(read_finish - response_time,3))
- content = decode_content(content, response.getheader('Content-Encoding', default='identity'))
+ print(report_text, ' Latency:', round(response_time - start_time, 3), ' Read time:', round(read_finish - response_time,3))
- if settings.debugging_save_responses and debug_name is not None:
- save_dir = os.path.join(settings.data_dir, 'debug')
- if not os.path.exists(save_dir):
- os.makedirs(save_dir)
+ return content
- with open(os.path.join(save_dir, debug_name), 'wb') as f:
- f.write(content)
- if return_response:
- return content, response
- return content
+def head(url, use_tor=False, report_text=None, max_redirects=10):
+ pool = get_pool(use_tor and settings.route_tor)
+ start_time = time.monotonic()
+
+ # default: Retry.DEFAULT = Retry(3)
+ # (in connectionpool.py in urllib3)
+ # According to the documentation for urlopen, a redirect counts as a retry
+ # So there are 3 redirects max by default. Let's change that
+ # to 10 since googlevideo redirects a lot.
+ retries = urllib3.Retry(
+ 3+max_redirects,
+ redirect=max_redirects,
+ raise_on_redirect=False)
+ headers = {'User-Agent': 'Python-urllib'}
+ response = pool.request('HEAD', url, headers=headers, retries=retries)
+ if report_text:
+ print(
+ report_text,
+ ' Latency:',
+ round(time.monotonic() - start_time, 3))
+ return response
-mobile_user_agent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1'
+mobile_user_agent = 'Mozilla/5.0 (Linux; Android 7.0; Redmi Note 4 Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36'
mobile_ua = (('User-Agent', mobile_user_agent),)
desktop_user_agent = 'Mozilla/5.0 (Windows NT 6.1; rv:52.0) Gecko/20100101 Firefox/52.0'
desktop_ua = (('User-Agent', desktop_user_agent),)
-
-
-
-
+json_header = (('Content-Type', 'application/json'),)
+desktop_xhr_headers = (
+ ('Accept', '*/*'),
+ ('Accept-Language', 'en-US,en;q=0.5'),
+ ('X-YouTube-Client-Name', '1'),
+ ('X-YouTube-Client-Version', '2.20240304.00.00'),
+) + desktop_ua
+mobile_xhr_headers = (
+ ('Accept', '*/*'),
+ ('Accept-Language', 'en-US,en;q=0.5'),
+ ('X-YouTube-Client-Name', '2'),
+ ('X-YouTube-Client-Version', '2.20240304.08.00'),
+) + mobile_ua
class RateLimitedQueue(gevent.queue.Queue):
@@ -202,7 +430,6 @@ class RateLimitedQueue(gevent.queue.Queue):
self.empty_start = 0
gevent.queue.Queue.__init__(self)
-
def get(self):
self.lock.acquire() # blocks if another greenlet currently has the lock
if self.count_since_last_wait >= self.subsequent_bursts and self.surpassed_initial:
@@ -234,9 +461,8 @@ class RateLimitedQueue(gevent.queue.Queue):
return item
-
def download_thumbnail(save_directory, video_id):
- url = "https://i.ytimg.com/vi/" + video_id + "/mqdefault.jpg"
+ url = f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
save_location = os.path.join(save_directory, video_id + ".jpg")
try:
thumbnail = fetch_url(url, report_text="Saved thumbnail: " + video_id)
@@ -246,12 +472,13 @@ def download_thumbnail(save_directory, video_id):
try:
f = open(save_location, 'wb')
except FileNotFoundError:
- os.makedirs(save_directory, exist_ok = True)
+ os.makedirs(save_directory, exist_ok=True)
f = open(save_location, 'wb')
f.write(thumbnail)
f.close()
return True
+
def download_thumbnails(save_directory, ids):
if not isinstance(ids, (list, tuple)):
ids = list(ids)
@@ -264,37 +491,26 @@ def download_thumbnails(save_directory, ids):
gevent.joinall([gevent.spawn(download_thumbnail, save_directory, ids[j]) for j in range(i*5 + 5, len(ids))])
-
-
-
-
def dict_add(*dicts):
for dictionary in dicts[1:]:
dicts[0].update(dictionary)
return dicts[0]
+
def video_id(url):
url_parts = urllib.parse.urlparse(url)
return urllib.parse.parse_qs(url_parts.query)['v'][0]
-def default_multi_get(object, *keys, default):
- ''' Like dict.get(), but for nested dictionaries/sequences, supporting keys or indices. Last argument is the default value to use in case of any IndexErrors or KeyErrors '''
- try:
- for key in keys:
- object = object[key]
- return object
- except (IndexError, KeyError):
- return default
-
# default, sddefault, mqdefault, hqdefault, hq720
def get_thumbnail_url(video_id):
- return "/i.ytimg.com/vi/" + video_id + "/mqdefault.jpg"
-
+ return f"{settings.img_prefix}https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
+
+
def seconds_to_timestamp(seconds):
seconds = int(seconds)
- hours, seconds = divmod(seconds,3600)
- minutes, seconds = divmod(seconds,60)
+ hours, seconds = divmod(seconds, 3600)
+ minutes, seconds = divmod(seconds, 60)
if hours != 0:
timestamp = str(hours) + ":"
timestamp += str(minutes).zfill(2) # zfill pads with zeros
@@ -305,19 +521,332 @@ def seconds_to_timestamp(seconds):
return timestamp
-
def update_query_string(query_string, items):
parameters = urllib.parse.parse_qs(query_string)
parameters.update(items)
return urllib.parse.urlencode(parameters, doseq=True)
+YOUTUBE_DOMAINS = ('youtube.com', 'youtu.be', 'youtube-nocookie.com')
+YOUTUBE_URL_RE_STR = r'https?://(?:[a-zA-Z0-9_-]*\.)?(?:'
+YOUTUBE_URL_RE_STR += r'|'.join(map(re.escape, YOUTUBE_DOMAINS))
+YOUTUBE_URL_RE_STR += r')(?:/[^"]*)?'
+YOUTUBE_URL_RE = re.compile(YOUTUBE_URL_RE_STR)
-def uppercase_escape(s):
- return re.sub(
- r'\\U([0-9a-fA-F]{8})',
- lambda m: chr(int(m.group(1), base=16)), s)
def prefix_url(url):
+ if url is None:
+ return None
url = url.lstrip('/') # some urls have // before them, which has a special meaning
return '/' + url
+
+
+def left_remove(string, substring):
+ '''removes substring from the start of string, if present'''
+ if string.startswith(substring):
+ return string[len(substring):]
+ return string
+
+
+def concat_or_none(*strings):
+ '''Concatenates strings. Returns None if any of the arguments are None'''
+ result = ''
+ for string in strings:
+ if string is None:
+ return None
+ result += string
+ return result
+
+
+def prefix_urls(item):
+ if settings.proxy_images:
+ try:
+ item['thumbnail'] = prefix_url(item['thumbnail'])
+ except KeyError:
+ pass
+
+ try:
+ item['author_url'] = prefix_url(item['author_url'])
+ except KeyError:
+ pass
+
+
+def add_extra_html_info(item):
+ if item['type'] == 'video':
+ item['url'] = (URL_ORIGIN + '/watch?v=' + item['id']) if item.get('id') else None
+
+ video_info = {}
+ for key in ('id', 'title', 'author', 'duration', 'author_id'):
+ try:
+ video_info[key] = item[key]
+ except KeyError:
+ video_info[key] = None
+
+ item['video_info'] = json.dumps(video_info)
+
+ elif item['type'] == 'playlist' and item['playlist_type'] == 'radio':
+ item['url'] = concat_or_none(
+ URL_ORIGIN,
+ '/watch?v=', item['first_video_id'],
+ '&list=', item['id']
+ )
+ elif item['type'] == 'playlist':
+ item['url'] = concat_or_none(URL_ORIGIN, '/playlist?list=', item['id'])
+ elif item['type'] == 'channel':
+ item['url'] = concat_or_none(URL_ORIGIN, "/channel/", item['id'])
+
+ if item.get('author_id') and 'author_url' not in item:
+ item['author_url'] = URL_ORIGIN + '/channel/' + item['author_id']
+
+
+def check_gevent_exceptions(*tasks):
+ for task in tasks:
+ if task.exception:
+ raise task.exception
+
+
+# https://stackoverflow.com/a/62888
+replacement_map = collections.OrderedDict([
+ ('<', '_'),
+ ('>', '_'),
+ (': ', ' - '),
+ (':', '-'),
+ ('"', "'"),
+ ('/', '_'),
+ ('\\', '_'),
+ ('|', '-'),
+ ('?', ''),
+ ('*', '_'),
+ ('\t', ' '),
+])
+
+DOS_names = {'con', 'prn', 'aux', 'nul', 'com0', 'com1', 'com2', 'com3',
+ 'com4', 'com5', 'com6', 'com7', 'com8', 'com9', 'lpt0',
+ 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7',
+ 'lpt8', 'lpt9'}
+
+
+def to_valid_filename(name):
+ '''Changes the name so it's valid on Windows, Linux, and Mac'''
+ # See https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
+ # for Windows specs
+
+ # Additional recommendations for Linux:
+ # https://dwheeler.com/essays/fixing-unix-linux-filenames.html#standards
+
+ # remove control characters
+ name = re.sub(r'[\x00-\x1f]', '_', name)
+
+ # reserved characters
+ for reserved_char, replacement in replacement_map.items():
+ name = name.replace(reserved_char, replacement)
+
+ # check for all periods/spaces
+ if all(c == '.' or c == ' ' for c in name):
+ name = '_'*len(name)
+
+ # remove trailing periods and spaces
+ name = name.rstrip('. ')
+
+ # check for reserved DOS names, such as nul or nul.txt
+ base_ext_parts = name.rsplit('.', maxsplit=1)
+ if base_ext_parts[0].lower() in DOS_names:
+ base_ext_parts[0] += '_'
+ name = '.'.join(base_ext_parts)
+
+ # check for blank name
+ if name == '':
+ name = '_'
+
+ # check if name begins with a hyphen, period, or space
+ if name[0] in ('-', '.', ' '):
+ name = '_' + name
+
+ return name
+
+
+# https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L72
+INNERTUBE_CLIENTS = {
+ 'android': {
+ 'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
+ 'INNERTUBE_CONTEXT': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'ANDROID',
+ 'clientVersion': '19.09.36',
+ 'osName': 'Android',
+ 'osVersion': '12',
+ 'androidSdkVersion': 31,
+ 'platform': 'MOBILE',
+ 'userAgent': 'com.google.android.youtube/19.09.36 (Linux; U; Android 12; US) gzip'
+ },
+ # https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
+ #'thirdParty': {
+ # 'embedUrl': 'https://google.com', # Can be any valid URL
+ #}
+ },
+ 'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
+ 'REQUIRE_JS_PLAYER': False,
+ },
+
+ 'android-test-suite': {
+ 'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
+ 'INNERTUBE_CONTEXT': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'ANDROID_TESTSUITE',
+ 'clientVersion': '1.9',
+ 'osName': 'Android',
+ 'osVersion': '12',
+ 'androidSdkVersion': 31,
+ 'platform': 'MOBILE',
+ 'userAgent': 'com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip'
+ },
+ # https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
+ #'thirdParty': {
+ # 'embedUrl': 'https://google.com', # Can be any valid URL
+ #}
+ },
+ 'INNERTUBE_CONTEXT_CLIENT_NAME': 3,
+ 'REQUIRE_JS_PLAYER': False,
+ },
+
+ 'ios': {
+ 'INNERTUBE_API_KEY': 'AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc',
+ 'INNERTUBE_CONTEXT': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'IOS',
+ 'clientVersion': '19.09.3',
+ 'deviceModel': 'iPhone14,3',
+ 'userAgent': 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iOS 15_6 like Mac OS X)'
+ }
+ },
+ 'INNERTUBE_CONTEXT_CLIENT_NAME': 5,
+ 'REQUIRE_JS_PLAYER': False
+ },
+
+ # This client can access age restricted videos (unless the uploader has disabled the 'allow embedding' option)
+ # See: https://github.com/zerodytrash/YouTube-Internal-Clients
+ 'tv_embedded': {
+ 'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
+ 'INNERTUBE_CONTEXT': {
+ 'client': {
+ 'hl': 'en',
+ 'gl': 'US',
+ 'clientName': 'TVHTML5_SIMPLY_EMBEDDED_PLAYER',
+ 'clientVersion': '2.0',
+ 'clientScreen': 'EMBED',
+ },
+ # https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
+ 'thirdParty': {
+ 'embedUrl': 'https://google.com', # Can be any valid URL
+ }
+
+ },
+ 'INNERTUBE_CONTEXT_CLIENT_NAME': 85,
+ 'REQUIRE_JS_PLAYER': True,
+ },
+
+ 'web': {
+ 'INNERTUBE_API_KEY': 'AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8',
+ 'INNERTUBE_CONTEXT': {
+ 'client': {
+ 'clientName': 'WEB',
+ 'clientVersion': '2.20220801.00.00',
+ 'userAgent': desktop_user_agent,
+ }
+ },
+ 'INNERTUBE_CONTEXT_CLIENT_NAME': 1
+ },
+ 'android_vr': {
+ 'INNERTUBE_API_KEY': 'AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w',
+ 'INNERTUBE_CONTEXT': {
+ 'client': {
+ 'clientName': 'ANDROID_VR',
+ 'clientVersion': '1.60.19',
+ 'deviceMake': 'Oculus',
+ 'deviceModel': 'Quest 3',
+ 'androidSdkVersion': 32,
+ 'userAgent': 'com.google.android.apps.youtube.vr.oculus/1.60.19 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
+ 'osName': 'Android',
+ 'osVersion': '12L',
+ },
+ },
+ 'INNERTUBE_CONTEXT_CLIENT_NAME': 28,
+ 'REQUIRE_JS_PLAYER': False,
+ },
+}
+
+def get_visitor_data():
+ visitor_data = None
+ visitor_data_cache = os.path.join(settings.data_dir, 'visitorData.txt')
+ if not os.path.exists(settings.data_dir):
+ os.makedirs(settings.data_dir)
+ if os.path.isfile(visitor_data_cache):
+ with open(visitor_data_cache, 'r') as file:
+ print('Getting visitor_data from cache')
+ visitor_data = file.read()
+ max_age = 12*3600
+ file_age = time.time() - os.path.getmtime(visitor_data_cache)
+ if file_age > max_age:
+ print('visitor_data cache is too old. Removing file...')
+ os.remove(visitor_data_cache)
+ return visitor_data
+
+ print('Fetching youtube homepage to get visitor_data')
+ yt_homepage = 'https://www.youtube.com'
+ yt_resp = fetch_url(yt_homepage, headers={'User-Agent': mobile_user_agent}, report_text='Getting youtube homepage')
+ visitor_data_re = r'''"visitorData":\s*?"(.+?)"'''
+ visitor_data_match = re.search(visitor_data_re, yt_resp.decode())
+ if visitor_data_match:
+ visitor_data = visitor_data_match.group(1)
+ print(f'Got visitor_data: {len(visitor_data)}')
+ with open(visitor_data_cache, 'w') as file:
+ print('Saving visitor_data cache...')
+ file.write(visitor_data)
+ return visitor_data
+ else:
+ print('Unable to get visitor_data value')
+ return visitor_data
+
+def call_youtube_api(client, api, data):
+ client_params = INNERTUBE_CLIENTS[client]
+ context = client_params['INNERTUBE_CONTEXT']
+ key = client_params['INNERTUBE_API_KEY']
+ host = client_params.get('INNERTUBE_HOST') or 'www.youtube.com'
+ user_agent = context['client'].get('userAgent') or mobile_user_agent
+ visitor_data = get_visitor_data()
+
+ url = 'https://' + host + '/youtubei/v1/' + api + '?key=' + key
+ if visitor_data:
+ context['client'].update({'visitorData': visitor_data})
+ data['context'] = context
+
+ data = json.dumps(data)
+ headers = (('Content-Type', 'application/json'),('User-Agent', user_agent))
+ if visitor_data:
+ headers = ( *headers, ('X-Goog-Visitor-Id', visitor_data ))
+ response = fetch_url(
+ url, data=data, headers=headers,
+ debug_name='youtubei_' + api + '_' + client,
+ report_text='Fetched ' + client + ' youtubei ' + api
+ ).decode('utf-8')
+ return response
+
+
+def strip_non_ascii(string):
+ ''' Returns the string without non ASCII characters'''
+ if string is None:
+ return ""
+ stripped = (c for c in string if 0 < ord(c) < 127)
+ return ''.join(stripped)
+
+
+def time_utc_isoformat(string):
+ t = datetime.strptime(string, '%Y-%m-%d')
+ t = t.astimezone().isoformat()
+ return t
diff --git a/youtube_dl/version.py b/youtube/version.py
index c7083cf..1ffb850 100644
--- a/youtube_dl/version.py
+++ b/youtube/version.py
@@ -1,3 +1,3 @@
from __future__ import unicode_literals
-__version__ = '2018.07.10'
+__version__ = 'v0.3.2'
diff --git a/youtube/watch.py b/youtube/watch.py
index 41c90e4..0274cd0 100644
--- a/youtube/watch.py
+++ b/youtube/watch.py
@@ -1,120 +1,284 @@
+import youtube
from youtube import yt_app
from youtube import util, comments, local_playlist, yt_data_extract
+from youtube.util import time_utc_isoformat
import settings
from flask import request
import flask
-from youtube_dl.YoutubeDL import YoutubeDL
-from youtube_dl.extractor.youtube import YoutubeError
import json
-import html
import gevent
import os
+import math
+import traceback
+import urllib
+import re
+import urllib3.exceptions
+from urllib.parse import parse_qs, urlencode
+from types import SimpleNamespace
+from math import ceil
+
+
+try:
+ with open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'r') as f:
+ decrypt_cache = json.loads(f.read())['decrypt_cache']
+except FileNotFoundError:
+ decrypt_cache = {}
+
+
+def codec_name(vcodec):
+ if vcodec.startswith('avc'):
+ return 'h264'
+ elif vcodec.startswith('av01'):
+ return 'av1'
+ elif vcodec.startswith('vp'):
+ return 'vp'
+ else:
+ return 'unknown'
-def get_related_items(info):
- results = []
- for item in info['related_vids']:
- if 'list' in item: # playlist:
- result = watch_page_related_playlist_info(item)
- else:
- result = watch_page_related_video_info(item)
- yt_data_extract.prefix_urls(result)
- yt_data_extract.add_extra_html_info(result)
- results.append(result)
- return results
-
-
-# json of related items retrieved directly from the watch page has different names for everything
-# converts these to standard names
-def watch_page_related_video_info(item):
- result = {key: item[key] for key in ('id', 'title', 'author')}
- result['duration'] = util.seconds_to_timestamp(item['length_seconds'])
- try:
- result['views'] = item['short_view_count_text']
- except KeyError:
- result['views'] = ''
- result['thumbnail'] = util.get_thumbnail_url(item['id'])
- result['type'] = 'video'
- return result
-
-def watch_page_related_playlist_info(item):
+def get_video_sources(info, target_resolution):
+ '''return dict with organized sources: {
+ 'uni_sources': [{}, ...], # video and audio in one file
+ 'uni_idx': int, # default unified source index
+ 'pair_sources': [{video: {}, audio: {}, quality: ..., ...}, ...],
+ 'pair_idx': int, # default pair source index
+ }
+ '''
+ audio_sources = []
+ video_only_sources = {}
+ uni_sources = []
+ pair_sources = []
+
+
+ for fmt in info['formats']:
+ if not all(fmt[attr] for attr in ('ext', 'url', 'itag')):
+ continue
+
+ # unified source
+ if fmt['acodec'] and fmt['vcodec']:
+ source = {
+ 'type': 'video/' + fmt['ext'],
+ 'quality_string': short_video_quality_string(fmt),
+ }
+ source['quality_string'] += ' (integrated)'
+ source.update(fmt)
+ uni_sources.append(source)
+ continue
+
+ if not (fmt['init_range'] and fmt['index_range']):
+ continue
+
+ # audio source
+ if fmt['acodec'] and not fmt['vcodec'] and (
+ fmt['audio_bitrate'] or fmt['bitrate']):
+ if fmt['bitrate']: # prefer this one, more accurate right now
+ fmt['audio_bitrate'] = int(fmt['bitrate']/1000)
+ source = {
+ 'type': 'audio/' + fmt['ext'],
+ 'quality_string': audio_quality_string(fmt),
+ }
+ source.update(fmt)
+ source['mime_codec'] = (source['type'] + '; codecs="'
+ + source['acodec'] + '"')
+ audio_sources.append(source)
+ # video-only source
+ elif all(fmt[attr] for attr in ('vcodec', 'quality', 'width', 'fps',
+ 'file_size')):
+ if codec_name(fmt['vcodec']) == 'unknown':
+ continue
+ source = {
+ 'type': 'video/' + fmt['ext'],
+ 'quality_string': short_video_quality_string(fmt),
+ }
+ source.update(fmt)
+ source['mime_codec'] = (source['type'] + '; codecs="'
+ + source['vcodec'] + '"')
+ quality = str(fmt['quality']) + 'p' + str(fmt['fps'])
+ if quality in video_only_sources:
+ video_only_sources[quality].append(source)
+ else:
+ video_only_sources[quality] = [source]
+
+ audio_sources.sort(key=lambda source: source['audio_bitrate'])
+ uni_sources.sort(key=lambda src: src['quality'])
+
+ webm_audios = [a for a in audio_sources if a['ext'] == 'webm']
+ mp4_audios = [a for a in audio_sources if a['ext'] == 'mp4']
+
+ for quality_string, sources in video_only_sources.items():
+ # choose an audio source to go with it
+ # 0.5 is semiarbitrary empirical constant to spread audio sources
+ # between 144p and 1080p. Use something better eventually.
+ quality, fps = map(int, quality_string.split('p'))
+ target_audio_bitrate = quality*fps/30*0.5
+ pair_info = {
+ 'quality_string': quality_string,
+ 'quality': quality,
+ 'height': sources[0]['height'],
+ 'width': sources[0]['width'],
+ 'fps': fps,
+ 'videos': sources,
+ 'audios': [],
+ }
+ for audio_choices in (webm_audios, mp4_audios):
+ if not audio_choices:
+ continue
+ closest_audio_source = audio_choices[0]
+ best_err = target_audio_bitrate - audio_choices[0]['audio_bitrate']
+ best_err = abs(best_err)
+ for audio_source in audio_choices[1:]:
+ err = abs(audio_source['audio_bitrate'] - target_audio_bitrate)
+ # once err gets worse we have passed the closest one
+ if err > best_err:
+ break
+ best_err = err
+ closest_audio_source = audio_source
+ pair_info['audios'].append(closest_audio_source)
+
+ if not pair_info['audios']:
+ continue
+
+ def video_rank(src):
+ ''' Sort by settings preference. Use file size as tiebreaker '''
+ setting_name = 'codec_rank_' + codec_name(src['vcodec'])
+ return (settings.current_settings_dict[setting_name],
+ src['file_size'])
+ pair_info['videos'].sort(key=video_rank)
+
+ pair_sources.append(pair_info)
+
+ pair_sources.sort(key=lambda src: src['quality'])
+
+ uni_idx = 0 if uni_sources else None
+ for i, source in enumerate(uni_sources):
+ if source['quality'] > target_resolution:
+ break
+ uni_idx = i
+
+ pair_idx = 0 if pair_sources else None
+ for i, pair_info in enumerate(pair_sources):
+ if pair_info['quality'] > target_resolution:
+ break
+ pair_idx = i
+
return {
- 'size': item['playlist_length'] if item['playlist_length'] != "0" else "50+",
- 'title': item['playlist_title'],
- 'id': item['list'],
- 'first_video_id': item['video_id'],
- 'thumbnail': util.get_thumbnail_url(item['video_id']),
- 'type': 'playlist',
+ 'uni_sources': uni_sources,
+ 'uni_idx': uni_idx,
+ 'pair_sources': pair_sources,
+ 'pair_idx': pair_idx,
}
-def get_video_sources(info):
- video_sources = []
- if not settings.theater_mode:
- max_resolution = 360
- else:
- max_resolution = settings.default_resolution
- for format in info['formats']:
- if format['acodec'] != 'none' and format['vcodec'] != 'none' and format['height'] <= max_resolution:
- video_sources.append({
- 'src': format['url'],
- 'type': 'video/' + format['ext'],
- 'height': format['height'],
- 'width': format['width'],
- })
+def make_caption_src(info, lang, auto=False, trans_lang=None):
+ label = lang
+ if auto:
+ label += ' (Automatic)'
+ if trans_lang:
+ label += ' -> ' + trans_lang
+ return {
+ 'url': util.prefix_url(yt_data_extract.get_caption_url(info, lang, 'vtt', auto, trans_lang)),
+ 'label': label,
+ 'srclang': trans_lang[0:2] if trans_lang else lang[0:2],
+ 'on': False,
+ }
+
+
+def lang_in(lang, sequence):
+ '''Tests if the language is in sequence, with e.g. en and en-US considered the same'''
+ if lang is None:
+ return False
+ lang = lang[0:2]
+ return lang in (l[0:2] for l in sequence)
+
- #### order the videos sources so the preferred resolution is first ###
+def lang_eq(lang1, lang2):
+ '''Tests if two iso 639-1 codes are equal, with en and en-US considered the same.
+ Just because the codes are equal does not mean the dialects are mutually intelligible, but this will have to do for now without a complex language model'''
+ if lang1 is None or lang2 is None:
+ return False
+ return lang1[0:2] == lang2[0:2]
- video_sources.sort(key=lambda source: source['height'], reverse=True)
- return video_sources
+def equiv_lang_in(lang, sequence):
+ '''Extracts a language in sequence which is equivalent to lang.
+ e.g. if lang is en, extracts en-GB from sequence.
+ Necessary because if only a specific variant like en-GB is available, can't ask YouTube for simply en. Need to get the available variant.'''
+ lang = lang[0:2]
+ for l in sequence:
+ if l[0:2] == lang:
+ return l
+ return None
+
def get_subtitle_sources(info):
+ '''Returns these sources, ordered from least to most intelligible:
+ native_video_lang (Automatic)
+ foreign_langs (Manual)
+ native_video_lang (Automatic) -> pref_lang
+ foreign_langs (Manual) -> pref_lang
+ native_video_lang (Manual) -> pref_lang
+ pref_lang (Automatic)
+ pref_lang (Manual)'''
sources = []
- default_found = False
- default = None
- for language, formats in info['subtitles'].items():
- for format in formats:
- if format['ext'] == 'vtt':
- source = {
- 'url': '/' + format['url'],
- 'label': language,
- 'srclang': language,
-
- # set as on by default if this is the preferred language and a default-on subtitles mode is in settings
- 'on': language == settings.subtitles_language and settings.subtitles_mode > 0,
- }
-
- if language == settings.subtitles_language:
- default_found = True
- default = source
- else:
- sources.append(source)
- break
-
- # Put it at the end to avoid browser bug when there are too many languages
- # (in firefox, it is impossible to select a language near the top of the list because it is cut off)
- if default_found:
- sources.append(default)
+ if not yt_data_extract.captions_available(info):
+ return []
+ pref_lang = settings.subtitles_language
+ native_video_lang = None
+ if info['automatic_caption_languages']:
+ native_video_lang = info['automatic_caption_languages'][0]
- try:
- formats = info['automatic_captions'][settings.subtitles_language]
- except KeyError:
- pass
- else:
- for format in formats:
- if format['ext'] == 'vtt':
- sources.append({
- 'url': '/' + format['url'],
- 'label': settings.subtitles_language + ' - Automatic',
- 'srclang': settings.subtitles_language,
+ highest_fidelity_is_manual = False
- # set as on by default if this is the preferred language and a default-on subtitles mode is in settings
- 'on': settings.subtitles_mode == 2 and not default_found,
+ # Sources are added in very specific order outlined above
+ # More intelligible sources are put further down to avoid browser bug when there are too many languages
+ # (in firefox, it is impossible to select a language near the top of the list because it is cut off)
- })
+ # native_video_lang (Automatic)
+ if native_video_lang and not lang_eq(native_video_lang, pref_lang):
+ sources.append(make_caption_src(info, native_video_lang, auto=True))
+
+ # foreign_langs (Manual)
+ for lang in info['manual_caption_languages']:
+ if not lang_eq(lang, pref_lang):
+ sources.append(make_caption_src(info, lang))
+
+ if (lang_in(pref_lang, info['translation_languages'])
+ and not lang_in(pref_lang, info['automatic_caption_languages'])
+ and not lang_in(pref_lang, info['manual_caption_languages'])):
+ # native_video_lang (Automatic) -> pref_lang
+ if native_video_lang and not lang_eq(pref_lang, native_video_lang):
+ sources.append(make_caption_src(info, native_video_lang, auto=True, trans_lang=pref_lang))
+
+ # foreign_langs (Manual) -> pref_lang
+ for lang in info['manual_caption_languages']:
+ if not lang_eq(lang, native_video_lang) and not lang_eq(lang, pref_lang):
+ sources.append(make_caption_src(info, lang, trans_lang=pref_lang))
+
+ # native_video_lang (Manual) -> pref_lang
+ if lang_in(native_video_lang, info['manual_caption_languages']):
+ sources.append(make_caption_src(info, native_video_lang, trans_lang=pref_lang))
+
+ # pref_lang (Automatic)
+ if lang_in(pref_lang, info['automatic_caption_languages']):
+ sources.append(make_caption_src(info, equiv_lang_in(pref_lang, info['automatic_caption_languages']), auto=True))
+
+ # pref_lang (Manual)
+ if lang_in(pref_lang, info['manual_caption_languages']):
+ sources.append(make_caption_src(info, equiv_lang_in(pref_lang, info['manual_caption_languages'])))
+ highest_fidelity_is_manual = True
+
+ if sources and sources[-1]['srclang'] == pref_lang:
+ # set as on by default since it's manual a default-on subtitles mode is in settings
+ if highest_fidelity_is_manual and settings.subtitles_mode > 0:
+ sources[-1]['on'] = True
+ # set as on by default since settings indicate to set it as such even if it's not manual
+ elif settings.subtitles_mode == 2:
+ sources[-1]['on'] = True
+
+ if len(sources) == 0:
+ assert len(info['automatic_caption_languages']) == 0 and len(info['manual_caption_languages']) == 0
return sources
@@ -135,106 +299,519 @@ def get_ordered_music_list_attributes(music_list):
return ordered_attributes
-def extract_info(downloader, *args, **kwargs):
+def save_decrypt_cache():
try:
- return downloader.extract_info(*args, **kwargs)
- except YoutubeError as e:
- return str(e)
+ f = open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'w')
+ except FileNotFoundError:
+ os.makedirs(settings.data_dir)
+ f = open(os.path.join(settings.data_dir, 'decrypt_function_cache.json'), 'w')
+
+ f.write(json.dumps({'version': 1, 'decrypt_cache':decrypt_cache}, indent=4, sort_keys=True))
+ f.close()
+
+
+def decrypt_signatures(info, video_id):
+ '''return error string, or False if no errors'''
+ if not yt_data_extract.requires_decryption(info):
+ return False
+ if not info['player_name']:
+ return 'Could not find player name'
+
+ player_name = info['player_name']
+ if player_name in decrypt_cache:
+ print('Using cached decryption function for: ' + player_name)
+ info['decryption_function'] = decrypt_cache[player_name]
+ else:
+ base_js = util.fetch_url(info['base_js'], debug_name='base.js', report_text='Fetched player ' + player_name)
+ base_js = base_js.decode('utf-8')
+ err = yt_data_extract.extract_decryption_function(info, base_js)
+ if err:
+ return err
+ decrypt_cache[player_name] = info['decryption_function']
+ save_decrypt_cache()
+ err = yt_data_extract.decrypt_signatures(info)
+ return err
+
+
+def _add_to_error(info, key, additional_message):
+ if key in info and info[key]:
+ info[key] += additional_message
+ else:
+ info[key] = additional_message
+
+
+def fetch_player_response(client, video_id):
+ return util.call_youtube_api(client, 'player', {
+ 'videoId': video_id,
+ })
+
+
+def fetch_watch_page_info(video_id, playlist_id, index):
+ # bpctr=9999999999 will bypass are-you-sure dialogs for controversial
+ # videos
+ url = 'https://m.youtube.com/embed/' + video_id + '?bpctr=9999999999'
+ if playlist_id:
+ url += '&list=' + playlist_id
+ if index:
+ url += '&index=' + index
+
+ headers = (
+ ('Accept', '*/*'),
+ ('Accept-Language', 'en-US,en;q=0.5'),
+ ('X-YouTube-Client-Name', '2'),
+ ('X-YouTube-Client-Version', '2.20180830'),
+ ) + util.mobile_ua
+
+ watch_page = util.fetch_url(url, headers=headers,
+ debug_name='watch')
+ watch_page = watch_page.decode('utf-8')
+ return yt_data_extract.extract_watch_info_from_html(watch_page)
+
+
+def extract_info(video_id, use_invidious, playlist_id=None, index=None):
+ primary_client = 'android_vr'
+ fallback_client = 'ios'
+ last_resort_client = 'tv_embedded'
+
+ tasks = (
+ # Get video metadata from here
+ gevent.spawn(fetch_watch_page_info, video_id, playlist_id, index),
+ gevent.spawn(fetch_player_response, primary_client, video_id)
+ )
+ gevent.joinall(tasks)
+ util.check_gevent_exceptions(*tasks)
+
+ info = tasks[0].value or {}
+ player_response = tasks[1].value or {}
+
+ yt_data_extract.update_with_new_urls(info, player_response)
+
+ # Fallback to 'ios' if no valid URLs are found
+ if not info.get('formats') or info.get('player_urls_missing'):
+ print(f"No URLs found in '{primary_client}', attempting with '{fallback_client}'.")
+ player_response = fetch_player_response(fallback_client, video_id) or {}
+ yt_data_extract.update_with_new_urls(info, player_response)
+
+ # Final attempt with 'tv_embedded' if there are still no URLs
+ if not info.get('formats') or info.get('player_urls_missing'):
+ print(f"No URLs found in '{fallback_client}', attempting with '{last_resort_client}'")
+ player_response = fetch_player_response(last_resort_client, video_id) or {}
+ yt_data_extract.update_with_new_urls(info, player_response)
+
+ # signature decryption
+ if info.get('formats'):
+ decryption_error = decrypt_signatures(info, video_id)
+ if decryption_error:
+ info['playability_error'] = 'Error decrypting url signatures: ' + decryption_error
+
+ # check if urls ready (non-live format) in former livestream
+ # urls not ready if all of them have no filesize
+ if info['was_live']:
+ info['urls_ready'] = False
+ for fmt in info['formats']:
+ if fmt['file_size'] is not None:
+ info['urls_ready'] = True
+ else:
+ info['urls_ready'] = True
+
+ # livestream urls
+ # sometimes only the livestream urls work soon after the livestream is over
+ info['hls_formats'] = []
+ if info.get('hls_manifest_url') and (info.get('live') or not info.get('formats') or not info['urls_ready']):
+ try:
+ manifest = util.fetch_url(info['hls_manifest_url'],
+ debug_name='hls_manifest.m3u8',
+ report_text='Fetched hls manifest'
+ ).decode('utf-8')
+ info['hls_formats'], err = yt_data_extract.extract_hls_formats(manifest)
+ if not err:
+ info['playability_error'] = None
+ for fmt in info['hls_formats']:
+ fmt['video_quality'] = video_quality_string(fmt)
+ except Exception as e:
+ print(f"Error obteniendo HLS manifest: {e}")
+ info['hls_formats'] = []
+
+ # check for 403. Unnecessary for tor video routing b/c ip address is same
+ info['invidious_used'] = False
+ info['invidious_reload_button'] = False
+ info['tor_bypass_used'] = False
+ if (settings.route_tor == 1
+ and info['formats'] and info['formats'][0]['url']):
+ try:
+ response = util.head(info['formats'][0]['url'],
+ report_text='Checked for URL access')
+ except urllib3.exceptions.HTTPError:
+ print('Error while checking for URL access:\n')
+ traceback.print_exc()
+ return info
+
+ if response.status == 403:
+ print('Access denied (403) for video urls.')
+ print('Routing video through Tor')
+ info['tor_bypass_used'] = True
+ for fmt in info['formats']:
+ fmt['url'] += '&use_tor=1'
+ elif 300 <= response.status < 400:
+ print('Error: exceeded max redirects while checking video URL')
+ return info
+
+
+def video_quality_string(format):
+ if format['vcodec']:
+ result = str(format['width'] or '?') + 'x' + str(format['height'] or '?')
+ if format['fps']:
+ result += ' ' + str(format['fps']) + 'fps'
+ return result
+ elif format['acodec']:
+ return 'audio only'
+
+ return '?'
+
+
+def short_video_quality_string(fmt):
+ result = str(fmt['quality'] or '?') + 'p'
+ if fmt['fps']:
+ result += str(fmt['fps'])
+ if fmt['vcodec'].startswith('av01'):
+ result += ' AV1'
+ elif fmt['vcodec'].startswith('avc'):
+ result += ' h264'
+ else:
+ result += ' ' + fmt['vcodec']
+ return result
+
+
+def audio_quality_string(fmt):
+ if fmt['acodec']:
+ if fmt['audio_bitrate']:
+ result = '%d' % fmt['audio_bitrate'] + 'k'
+ else:
+ result = '?k'
+ if fmt['audio_sample_rate']:
+ result += ' ' + '%.3G' % (fmt['audio_sample_rate']/1000) + 'kHz'
+ return result
+ elif fmt['vcodec']:
+ return 'video only'
+ return '?'
+
+
+# from https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/utils.py
+def format_bytes(bytes):
+ if bytes is None:
+ return 'N/A'
+ if type(bytes) is str:
+ bytes = float(bytes)
+ if bytes == 0.0:
+ exponent = 0
+ else:
+ exponent = int(math.log(bytes, 1024.0))
+ suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
+ converted = float(bytes) / float(1024 ** exponent)
+ return '%.2f%s' % (converted, suffix)
+
+
+@yt_app.route('/ytl-api/storyboard.vtt')
+def get_storyboard_vtt():
+ """
+ See:
+ https://github.com/iv-org/invidious/blob/9a8b81fcbe49ff8d88f197b7f731d6bf79fc8087/src/invidious.cr#L3603
+ https://github.com/iv-org/invidious/blob/3bb7fbb2f119790ee6675076b31cd990f75f64bb/src/invidious/videos.cr#L623
+ """
+
+ spec_url = request.args.get('spec_url')
+ url, *boards = spec_url.split('|')
+ base_url, q = url.split('?')
+ q = parse_qs(q) # for url query
+
+ storyboard = None
+ wanted_height = 90
+
+ for i, board in enumerate(boards):
+ *t, _, sigh = board.split("#")
+ width, height, count, width_cnt, height_cnt, interval = map(int, t)
+ if height != wanted_height: continue
+ q['sigh'] = [sigh]
+ url = f"{base_url}?{urlencode(q, doseq=True)}"
+ storyboard = SimpleNamespace(
+ url = url.replace("$L", str(i)).replace("$N", "M$M"),
+ width = width,
+ height = height,
+ interval = interval,
+ width_cnt = width_cnt,
+ height_cnt = height_cnt,
+ storyboard_count = ceil(count / (width_cnt * height_cnt))
+ )
+
+ if not storyboard:
+ flask.abort(404)
+
+ def to_ts(ms):
+ s, ms = divmod(ms, 1000)
+ h, s = divmod(s, 3600)
+ m, s = divmod(s, 60)
+ return f"{h:02}:{m:02}:{s:02}.{ms:03}"
+ r = "WEBVTT" # result
+ ts = 0 # current timestamp
+ for i in range(storyboard.storyboard_count):
+ url = '/' + storyboard.url.replace("$M", str(i))
+ interval = storyboard.interval
+ w, h = storyboard.width, storyboard.height
+ w_cnt, h_cnt = storyboard.width_cnt, storyboard.height_cnt
+ for j in range(h_cnt):
+ for k in range(w_cnt):
+ r += f"{to_ts(ts)} --> {to_ts(ts+interval)}\n"
+ r += f"{url}#xywh={w * k},{h * j},{w},{h}\n\n"
+ ts += interval
+ return flask.Response(r, mimetype='text/vtt')
+
+
+time_table = {'h': 3600, 'm': 60, 's': 1}
@yt_app.route('/watch')
-def get_watch_page():
- video_id = request.args['v']
+@yt_app.route('/embed')
+@yt_app.route('/embed/<video_id>')
+@yt_app.route('/shorts')
+@yt_app.route('/shorts/<video_id>')
+def get_watch_page(video_id=None):
+ video_id = request.args.get('v') or video_id
+ if not video_id:
+ return flask.render_template('error.html', error_message='Missing video id'), 404
if len(video_id) < 11:
- flask.abort(404)
- flask.abort(flask.Response('Incomplete video id (too short): ' + video_id))
+ return flask.render_template('error.html', error_message='Incomplete video id (too short): ' + video_id), 404
+
+ time_start_str = request.args.get('t', '0s')
+ time_start = 0
+ if re.fullmatch(r'(\d+(h|m|s))+', time_start_str):
+ for match in re.finditer(r'(\d+)(h|m|s)', time_start_str):
+ time_start += int(match.group(1))*time_table[match.group(2)]
+ elif re.fullmatch(r'\d+', time_start_str):
+ time_start = int(time_start_str)
lc = request.args.get('lc', '')
- if settings.route_tor:
- proxy = 'socks5://127.0.0.1:9150/'
+ playlist_id = request.args.get('list')
+ index = request.args.get('index')
+ use_invidious = bool(int(request.args.get('use_invidious', '1')))
+ if request.path.startswith('/embed') and settings.embed_page_mode:
+ tasks = (
+ gevent.spawn((lambda: {})),
+ gevent.spawn(extract_info, video_id, use_invidious,
+ playlist_id=playlist_id, index=index),
+ )
else:
- proxy = ''
- yt_dl_downloader = YoutubeDL(params={'youtube_include_dash_manifest':False, 'proxy':proxy})
- tasks = (
- gevent.spawn(comments.video_comments, video_id, int(settings.default_comment_sorting), lc=lc ),
- gevent.spawn(extract_info, yt_dl_downloader, "https://www.youtube.com/watch?v=" + video_id, download=False)
- )
+ tasks = (
+ gevent.spawn(comments.video_comments, video_id,
+ int(settings.default_comment_sorting), lc=lc),
+ gevent.spawn(extract_info, video_id, use_invidious,
+ playlist_id=playlist_id, index=index),
+ )
gevent.joinall(tasks)
+ util.check_gevent_exceptions(tasks[1])
comments_info, info = tasks[0].value, tasks[1].value
- if isinstance(info, str): # youtube error
- return flask.render_template('error.html', error_message = info)
+ if info['error']:
+ return flask.render_template('error.html', error_message=info['error'])
video_info = {
- "duration": util.seconds_to_timestamp(info["duration"]),
- "id": info['id'],
- "title": info['title'],
- "author": info['uploader'],
+ 'duration': util.seconds_to_timestamp(info['duration'] or 0),
+ 'id': info['id'],
+ 'title': info['title'],
+ 'author': info['author'],
+ 'author_id': info['author_id'],
}
- upload_year = info["upload_date"][0:4]
- upload_month = info["upload_date"][4:6]
- upload_day = info["upload_date"][6:8]
- upload_date = upload_month + "/" + upload_day + "/" + upload_year
-
- if settings.related_videos_mode:
- related_videos = get_related_items(info)
- else:
- related_videos = []
-
-
- if settings.gather_googlevideo_domains:
- with open(os.path.join(settings.data_dir, 'googlevideo-domains.txt'), 'a+', encoding='utf-8') as f:
- url = info['formats'][0]['url']
- subdomain = url[0:url.find(".googlevideo.com")]
- f.write(subdomain + "\n")
-
+ # prefix urls, and other post-processing not handled by yt_data_extract
+ for item in info['related_videos']:
+ item['thumbnail'] = "https://i.ytimg.com/vi/{}/hqdefault.jpg".format(item['id']) # set HQ relateds thumbnail videos
+ util.prefix_urls(item)
+ util.add_extra_html_info(item)
+ for song in info['music_list']:
+ song['url'] = util.prefix_url(song['url'])
+ if info['playlist']:
+ playlist_id = info['playlist']['id']
+ for item in info['playlist']['items']:
+ util.prefix_urls(item)
+ util.add_extra_html_info(item)
+ if playlist_id:
+ item['url'] += '&list=' + playlist_id
+ if item['index']:
+ item['url'] += '&index=' + str(item['index'])
+ info['playlist']['author_url'] = util.prefix_url(
+ info['playlist']['author_url'])
+ if settings.img_prefix:
+ # Don't prefix hls_formats for now because the urls inside the manifest
+ # would need to be prefixed as well.
+ for fmt in info['formats']:
+ fmt['url'] = util.prefix_url(fmt['url'])
+
+ # Add video title to end of url path so it has a filename other than just
+ # "videoplayback" when downloaded
+ title = urllib.parse.quote(util.to_valid_filename(info['title'] or ''))
+ for fmt in info['formats']:
+ filename = title
+ ext = fmt.get('ext')
+ if ext:
+ filename += '.' + ext
+ fmt['url'] = fmt['url'].replace(
+ '/videoplayback',
+ '/videoplayback/name/' + filename)
download_formats = []
- for format in info['formats']:
+ for format in (info['formats'] + info['hls_formats']):
+ if format['acodec'] and format['vcodec']:
+ codecs_string = format['acodec'] + ', ' + format['vcodec']
+ else:
+ codecs_string = format['acodec'] or format['vcodec'] or '?'
download_formats.append({
'url': format['url'],
- 'ext': format['ext'],
- 'resolution': yt_dl_downloader.format_resolution(format),
- 'note': yt_dl_downloader._format_note(format),
+ 'ext': format['ext'] or '?',
+ 'audio_quality': audio_quality_string(format),
+ 'video_quality': video_quality_string(format),
+ 'file_size': format_bytes(format['file_size']),
+ 'codecs': codecs_string,
})
- video_sources = get_video_sources(info)
- video_height = video_sources[0]['height']
+ if (settings.route_tor == 2) or info['tor_bypass_used']:
+ target_resolution = 240
+ else:
+ target_resolution = settings.default_resolution
+
+ source_info = get_video_sources(info, target_resolution)
+ uni_sources = source_info['uni_sources']
+ pair_sources = source_info['pair_sources']
+ uni_idx, pair_idx = source_info['uni_idx'], source_info['pair_idx']
+
+ pair_quality = yt_data_extract.deep_get(pair_sources, pair_idx, 'quality')
+ uni_quality = yt_data_extract.deep_get(uni_sources, uni_idx, 'quality')
+
+ pair_error = abs((pair_quality or 360) - target_resolution)
+ uni_error = abs((uni_quality or 360) - target_resolution)
+ if uni_error == pair_error:
+ # use settings.prefer_uni_sources as a tiebreaker
+ closer_to_target = 'uni' if settings.prefer_uni_sources else 'pair'
+ elif uni_error < pair_error:
+ closer_to_target = 'uni'
+ else:
+ closer_to_target = 'pair'
+
+ if settings.prefer_uni_sources == 2:
+ # Use uni sources unless there's no choice.
+ using_pair_sources = (
+ bool(pair_sources) and (not uni_sources)
+ )
+ else:
+ # Use the pair sources if they're closer to the desired resolution
+ using_pair_sources = (
+ bool(pair_sources)
+ and (not uni_sources or closer_to_target == 'pair')
+ )
+ if using_pair_sources:
+ video_height = pair_sources[pair_idx]['height']
+ video_width = pair_sources[pair_idx]['width']
+ else:
+ video_height = yt_data_extract.deep_get(
+ uni_sources, uni_idx, 'height', default=360
+ )
+ video_width = yt_data_extract.deep_get(
+ uni_sources, uni_idx, 'width', default=640
+ )
+
+
# 1 second per pixel, or the actual video width
- theater_video_target_width = max(640, info['duration'], video_sources[0]['width'])
+ theater_video_target_width = max(640, info['duration'] or 0, video_width)
+
+ # Check for false determination of disabled comments, which comes from
+ # the watch page. But if we got comments in the separate request for those,
+ # then the determination is wrong.
+ if info['comments_disabled'] and comments_info.get('comments'):
+ info['comments_disabled'] = False
+ print('Warning: False determination that comments are disabled')
+ print('Comment count:', info['comment_count'])
+ info['comment_count'] = None # hack to make it obvious there's a bug
+
+ # captions and transcript
+ subtitle_sources = get_subtitle_sources(info)
+ other_downloads = []
+ for source in subtitle_sources:
+ best_caption_parse = urllib.parse.urlparse(
+ source['url'].lstrip('/'))
+ transcript_url = (util.URL_ORIGIN
+ + '/watch/transcript'
+ + best_caption_parse.path
+ + '?' + best_caption_parse.query)
+ other_downloads.append({
+ 'label': 'Video Transcript: ' + source['label'],
+ 'ext': 'txt',
+ 'url': transcript_url
+ })
- return flask.render_template('watch.html',
+ if request.path.startswith('/embed') and settings.embed_page_mode:
+ template_name = 'embed.html'
+ else:
+ template_name = 'watch.html'
+ return flask.render_template(template_name,
header_playlist_names = local_playlist.get_playlist_names(),
- uploader_channel_url = '/' + info['uploader_url'],
- upload_date = upload_date,
- views = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("view_count", None)),
- likes = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("like_count", None)),
- dislikes = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("dislike_count", None)),
+ uploader_channel_url = ('/' + info['author_url']) if info['author_url'] else '',
+ time_published = info['time_published'],
+ view_count = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("view_count", None)),
+ like_count = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("like_count", None)),
+ dislike_count = (lambda x: '{:,}'.format(x) if x is not None else "")(info.get("dislike_count", None)),
download_formats = download_formats,
+ other_downloads = other_downloads,
video_info = json.dumps(video_info),
- video_sources = video_sources,
- subtitle_sources = get_subtitle_sources(info),
- related = related_videos,
+ hls_formats = info['hls_formats'],
+ subtitle_sources = subtitle_sources,
+ related = info['related_videos'],
+ playlist = info['playlist'],
music_list = info['music_list'],
music_attributes = get_ordered_music_list_attributes(info['music_list']),
comments_info = comments_info,
-
- theater_mode = settings.theater_mode,
- related_videos_mode = settings.related_videos_mode,
- comments_mode = settings.comments_mode,
+ comment_count = info['comment_count'],
+ comments_disabled = info['comments_disabled'],
video_height = video_height,
+ video_width = video_width,
theater_video_target_width = theater_video_target_width,
title = info['title'],
- uploader = info['uploader'],
+ uploader = info['author'],
description = info['description'],
unlisted = info['unlisted'],
+ limited_state = info['limited_state'],
+ age_restricted = info['age_restricted'],
+ live = info['live'],
+ playability_error = info['playability_error'],
+
+ allowed_countries = info['allowed_countries'],
+ ip_address = info['ip_address'] if settings.route_tor else None,
+ invidious_used = info['invidious_used'],
+ invidious_reload_button = info['invidious_reload_button'],
+ video_url = util.URL_ORIGIN + '/watch?v=' + video_id,
+ video_id = video_id,
+ storyboard_url = (util.URL_ORIGIN + '/ytl-api/storyboard.vtt?' +
+ urlencode([('spec_url', info['storyboard_spec_url'])])
+ if info['storyboard_spec_url'] else None),
+
+ js_data = {
+ 'video_id': info['id'],
+ 'video_duration': info['duration'],
+ 'settings': settings.current_settings_dict,
+ 'has_manual_captions': any(s.get('on') for s in subtitle_sources),
+ **source_info,
+ 'using_pair_sources': using_pair_sources,
+ 'time_start': time_start,
+ 'playlist': info['playlist'],
+ 'related': info['related_videos'],
+ 'playability_error': info['playability_error'],
+ },
+ font_family = youtube.font_choices[settings.font], # for embed page
+ **source_info,
+ using_pair_sources = using_pair_sources,
)
@@ -245,5 +822,65 @@ def get_captions(dummy):
return result
+times_reg = re.compile(r'^\d\d:\d\d:\d\d\.\d\d\d --> \d\d:\d\d:\d\d\.\d\d\d.*$')
+inner_timestamp_removal_reg = re.compile(r'<[^>]+>')
+@yt_app.route('/watch/transcript/<path:caption_path>')
+def get_transcript(caption_path):
+ try:
+ captions = util.fetch_url('https://www.youtube.com/'
+ + caption_path
+ + '?' + request.environ['QUERY_STRING']).decode('utf-8')
+ except util.FetchError as e:
+ msg = ('Error retrieving captions: ' + str(e) + '\n\n'
+ + 'The caption url may have expired.')
+ print(msg)
+ return flask.Response(
+ msg,
+ status=e.code,
+ mimetype='text/plain;charset=UTF-8')
+
+ lines = captions.splitlines()
+ segments = []
+
+ # skip captions file header
+ i = 0
+ while lines[i] != '':
+ i += 1
+
+ current_segment = None
+ while i < len(lines):
+ line = lines[i]
+ if line == '':
+ if ((current_segment is not None)
+ and (current_segment['begin'] is not None)):
+ segments.append(current_segment)
+ current_segment = {
+ 'begin': None,
+ 'end': None,
+ 'lines': [],
+ }
+ elif times_reg.fullmatch(line.rstrip()):
+ current_segment['begin'], current_segment['end'] = line.split(' --> ')
+ else:
+ current_segment['lines'].append(
+ inner_timestamp_removal_reg.sub('', line))
+ i += 1
+
+ # if automatic captions, but not translated
+ if request.args.get('kind') == 'asr' and not request.args.get('tlang'):
+ # Automatic captions repeat content. The new segment is displayed
+ # on the bottom row; the old one is displayed on the top row.
+ # So grab the bottom row only
+ for seg in segments:
+ seg['text'] = seg['lines'][1]
+ else:
+ for seg in segments:
+ seg['text'] = ' '.join(map(str.rstrip, seg['lines']))
+ result = ''
+ for seg in segments:
+ if seg['text'] != ' ':
+ result += seg['begin'] + ' ' + seg['text'] + '\r\n'
+ return flask.Response(result.encode('utf-8'),
+ mimetype='text/plain;charset=UTF-8')
diff --git a/youtube/yt_data_extract.py b/youtube/yt_data_extract.py
deleted file mode 100644
index 95c68bc..0000000
--- a/youtube/yt_data_extract.py
+++ /dev/null
@@ -1,451 +0,0 @@
-from youtube import util
-
-import html
-import json
-import re
-import urllib
-from math import ceil
-
-# videos (all of type str):
-
-# id
-# title
-# url
-# author
-# author_url
-# thumbnail
-# description
-# published
-# duration
-# likes
-# dislikes
-# views
-# playlist_index
-
-# playlists:
-
-# id
-# title
-# url
-# author
-# author_url
-# thumbnail
-# description
-# updated
-# size
-# first_video_id
-
-
-
-
-
-def get_plain_text(node):
- try:
- return node['simpleText']
- except KeyError:
- return ''.join(text_run['text'] for text_run in node['runs'])
-
-def format_text_runs(runs):
- if isinstance(runs, str):
- return runs
- result = ''
- for text_run in runs:
- if text_run.get("bold", False):
- result += "<b>" + html.escape(text_run["text"]) + "</b>"
- elif text_run.get('italics', False):
- result += "<i>" + html.escape(text_run["text"]) + "</i>"
- else:
- result += html.escape(text_run["text"])
- return result
-
-
-
-
-
-
-
-
-def get_url(node):
- try:
- return node['runs'][0]['navigationEndpoint']['commandMetadata']['webCommandMetadata']['url']
- except KeyError:
- return node['navigationEndpoint']['commandMetadata']['webCommandMetadata']['url']
-
-
-def get_text(node):
- if node == {}:
- return ''
- try:
- return node['simpleText']
- except KeyError:
- pass
- try:
- return node['runs'][0]['text']
- except IndexError: # empty text runs
- return ''
- except KeyError:
- print(node)
- raise
-
-def get_formatted_text(node):
- try:
- return node['runs']
- except KeyError:
- return node['simpleText']
-
-def get_badges(node):
- badges = []
- for badge_node in node:
- badge = badge_node['metadataBadgeRenderer']['label']
- badges.append(badge)
- return badges
-
-def get_thumbnail(node):
- try:
- return node['thumbnails'][0]['url'] # polymer format
- except KeyError:
- return node['url'] # ajax format
-
-dispatch = {
-
-# polymer format
- 'title': ('title', get_text),
- 'publishedTimeText': ('published', get_text),
- 'videoId': ('id', lambda node: node),
- 'descriptionSnippet': ('description', get_formatted_text),
- 'lengthText': ('duration', get_text),
- 'thumbnail': ('thumbnail', get_thumbnail),
- 'thumbnails': ('thumbnail', lambda node: node[0]['thumbnails'][0]['url']),
-
- 'viewCountText': ('views', get_text),
- 'numVideosText': ('size', lambda node: get_text(node).split(' ')[0]), # the format is "324 videos"
- 'videoCountText': ('size', get_text),
- 'playlistId': ('id', lambda node: node),
- 'descriptionText': ('description', get_formatted_text),
-
- 'subscriberCountText': ('subscriber_count', get_text),
- 'channelId': ('id', lambda node: node),
- 'badges': ('badges', get_badges),
-
-# ajax format
- 'view_count_text': ('views', get_text),
- 'num_videos_text': ('size', lambda node: get_text(node).split(' ')[0]),
- 'owner_text': ('author', get_text),
- 'owner_endpoint': ('author_url', lambda node: node['url']),
- 'description': ('description', get_formatted_text),
- 'index': ('playlist_index', get_text),
- 'short_byline': ('author', get_text),
- 'length': ('duration', get_text),
- 'video_id': ('id', lambda node: node),
-
-}
-
-def ajax_info(item_json):
- try:
- info = {}
- for key, node in item_json.items():
- try:
- simple_key, function = dispatch[key]
- except KeyError:
- continue
- info[simple_key] = function(node)
- return info
- except KeyError:
- print(item_json)
- raise
-
-
-youtube_url_re = re.compile(r'^(?:(?:(?:https?:)?//)?(?:www\.)?youtube\.com)?(/.*)$')
-def normalize_url(url):
- match = youtube_url_re.fullmatch(url)
- if match is None:
- raise Exception()
-
- return 'https://www.youtube.com' + match.group(1)
-
-def prefix_urls(item):
- try:
- item['thumbnail'] = util.prefix_url(item['thumbnail'])
- except KeyError:
- pass
-
- try:
- item['author_url'] = util.prefix_url(item['author_url'])
- except KeyError:
- pass
-
-def add_extra_html_info(item):
- if item['type'] == 'video':
- item['url'] = util.URL_ORIGIN + '/watch?v=' + item['id']
-
- video_info = {}
- for key in ('id', 'title', 'author', 'duration'):
- try:
- video_info[key] = item[key]
- except KeyError:
- video_info[key] = ''
-
- item['video_info'] = json.dumps(video_info)
-
- elif item['type'] == 'playlist':
- item['url'] = util.URL_ORIGIN + '/playlist?list=' + item['id']
- elif item['type'] == 'channel':
- item['url'] = util.URL_ORIGIN + "/channel/" + item['id']
-
-
-def renderer_info(renderer, additional_info={}):
- type = list(renderer.keys())[0]
- renderer = renderer[type]
- info = {}
- if type == 'itemSectionRenderer':
- return renderer_info(renderer['contents'][0], additional_info)
-
- if type in ('movieRenderer', 'clarificationRenderer'):
- info['type'] = 'unsupported'
- return info
-
- info.update(additional_info)
-
-
- if type in ('compactVideoRenderer', 'videoRenderer', 'playlistVideoRenderer', 'gridVideoRenderer'):
- info['type'] = 'video'
- elif type in ('playlistRenderer', 'compactPlaylistRenderer', 'gridPlaylistRenderer',
- 'radioRenderer', 'compactRadioRenderer', 'gridRadioRenderer',
- 'showRenderer', 'compactShowRenderer', 'gridShowRenderer'):
- info['type'] = 'playlist'
- elif type == 'channelRenderer':
- info['type'] = 'channel'
- elif type == 'playlistHeaderRenderer':
- info['type'] = 'playlist_metadata'
- else:
- info['type'] = 'unsupported'
- return info
-
- try:
- if 'viewCountText' in renderer: # prefer this one as it contains all the digits
- info['views'] = get_text(renderer['viewCountText'])
- elif 'shortViewCountText' in renderer:
- info['views'] = get_text(renderer['shortViewCountText'])
-
- if 'ownerText' in renderer:
- info['author'] = renderer['ownerText']['runs'][0]['text']
- info['author_url'] = normalize_url(renderer['ownerText']['runs'][0]['navigationEndpoint']['commandMetadata']['webCommandMetadata']['url'])
- try:
- overlays = renderer['thumbnailOverlays']
- except KeyError:
- pass
- else:
- for overlay in overlays:
- if 'thumbnailOverlayTimeStatusRenderer' in overlay:
- info['duration'] = get_text(overlay['thumbnailOverlayTimeStatusRenderer']['text'])
- # show renderers don't have videoCountText
- elif 'thumbnailOverlayBottomPanelRenderer' in overlay:
- info['size'] = get_text(overlay['thumbnailOverlayBottomPanelRenderer']['text'])
-
- # show renderers don't have playlistId, have to dig into the url to get it
- try:
- info['id'] = renderer['navigationEndpoint']['watchEndpoint']['playlistId']
- except KeyError:
- pass
- for key, node in renderer.items():
- if key in ('longBylineText', 'shortBylineText'):
- info['author'] = get_text(node)
- try:
- info['author_url'] = normalize_url(get_url(node))
- except KeyError:
- pass
-
- # show renderers don't have thumbnail key at top level, dig into thumbnailRenderer
- elif key == 'thumbnailRenderer' and 'showCustomThumbnailRenderer' in node:
- info['thumbnail'] = node['showCustomThumbnailRenderer']['thumbnail']['thumbnails'][0]['url']
- else:
- try:
- simple_key, function = dispatch[key]
- except KeyError:
- continue
- info[simple_key] = function(node)
- if info['type'] == 'video' and 'duration' not in info:
- info['duration'] = 'Live'
-
- return info
- except KeyError:
- print(renderer)
- raise
-
-
-def parse_info_prepare_for_html(renderer, additional_info={}):
- item = renderer_info(renderer, additional_info)
- prefix_urls(item)
- add_extra_html_info(item)
-
- return item
-
-
-def extract_channel_info(polymer_json, tab):
- info = {'errors': []}
- response = polymer_json[1]['response']
- try:
- microformat = response['microformat']['microformatDataRenderer']
-
- # channel doesn't exist or was terminated
- # example terminated channel: https://www.youtube.com/channel/UCnKJeK_r90jDdIuzHXC0Org
- except KeyError:
- if 'alerts' in response and len(response['alerts']) > 0:
- for alert in response['alerts']:
- info['errors'].append(alert['alertRenderer']['text']['simpleText'])
- return info
- elif 'errors' in response['responseContext']:
- for error in response['responseContext']['errors']['error']:
- if error['code'] == 'INVALID_VALUE' and error['location'] == 'browse_id':
- info['errors'].append('This channel does not exist')
- return info
- info['errors'].append('Failure getting microformat')
- return info
-
-
- info['current_tab'] = tab
-
-
- # stuff from microformat (info given by youtube for every page on channel)
- info['short_description'] = microformat['description']
- info['channel_name'] = microformat['title']
- info['avatar'] = microformat['thumbnail']['thumbnails'][0]['url']
- channel_url = microformat['urlCanonical'].rstrip('/')
- channel_id = channel_url[channel_url.rfind('/')+1:]
- info['channel_id'] = channel_id
- info['channel_url'] = 'https://www.youtube.com/channel/' + channel_id
-
- info['items'] = []
-
- # empty channel
- if 'contents' not in response and 'continuationContents' not in response:
- return info
-
-
- # find the tab with content
- # example channel where tabs do not have definite index: https://www.youtube.com/channel/UC4gQ8i3FD7YbhOgqUkeQEJg
- # TODO: maybe use the 'selected' attribute for this?
- if 'continuationContents' not in response:
- tab_renderer = None
- tab_content = None
- for tab_json in response['contents']['twoColumnBrowseResultsRenderer']['tabs']:
- try:
- tab_renderer = tab_json['tabRenderer']
- except KeyError:
- tab_renderer = tab_json['expandableTabRenderer']
- try:
- tab_content = tab_renderer['content']
- break
- except KeyError:
- pass
- else: # didn't break
- raise Exception("No tabs found with content")
- assert tab == tab_renderer['title'].lower()
-
-
- # extract tab-specific info
- if tab in ('videos', 'playlists', 'search'): # find the list of items
- if 'continuationContents' in response:
- try:
- items = response['continuationContents']['gridContinuation']['items']
- except KeyError:
- items = response['continuationContents']['sectionListContinuation']['contents'] # for search
- else:
- contents = tab_content['sectionListRenderer']['contents']
- if 'itemSectionRenderer' in contents[0]:
- item_section = contents[0]['itemSectionRenderer']['contents'][0]
- try:
- items = item_section['gridRenderer']['items']
- except KeyError:
- if "messageRenderer" in item_section:
- items = []
- else:
- raise Exception('gridRenderer missing but messageRenderer not found')
- else:
- items = contents # for search
-
- additional_info = {'author': info['channel_name'], 'author_url': 'https://www.youtube.com/channel/' + channel_id}
- info['items'] = [renderer_info(renderer, additional_info) for renderer in items]
-
- elif tab == 'about':
- channel_metadata = tab_content['sectionListRenderer']['contents'][0]['itemSectionRenderer']['contents'][0]['channelAboutFullMetadataRenderer']
-
-
- info['links'] = []
- for link_json in channel_metadata.get('primaryLinks', ()):
- url = link_json['navigationEndpoint']['urlEndpoint']['url']
- if url.startswith('/redirect'): # youtube puts these on external links to do tracking
- query_string = url[url.find('?')+1: ]
- url = urllib.parse.parse_qs(query_string)['q'][0]
-
- text = get_plain_text(link_json['title'])
-
- info['links'].append( (text, url) )
-
-
- info['stats'] = []
- for stat_name in ('subscriberCountText', 'joinedDateText', 'viewCountText', 'country'):
- try:
- stat = channel_metadata[stat_name]
- except KeyError:
- continue
- info['stats'].append(get_plain_text(stat))
-
- if 'description' in channel_metadata:
- info['description'] = get_text(channel_metadata['description'])
- else:
- info['description'] = ''
-
- else:
- raise NotImplementedError('Unknown or unsupported channel tab: ' + tab)
-
- return info
-
-def extract_search_info(polymer_json):
- info = {}
- info['estimated_results'] = int(polymer_json[1]['response']['estimatedResults'])
- info['estimated_pages'] = ceil(info['estimated_results']/20)
-
- # almost always is the first "section", but if there's an advertisement for a google product like Stadia or Home in the search results, then that becomes the first "section" and the search results are in the second. So just join all of them for resiliency
- results = []
- for section in polymer_json[1]['response']['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents']:
- results += section['itemSectionRenderer']['contents']
-
- info['items'] = []
- info['corrections'] = {'type': None}
- for renderer in results:
- type = list(renderer.keys())[0]
- if type == 'shelfRenderer':
- continue
- if type == 'didYouMeanRenderer':
- renderer = renderer[type]
- corrected_query_string = request.args.to_dict(flat=False)
- corrected_query_string['query'] = [renderer['correctedQueryEndpoint']['searchEndpoint']['query']]
- corrected_query_url = util.URL_ORIGIN + '/search?' + urllib.parse.urlencode(corrected_query_string, doseq=True)
-
- info['corrections'] = {
- 'type': 'did_you_mean',
- 'corrected_query': yt_data_extract.format_text_runs(renderer['correctedQuery']['runs']),
- 'corrected_query_url': corrected_query_url,
- }
- continue
- if type == 'showingResultsForRenderer':
- renderer = renderer[type]
- no_autocorrect_query_string = request.args.to_dict(flat=False)
- no_autocorrect_query_string['autocorrect'] = ['0']
- no_autocorrect_query_url = util.URL_ORIGIN + '/search?' + urllib.parse.urlencode(no_autocorrect_query_string, doseq=True)
-
- info['corrections'] = {
- 'type': 'showing_results_for',
- 'corrected_query': yt_data_extract.format_text_runs(renderer['correctedQuery']['runs']),
- 'original_query_url': no_autocorrect_query_url,
- 'original_query': renderer['originalQuery']['simpleText'],
- }
- continue
-
- item_info = renderer_info(renderer)
- if item_info['type'] != 'unsupported':
- info['items'].append(item_info)
-
-
- return info
diff --git a/youtube/yt_data_extract/__init__.py b/youtube/yt_data_extract/__init__.py
new file mode 100644
index 0000000..de1812d
--- /dev/null
+++ b/youtube/yt_data_extract/__init__.py
@@ -0,0 +1,13 @@
+from .common import (get, multi_get, deep_get, multi_deep_get,
+ liberal_update, conservative_update, remove_redirect, normalize_url,
+ extract_str, extract_formatted_text, extract_int, extract_approx_int,
+ extract_date, extract_item_info, extract_items, extract_response)
+
+from .everything_else import (extract_channel_info, extract_search_info,
+ extract_playlist_metadata, extract_playlist_info, extract_comments_info)
+
+from .watch_extraction import (extract_watch_info, get_caption_url,
+ update_with_new_urls, requires_decryption,
+ extract_decryption_function, decrypt_signatures, _formats,
+ update_format_with_type_info, extract_hls_formats,
+ extract_watch_info_from_html, captions_available)
diff --git a/youtube/yt_data_extract/common.py b/youtube/yt_data_extract/common.py
new file mode 100644
index 0000000..7903db5
--- /dev/null
+++ b/youtube/yt_data_extract/common.py
@@ -0,0 +1,610 @@
+import re
+import urllib.parse
+import collections
+import collections.abc
+
+def get(object, key, default=None, types=()):
+ '''Like dict.get(), but returns default if the result doesn't match one of the types.
+ Also works for indexing lists.'''
+ try:
+ result = object[key]
+ except (TypeError, IndexError, KeyError):
+ return default
+
+ if not types or isinstance(result, types):
+ return result
+ else:
+ return default
+
+def multi_get(object, *keys, default=None, types=()):
+ '''Like get, but try other keys if the first fails'''
+ for key in keys:
+ try:
+ result = object[key]
+ except (TypeError, IndexError, KeyError):
+ pass
+ else:
+ if not types or isinstance(result, types):
+ return result
+ else:
+ continue
+ return default
+
+
+def deep_get(object, *keys, default=None, types=()):
+ '''Like dict.get(), but for nested dictionaries/sequences, supporting keys or indices.
+ Last argument is the default value to use in case of any IndexErrors or KeyErrors.
+ If types is given and the result doesn't match one of those types, default is returned'''
+ try:
+ for key in keys:
+ object = object[key]
+ except (TypeError, IndexError, KeyError):
+ return default
+ else:
+ if not types or isinstance(object, types):
+ return object
+ else:
+ return default
+
+def multi_deep_get(object, *key_sequences, default=None, types=()):
+ '''Like deep_get, but can try different key sequences in case one fails.
+ Return default if all of them fail. key_sequences is a list of lists'''
+ for key_sequence in key_sequences:
+ _object = object
+ try:
+ for key in key_sequence:
+ _object = _object[key]
+ except (TypeError, IndexError, KeyError):
+ pass
+ else:
+ if not types or isinstance(_object, types):
+ return _object
+ else:
+ continue
+ return default
+
+
+def _is_empty(value):
+ '''Determines if value is None or an empty iterable, such as '' and []'''
+ if value is None:
+ return True
+ elif isinstance(value, collections.abc.Iterable) and not value:
+ return True
+ return False
+
+
+def liberal_update(obj, key, value):
+ '''Updates obj[key] with value as long as value is not None or empty.
+ Ensures obj[key] will at least get an empty value, however'''
+ if (not _is_empty(value)) or (key not in obj):
+ obj[key] = value
+
+def conservative_update(obj, key, value):
+ '''Only updates obj if it doesn't have key or obj[key] is None/empty'''
+ if _is_empty(obj.get(key)):
+ obj[key] = value
+
+
+def liberal_dict_update(dict1, dict2):
+ '''Update dict1 with keys from dict2 using liberal_update'''
+ for key, value in dict2.items():
+ liberal_update(dict1, key, value)
+
+
+def conservative_dict_update(dict1, dict2):
+ '''Update dict1 with keys from dict2 using conservative_update'''
+ for key, value in dict2.items():
+ conservative_update(dict1, key, value)
+
+
+def concat_or_none(*strings):
+ '''Concatenates strings. Returns None if any of the arguments are None'''
+ result = ''
+ for string in strings:
+ if string is None:
+ return None
+ result += string
+ return result
+
+def remove_redirect(url):
+ if url is None:
+ return None
+ if re.fullmatch(r'(((https?:)?//)?(www.)?youtube.com)?/redirect\?.*', url) is not None: # YouTube puts these on external links to do tracking
+ query_string = url[url.find('?')+1: ]
+ return urllib.parse.parse_qs(query_string)['q'][0]
+ return url
+
+norm_url_re = re.compile(r'^(?:(?:https?:)?//)?((?:[\w-]+\.)+[\w-]+)?(/.*)$')
+def normalize_url(url):
+ '''Insert https, resolve relative paths for youtube.com, and put www. infront of youtube.com'''
+ if url is None:
+ return None
+ match = norm_url_re.fullmatch(url)
+ if match is None:
+ raise Exception(url)
+
+ domain = match.group(1) or 'www.youtube.com'
+ if domain == 'youtube.com':
+ domain = 'www.youtube.com'
+
+ return 'https://' + domain + match.group(2)
+
+def _recover_urls(runs):
+ for run in runs:
+ url = deep_get(run, 'navigationEndpoint', 'urlEndpoint', 'url')
+ text = run.get('text', '')
+ # second condition is necessary because YouTube makes other things into urls, such as hashtags, which we want to keep as text
+ if url is not None and (text.startswith('http://') or text.startswith('https://')):
+ url = remove_redirect(url)
+ run['url'] = url
+ run['text'] = url # YouTube truncates the url text, use actual url instead
+
+def extract_str(node, default=None, recover_urls=False):
+ '''default is the value returned if the extraction fails. If recover_urls is true, will attempt to fix YouTube's truncation of url text (most prominently seen in descriptions)'''
+ if isinstance(node, str):
+ return node
+
+ try:
+ return node['simpleText']
+ except (KeyError, TypeError):
+ pass
+
+ if isinstance(node, dict) and 'runs' in node:
+ if recover_urls:
+ _recover_urls(node['runs'])
+ return ''.join(text_run.get('text', '') for text_run in node['runs'])
+
+ return default
+
+def extract_formatted_text(node):
+ if not node:
+ return []
+ if 'runs' in node:
+ _recover_urls(node['runs'])
+ return node['runs']
+ elif 'simpleText' in node:
+ return [{'text': node['simpleText']}]
+ return []
+
+def extract_int(string, default=None, whole_word=True):
+ if isinstance(string, int):
+ return string
+ if not isinstance(string, str):
+ string = extract_str(string)
+ if not string:
+ return default
+ if whole_word:
+ match = re.search(r'\b(\d+)\b', string.replace(',', ''))
+ else:
+ match = re.search(r'(\d+)', string.replace(',', ''))
+ if match is None:
+ return default
+ try:
+ return int(match.group(1))
+ except ValueError:
+ return default
+
+def extract_approx_int(string):
+ '''e.g. "15.1M" from "15.1M subscribers" or '4,353' from 4353'''
+ if not isinstance(string, str):
+ string = extract_str(string)
+ if not string:
+ return None
+ match = re.search(r'\b(\d+(?:\.\d+)?[KMBTkmbt]?)\b', string.replace(',', ''))
+ if match is None:
+ return None
+ result = match.group(1)
+ if re.fullmatch(r'\d+', result):
+ result = '{:,}'.format(int(result))
+ return result
+
+MONTH_ABBREVIATIONS = {'jan':'1', 'feb':'2', 'mar':'3', 'apr':'4', 'may':'5', 'jun':'6', 'jul':'7', 'aug':'8', 'sep':'9', 'oct':'10', 'nov':'11', 'dec':'12'}
+def extract_date(date_text):
+ '''Input: "Mar 9, 2019". Output: "2019-3-9"'''
+ if not isinstance(date_text, str):
+ date_text = extract_str(date_text)
+ if date_text is None:
+ return None
+
+ date_text = date_text.replace(',', '').lower()
+ parts = date_text.split()
+ if len(parts) >= 3:
+ month, day, year = parts[-3:]
+ month = MONTH_ABBREVIATIONS.get(month[0:3]) # slicing in case they start writing out the full month name
+ if month and (re.fullmatch(r'\d\d?', day) is not None) and (re.fullmatch(r'\d{4}', year) is not None):
+ return year + '-' + month + '-' + day
+ return None
+
+def check_missing_keys(object, *key_sequences):
+ for key_sequence in key_sequences:
+ _object = object
+ try:
+ for key in key_sequence:
+ _object = _object[key]
+ except (KeyError, IndexError, TypeError):
+ return 'Could not find ' + key
+
+ return None
+
+def extract_item_info(item, additional_info={}):
+ if not item:
+ return {'error': 'No item given'}
+
+ type = get(list(item.keys()), 0)
+ if not type:
+ return {'error': 'Could not find type'}
+ item = item[type]
+
+ info = {'error': None}
+ if type in ('itemSectionRenderer', 'compactAutoplayRenderer'):
+ return extract_item_info(deep_get(item, 'contents', 0), additional_info)
+
+ if type in ('movieRenderer', 'clarificationRenderer'):
+ info['type'] = 'unsupported'
+ return info
+
+ # type looks like e.g. 'compactVideoRenderer' or 'gridVideoRenderer'
+ # camelCase split, https://stackoverflow.com/a/37697078
+ type_parts = [s.lower() for s in re.sub(r'([A-Z][a-z]+)', r' \1', type).split()]
+ if len(type_parts) < 2:
+ info['type'] = 'unsupported'
+ return
+ primary_type = type_parts[-2]
+ if primary_type == 'video':
+ info['type'] = 'video'
+ elif type_parts[0] == 'reel': # shorts
+ info['type'] = 'video'
+ primary_type = 'video'
+ elif primary_type in ('playlist', 'radio', 'show'):
+ info['type'] = 'playlist'
+ info['playlist_type'] = primary_type
+ elif primary_type == 'channel':
+ info['type'] = 'channel'
+ elif type == 'videoWithContextRenderer': # stupid exception
+ info['type'] = 'video'
+ primary_type = 'video'
+ else:
+ info['type'] = 'unsupported'
+
+ # videoWithContextRenderer changes it to 'headline' just to be annoying
+ info['title'] = extract_str(multi_get(item, 'title', 'headline'))
+ if primary_type != 'channel':
+ info['author'] = extract_str(multi_get(item, 'longBylineText', 'shortBylineText', 'ownerText'))
+ info['author_id'] = extract_str(multi_deep_get(item,
+ ['longBylineText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId'],
+ ['shortBylineText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId'],
+ ['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId']
+ ))
+ info['author_url'] = ('https://www.youtube.com/channel/' + info['author_id']) if info['author_id'] else None
+ info['description'] = extract_formatted_text(multi_deep_get(
+ item,
+ ['descriptionText'], ['descriptionSnippet'],
+ ['detailedMetadataSnippets', 0, 'snippetText'],
+ ))
+ info['thumbnail'] = normalize_url(multi_deep_get(item,
+ ['thumbnail', 'thumbnails', 0, 'url'], # videos
+ ['thumbnails', 0, 'thumbnails', 0, 'url'], # playlists
+ ['thumbnailRenderer', 'showCustomThumbnailRenderer', 'thumbnail', 'thumbnails', 0, 'url'], # shows
+ ))
+
+ info['badges'] = []
+ for badge_node in multi_get(item, 'badges', 'ownerBadges', default=()):
+ badge = deep_get(badge_node, 'metadataBadgeRenderer', 'label')
+ if badge:
+ info['badges'].append(badge)
+
+ if primary_type in ('video', 'playlist'):
+ info['time_published'] = None
+ timestamp = re.search(r'(\d+ \w+ ago)',
+ extract_str(item.get('publishedTimeText'), default=''))
+ if timestamp:
+ info['time_published'] = timestamp.group(1)
+
+ if primary_type == 'video':
+ info['id'] = multi_deep_get(item,
+ ['videoId'],
+ ['navigationEndpoint', 'watchEndpoint', 'videoId'],
+ ['navigationEndpoint', 'reelWatchEndpoint', 'videoId'] # shorts
+ )
+ info['view_count'] = extract_int(item.get('viewCountText'))
+
+ # dig into accessibility data to get view_count for videos marked as recommended, and to get time_published
+ accessibility_label = multi_deep_get(item,
+ ['title', 'accessibility', 'accessibilityData', 'label'],
+ ['headline', 'accessibility', 'accessibilityData', 'label'],
+ default='')
+ timestamp = re.search(r'(\d+ \w+ ago)', accessibility_label)
+ if timestamp:
+ conservative_update(info, 'time_published', timestamp.group(1))
+ view_count = re.search(r'(\d+) views', accessibility_label.replace(',', ''))
+ if view_count:
+ conservative_update(info, 'view_count', int(view_count.group(1)))
+
+ if info['view_count']:
+ info['approx_view_count'] = '{:,}'.format(info['view_count'])
+ else:
+ info['approx_view_count'] = extract_approx_int(multi_get(item,
+ 'shortViewCountText',
+ 'viewCountText' # shorts
+ ))
+
+ # handle case where it is "No views"
+ if not info['approx_view_count']:
+ if ('No views' in item.get('shortViewCountText', '')
+ or 'no views' in accessibility_label.lower()
+ or 'No views' in extract_str(item.get('viewCountText', '')) # shorts
+ ):
+ info['view_count'] = 0
+ info['approx_view_count'] = '0'
+
+ info['duration'] = extract_str(item.get('lengthText'))
+
+ # dig into accessibility data to get duration for shorts
+ accessibility_label = deep_get(item,
+ 'accessibility', 'accessibilityData', 'label',
+ default='')
+ duration = re.search(r'(\d+) (second|seconds|minute) - play video$',
+ accessibility_label)
+ if duration:
+ if duration.group(2) == 'minute':
+ conservative_update(info, 'duration', '1:00')
+ else:
+ conservative_update(info,
+ 'duration', '0:' + duration.group(1).zfill(2))
+
+ # if it's an item in a playlist, get its index
+ if 'index' in item: # url has wrong index on playlist page
+ info['index'] = extract_int(item.get('index'))
+ elif 'indexText' in item:
+ # Current item in playlist has ▶ instead of the actual index, must
+ # dig into url
+ match = re.search(r'index=(\d+)', deep_get(item,
+ 'navigationEndpoint', 'commandMetadata', 'webCommandMetadata',
+ 'url', default=''))
+ if match is None: # worth a try then
+ info['index'] = extract_int(item.get('indexText'))
+ else:
+ info['index'] = int(match.group(1))
+ else:
+ info['index'] = None
+
+ elif primary_type in ('playlist', 'radio'):
+ info['id'] = item.get('playlistId')
+ info['video_count'] = extract_int(item.get('videoCount'))
+ info['first_video_id'] = deep_get(item, 'navigationEndpoint',
+ 'watchEndpoint', 'videoId')
+ elif primary_type == 'channel':
+ info['id'] = item.get('channelId')
+ info['approx_subscriber_count'] = extract_approx_int(item.get('subscriberCountText'))
+ elif primary_type == 'show':
+ info['id'] = deep_get(item, 'navigationEndpoint', 'watchEndpoint', 'playlistId')
+ info['first_video_id'] = deep_get(item, 'navigationEndpoint',
+ 'watchEndpoint', 'videoId')
+
+ if primary_type in ('playlist', 'channel'):
+ conservative_update(info, 'video_count', extract_int(item.get('videoCountText')))
+
+ for overlay in item.get('thumbnailOverlays', []):
+ conservative_update(info, 'duration', extract_str(deep_get(
+ overlay, 'thumbnailOverlayTimeStatusRenderer', 'text'
+ )))
+ # show renderers don't have videoCountText
+ conservative_update(info, 'video_count', extract_int(deep_get(
+ overlay, 'thumbnailOverlayBottomPanelRenderer', 'text'
+ )))
+
+ info.update(additional_info)
+
+ return info
+
+def extract_response(polymer_json):
+ '''return response, error'''
+ # /youtubei/v1/browse endpoint returns response directly
+ if isinstance(polymer_json, dict) and 'responseContext' in polymer_json:
+ # this is the response
+ return polymer_json, None
+
+ response = multi_deep_get(polymer_json, [1, 'response'], ['response'])
+ if response is None:
+ return None, 'Failed to extract response'
+ else:
+ return response, None
+
+
+_item_types = {
+ 'movieRenderer',
+ 'didYouMeanRenderer',
+ 'showingResultsForRenderer',
+
+ 'videoRenderer',
+ 'compactVideoRenderer',
+ 'compactAutoplayRenderer',
+ 'videoWithContextRenderer',
+ 'gridVideoRenderer',
+ 'playlistVideoRenderer',
+
+ 'reelItemRenderer',
+
+ 'playlistRenderer',
+ 'compactPlaylistRenderer',
+ 'gridPlaylistRenderer',
+
+ 'radioRenderer',
+ 'compactRadioRenderer',
+ 'gridRadioRenderer',
+
+ 'showRenderer',
+ 'compactShowRenderer',
+ 'gridShowRenderer',
+
+
+ 'channelRenderer',
+ 'compactChannelRenderer',
+ 'gridChannelRenderer',
+}
+
+def _traverse_browse_renderer(renderer):
+ for tab in get(renderer, 'tabs', ()):
+ tab_renderer = multi_get(tab, 'tabRenderer', 'expandableTabRenderer')
+ if tab_renderer is None:
+ continue
+ if tab_renderer.get('selected', False):
+ return get(tab_renderer, 'content', {})
+ print('Could not find tab with content')
+ return {}
+
+def _traverse_standard_list(renderer):
+ renderer_list = multi_get(renderer, 'contents', 'items', default=())
+ continuation = deep_get(renderer, 'continuations', 0, 'nextContinuationData', 'continuation')
+ return renderer_list, continuation
+
+# these renderers contain one inside them
+nested_renderer_dispatch = {
+ 'singleColumnBrowseResultsRenderer': _traverse_browse_renderer,
+ 'twoColumnBrowseResultsRenderer': _traverse_browse_renderer,
+ 'twoColumnSearchResultsRenderer': lambda r: get(r, 'primaryContents', {}),
+ 'richItemRenderer': lambda r: get(r, 'content', {}),
+ 'engagementPanelSectionListRenderer': lambda r: get(r, 'content', {}),
+}
+
+# these renderers contain a list of renderers inside them
+nested_renderer_list_dispatch = {
+ 'sectionListRenderer': _traverse_standard_list,
+ 'itemSectionRenderer': _traverse_standard_list,
+ 'gridRenderer': _traverse_standard_list,
+ 'richGridRenderer': _traverse_standard_list,
+ 'playlistVideoListRenderer': _traverse_standard_list,
+ 'structuredDescriptionContentRenderer': _traverse_standard_list,
+ 'slimVideoMetadataSectionRenderer': _traverse_standard_list,
+ 'singleColumnWatchNextResults': lambda r: (deep_get(r, 'results', 'results', 'contents', default=[]), None),
+}
+def get_nested_renderer_list_function(key):
+ if key in nested_renderer_list_dispatch:
+ return nested_renderer_list_dispatch[key]
+ elif key.endswith('Continuation'):
+ return _traverse_standard_list
+ return None
+
+def extract_items_from_renderer(renderer, item_types=_item_types):
+ ctoken = None
+ items = []
+
+ iter_stack = collections.deque()
+ current_iter = iter(())
+
+ while True:
+ # mode 1: get a new renderer by iterating.
+ # goes down the stack for an iterator if one has been exhausted
+ if not renderer:
+ try:
+ renderer = current_iter.__next__()
+ except StopIteration:
+ try:
+ current_iter = iter_stack.pop()
+ except IndexError:
+ return items, ctoken
+ # Get new renderer or check that the one we got is good before
+ # proceeding to mode 2
+ continue
+
+
+ # mode 2: dig into the current renderer
+ key, value = list(renderer.items())[0]
+
+ # the renderer is an item
+ if key in item_types:
+ items.append(renderer)
+
+ # ctoken sometimes placed in these renderers, e.g. channel playlists
+ elif key == 'continuationItemRenderer':
+ cont = deep_get(
+ value, 'continuationEndpoint', 'continuationCommand', 'token'
+ )
+ if cont:
+ ctoken = cont
+
+ # has a list in it, add it to the iter stack
+ elif get_nested_renderer_list_function(key):
+ renderer_list, cont = get_nested_renderer_list_function(key)(value)
+ if renderer_list:
+ iter_stack.append(current_iter)
+ current_iter = iter(renderer_list)
+ if cont:
+ ctoken = cont
+
+ # new renderer nested inside this one
+ elif key in nested_renderer_dispatch:
+ renderer = nested_renderer_dispatch[key](value)
+ continue # don't reset renderer to None
+
+ renderer = None
+
+
+def extract_items_from_renderer_list(renderers, item_types=_item_types):
+ '''Same as extract_items_from_renderer, but provide a list of renderers'''
+ items = []
+ ctoken = None
+ for renderer in renderers:
+ new_items, new_ctoken = extract_items_from_renderer(
+ renderer,
+ item_types=item_types)
+ items += new_items
+ # prioritize ctoken associated with items
+ if (not ctoken) or (new_ctoken and new_items):
+ ctoken = new_ctoken
+ return items, ctoken
+
+
+def extract_items(response, item_types=_item_types,
+ search_engagement_panels=False):
+ '''return items, ctoken'''
+ items = []
+ ctoken = None
+ if 'continuationContents' in response:
+ # sometimes there's another, empty, junk [something]Continuation key
+ # find real one
+ for key, renderer_cont in get(response,
+ 'continuationContents', {}).items():
+ # e.g. commentSectionContinuation, playlistVideoListContinuation
+ if key.endswith('Continuation'):
+ items, ctoken = extract_items_from_renderer(
+ {key: renderer_cont},
+ item_types=item_types)
+ if items:
+ break
+ if ('onResponseReceivedEndpoints' in response
+ or 'onResponseReceivedActions' in response):
+ for endpoint in multi_get(response,
+ 'onResponseReceivedEndpoints',
+ 'onResponseReceivedActions',
+ []):
+ new_items, new_ctoken = extract_items_from_renderer_list(
+ multi_deep_get(
+ endpoint,
+ ['reloadContinuationItemsCommand', 'continuationItems'],
+ ['appendContinuationItemsAction', 'continuationItems'],
+ default=[]
+ ),
+ item_types=item_types,
+ )
+ items += new_items
+ if (not ctoken) or (new_ctoken and new_items):
+ ctoken = new_ctoken
+ if 'contents' in response:
+ renderer = get(response, 'contents', {})
+ new_items, new_ctoken = extract_items_from_renderer(
+ renderer,
+ item_types=item_types)
+ items += new_items
+ if (not ctoken) or (new_ctoken and new_items):
+ ctoken = new_ctoken
+
+ if search_engagement_panels and 'engagementPanels' in response:
+ new_items, new_ctoken = extract_items_from_renderer_list(
+ response['engagementPanels'], item_types=item_types
+ )
+ items += new_items
+ if (not ctoken) or (new_ctoken and new_items):
+ ctoken = new_ctoken
+
+ return items, ctoken
diff --git a/youtube/yt_data_extract/everything_else.py b/youtube/yt_data_extract/everything_else.py
new file mode 100644
index 0000000..0f64649
--- /dev/null
+++ b/youtube/yt_data_extract/everything_else.py
@@ -0,0 +1,372 @@
+from .common import (get, multi_get, deep_get, multi_deep_get,
+ liberal_update, conservative_update, remove_redirect, normalize_url,
+ extract_str, extract_formatted_text, extract_int, extract_approx_int,
+ extract_date, check_missing_keys, extract_item_info, extract_items,
+ extract_response)
+from youtube import proto
+
+import re
+import urllib
+from math import ceil
+
+def extract_channel_info(polymer_json, tab, continuation=False):
+ response, err = extract_response(polymer_json)
+ if err:
+ return {'error': err}
+
+
+ metadata = deep_get(response, 'metadata', 'channelMetadataRenderer',
+ default={})
+ if not metadata:
+ metadata = deep_get(response, 'microformat', 'microformatDataRenderer',
+ default={})
+
+ # channel doesn't exist or was terminated
+ # example terminated channel: https://www.youtube.com/channel/UCnKJeK_r90jDdIuzHXC0Org
+ # metadata and microformat are not present for continuation requests
+ if not metadata and not continuation:
+ if response.get('alerts'):
+ error_string = ' '.join(
+ extract_str(deep_get(alert, 'alertRenderer', 'text'), default='')
+ for alert in response['alerts']
+ )
+ if not error_string:
+ error_string = 'Failed to extract error'
+ return {'error': error_string}
+ elif deep_get(response, 'responseContext', 'errors'):
+ for error in response['responseContext']['errors'].get('error', []):
+ if error.get('code') == 'INVALID_VALUE' and error.get('location') == 'browse_id':
+ return {'error': 'This channel does not exist'}
+ return {'error': 'Failure getting metadata'}
+
+ info = {'error': None}
+ info['current_tab'] = tab
+
+ info['approx_subscriber_count'] = extract_approx_int(deep_get(response,
+ 'header', 'c4TabbedHeaderRenderer', 'subscriberCountText'))
+
+ # stuff from microformat (info given by youtube for first page on channel)
+ info['short_description'] = metadata.get('description')
+ if info['short_description'] and len(info['short_description']) > 730:
+ info['short_description'] = info['short_description'][0:730] + '...'
+ info['channel_name'] = metadata.get('title')
+ info['avatar'] = normalize_url(multi_deep_get(metadata,
+ ['avatar', 'thumbnails', 0, 'url'],
+ ['thumbnail', 'thumbnails', 0, 'url'],
+ ))
+ channel_url = multi_get(metadata, 'urlCanonical', 'channelUrl')
+ if channel_url:
+ channel_id = get(channel_url.rstrip('/').split('/'), -1)
+ info['channel_id'] = channel_id
+ else:
+ info['channel_id'] = metadata.get('externalId')
+ if info['channel_id']:
+ info['channel_url'] = 'https://www.youtube.com/channel/' + channel_id
+ else:
+ info['channel_url'] = None
+
+ # get items
+ info['items'] = []
+ info['ctoken'] = None
+
+ # empty channel
+ #if 'contents' not in response and 'continuationContents' not in response:
+ # return info
+
+ if tab in ('videos', 'shorts', 'streams', 'playlists', 'search'):
+ items, ctoken = extract_items(response)
+ additional_info = {
+ 'author': info['channel_name'],
+ 'author_id': info['channel_id'],
+ 'author_url': info['channel_url'],
+ }
+ info['items'] = [extract_item_info(renderer, additional_info) for renderer in items]
+ info['ctoken'] = ctoken
+ if tab in ('search', 'playlists'):
+ info['is_last_page'] = (ctoken is None)
+ elif tab == 'about':
+ # Latest type
+ items, _ = extract_items(response, item_types={'aboutChannelRenderer'})
+ if items:
+ a_metadata = deep_get(items, 0, 'aboutChannelRenderer',
+ 'metadata', 'aboutChannelViewModel')
+ if not a_metadata:
+ info['error'] = 'Could not find aboutChannelViewModel'
+ return info
+
+ info['links'] = []
+ for link_outer in a_metadata.get('links', ()):
+ link = link_outer.get('channelExternalLinkViewModel') or {}
+ link_content = extract_str(deep_get(link, 'link', 'content'))
+ for run in deep_get(link, 'link', 'commandRuns') or ():
+ url = remove_redirect(deep_get(run, 'onTap',
+ 'innertubeCommand', 'urlEndpoint', 'url'))
+ if url and not (url.startswith('http://')
+ or url.startswith('https://')):
+ url = 'https://' + url
+ if link_content is None or (link_content in url):
+ break
+ else: # didn't break
+ url = link_content
+ if url and not (url.startswith('http://')
+ or url.startswith('https://')):
+ url = 'https://' + url
+ text = extract_str(deep_get(link, 'title', 'content'))
+ info['links'].append( (text, url) )
+
+ info['date_joined'] = extract_date(
+ a_metadata.get('joinedDateText')
+ )
+ info['view_count'] = extract_int(a_metadata.get('viewCountText'))
+ info['approx_view_count'] = extract_approx_int(
+ a_metadata.get('viewCountText')
+ )
+ info['description'] = extract_str(
+ a_metadata.get('description'), default=''
+ )
+ info['approx_video_count'] = extract_approx_int(
+ a_metadata.get('videoCountText')
+ )
+ info['approx_subscriber_count'] = extract_approx_int(
+ a_metadata.get('subscriberCountText')
+ )
+ info['country'] = extract_str(a_metadata.get('country'))
+ info['canonical_url'] = extract_str(
+ a_metadata.get('canonicalChannelUrl')
+ )
+
+ # Old type
+ else:
+ items, _ = extract_items(response,
+ item_types={'channelAboutFullMetadataRenderer'})
+ if not items:
+ info['error'] = 'Could not find aboutChannelRenderer or channelAboutFullMetadataRenderer'
+ return info
+ a_metadata = items[0]['channelAboutFullMetadataRenderer']
+
+ info['links'] = []
+ for link_json in a_metadata.get('primaryLinks', ()):
+ url = remove_redirect(deep_get(link_json, 'navigationEndpoint',
+ 'urlEndpoint', 'url'))
+ if url and not (url.startswith('http://')
+ or url.startswith('https://')):
+ url = 'https://' + url
+ text = extract_str(link_json.get('title'))
+ info['links'].append( (text, url) )
+
+ info['date_joined'] = extract_date(a_metadata.get('joinedDateText'))
+ info['view_count'] = extract_int(a_metadata.get('viewCountText'))
+ info['description'] = extract_str(a_metadata.get(
+ 'description'), default='')
+
+ info['approx_video_count'] = None
+ info['approx_subscriber_count'] = None
+ info['country'] = None
+ info['canonical_url'] = None
+ else:
+ raise NotImplementedError('Unknown or unsupported channel tab: ' + tab)
+
+ return info
+
+def extract_search_info(polymer_json):
+ response, err = extract_response(polymer_json)
+ if err:
+ return {'error': err}
+ info = {'error': None}
+ info['estimated_results'] = int(response['estimatedResults'])
+ info['estimated_pages'] = ceil(info['estimated_results']/20)
+
+
+ results, _ = extract_items(response)
+
+
+ info['items'] = []
+ info['corrections'] = {'type': None}
+ for renderer in results:
+ type = list(renderer.keys())[0]
+ if type == 'shelfRenderer':
+ continue
+ if type == 'didYouMeanRenderer':
+ renderer = renderer[type]
+
+ info['corrections'] = {
+ 'type': 'did_you_mean',
+ 'corrected_query': renderer['correctedQueryEndpoint']['searchEndpoint']['query'],
+ 'corrected_query_text': renderer['correctedQuery']['runs'],
+ }
+ continue
+ if type == 'showingResultsForRenderer':
+ renderer = renderer[type]
+
+ info['corrections'] = {
+ 'type': 'showing_results_for',
+ 'corrected_query_text': renderer['correctedQuery']['runs'],
+ 'original_query_text': renderer['originalQuery']['simpleText'],
+ }
+ continue
+
+ i_info = extract_item_info(renderer)
+ if i_info.get('type') != 'unsupported':
+ info['items'].append(i_info)
+
+
+ return info
+
+def extract_playlist_metadata(polymer_json):
+ response, err = extract_response(polymer_json)
+ if err:
+ return {'error': err}
+
+ metadata = {'error': None}
+ header = deep_get(response, 'header', 'playlistHeaderRenderer', default={})
+ metadata['title'] = extract_str(header.get('title'))
+
+ metadata['first_video_id'] = deep_get(header, 'playEndpoint', 'watchEndpoint', 'videoId')
+ first_id = re.search(r'([a-z_\-]{11})', deep_get(header,
+ 'thumbnail', 'thumbnails', 0, 'url', default=''))
+ if first_id:
+ conservative_update(metadata, 'first_video_id', first_id.group(1))
+ if metadata['first_video_id'] is None:
+ metadata['thumbnail'] = None
+ else:
+ metadata['thumbnail'] = f"https://i.ytimg.com/vi/{metadata['first_video_id']}/hqdefault.jpg"
+
+ metadata['video_count'] = extract_int(header.get('numVideosText'))
+ metadata['description'] = extract_str(header.get('descriptionText'), default='')
+ metadata['author'] = extract_str(header.get('ownerText'))
+ metadata['author_id'] = multi_deep_get(header,
+ ['ownerText', 'runs', 0, 'navigationEndpoint', 'browseEndpoint', 'browseId'],
+ ['ownerEndpoint', 'browseEndpoint', 'browseId'])
+ if metadata['author_id']:
+ metadata['author_url'] = 'https://www.youtube.com/channel/' + metadata['author_id']
+ else:
+ metadata['author_url'] = None
+ metadata['view_count'] = extract_int(header.get('viewCountText'))
+ metadata['like_count'] = extract_int(header.get('likesCountWithoutLikeText'))
+ for stat in header.get('stats', ()):
+ text = extract_str(stat)
+ if 'videos' in text:
+ conservative_update(metadata, 'video_count', extract_int(text))
+ elif 'views' in text:
+ conservative_update(metadata, 'view_count', extract_int(text))
+ elif 'updated' in text:
+ metadata['time_published'] = extract_date(text)
+
+ microformat = deep_get(response, 'microformat', 'microformatDataRenderer',
+ default={})
+ conservative_update(
+ metadata, 'title', extract_str(microformat.get('title'))
+ )
+ conservative_update(
+ metadata, 'description', extract_str(microformat.get('description'))
+ )
+ conservative_update(
+ metadata, 'thumbnail', deep_get(microformat, 'thumbnail',
+ 'thumbnails', -1, 'url')
+ )
+
+ return metadata
+
+def extract_playlist_info(polymer_json):
+ response, err = extract_response(polymer_json)
+ if err:
+ return {'error': err}
+ info = {'error': None}
+ video_list, _ = extract_items(response)
+
+ info['items'] = [extract_item_info(renderer) for renderer in video_list]
+
+ info['metadata'] = extract_playlist_metadata(polymer_json)
+
+ return info
+
+def _ctoken_metadata(ctoken):
+ result = dict()
+ params = proto.parse(proto.b64_to_bytes(ctoken))
+ result['video_id'] = proto.parse(params[2])[2].decode('ascii')
+
+ offset_information = proto.parse(params[6])
+ result['offset'] = offset_information.get(5, 0)
+
+ result['is_replies'] = False
+ if (3 in offset_information) and (2 in proto.parse(offset_information[3])):
+ result['is_replies'] = True
+ result['sort'] = None
+ else:
+ try:
+ result['sort'] = proto.parse(offset_information[4])[6]
+ except KeyError:
+ result['sort'] = 0
+ return result
+
+def extract_comments_info(polymer_json, ctoken=None):
+ response, err = extract_response(polymer_json)
+ if err:
+ return {'error': err}
+ info = {'error': None}
+
+ if ctoken:
+ metadata = _ctoken_metadata(ctoken)
+ else:
+ metadata = {}
+ info['video_id'] = metadata.get('video_id')
+ info['offset'] = metadata.get('offset')
+ info['is_replies'] = metadata.get('is_replies')
+ info['sort'] = metadata.get('sort')
+ info['video_title'] = None
+
+ comments, ctoken = extract_items(response,
+ item_types={'commentThreadRenderer', 'commentRenderer'})
+ info['comments'] = []
+ info['ctoken'] = ctoken
+ for comment in comments:
+ comment_info = {}
+
+ if 'commentThreadRenderer' in comment: # top level comments
+ conservative_update(info, 'is_replies', False)
+ comment_thread = comment['commentThreadRenderer']
+ info['video_title'] = extract_str(comment_thread.get('commentTargetTitle'))
+ if 'replies' not in comment_thread:
+ comment_info['reply_count'] = 0
+ comment_info['reply_ctoken'] = None
+ else:
+ comment_info['reply_count'] = extract_int(deep_get(comment_thread,
+ 'replies', 'commentRepliesRenderer', 'moreText'
+ ), default=1) # With 1 reply, the text reads "View reply"
+ comment_info['reply_ctoken'] = multi_deep_get(
+ comment_thread,
+ ['replies', 'commentRepliesRenderer', 'contents', 0,
+ 'continuationItemRenderer', 'button', 'buttonRenderer',
+ 'command', 'continuationCommand', 'token'],
+ ['replies', 'commentRepliesRenderer', 'continuations', 0,
+ 'nextContinuationData', 'continuation']
+ )
+ comment_renderer = deep_get(comment_thread, 'comment', 'commentRenderer', default={})
+ elif 'commentRenderer' in comment: # replies
+ comment_info['reply_count'] = 0 # replyCount, below, not present for replies even if the reply has further replies to it
+ comment_info['reply_ctoken'] = None
+ conservative_update(info, 'is_replies', True)
+ comment_renderer = comment['commentRenderer']
+ else:
+ comment_renderer = {}
+
+ # These 3 are sometimes absent, likely because the channel was deleted
+ comment_info['author'] = extract_str(comment_renderer.get('authorText'))
+ comment_info['author_url'] = normalize_url(deep_get(comment_renderer,
+ 'authorEndpoint', 'commandMetadata', 'webCommandMetadata', 'url'))
+ comment_info['author_id'] = deep_get(comment_renderer,
+ 'authorEndpoint', 'browseEndpoint', 'browseId')
+
+ comment_info['author_avatar'] = normalize_url(deep_get(
+ comment_renderer, 'authorThumbnail', 'thumbnails', 0, 'url'))
+ comment_info['id'] = comment_renderer.get('commentId')
+ comment_info['text'] = extract_formatted_text(comment_renderer.get('contentText'))
+ comment_info['time_published'] = extract_str(comment_renderer.get('publishedTimeText'))
+ comment_info['like_count'] = comment_renderer.get('likeCount')
+ comment_info['approx_like_count'] = extract_approx_int(
+ comment_renderer.get('voteCount'))
+ liberal_update(comment_info, 'reply_count', comment_renderer.get('replyCount'))
+
+ info['comments'].append(comment_info)
+
+ return info
diff --git a/youtube/yt_data_extract/watch_extraction.py b/youtube/yt_data_extract/watch_extraction.py
new file mode 100644
index 0000000..e09e2d3
--- /dev/null
+++ b/youtube/yt_data_extract/watch_extraction.py
@@ -0,0 +1,948 @@
+from .common import (get, multi_get, deep_get, multi_deep_get,
+ liberal_update, conservative_update, remove_redirect, normalize_url,
+ extract_str, extract_formatted_text, extract_int, extract_approx_int,
+ extract_date, check_missing_keys, extract_item_info, extract_items,
+ extract_response, concat_or_none, liberal_dict_update,
+ conservative_dict_update)
+
+import json
+import urllib.parse
+import traceback
+import re
+
+# from https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/extractor/youtube.py
+_formats = {
+ '5': {'ext': 'flv', 'width': 400, 'height': 240, 'acodec': 'mp3', 'audio_bitrate': 64, 'vcodec': 'h263'},
+ '6': {'ext': 'flv', 'width': 450, 'height': 270, 'acodec': 'mp3', 'audio_bitrate': 64, 'vcodec': 'h263'},
+ '13': {'ext': '3gp', 'acodec': 'aac', 'vcodec': 'mp4v'},
+ '17': {'ext': '3gp', 'width': 176, 'height': 144, 'acodec': 'aac', 'audio_bitrate': 24, 'vcodec': 'mp4v'},
+ '18': {'ext': 'mp4', 'width': 640, 'height': 360, 'acodec': 'aac', 'audio_bitrate': 96, 'vcodec': 'h264'},
+ '22': {'ext': 'mp4', 'width': 1280, 'height': 720, 'acodec': 'aac', 'audio_bitrate': 192, 'vcodec': 'h264'},
+ '34': {'ext': 'flv', 'width': 640, 'height': 360, 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ '35': {'ext': 'flv', 'width': 854, 'height': 480, 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ # itag 36 videos are either 320x180 (BaW_jenozKc) or 320x240 (__2ABJjxzNo), audio_bitrate varies as well
+ '36': {'ext': '3gp', 'width': 320, 'acodec': 'aac', 'vcodec': 'mp4v'},
+ '37': {'ext': 'mp4', 'width': 1920, 'height': 1080, 'acodec': 'aac', 'audio_bitrate': 192, 'vcodec': 'h264'},
+ '38': {'ext': 'mp4', 'width': 4096, 'height': 3072, 'acodec': 'aac', 'audio_bitrate': 192, 'vcodec': 'h264'},
+ '43': {'ext': 'webm', 'width': 640, 'height': 360, 'acodec': 'vorbis', 'audio_bitrate': 128, 'vcodec': 'vp8'},
+ '44': {'ext': 'webm', 'width': 854, 'height': 480, 'acodec': 'vorbis', 'audio_bitrate': 128, 'vcodec': 'vp8'},
+ '45': {'ext': 'webm', 'width': 1280, 'height': 720, 'acodec': 'vorbis', 'audio_bitrate': 192, 'vcodec': 'vp8'},
+ '46': {'ext': 'webm', 'width': 1920, 'height': 1080, 'acodec': 'vorbis', 'audio_bitrate': 192, 'vcodec': 'vp8'},
+ '59': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ '78': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+
+
+ # 3D videos
+ '82': {'ext': 'mp4', 'height': 360, 'format_note': '3D', 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ '83': {'ext': 'mp4', 'height': 480, 'format_note': '3D', 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ '84': {'ext': 'mp4', 'height': 720, 'format_note': '3D', 'acodec': 'aac', 'audio_bitrate': 192, 'vcodec': 'h264'},
+ '85': {'ext': 'mp4', 'height': 1080, 'format_note': '3D', 'acodec': 'aac', 'audio_bitrate': 192, 'vcodec': 'h264'},
+ '100': {'ext': 'webm', 'height': 360, 'format_note': '3D', 'acodec': 'vorbis', 'audio_bitrate': 128, 'vcodec': 'vp8'},
+ '101': {'ext': 'webm', 'height': 480, 'format_note': '3D', 'acodec': 'vorbis', 'audio_bitrate': 192, 'vcodec': 'vp8'},
+ '102': {'ext': 'webm', 'height': 720, 'format_note': '3D', 'acodec': 'vorbis', 'audio_bitrate': 192, 'vcodec': 'vp8'},
+
+ # Apple HTTP Live Streaming
+ '91': {'ext': 'mp4', 'height': 144, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 48, 'vcodec': 'h264'},
+ '92': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 48, 'vcodec': 'h264'},
+ '93': {'ext': 'mp4', 'height': 360, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ '94': {'ext': 'mp4', 'height': 480, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 128, 'vcodec': 'h264'},
+ '95': {'ext': 'mp4', 'height': 720, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 256, 'vcodec': 'h264'},
+ '96': {'ext': 'mp4', 'height': 1080, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 256, 'vcodec': 'h264'},
+ '132': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 48, 'vcodec': 'h264'},
+ '151': {'ext': 'mp4', 'height': 72, 'format_note': 'HLS', 'acodec': 'aac', 'audio_bitrate': 24, 'vcodec': 'h264'},
+
+ # DASH mp4 video
+ '133': {'ext': 'mp4', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '134': {'ext': 'mp4', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '135': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '136': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '137': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '138': {'ext': 'mp4', 'format_note': 'DASH video', 'vcodec': 'h264'}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
+ '160': {'ext': 'mp4', 'height': 144, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '212': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '264': {'ext': 'mp4', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'h264'},
+ '298': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
+ '299': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
+ '266': {'ext': 'mp4', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'h264'},
+
+ # Dash mp4 audio
+ '139': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'audio_bitrate': 48, 'container': 'm4a_dash'},
+ '140': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'audio_bitrate': 128, 'container': 'm4a_dash'},
+ '141': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'audio_bitrate': 256, 'container': 'm4a_dash'},
+ '256': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
+ '258': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
+ '325': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'dtse', 'container': 'm4a_dash'},
+ '328': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'ec-3', 'container': 'm4a_dash'},
+
+ # Dash webm
+ '167': {'ext': 'webm', 'height': 360, 'width': 640, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
+ '168': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
+ '169': {'ext': 'webm', 'height': 720, 'width': 1280, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
+ '170': {'ext': 'webm', 'height': 1080, 'width': 1920, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
+ '218': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
+ '219': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
+ '278': {'ext': 'webm', 'height': 144, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp9'},
+ '242': {'ext': 'webm', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '243': {'ext': 'webm', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '244': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '245': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '246': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '247': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '248': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '271': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug)
+ '272': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '302': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
+ '303': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
+ '308': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
+ '313': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
+ '315': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
+
+ # Dash webm audio
+ '171': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'audio_bitrate': 128},
+ '172': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'audio_bitrate': 256},
+
+ # Dash webm audio with opus inside
+ '249': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'audio_bitrate': 50},
+ '250': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'audio_bitrate': 70},
+ '251': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'audio_bitrate': 160},
+
+ # RTMP (unnamed)
+ '_rtmp': {'protocol': 'rtmp'},
+
+ # av01 video only formats sometimes served with "unknown" codecs
+ '394': {'vcodec': 'av01.0.05M.08'},
+ '395': {'vcodec': 'av01.0.05M.08'},
+ '396': {'vcodec': 'av01.0.05M.08'},
+ '397': {'vcodec': 'av01.0.05M.08'},
+}
+
+
+def _extract_from_video_information_renderer(renderer_content):
+ subtitle = extract_str(renderer_content.get('expandedSubtitle'),
+ default='')
+ info = {
+ 'title': extract_str(renderer_content.get('title')),
+ 'view_count': extract_int(subtitle),
+ 'unlisted': False,
+ 'live': 'watching' in subtitle,
+ }
+ for badge in renderer_content.get('badges', []):
+ if deep_get(badge, 'metadataBadgeRenderer', 'label') == 'Unlisted':
+ info['unlisted'] = True
+ return info
+
+def _extract_likes_dislikes(renderer_content):
+ def extract_button_count(toggle_button_renderer):
+ # all the digits can be found in the accessibility data
+ count = extract_int(multi_deep_get(
+ toggle_button_renderer,
+ ['defaultText', 'accessibility', 'accessibilityData', 'label'],
+ ['accessibility', 'label'],
+ ['accessibilityData', 'accessibilityData', 'label'],
+ ['accessibilityText'],
+ ))
+
+ # this count doesn't have all the digits, it's like 53K for instance
+ dumb_count = extract_int(extract_str(multi_get(
+ toggle_button_renderer, ['defaultText', 'title'])))
+
+ # The accessibility text will be "No likes" or "No dislikes" or
+ # something like that, but dumb count will be 0
+ if dumb_count == 0:
+ count = 0
+ return count
+
+ info = {
+ 'like_count': None,
+ 'dislike_count': None,
+ }
+ for button in renderer_content.get('buttons', ()):
+ if 'slimMetadataToggleButtonRenderer' in button:
+ button_renderer = button['slimMetadataToggleButtonRenderer']
+ count = extract_button_count(deep_get(button_renderer,
+ 'button',
+ 'toggleButtonRenderer'))
+ if 'isLike' in button_renderer:
+ info['like_count'] = count
+ elif 'isDislike' in button_renderer:
+ info['dislike_count'] = count
+ elif 'slimMetadataButtonRenderer' in button:
+ button_renderer = button['slimMetadataButtonRenderer']
+ liberal_update(info, 'like_count', extract_button_count(
+ multi_deep_get(button_renderer,
+ ['button', 'segmentedLikeDislikeButtonRenderer',
+ 'likeButton', 'toggleButtonRenderer'],
+ ['button', 'segmentedLikeDislikeButtonViewModel',
+ 'likeButtonViewModel', 'likeButtonViewModel',
+ 'toggleButtonViewModel', 'toggleButtonViewModel',
+ 'defaultButtonViewModel', 'buttonViewModel']
+ )
+ ))
+ '''liberal_update(info, 'dislike_count', extract_button_count(
+ deep_get(
+ button_renderer, 'button',
+ 'segmentedLikeDislikeButtonRenderer',
+ 'dislikeButton', 'toggleButtonRenderer'
+ )
+ ))'''
+ return info
+
+def _extract_from_owner_renderer(renderer_content):
+ return {
+ 'author': extract_str(renderer_content.get('title')),
+ 'author_id': deep_get(
+ renderer_content,
+ 'navigationEndpoint', 'browseEndpoint', 'browseId'),
+ }
+
+def _extract_from_video_header_renderer(renderer_content):
+ return {
+ 'title': extract_str(renderer_content.get('title')),
+ 'time_published': extract_date(extract_str(
+ renderer_content.get('publishDate'))),
+ }
+
+def _extract_from_description_renderer(renderer_content):
+ return {
+ 'description': extract_str(
+ renderer_content.get('descriptionBodyText'), recover_urls=True),
+ }
+
+def _extract_metadata_row_info(renderer_content):
+ # extract category and music list
+ info = {
+ 'category': None,
+ 'music_list': [],
+ }
+
+ current_song = {}
+ for row in deep_get(renderer_content, 'rows', default=[]):
+ row_title = extract_str(deep_get(row, 'metadataRowRenderer', 'title'), default='')
+ row_content = extract_str(deep_get(row, 'metadataRowRenderer', 'contents', 0))
+ if row_title == 'Category':
+ info['category'] = row_content
+ elif row_title in ('Song', 'Music'):
+ if current_song:
+ info['music_list'].append(current_song)
+ current_song = {'title': row_content}
+ elif row_title == 'Artist':
+ current_song['artist'] = row_content
+ elif row_title == 'Album':
+ current_song['album'] = row_content
+ elif row_title == 'Writers':
+ current_song['writers'] = row_content
+ elif row_title.startswith('Licensed'):
+ current_song['licensor'] = row_content
+ if current_song:
+ info['music_list'].append(current_song)
+
+ return info
+
+def _extract_from_music_renderer(renderer_content):
+ # latest format for the music list
+ info = {
+ 'music_list': [],
+ }
+
+ for carousel in renderer_content.get('carouselLockups', []):
+ song = {}
+ carousel = carousel.get('carouselLockupRenderer', {})
+ video_renderer = carousel.get('videoLockup', {})
+ video_renderer_info = extract_item_info(video_renderer)
+ video_id = video_renderer_info.get('id')
+ song['url'] = concat_or_none('https://www.youtube.com/watch?v=',
+ video_id)
+ song['title'] = video_renderer_info.get('title')
+ for row in carousel.get('infoRows', []):
+ row = row.get('infoRowRenderer', {})
+ title = extract_str(row.get('title'))
+ data = extract_str(row.get('defaultMetadata'))
+ if title == 'SONG':
+ song['title'] = data
+ elif title == 'ARTIST':
+ song['artist'] = data
+ elif title == 'ALBUM':
+ song['album'] = data
+ elif title == 'WRITERS':
+ song['writers'] = data
+ info['music_list'].append(song)
+ return info
+
+def _extract_from_video_metadata(renderer_content):
+ info = _extract_from_video_information_renderer(renderer_content)
+ liberal_dict_update(info, _extract_likes_dislikes(renderer_content))
+ liberal_dict_update(info, _extract_from_owner_renderer(renderer_content))
+ liberal_dict_update(info, _extract_metadata_row_info(deep_get(
+ renderer_content, 'metadataRowContainer',
+ 'metadataRowContainerRenderer', default={}
+ )))
+ liberal_update(info, 'title', extract_str(renderer_content.get('title')))
+ liberal_update(
+ info, 'description',
+ extract_str(renderer_content.get('description'), recover_urls=True)
+ )
+ liberal_update(info, 'time_published',
+ extract_date(renderer_content.get('dateText')))
+ return info
+
+visible_extraction_dispatch = {
+ # Either these ones spread around in various places
+ 'slimVideoInformationRenderer': _extract_from_video_information_renderer,
+ 'slimVideoActionBarRenderer': _extract_likes_dislikes,
+ 'slimOwnerRenderer': _extract_from_owner_renderer,
+ 'videoDescriptionHeaderRenderer': _extract_from_video_header_renderer,
+ 'videoDescriptionMusicSectionRenderer': _extract_from_music_renderer,
+ 'expandableVideoDescriptionRenderer': _extract_from_description_renderer,
+ 'metadataRowContainerRenderer': _extract_metadata_row_info,
+ # OR just this one, which contains SOME of the above inside it
+ 'slimVideoMetadataRenderer': _extract_from_video_metadata,
+}
+
+def _extract_watch_info_mobile(top_level):
+ '''Scrapes information from the visible page'''
+ info = {}
+ response = top_level.get('response', {})
+
+ # this renderer has the stuff visible on the page
+ # check for playlist
+ items, _ = extract_items(response,
+ item_types={'singleColumnWatchNextResults'})
+ if items:
+ watch_next_results = items[0]['singleColumnWatchNextResults']
+ playlist = deep_get(watch_next_results, 'playlist', 'playlist')
+ if playlist is None:
+ info['playlist'] = None
+ else:
+ info['playlist'] = {}
+ info['playlist']['title'] = playlist.get('title')
+ info['playlist']['author'] = extract_str(multi_get(playlist,
+ 'ownerName', 'longBylineText', 'shortBylineText', 'ownerText'))
+ author_id = deep_get(playlist, 'longBylineText', 'runs', 0,
+ 'navigationEndpoint', 'browseEndpoint', 'browseId')
+ info['playlist']['author_id'] = author_id
+ info['playlist']['author_url'] = concat_or_none(
+ 'https://www.youtube.com/channel/', author_id)
+ info['playlist']['id'] = playlist.get('playlistId')
+ info['playlist']['url'] = concat_or_none(
+ 'https://www.youtube.com/playlist?list=',
+ info['playlist']['id'])
+ info['playlist']['video_count'] = playlist.get('totalVideos')
+ info['playlist']['current_index'] = playlist.get('currentIndex')
+ info['playlist']['items'] = [
+ extract_item_info(i) for i in playlist.get('contents', ())]
+ else:
+ info['playlist'] = None
+
+ # use dispatch table to get information scattered in various renderers
+ items, _ = extract_items(
+ response,
+ item_types=visible_extraction_dispatch.keys(),
+ search_engagement_panels=True
+ )
+ found = set()
+ for renderer in items:
+ name, renderer_content = list(renderer.items())[0]
+ found.add(name)
+ liberal_dict_update(
+ info,
+ visible_extraction_dispatch[name](renderer_content)
+ )
+ # Call the function on blank dict for any that weren't found
+ # so that the empty keys get added
+ for name in visible_extraction_dispatch.keys() - found:
+ liberal_dict_update(info, visible_extraction_dispatch[name]({}))
+
+ # comment section info
+ items, _ = extract_items(response, item_types={
+ 'commentSectionRenderer', 'commentsEntryPointHeaderRenderer'})
+ if items:
+ header_type = list(items[0])[0]
+ comment_info = items[0][header_type]
+ # This seems to be some kind of A/B test being done on mobile, where
+ # this is present instead of the normal commentSectionRenderer. It can
+ # be seen here:
+ # https://www.androidpolice.com/2019/10/31/google-youtube-app-comment-section-below-videos/
+ # https://www.youtube.com/watch?v=bR5Q-wD-6qo
+ if header_type == 'commentsEntryPointHeaderRenderer':
+ comment_count_text = extract_str(multi_get(
+ comment_info, 'commentCount', 'headerText'))
+ else:
+ comment_count_text = extract_str(deep_get(comment_info,
+ 'header', 'commentSectionHeaderRenderer', 'countText'))
+ if comment_count_text == 'Comments': # just this with no number, means 0 comments
+ info['comment_count'] = '0'
+ else:
+ info['comment_count'] = extract_approx_int(comment_count_text)
+ info['comments_disabled'] = False
+ else: # no comment section present means comments are disabled
+ info['comment_count'] = '0'
+ info['comments_disabled'] = True
+
+ # check for limited state
+ items, _ = extract_items(response, item_types={'limitedStateMessageRenderer'})
+ if items:
+ info['limited_state'] = True
+ else:
+ info['limited_state'] = False
+
+ # related videos
+ related, _ = extract_items(response)
+ info['related_videos'] = [extract_item_info(renderer) for renderer in related]
+
+ return info
+
+def _extract_watch_info_desktop(top_level):
+ info = {
+ 'comment_count': None,
+ 'comments_disabled': None,
+ 'limited_state': None,
+ 'playlist': None,
+ }
+
+ video_info = {}
+ for renderer in deep_get(top_level, 'response', 'contents', 'twoColumnWatchNextResults', 'results', 'results', 'contents', default=()):
+ if renderer and list(renderer.keys())[0] in ('videoPrimaryInfoRenderer', 'videoSecondaryInfoRenderer'):
+ video_info.update(list(renderer.values())[0])
+
+ info.update(_extract_metadata_row_info(video_info))
+ info['description'] = extract_str(video_info.get('description', None), recover_urls=True)
+ info['time_published'] = extract_date(extract_str(video_info.get('dateText', None)))
+
+ likes_dislikes = deep_get(video_info, 'sentimentBar', 'sentimentBarRenderer', 'tooltip', default='').split('/')
+ if len(likes_dislikes) == 2:
+ info['like_count'] = extract_int(likes_dislikes[0])
+ info['dislike_count'] = extract_int(likes_dislikes[1])
+ else:
+ info['like_count'] = None
+ info['dislike_count'] = None
+
+ info['title'] = extract_str(video_info.get('title', None))
+ info['author'] = extract_str(deep_get(video_info, 'owner', 'videoOwnerRenderer', 'title'))
+ info['author_id'] = deep_get(video_info, 'owner', 'videoOwnerRenderer', 'navigationEndpoint', 'browseEndpoint', 'browseId')
+ info['view_count'] = extract_int(extract_str(deep_get(video_info, 'viewCount', 'videoViewCountRenderer', 'viewCount')))
+
+ related = deep_get(top_level, 'response', 'contents', 'twoColumnWatchNextResults', 'secondaryResults', 'secondaryResults', 'results', default=[])
+ info['related_videos'] = [extract_item_info(renderer) for renderer in related]
+
+ return info
+
+def update_format_with_codec_info(fmt, codec):
+ if any(codec.startswith(c) for c in ('av', 'vp', 'h263', 'h264', 'mp4v')):
+ if codec == 'vp8.0':
+ codec = 'vp8'
+ conservative_update(fmt, 'vcodec', codec)
+ elif (codec.startswith('mp4a')
+ or codec in ('opus', 'mp3', 'aac', 'dtse', 'ec-3', 'vorbis',
+ 'ac-3')):
+ conservative_update(fmt, 'acodec', codec)
+ else:
+ print('Warning: unrecognized codec: ' + codec)
+
+fmt_type_re = re.compile(
+ r'(text|audio|video)/([\w0-9]+); codecs="([^"]+)"')
+def update_format_with_type_info(fmt, yt_fmt):
+ # 'type' for invidious api format
+ mime_type = multi_get(yt_fmt, 'mimeType', 'type')
+ if mime_type is None:
+ return
+ match = re.fullmatch(fmt_type_re, mime_type)
+ if match is None:
+ print('Warning: Could not read mimetype', mime_type)
+ return
+ type, fmt['ext'], codecs = match.groups()
+ codecs = codecs.split(', ')
+ for codec in codecs:
+ update_format_with_codec_info(fmt, codec)
+ if type == 'audio':
+ assert len(codecs) == 1
+
+def _extract_formats(info, player_response):
+ streaming_data = player_response.get('streamingData', {})
+ yt_formats = streaming_data.get('formats', []) + streaming_data.get('adaptiveFormats', [])
+
+ info['formats'] = []
+ # because we may retry the extract_formats with a different player_response
+ # so keep what we have
+ conservative_update(info, 'hls_manifest_url',
+ streaming_data.get('hlsManifestUrl'))
+ conservative_update(info, 'dash_manifest_url',
+ streaming_data.get('dash_manifest_url'))
+
+ for yt_fmt in yt_formats:
+ itag = yt_fmt.get('itag')
+
+ # Translated audio track
+ # Example: https://www.youtube.com/watch?v=gF9kkB0UWYQ
+ # Only get the original language for now so a foreign
+ # translation will not be picked just because it comes first
+ if deep_get(yt_fmt, 'audioTrack', 'audioIsDefault') is False:
+ continue
+
+ fmt = {}
+ fmt['itag'] = itag
+ fmt['ext'] = None
+ fmt['audio_bitrate'] = None
+ fmt['bitrate'] = yt_fmt.get('bitrate')
+ fmt['acodec'] = None
+ fmt['vcodec'] = None
+ fmt['width'] = yt_fmt.get('width')
+ fmt['height'] = yt_fmt.get('height')
+ fmt['file_size'] = extract_int(yt_fmt.get('contentLength'))
+ fmt['audio_sample_rate'] = extract_int(yt_fmt.get('audioSampleRate'))
+ fmt['duration_ms'] = yt_fmt.get('approxDurationMs')
+ fmt['fps'] = yt_fmt.get('fps')
+ fmt['init_range'] = yt_fmt.get('initRange')
+ fmt['index_range'] = yt_fmt.get('indexRange')
+ for key in ('init_range', 'index_range'):
+ if fmt[key]:
+ fmt[key]['start'] = int(fmt[key]['start'])
+ fmt[key]['end'] = int(fmt[key]['end'])
+ update_format_with_type_info(fmt, yt_fmt)
+ cipher = dict(urllib.parse.parse_qsl(multi_get(yt_fmt,
+ 'cipher', 'signatureCipher', default='')))
+ if cipher:
+ fmt['url'] = cipher.get('url')
+ else:
+ fmt['url'] = yt_fmt.get('url')
+ fmt['s'] = cipher.get('s')
+ fmt['sp'] = cipher.get('sp')
+
+ # update with information from big table
+ hardcoded_itag_info = _formats.get(str(itag), {})
+ for key, value in hardcoded_itag_info.items():
+ conservative_update(fmt, key, value) # prefer info from YouTube
+ fmt['quality'] = hardcoded_itag_info.get('height')
+ conservative_update(
+ fmt, 'quality',
+ extract_int(yt_fmt.get('quality'), whole_word=False)
+ )
+ conservative_update(
+ fmt, 'quality',
+ extract_int(yt_fmt.get('qualityLabel'), whole_word=False)
+ )
+
+ info['formats'].append(fmt)
+
+ # get ip address
+ if info['formats']:
+ query_string = (info['formats'][0].get('url') or '?').split('?')[1]
+ info['ip_address'] = deep_get(
+ urllib.parse.parse_qs(query_string), 'ip', 0)
+ else:
+ info['ip_address'] = None
+
+hls_regex = re.compile(r'[\w_-]+=(?:"[^"]+"|[^",]+),')
+def extract_hls_formats(hls_manifest):
+ '''returns hls_formats, err'''
+ hls_formats = []
+ try:
+ lines = hls_manifest.splitlines()
+ i = 0
+ while i < len(lines):
+ if lines[i].startswith('#EXT-X-STREAM-INF'):
+ fmt = {'acodec': None, 'vcodec': None, 'height': None,
+ 'width': None, 'fps': None, 'audio_bitrate': None,
+ 'itag': None, 'file_size': None, 'duration_ms': None,
+ 'audio_sample_rate': None, 'url': None}
+ properties = lines[i].split(':')[1]
+ properties += ',' # make regex work for last key-value pair
+
+ for pair in hls_regex.findall(properties):
+ key, value = pair.rstrip(',').split('=')
+ if key == 'CODECS':
+ for codec in value.strip('"').split(','):
+ update_format_with_codec_info(fmt, codec)
+ elif key == 'RESOLUTION':
+ fmt['width'], fmt['height'] = map(int, value.split('x'))
+ fmt['resolution'] = value
+ elif key == 'FRAME-RATE':
+ fmt['fps'] = int(value)
+ i += 1
+ fmt['url'] = lines[i]
+ assert fmt['url'].startswith('http')
+ fmt['ext'] = 'm3u8'
+ hls_formats.append(fmt)
+ i += 1
+ except Exception as e:
+ traceback.print_exc()
+ return [], str(e)
+ return hls_formats, None
+
+
+def _extract_playability_error(info, player_response, error_prefix=''):
+ if info['formats']:
+ info['playability_status'] = None
+ info['playability_error'] = None
+ return
+
+ playability_status = deep_get(player_response, 'playabilityStatus', 'status', default=None)
+ info['playability_status'] = playability_status
+
+ playability_reason = extract_str(multi_deep_get(player_response,
+ ['playabilityStatus', 'reason'],
+ ['playabilityStatus', 'errorScreen', 'playerErrorMessageRenderer', 'reason'],
+ default='Could not find playability error')
+ )
+
+ if playability_status not in (None, 'OK'):
+ info['playability_error'] = error_prefix + playability_reason
+ elif not info['playability_error']: # do not override
+ info['playability_error'] = error_prefix + 'Unknown playability error'
+
+SUBTITLE_FORMATS = ('srv1', 'srv2', 'srv3', 'ttml', 'vtt')
+def extract_watch_info(polymer_json):
+ info = {'playability_error': None, 'error': None,
+ 'player_response_missing': None}
+
+ if isinstance(polymer_json, dict):
+ top_level = polymer_json
+ elif isinstance(polymer_json, (list, tuple)):
+ top_level = {}
+ for page_part in polymer_json:
+ if not isinstance(page_part, dict):
+ return {'error': 'Invalid page part'}
+ top_level.update(page_part)
+ else:
+ return {'error': 'Invalid top level polymer data'}
+
+ error = check_missing_keys(top_level,
+ ['player', 'args'],
+ ['player', 'assets', 'js'],
+ ['playerResponse'],
+ )
+ if error:
+ info['playability_error'] = error
+
+ player_response = top_level.get('playerResponse', {})
+
+ # usually, only the embedded one has the urls
+ player_args = deep_get(top_level, 'player', 'args', default={})
+ if 'player_response' in player_args:
+ embedded_player_response = json.loads(player_args['player_response'])
+ else:
+ embedded_player_response = {}
+
+ # captions
+ info['automatic_caption_languages'] = []
+ info['manual_caption_languages'] = []
+ info['_manual_caption_language_names'] = {} # language name written in that language, needed in some cases to create the url
+ info['translation_languages'] = []
+ captions_info = player_response.get('captions', {})
+ info['_captions_base_url'] = normalize_url(deep_get(captions_info, 'playerCaptionsRenderer', 'baseUrl'))
+ # Sometimes the above playerCaptionsRender is randomly missing
+ # Extract base_url from one of the captions by removing lang specifiers
+ if not info['_captions_base_url']:
+ base_url = normalize_url(deep_get(
+ captions_info,
+ 'playerCaptionsTracklistRenderer',
+ 'captionTracks',
+ 0,
+ 'baseUrl'
+ ))
+ if base_url:
+ url_parts = urllib.parse.urlparse(base_url)
+ qs = urllib.parse.parse_qs(url_parts.query)
+ for key in ('tlang', 'lang', 'name', 'kind', 'fmt'):
+ if key in qs:
+ del qs[key]
+ base_url = urllib.parse.urlunparse(url_parts._replace(
+ query=urllib.parse.urlencode(qs, doseq=True)))
+ info['_captions_base_url'] = base_url
+ for caption_track in deep_get(captions_info, 'playerCaptionsTracklistRenderer', 'captionTracks', default=()):
+ lang_code = caption_track.get('languageCode')
+ if not lang_code:
+ continue
+ if caption_track.get('kind') == 'asr':
+ info['automatic_caption_languages'].append(lang_code)
+ else:
+ info['manual_caption_languages'].append(lang_code)
+ base_url = caption_track.get('baseUrl', '')
+ lang_name = deep_get(urllib.parse.parse_qs(urllib.parse.urlparse(base_url).query), 'name', 0)
+ if lang_name:
+ info['_manual_caption_language_names'][lang_code] = lang_name
+
+ for translation_lang_info in deep_get(captions_info, 'playerCaptionsTracklistRenderer', 'translationLanguages', default=()):
+ lang_code = translation_lang_info.get('languageCode')
+ if lang_code:
+ info['translation_languages'].append(lang_code)
+ if translation_lang_info.get('isTranslatable') == False:
+ print('WARNING: Found non-translatable caption language')
+
+ # formats
+ _extract_formats(info, embedded_player_response)
+ if not info['formats']:
+ _extract_formats(info, player_response)
+
+ # see https://github.com/user234683/youtube-local/issues/22#issuecomment-706395160
+ info['player_urls_missing'] = (
+ not info['formats'] and not embedded_player_response)
+
+ # playability errors
+ _extract_playability_error(info, player_response)
+
+ # check age-restriction
+ info['age_restricted'] = (info['playability_status'] == 'LOGIN_REQUIRED' and info['playability_error'] and ' age' in info['playability_error'])
+
+ # base_js (for decryption of signatures)
+ info['base_js'] = deep_get(top_level, 'player', 'assets', 'js')
+ if info['base_js']:
+ info['base_js'] = normalize_url(info['base_js'])
+ # must uniquely identify url
+ info['player_name'] = urllib.parse.urlparse(info['base_js']).path
+ else:
+ info['player_name'] = None
+
+ # extract stuff from visible parts of page
+ mobile = 'singleColumnWatchNextResults' in deep_get(top_level, 'response', 'contents', default={})
+ if mobile:
+ info.update(_extract_watch_info_mobile(top_level))
+ else:
+ info.update(_extract_watch_info_desktop(top_level))
+
+ # stuff from videoDetails. Use liberal_update to prioritize info from videoDetails over existing info
+ vd = deep_get(top_level, 'playerResponse', 'videoDetails', default={})
+ liberal_update(info, 'title', extract_str(vd.get('title')))
+ liberal_update(info, 'duration', extract_int(vd.get('lengthSeconds')))
+ liberal_update(info, 'view_count', extract_int(vd.get('viewCount')))
+ # videos with no description have a blank string
+ liberal_update(info, 'description', vd.get('shortDescription'))
+ liberal_update(info, 'id', vd.get('videoId'))
+ liberal_update(info, 'author', vd.get('author'))
+ liberal_update(info, 'author_id', vd.get('channelId'))
+ info['was_live'] = vd.get('isLiveContent')
+ conservative_update(info, 'unlisted', not vd.get('isCrawlable', True)) #isCrawlable is false on limited state videos even if they aren't unlisted
+ liberal_update(info, 'tags', vd.get('keywords', []))
+
+ # fallback stuff from microformat
+ mf = deep_get(top_level, 'playerResponse', 'microformat', 'playerMicroformatRenderer', default={})
+ conservative_update(info, 'title', extract_str(mf.get('title')))
+ conservative_update(info, 'duration', extract_int(mf.get('lengthSeconds')))
+ # this gives the view count for limited state videos
+ conservative_update(info, 'view_count', extract_int(mf.get('viewCount')))
+ conservative_update(info, 'description', extract_str(mf.get('description'), recover_urls=True))
+ conservative_update(info, 'author', mf.get('ownerChannelName'))
+ conservative_update(info, 'author_id', mf.get('externalChannelId'))
+ conservative_update(info, 'live', deep_get(mf, 'liveBroadcastDetails',
+ 'isLiveNow'))
+ liberal_update(info, 'unlisted', mf.get('isUnlisted'))
+ liberal_update(info, 'category', mf.get('category'))
+ liberal_update(info, 'time_published', mf.get('publishDate'))
+ liberal_update(info, 'time_uploaded', mf.get('uploadDate'))
+ family_safe = mf.get('isFamilySafe')
+ if family_safe is None:
+ conservative_update(info, 'age_restricted', None)
+ else:
+ conservative_update(info, 'age_restricted', not family_safe)
+ info['allowed_countries'] = mf.get('availableCountries', [])
+
+ # other stuff
+ info['author_url'] = 'https://www.youtube.com/channel/' + info['author_id'] if info['author_id'] else None
+ info['storyboard_spec_url'] = deep_get(player_response, 'storyboards', 'playerStoryboardSpecRenderer', 'spec')
+
+ return info
+
+single_char_codes = {
+ 'n': '\n',
+ '\\': '\\',
+ '"': '"',
+ "'": "'",
+ 'b': '\b',
+ 'f': '\f',
+ 'n': '\n',
+ 'r': '\r',
+ 't': '\t',
+ 'v': '\x0b',
+ '0': '\x00',
+ '\n': '', # backslash followed by literal newline joins lines
+}
+def js_escape_replace(match):
+ r'''Resolves javascript string escape sequences such as \x..'''
+ # some js-strings in the watch page html include them for no reason
+ # https://mathiasbynens.be/notes/javascript-escapes
+ escaped_sequence = match.group(1)
+ if escaped_sequence[0] in ('x', 'u'):
+ return chr(int(escaped_sequence[1:], base=16))
+
+ # In javascript, if it's not one of those escape codes, it's just the
+ # literal character. e.g., "\a" = "a"
+ return single_char_codes.get(escaped_sequence, escaped_sequence)
+
+# works but complicated and unsafe:
+#PLAYER_RESPONSE_RE = re.compile(r'<script[^>]*?>[^<]*?var ytInitialPlayerResponse = ({(?:"(?:[^"\\]|\\.)*?"|[^"])+?});')
+
+# Because there are sometimes additional statements after the json object
+# so we just capture all of those until end of script and tell json decoder
+# to ignore extra stuff after the json object
+PLAYER_RESPONSE_RE = re.compile(r'<script[^>]*?>[^<]*?var ytInitialPlayerResponse = ({.*?)</script>')
+INITIAL_DATA_RE = re.compile(r"<script[^>]*?>var ytInitialData = '(.+?[^\\])';")
+BASE_JS_RE = re.compile(r'jsUrl":\s*"([\w\-\./]+?/base.js)"')
+JS_STRING_ESCAPE_RE = re.compile(r'\\([^xu]|x..|u....)')
+def extract_watch_info_from_html(watch_html):
+ base_js_match = BASE_JS_RE.search(watch_html)
+ player_response_match = PLAYER_RESPONSE_RE.search(watch_html)
+ initial_data_match = INITIAL_DATA_RE.search(watch_html)
+
+ if base_js_match is not None:
+ base_js_url = base_js_match.group(1)
+ else:
+ base_js_url = None
+
+ if player_response_match is not None:
+ decoder = json.JSONDecoder()
+ # this will make it ignore extra stuff after end of object
+ player_response = decoder.raw_decode(player_response_match.group(1))[0]
+ else:
+ return {'error': 'Could not find ytInitialPlayerResponse'}
+ player_response = None
+
+ if initial_data_match is not None:
+ initial_data = initial_data_match.group(1)
+ initial_data = JS_STRING_ESCAPE_RE.sub(js_escape_replace, initial_data)
+ initial_data = json.loads(initial_data)
+ else:
+ print('extract_watch_info_from_html: failed to find initialData')
+ initial_data = None
+
+ # imitate old format expected by extract_watch_info
+ fake_polymer_json = {
+ 'player': {
+ 'args': {},
+ 'assets': {
+ 'js': base_js_url
+ }
+ },
+ 'playerResponse': player_response,
+ 'response': initial_data,
+ }
+
+ return extract_watch_info(fake_polymer_json)
+
+
+def captions_available(info):
+ return bool(info['_captions_base_url'])
+
+
+def get_caption_url(info, language, format, automatic=False, translation_language=None):
+ '''Gets the url for captions with the given language and format. If automatic is True, get the automatic captions for that language. If translation_language is given, translate the captions from `language` to `translation_language`. If automatic is true and translation_language is given, the automatic captions will be translated.'''
+ url = info['_captions_base_url']
+ if not url:
+ return None
+ url += '&lang=' + language
+ url += '&fmt=' + format
+ if automatic:
+ url += '&kind=asr'
+ elif language in info['_manual_caption_language_names']:
+ url += '&name=' + urllib.parse.quote(info['_manual_caption_language_names'][language], safe='')
+
+ if translation_language:
+ url += '&tlang=' + translation_language
+ return url
+
+def update_with_new_urls(info, player_response):
+ '''Inserts urls from player_response json'''
+ ERROR_PREFIX = 'Error getting missing player or bypassing age-restriction: '
+
+ try:
+ player_response = json.loads(player_response)
+ except json.decoder.JSONDecodeError:
+ traceback.print_exc()
+ info['playability_error'] = ERROR_PREFIX + 'Failed to parse json response'
+ return
+
+ _extract_formats(info, player_response)
+ _extract_playability_error(info, player_response, error_prefix=ERROR_PREFIX)
+
+def requires_decryption(info):
+ return ('formats' in info) and info['formats'] and info['formats'][0]['s']
+
+# adapted from youtube-dl and invidious:
+# https://github.com/omarroth/invidious/blob/master/src/invidious/helpers/signatures.cr
+decrypt_function_re = re.compile(r'function\(a\)\{(a=a\.split\(""\)[^\}{]+)return a\.join\(""\)\}')
+# gives us e.g. rt, .xK, 5 from rt.xK(a,5) or rt, ["xK"], 5 from rt["xK"](a,5)
+# (var, operation, argument)
+var_op_arg_re = re.compile(r'(\w+)(\.\w+|\["[^"]+"\])\(a,(\d+)\)')
+def extract_decryption_function(info, base_js):
+ '''Insert decryption function into info. Return error string if not successful.
+ Decryption function is a list of list[2] of numbers.
+ It is advisable to cache the decryption function (uniquely identified by info['player_name']) so base.js (1 MB) doesn't need to be redownloaded each time'''
+ info['decryption_function'] = None
+ decrypt_function_match = decrypt_function_re.search(base_js)
+ if decrypt_function_match is None:
+ return 'Could not find decryption function in base.js'
+
+ function_body = decrypt_function_match.group(1).split(';')[1:-1]
+ if not function_body:
+ return 'Empty decryption function body'
+
+ var_with_operation_match = var_op_arg_re.fullmatch(function_body[0])
+ if var_with_operation_match is None:
+ return 'Could not find var_name'
+
+ var_name = var_with_operation_match.group(1)
+ var_body_match = re.search(r'var ' + re.escape(var_name) + r'=\{(.*?)\};', base_js, flags=re.DOTALL)
+ if var_body_match is None:
+ return 'Could not find var_body'
+
+ operations = var_body_match.group(1).replace('\n', '').split('},')
+ if not operations:
+ return 'Did not find any definitions in var_body'
+ operations[-1] = operations[-1][:-1] # remove the trailing '}' since we split by '},' on the others
+ operation_definitions = {}
+ for op in operations:
+ colon_index = op.find(':')
+ opening_brace_index = op.find('{')
+
+ if colon_index == -1 or opening_brace_index == -1:
+ return 'Could not parse operation'
+ op_name = op[:colon_index]
+ op_body = op[opening_brace_index+1:]
+ if op_body == 'a.reverse()':
+ operation_definitions[op_name] = 0
+ elif op_body == 'a.splice(0,b)':
+ operation_definitions[op_name] = 1
+ elif op_body.startswith('var c=a[0]'):
+ operation_definitions[op_name] = 2
+ else:
+ return 'Unknown op_body: ' + op_body
+
+ decryption_function = []
+ for op_with_arg in function_body:
+ match = var_op_arg_re.fullmatch(op_with_arg)
+ if match is None:
+ return 'Could not parse operation with arg'
+ op_name = match.group(2).strip('[].')
+ if op_name not in operation_definitions:
+ return 'Unknown op_name: ' + str(op_name)
+ op_argument = match.group(3)
+ decryption_function.append([operation_definitions[op_name], int(op_argument)])
+
+ info['decryption_function'] = decryption_function
+ return False
+
+def _operation_2(a, b):
+ c = a[0]
+ a[0] = a[b % len(a)]
+ a[b % len(a)] = c
+
+def decrypt_signatures(info):
+ '''Applies info['decryption_function'] to decrypt all the signatures. Return err.'''
+ if not info.get('decryption_function'):
+ return 'decryption_function not in info'
+ for format in info['formats']:
+ if not format['s'] or not format['sp'] or not format['url']:
+ print('Warning: s, sp, or url not in format')
+ continue
+
+ a = list(format['s'])
+ for op, argument in info['decryption_function']:
+ if op == 0:
+ a.reverse()
+ elif op == 1:
+ a = a[argument:]
+ else:
+ _operation_2(a, argument)
+
+ signature = ''.join(a)
+ format['url'] += '&' + format['sp'] + '=' + signature
+ return False
diff --git a/youtube_dl/YoutubeDL.py b/youtube_dl/YoutubeDL.py
deleted file mode 100644
index 38ba43a..0000000
--- a/youtube_dl/YoutubeDL.py
+++ /dev/null
@@ -1,2392 +0,0 @@
-#!/usr/bin/env python
-# coding: utf-8
-
-from __future__ import absolute_import, unicode_literals
-
-import collections
-import contextlib
-import copy
-import datetime
-import errno
-import fileinput
-import io
-import itertools
-import json
-import locale
-import operator
-import os
-import platform
-import re
-import shutil
-import subprocess
-import socket
-import sys
-import time
-import tokenize
-import traceback
-import random
-
-from string import ascii_letters
-
-from .compat import (
- compat_basestring,
- compat_cookiejar,
- compat_get_terminal_size,
- compat_http_client,
- compat_kwargs,
- compat_numeric_types,
- compat_os_name,
- compat_str,
- compat_tokenize_tokenize,
- compat_urllib_error,
- compat_urllib_request,
- compat_urllib_request_DataHandler,
-)
-from .utils import (
- age_restricted,
- args_to_str,
- ContentTooShortError,
- date_from_str,
- DateRange,
- DEFAULT_OUTTMPL,
- determine_ext,
- determine_protocol,
- DownloadError,
- encode_compat_str,
- encodeFilename,
- error_to_compat_str,
- expand_path,
- ExtractorError,
- format_bytes,
- formatSeconds,
- GeoRestrictedError,
- int_or_none,
- ISO3166Utils,
- locked_file,
- make_HTTPS_handler,
- MaxDownloadsReached,
- orderedSet,
- PagedList,
- parse_filesize,
- PerRequestProxyHandler,
- platform_name,
- PostProcessingError,
- preferredencoding,
- prepend_extension,
- register_socks_protocols,
- render_table,
- replace_extension,
- SameFileError,
- sanitize_filename,
- sanitize_path,
- sanitize_url,
- sanitized_Request,
- std_headers,
- subtitles_filename,
- UnavailableVideoError,
- url_basename,
- version_tuple,
- write_json_file,
- write_string,
- YoutubeDLCookieProcessor,
- YoutubeDLHandler,
-)
-from .cache import Cache
-from .extractor import get_info_extractor, gen_extractor_classes, _LAZY_LOADER
-from .extractor.openload import PhantomJSwrapper
-from .downloader import get_suitable_downloader
-from .downloader.rtmp import rtmpdump_version
-from .postprocessor import (
- FFmpegFixupM3u8PP,
- FFmpegFixupM4aPP,
- FFmpegFixupStretchedPP,
- FFmpegMergerPP,
- FFmpegPostProcessor,
- get_postprocessor,
-)
-from .version import __version__
-
-if compat_os_name == 'nt':
- import ctypes
-
-
-class YoutubeDL(object):
- """YoutubeDL class.
-
- YoutubeDL objects are the ones responsible of downloading the
- actual video file and writing it to disk if the user has requested
- it, among some other tasks. In most cases there should be one per
- program. As, given a video URL, the downloader doesn't know how to
- extract all the needed information, task that InfoExtractors do, it
- has to pass the URL to one of them.
-
- For this, YoutubeDL objects have a method that allows
- InfoExtractors to be registered in a given order. When it is passed
- a URL, the YoutubeDL object handles it to the first InfoExtractor it
- finds that reports being able to handle it. The InfoExtractor extracts
- all the information about the video or videos the URL refers to, and
- YoutubeDL process the extracted information, possibly using a File
- Downloader to download the video.
-
- YoutubeDL objects accept a lot of parameters. In order not to saturate
- the object constructor with arguments, it receives a dictionary of
- options instead. These options are available through the params
- attribute for the InfoExtractors to use. The YoutubeDL also
- registers itself as the downloader in charge for the InfoExtractors
- that are added to it, so this is a "mutual registration".
-
- Available options:
-
- username: Username for authentication purposes.
- password: Password for authentication purposes.
- videopassword: Password for accessing a video.
- ap_mso: Adobe Pass multiple-system operator identifier.
- ap_username: Multiple-system operator account username.
- ap_password: Multiple-system operator account password.
- usenetrc: Use netrc for authentication instead.
- verbose: Print additional info to stdout.
- quiet: Do not print messages to stdout.
- no_warnings: Do not print out anything for warnings.
- forceurl: Force printing final URL.
- forcetitle: Force printing title.
- forceid: Force printing ID.
- forcethumbnail: Force printing thumbnail URL.
- forcedescription: Force printing description.
- forcefilename: Force printing final filename.
- forceduration: Force printing duration.
- forcejson: Force printing info_dict as JSON.
- dump_single_json: Force printing the info_dict of the whole playlist
- (or video) as a single JSON line.
- simulate: Do not download the video files.
- format: Video format code. See options.py for more information.
- outtmpl: Template for output names.
- restrictfilenames: Do not allow "&" and spaces in file names
- ignoreerrors: Do not stop on download errors.
- force_generic_extractor: Force downloader to use the generic extractor
- nooverwrites: Prevent overwriting files.
- playliststart: Playlist item to start at.
- playlistend: Playlist item to end at.
- playlist_items: Specific indices of playlist to download.
- playlistreverse: Download playlist items in reverse order.
- playlistrandom: Download playlist items in random order.
- matchtitle: Download only matching titles.
- rejecttitle: Reject downloads for matching titles.
- logger: Log messages to a logging.Logger instance.
- logtostderr: Log messages to stderr instead of stdout.
- writedescription: Write the video description to a .description file
- writeinfojson: Write the video description to a .info.json file
- writeannotations: Write the video annotations to a .annotations.xml file
- writethumbnail: Write the thumbnail image to a file
- write_all_thumbnails: Write all thumbnail formats to files
- writesubtitles: Write the video subtitles to a file
- writeautomaticsub: Write the automatically generated subtitles to a file
- allsubtitles: Downloads all the subtitles of the video
- (requires writesubtitles or writeautomaticsub)
- listsubtitles: Lists all available subtitles for the video
- subtitlesformat: The format code for subtitles
- subtitleslangs: List of languages of the subtitles to download
- keepvideo: Keep the video file after post-processing
- daterange: A DateRange object, download only if the upload_date is in the range.
- skip_download: Skip the actual download of the video file
- cachedir: Location of the cache files in the filesystem.
- False to disable filesystem cache.
- noplaylist: Download single video instead of a playlist if in doubt.
- age_limit: An integer representing the user's age in years.
- Unsuitable videos for the given age are skipped.
- min_views: An integer representing the minimum view count the video
- must have in order to not be skipped.
- Videos without view count information are always
- downloaded. None for no limit.
- max_views: An integer representing the maximum view count.
- Videos that are more popular than that are not
- downloaded.
- Videos without view count information are always
- downloaded. None for no limit.
- download_archive: File name of a file where all downloads are recorded.
- Videos already present in the file are not downloaded
- again.
- cookiefile: File name where cookies should be read from and dumped to.
- nocheckcertificate:Do not verify SSL certificates
- prefer_insecure: Use HTTP instead of HTTPS to retrieve information.
- At the moment, this is only supported by YouTube.
- proxy: URL of the proxy server to use
- geo_verification_proxy: URL of the proxy to use for IP address verification
- on geo-restricted sites.
- socket_timeout: Time to wait for unresponsive hosts, in seconds
- bidi_workaround: Work around buggy terminals without bidirectional text
- support, using fridibi
- debug_printtraffic:Print out sent and received HTTP traffic
- include_ads: Download ads as well
- default_search: Prepend this string if an input url is not valid.
- 'auto' for elaborate guessing
- encoding: Use this encoding instead of the system-specified.
- extract_flat: Do not resolve URLs, return the immediate result.
- Pass in 'in_playlist' to only show this behavior for
- playlist items.
- postprocessors: A list of dictionaries, each with an entry
- * key: The name of the postprocessor. See
- youtube_dl/postprocessor/__init__.py for a list.
- as well as any further keyword arguments for the
- postprocessor.
- progress_hooks: A list of functions that get called on download
- progress, with a dictionary with the entries
- * status: One of "downloading", "error", or "finished".
- Check this first and ignore unknown values.
-
- If status is one of "downloading", or "finished", the
- following properties may also be present:
- * filename: The final filename (always present)
- * tmpfilename: The filename we're currently writing to
- * downloaded_bytes: Bytes on disk
- * total_bytes: Size of the whole file, None if unknown
- * total_bytes_estimate: Guess of the eventual file size,
- None if unavailable.
- * elapsed: The number of seconds since download started.
- * eta: The estimated time in seconds, None if unknown
- * speed: The download speed in bytes/second, None if
- unknown
- * fragment_index: The counter of the currently
- downloaded video fragment.
- * fragment_count: The number of fragments (= individual
- files that will be merged)
-
- Progress hooks are guaranteed to be called at least once
- (with status "finished") if the download is successful.
- merge_output_format: Extension to use when merging formats.
- fixup: Automatically correct known faults of the file.
- One of:
- - "never": do nothing
- - "warn": only emit a warning
- - "detect_or_warn": check whether we can do anything
- about it, warn otherwise (default)
- source_address: Client-side IP address to bind to.
- call_home: Boolean, true iff we are allowed to contact the
- youtube-dl servers for debugging.
- sleep_interval: Number of seconds to sleep before each download when
- used alone or a lower bound of a range for randomized
- sleep before each download (minimum possible number
- of seconds to sleep) when used along with
- max_sleep_interval.
- max_sleep_interval:Upper bound of a range for randomized sleep before each
- download (maximum possible number of seconds to sleep).
- Must only be used along with sleep_interval.
- Actual sleep time will be a random float from range
- [sleep_interval; max_sleep_interval].
- listformats: Print an overview of available video formats and exit.
- list_thumbnails: Print a table of all thumbnails and exit.
- match_filter: A function that gets called with the info_dict of
- every video.
- If it returns a message, the video is ignored.
- If it returns None, the video is downloaded.
- match_filter_func in utils.py is one example for this.
- no_color: Do not emit color codes in output.
- geo_bypass: Bypass geographic restriction via faking X-Forwarded-For
- HTTP header
- geo_bypass_country:
- Two-letter ISO 3166-2 country code that will be used for
- explicit geographic restriction bypassing via faking
- X-Forwarded-For HTTP header
- geo_bypass_ip_block:
- IP range in CIDR notation that will be used similarly to
- geo_bypass_country
-
- The following options determine which downloader is picked:
- external_downloader: Executable of the external downloader to call.
- None or unset for standard (built-in) downloader.
- hls_prefer_native: Use the native HLS downloader instead of ffmpeg/avconv
- if True, otherwise use ffmpeg/avconv if False, otherwise
- use downloader suggested by extractor if None.
-
- The following parameters are not used by YoutubeDL itself, they are used by
- the downloader (see youtube_dl/downloader/common.py):
- nopart, updatetime, buffersize, ratelimit, min_filesize, max_filesize, test,
- noresizebuffer, retries, continuedl, noprogress, consoletitle,
- xattr_set_filesize, external_downloader_args, hls_use_mpegts,
- http_chunk_size.
-
- The following options are used by the post processors:
- prefer_ffmpeg: If False, use avconv instead of ffmpeg if both are available,
- otherwise prefer ffmpeg.
- postprocessor_args: A list of additional command-line arguments for the
- postprocessor.
-
- The following options are used by the Youtube extractor:
- youtube_include_dash_manifest: If True (default), DASH manifests and related
- data will be downloaded and processed by extractor.
- You can reduce network I/O by disabling it if you don't
- care about DASH.
- """
-
- _NUMERIC_FIELDS = set((
- 'width', 'height', 'tbr', 'abr', 'asr', 'vbr', 'fps', 'filesize', 'filesize_approx',
- 'timestamp', 'upload_year', 'upload_month', 'upload_day',
- 'duration', 'view_count', 'like_count', 'dislike_count', 'repost_count',
- 'average_rating', 'comment_count', 'age_limit',
- 'start_time', 'end_time',
- 'chapter_number', 'season_number', 'episode_number',
- 'track_number', 'disc_number', 'release_year',
- 'playlist_index',
- ))
-
- params = None
- _ies = []
- _pps = []
- _download_retcode = None
- _num_downloads = None
- _screen_file = None
-
- def __init__(self, params=None, auto_init=True):
- """Create a FileDownloader object with the given options."""
- if params is None:
- params = {}
- self._ies = []
- self._ies_instances = {}
- self._pps = []
- self._progress_hooks = []
- self._download_retcode = 0
- self._num_downloads = 0
- self._screen_file = [sys.stdout, sys.stderr][params.get('logtostderr', False)]
- self._err_file = sys.stderr
- self.params = {
- # Default parameters
- 'nocheckcertificate': False,
- }
- self.params.update(params)
- self.cache = Cache(self)
-
- def check_deprecated(param, option, suggestion):
- if self.params.get(param) is not None:
- self.report_warning(
- '%s is deprecated. Use %s instead.' % (option, suggestion))
- return True
- return False
-
- if check_deprecated('cn_verification_proxy', '--cn-verification-proxy', '--geo-verification-proxy'):
- if self.params.get('geo_verification_proxy') is None:
- self.params['geo_verification_proxy'] = self.params['cn_verification_proxy']
-
- check_deprecated('autonumber_size', '--autonumber-size', 'output template with %(autonumber)0Nd, where N in the number of digits')
- check_deprecated('autonumber', '--auto-number', '-o "%(autonumber)s-%(title)s.%(ext)s"')
- check_deprecated('usetitle', '--title', '-o "%(title)s-%(id)s.%(ext)s"')
-
- if params.get('bidi_workaround', False):
- try:
- import pty
- master, slave = pty.openpty()
- width = compat_get_terminal_size().columns
- if width is None:
- width_args = []
- else:
- width_args = ['-w', str(width)]
- sp_kwargs = dict(
- stdin=subprocess.PIPE,
- stdout=slave,
- stderr=self._err_file)
- try:
- self._output_process = subprocess.Popen(
- ['bidiv'] + width_args, **sp_kwargs
- )
- except OSError:
- self._output_process = subprocess.Popen(
- ['fribidi', '-c', 'UTF-8'] + width_args, **sp_kwargs)
- self._output_channel = os.fdopen(master, 'rb')
- except OSError as ose:
- if ose.errno == errno.ENOENT:
- self.report_warning('Could not find fribidi executable, ignoring --bidi-workaround . Make sure that fribidi is an executable file in one of the directories in your $PATH.')
- else:
- raise
-
- if (sys.platform != 'win32' and
- sys.getfilesystemencoding() in ['ascii', 'ANSI_X3.4-1968'] and
- not params.get('restrictfilenames', False)):
- # Unicode filesystem API will throw errors (#1474, #13027)
- self.report_warning(
- 'Assuming --restrict-filenames since file system encoding '
- 'cannot encode all characters. '
- 'Set the LC_ALL environment variable to fix this.')
- self.params['restrictfilenames'] = True
-
- if isinstance(params.get('outtmpl'), bytes):
- self.report_warning(
- 'Parameter outtmpl is bytes, but should be a unicode string. '
- 'Put from __future__ import unicode_literals at the top of your code file or consider switching to Python 3.x.')
-
- self._setup_opener()
-
- if auto_init:
- self.print_debug_header()
- self.add_default_info_extractors()
-
- for pp_def_raw in self.params.get('postprocessors', []):
- pp_class = get_postprocessor(pp_def_raw['key'])
- pp_def = dict(pp_def_raw)
- del pp_def['key']
- pp = pp_class(self, **compat_kwargs(pp_def))
- self.add_post_processor(pp)
-
- for ph in self.params.get('progress_hooks', []):
- self.add_progress_hook(ph)
-
- register_socks_protocols()
-
- def warn_if_short_id(self, argv):
- # short YouTube ID starting with dash?
- idxs = [
- i for i, a in enumerate(argv)
- if re.match(r'^-[0-9A-Za-z_-]{10}$', a)]
- if idxs:
- correct_argv = (
- ['youtube-dl'] +
- [a for i, a in enumerate(argv) if i not in idxs] +
- ['--'] + [argv[i] for i in idxs]
- )
- self.report_warning(
- 'Long argument string detected. '
- 'Use -- to separate parameters and URLs, like this:\n%s\n' %
- args_to_str(correct_argv))
-
- def add_info_extractor(self, ie):
- """Add an InfoExtractor object to the end of the list."""
- self._ies.append(ie)
- if not isinstance(ie, type):
- self._ies_instances[ie.ie_key()] = ie
- ie.set_downloader(self)
-
- def get_info_extractor(self, ie_key):
- """
- Get an instance of an IE with name ie_key, it will try to get one from
- the _ies list, if there's no instance it will create a new one and add
- it to the extractor list.
- """
- ie = self._ies_instances.get(ie_key)
- if ie is None:
- ie = get_info_extractor(ie_key)()
- self.add_info_extractor(ie)
- return ie
-
- def add_default_info_extractors(self):
- """
- Add the InfoExtractors returned by gen_extractors to the end of the list
- """
- for ie in gen_extractor_classes():
- self.add_info_extractor(ie)
-
- def add_post_processor(self, pp):
- """Add a PostProcessor object to the end of the chain."""
- self._pps.append(pp)
- pp.set_downloader(self)
-
- def add_progress_hook(self, ph):
- """Add the progress hook (currently only for the file downloader)"""
- self._progress_hooks.append(ph)
-
- def _bidi_workaround(self, message):
- if not hasattr(self, '_output_channel'):
- return message
-
- assert hasattr(self, '_output_process')
- assert isinstance(message, compat_str)
- line_count = message.count('\n') + 1
- self._output_process.stdin.write((message + '\n').encode('utf-8'))
- self._output_process.stdin.flush()
- res = ''.join(self._output_channel.readline().decode('utf-8')
- for _ in range(line_count))
- return res[:-len('\n')]
-
- def to_screen(self, message, skip_eol=False):
- """Print message to stdout if not in quiet mode."""
- return self.to_stdout(message, skip_eol, check_quiet=True)
-
- def _write_string(self, s, out=None):
- write_string(s, out=out, encoding=self.params.get('encoding'))
-
- def to_stdout(self, message, skip_eol=False, check_quiet=False):
- """Print message to stdout if not in quiet mode."""
- if self.params.get('logger'):
- self.params['logger'].debug(message)
- elif not check_quiet or not self.params.get('quiet', False):
- message = self._bidi_workaround(message)
- terminator = ['\n', ''][skip_eol]
- output = message + terminator
-
- self._write_string(output, self._screen_file)
-
- def to_stderr(self, message):
- """Print message to stderr."""
- assert isinstance(message, compat_str)
- if self.params.get('logger'):
- self.params['logger'].error(message)
- else:
- message = self._bidi_workaround(message)
- output = message + '\n'
- self._write_string(output, self._err_file)
-
- def to_console_title(self, message):
- if not self.params.get('consoletitle', False):
- return
- if compat_os_name == 'nt':
- if ctypes.windll.kernel32.GetConsoleWindow():
- # c_wchar_p() might not be necessary if `message` is
- # already of type unicode()
- ctypes.windll.kernel32.SetConsoleTitleW(ctypes.c_wchar_p(message))
- elif 'TERM' in os.environ:
- self._write_string('\033]0;%s\007' % message, self._screen_file)
-
- def save_console_title(self):
- if not self.params.get('consoletitle', False):
- return
- if self.params.get('simulate', False):
- return
- if compat_os_name != 'nt' and 'TERM' in os.environ:
- # Save the title on stack
- self._write_string('\033[22;0t', self._screen_file)
-
- def restore_console_title(self):
- if not self.params.get('consoletitle', False):
- return
- if self.params.get('simulate', False):
- return
- if compat_os_name != 'nt' and 'TERM' in os.environ:
- # Restore the title from stack
- self._write_string('\033[23;0t', self._screen_file)
-
- def __enter__(self):
- self.save_console_title()
- return self
-
- def __exit__(self, *args):
- self.restore_console_title()
-
- if self.params.get('cookiefile') is not None:
- self.cookiejar.save()
-
- def trouble(self, message=None, tb=None):
- """Determine action to take when a download problem appears.
-
- Depending on if the downloader has been configured to ignore
- download errors or not, this method may throw an exception or
- not when errors are found, after printing the message.
-
- tb, if given, is additional traceback information.
- """
- if message is not None:
- self.to_stderr(message)
- if self.params.get('verbose'):
- if tb is None:
- if sys.exc_info()[0]: # if .trouble has been called from an except block
- tb = ''
- if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
- tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
- tb += encode_compat_str(traceback.format_exc())
- else:
- tb_data = traceback.format_list(traceback.extract_stack())
- tb = ''.join(tb_data)
- self.to_stderr(tb)
- if not self.params.get('ignoreerrors', False):
- if sys.exc_info()[0] and hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
- exc_info = sys.exc_info()[1].exc_info
- else:
- exc_info = sys.exc_info()
- raise DownloadError(message, exc_info)
- self._download_retcode = 1
-
- def report_warning(self, message):
- '''
- Print the message to stderr, it will be prefixed with 'WARNING:'
- If stderr is a tty file the 'WARNING:' will be colored
- '''
- if self.params.get('logger') is not None:
- self.params['logger'].warning(message)
- else:
- if self.params.get('no_warnings'):
- return
- if not self.params.get('no_color') and self._err_file.isatty() and compat_os_name != 'nt':
- _msg_header = '\033[0;33mWARNING:\033[0m'
- else:
- _msg_header = 'WARNING:'
- warning_message = '%s %s' % (_msg_header, message)
- self.to_stderr(warning_message)
-
- def report_error(self, message, tb=None):
- '''
- Do the same as trouble, but prefixes the message with 'ERROR:', colored
- in red if stderr is a tty file.
- '''
- if not self.params.get('no_color') and self._err_file.isatty() and compat_os_name != 'nt':
- _msg_header = '\033[0;31mERROR:\033[0m'
- else:
- _msg_header = 'ERROR:'
- error_message = '%s %s' % (_msg_header, message)
- self.trouble(error_message, tb)
-
- def report_file_already_downloaded(self, file_name):
- """Report file has already been fully downloaded."""
- try:
- self.to_screen('[download] %s has already been downloaded' % file_name)
- except UnicodeEncodeError:
- self.to_screen('[download] The file has already been downloaded')
-
- def prepare_filename(self, info_dict):
- """Generate the output filename."""
- try:
- template_dict = dict(info_dict)
-
- template_dict['epoch'] = int(time.time())
- autonumber_size = self.params.get('autonumber_size')
- if autonumber_size is None:
- autonumber_size = 5
- template_dict['autonumber'] = self.params.get('autonumber_start', 1) - 1 + self._num_downloads
- if template_dict.get('resolution') is None:
- if template_dict.get('width') and template_dict.get('height'):
- template_dict['resolution'] = '%dx%d' % (template_dict['width'], template_dict['height'])
- elif template_dict.get('height'):
- template_dict['resolution'] = '%sp' % template_dict['height']
- elif template_dict.get('width'):
- template_dict['resolution'] = '%dx?' % template_dict['width']
-
- sanitize = lambda k, v: sanitize_filename(
- compat_str(v),
- restricted=self.params.get('restrictfilenames'),
- is_id=(k == 'id' or k.endswith('_id')))
- template_dict = dict((k, v if isinstance(v, compat_numeric_types) else sanitize(k, v))
- for k, v in template_dict.items()
- if v is not None and not isinstance(v, (list, tuple, dict)))
- template_dict = collections.defaultdict(lambda: 'NA', template_dict)
-
- outtmpl = self.params.get('outtmpl', DEFAULT_OUTTMPL)
-
- # For fields playlist_index and autonumber convert all occurrences
- # of %(field)s to %(field)0Nd for backward compatibility
- field_size_compat_map = {
- 'playlist_index': len(str(template_dict['n_entries'])),
- 'autonumber': autonumber_size,
- }
- FIELD_SIZE_COMPAT_RE = r'(?<!%)%\((?P<field>autonumber|playlist_index)\)s'
- mobj = re.search(FIELD_SIZE_COMPAT_RE, outtmpl)
- if mobj:
- outtmpl = re.sub(
- FIELD_SIZE_COMPAT_RE,
- r'%%(\1)0%dd' % field_size_compat_map[mobj.group('field')],
- outtmpl)
-
- # Missing numeric fields used together with integer presentation types
- # in format specification will break the argument substitution since
- # string 'NA' is returned for missing fields. We will patch output
- # template for missing fields to meet string presentation type.
- for numeric_field in self._NUMERIC_FIELDS:
- if numeric_field not in template_dict:
- # As of [1] format syntax is:
- # %[mapping_key][conversion_flags][minimum_width][.precision][length_modifier]type
- # 1. https://docs.python.org/2/library/stdtypes.html#string-formatting
- FORMAT_RE = r'''(?x)
- (?<!%)
- %
- \({0}\) # mapping key
- (?:[#0\-+ ]+)? # conversion flags (optional)
- (?:\d+)? # minimum field width (optional)
- (?:\.\d+)? # precision (optional)
- [hlL]? # length modifier (optional)
- [diouxXeEfFgGcrs%] # conversion type
- '''
- outtmpl = re.sub(
- FORMAT_RE.format(numeric_field),
- r'%({0})s'.format(numeric_field), outtmpl)
-
- # expand_path translates '%%' into '%' and '$$' into '$'
- # correspondingly that is not what we want since we need to keep
- # '%%' intact for template dict substitution step. Working around
- # with boundary-alike separator hack.
- sep = ''.join([random.choice(ascii_letters) for _ in range(32)])
- outtmpl = outtmpl.replace('%%', '%{0}%'.format(sep)).replace('$$', '${0}$'.format(sep))
-
- # outtmpl should be expand_path'ed before template dict substitution
- # because meta fields may contain env variables we don't want to
- # be expanded. For example, for outtmpl "%(title)s.%(ext)s" and
- # title "Hello $PATH", we don't want `$PATH` to be expanded.
- filename = expand_path(outtmpl).replace(sep, '') % template_dict
-
- # Temporary fix for #4787
- # 'Treat' all problem characters by passing filename through preferredencoding
- # to workaround encoding issues with subprocess on python2 @ Windows
- if sys.version_info < (3, 0) and sys.platform == 'win32':
- filename = encodeFilename(filename, True).decode(preferredencoding())
- return sanitize_path(filename)
- except ValueError as err:
- self.report_error('Error in output template: ' + str(err) + ' (encoding: ' + repr(preferredencoding()) + ')')
- return None
-
- def _match_entry(self, info_dict, incomplete):
- """ Returns None iff the file should be downloaded """
-
- video_title = info_dict.get('title', info_dict.get('id', 'video'))
- if 'title' in info_dict:
- # This can happen when we're just evaluating the playlist
- title = info_dict['title']
- matchtitle = self.params.get('matchtitle', False)
- if matchtitle:
- if not re.search(matchtitle, title, re.IGNORECASE):
- return '"' + title + '" title did not match pattern "' + matchtitle + '"'
- rejecttitle = self.params.get('rejecttitle', False)
- if rejecttitle:
- if re.search(rejecttitle, title, re.IGNORECASE):
- return '"' + title + '" title matched reject pattern "' + rejecttitle + '"'
- date = info_dict.get('upload_date')
- if date is not None:
- dateRange = self.params.get('daterange', DateRange())
- if date not in dateRange:
- return '%s upload date is not in range %s' % (date_from_str(date).isoformat(), dateRange)
- view_count = info_dict.get('view_count')
- if view_count is not None:
- min_views = self.params.get('min_views')
- if min_views is not None and view_count < min_views:
- return 'Skipping %s, because it has not reached minimum view count (%d/%d)' % (video_title, view_count, min_views)
- max_views = self.params.get('max_views')
- if max_views is not None and view_count > max_views:
- return 'Skipping %s, because it has exceeded the maximum view count (%d/%d)' % (video_title, view_count, max_views)
- if age_restricted(info_dict.get('age_limit'), self.params.get('age_limit')):
- return 'Skipping "%s" because it is age restricted' % video_title
- if self.in_download_archive(info_dict):
- return '%s has already been recorded in archive' % video_title
-
- if not incomplete:
- match_filter = self.params.get('match_filter')
- if match_filter is not None:
- ret = match_filter(info_dict)
- if ret is not None:
- return ret
-
- return None
-
- @staticmethod
- def add_extra_info(info_dict, extra_info):
- '''Set the keys from extra_info in info dict if they are missing'''
- for key, value in extra_info.items():
- info_dict.setdefault(key, value)
-
- def extract_info(self, url, download=True, ie_key=None, extra_info={},
- process=True, force_generic_extractor=False):
- '''
- Returns a list with a dictionary for each video we find.
- If 'download', also downloads the videos.
- extra_info is a dict containing the extra values to add to each result
- '''
-
- if not ie_key and force_generic_extractor:
- ie_key = 'Generic'
-
- if ie_key:
- ies = [self.get_info_extractor(ie_key)]
- else:
- ies = self._ies
-
- for ie in ies:
- if not ie.suitable(url):
- continue
-
- ie = self.get_info_extractor(ie.ie_key())
- if not ie.working():
- self.report_warning('The program functionality for this site has been marked as broken, '
- 'and will probably not work.')
-
- try:
- ie_result = ie.extract(url)
- if ie_result is None: # Finished already (backwards compatibility; listformats and friends should be moved here)
- break
- if isinstance(ie_result, list):
- # Backwards compatibility: old IE result format
- ie_result = {
- '_type': 'compat_list',
- 'entries': ie_result,
- }
- self.add_default_extra_info(ie_result, ie, url)
- if process:
- return self.process_ie_result(ie_result, download, extra_info)
- else:
- return ie_result
- except GeoRestrictedError as e:
- msg = e.msg
- if e.countries:
- msg += '\nThis video is available in %s.' % ', '.join(
- map(ISO3166Utils.short2full, e.countries))
- msg += '\nYou might want to use a VPN or a proxy server (with --proxy) to workaround.'
- self.report_error(msg)
- break
- except ExtractorError as e: # An error we somewhat expected
- self.report_error(compat_str(e), e.format_traceback())
- break
- except MaxDownloadsReached:
- raise
- except Exception as e:
- if self.params.get('ignoreerrors', False):
- self.report_error(error_to_compat_str(e), tb=encode_compat_str(traceback.format_exc()))
- break
- else:
- raise
- else:
- self.report_error('no suitable InfoExtractor for URL %s' % url)
-
- def add_default_extra_info(self, ie_result, ie, url):
- self.add_extra_info(ie_result, {
- 'extractor': ie.IE_NAME,
- 'webpage_url': url,
- 'webpage_url_basename': url_basename(url),
- 'extractor_key': ie.ie_key(),
- })
-
- def process_ie_result(self, ie_result, download=True, extra_info={}):
- """
- Take the result of the ie(may be modified) and resolve all unresolved
- references (URLs, playlist items).
-
- It will also download the videos if 'download'.
- Returns the resolved ie_result.
- """
- result_type = ie_result.get('_type', 'video')
-
- if result_type in ('url', 'url_transparent'):
- ie_result['url'] = sanitize_url(ie_result['url'])
- extract_flat = self.params.get('extract_flat', False)
- if ((extract_flat == 'in_playlist' and 'playlist' in extra_info) or
- extract_flat is True):
- if self.params.get('forcejson', False):
- self.to_stdout(json.dumps(ie_result))
- return ie_result
-
- if result_type == 'video':
- self.add_extra_info(ie_result, extra_info)
- return self.process_video_result(ie_result, download=download)
- elif result_type == 'url':
- # We have to add extra_info to the results because it may be
- # contained in a playlist
- return self.extract_info(ie_result['url'],
- download,
- ie_key=ie_result.get('ie_key'),
- extra_info=extra_info)
- elif result_type == 'url_transparent':
- # Use the information from the embedding page
- info = self.extract_info(
- ie_result['url'], ie_key=ie_result.get('ie_key'),
- extra_info=extra_info, download=False, process=False)
-
- # extract_info may return None when ignoreerrors is enabled and
- # extraction failed with an error, don't crash and return early
- # in this case
- if not info:
- return info
-
- force_properties = dict(
- (k, v) for k, v in ie_result.items() if v is not None)
- for f in ('_type', 'url', 'id', 'extractor', 'extractor_key', 'ie_key'):
- if f in force_properties:
- del force_properties[f]
- new_result = info.copy()
- new_result.update(force_properties)
-
- # Extracted info may not be a video result (i.e.
- # info.get('_type', 'video') != video) but rather an url or
- # url_transparent. In such cases outer metadata (from ie_result)
- # should be propagated to inner one (info). For this to happen
- # _type of info should be overridden with url_transparent. This
- # fixes issue from https://github.com/rg3/youtube-dl/pull/11163.
- if new_result.get('_type') == 'url':
- new_result['_type'] = 'url_transparent'
-
- return self.process_ie_result(
- new_result, download=download, extra_info=extra_info)
- elif result_type in ('playlist', 'multi_video'):
- # We process each entry in the playlist
- playlist = ie_result.get('title') or ie_result.get('id')
- self.to_screen('[download] Downloading playlist: %s' % playlist)
-
- playlist_results = []
-
- playliststart = self.params.get('playliststart', 1) - 1
- playlistend = self.params.get('playlistend')
- # For backwards compatibility, interpret -1 as whole list
- if playlistend == -1:
- playlistend = None
-
- playlistitems_str = self.params.get('playlist_items')
- playlistitems = None
- if playlistitems_str is not None:
- def iter_playlistitems(format):
- for string_segment in format.split(','):
- if '-' in string_segment:
- start, end = string_segment.split('-')
- for item in range(int(start), int(end) + 1):
- yield int(item)
- else:
- yield int(string_segment)
- playlistitems = orderedSet(iter_playlistitems(playlistitems_str))
-
- ie_entries = ie_result['entries']
-
- def make_playlistitems_entries(list_ie_entries):
- num_entries = len(list_ie_entries)
- return [
- list_ie_entries[i - 1] for i in playlistitems
- if -num_entries <= i - 1 < num_entries]
-
- def report_download(num_entries):
- self.to_screen(
- '[%s] playlist %s: Downloading %d videos' %
- (ie_result['extractor'], playlist, num_entries))
-
- if isinstance(ie_entries, list):
- n_all_entries = len(ie_entries)
- if playlistitems:
- entries = make_playlistitems_entries(ie_entries)
- else:
- entries = ie_entries[playliststart:playlistend]
- n_entries = len(entries)
- self.to_screen(
- '[%s] playlist %s: Collected %d video ids (downloading %d of them)' %
- (ie_result['extractor'], playlist, n_all_entries, n_entries))
- elif isinstance(ie_entries, PagedList):
- if playlistitems:
- entries = []
- for item in playlistitems:
- entries.extend(ie_entries.getslice(
- item - 1, item
- ))
- else:
- entries = ie_entries.getslice(
- playliststart, playlistend)
- n_entries = len(entries)
- report_download(n_entries)
- else: # iterable
- if playlistitems:
- entries = make_playlistitems_entries(list(itertools.islice(
- ie_entries, 0, max(playlistitems))))
- else:
- entries = list(itertools.islice(
- ie_entries, playliststart, playlistend))
- n_entries = len(entries)
- report_download(n_entries)
-
- if self.params.get('playlistreverse', False):
- entries = entries[::-1]
-
- if self.params.get('playlistrandom', False):
- random.shuffle(entries)
-
- x_forwarded_for = ie_result.get('__x_forwarded_for_ip')
-
- for i, entry in enumerate(entries, 1):
- self.to_screen('[download] Downloading video %s of %s' % (i, n_entries))
- # This __x_forwarded_for_ip thing is a bit ugly but requires
- # minimal changes
- if x_forwarded_for:
- entry['__x_forwarded_for_ip'] = x_forwarded_for
- extra = {
- 'n_entries': n_entries,
- 'playlist': playlist,
- 'playlist_id': ie_result.get('id'),
- 'playlist_title': ie_result.get('title'),
- 'playlist_uploader': ie_result.get('uploader'),
- 'playlist_uploader_id': ie_result.get('uploader_id'),
- 'playlist_index': i + playliststart,
- 'extractor': ie_result['extractor'],
- 'webpage_url': ie_result['webpage_url'],
- 'webpage_url_basename': url_basename(ie_result['webpage_url']),
- 'extractor_key': ie_result['extractor_key'],
- }
-
- reason = self._match_entry(entry, incomplete=True)
- if reason is not None:
- self.to_screen('[download] ' + reason)
- continue
-
- entry_result = self.process_ie_result(entry,
- download=download,
- extra_info=extra)
- playlist_results.append(entry_result)
- ie_result['entries'] = playlist_results
- self.to_screen('[download] Finished downloading playlist: %s' % playlist)
- return ie_result
- elif result_type == 'compat_list':
- self.report_warning(
- 'Extractor %s returned a compat_list result. '
- 'It needs to be updated.' % ie_result.get('extractor'))
-
- def _fixup(r):
- self.add_extra_info(
- r,
- {
- 'extractor': ie_result['extractor'],
- 'webpage_url': ie_result['webpage_url'],
- 'webpage_url_basename': url_basename(ie_result['webpage_url']),
- 'extractor_key': ie_result['extractor_key'],
- }
- )
- return r
- ie_result['entries'] = [
- self.process_ie_result(_fixup(r), download, extra_info)
- for r in ie_result['entries']
- ]
- return ie_result
- else:
- raise Exception('Invalid result type: %s' % result_type)
-
- def _build_format_filter(self, filter_spec):
- " Returns a function to filter the formats according to the filter_spec "
-
- OPERATORS = {
- '<': operator.lt,
- '<=': operator.le,
- '>': operator.gt,
- '>=': operator.ge,
- '=': operator.eq,
- '!=': operator.ne,
- }
- operator_rex = re.compile(r'''(?x)\s*
- (?P<key>width|height|tbr|abr|vbr|asr|filesize|filesize_approx|fps)
- \s*(?P<op>%s)(?P<none_inclusive>\s*\?)?\s*
- (?P<value>[0-9.]+(?:[kKmMgGtTpPeEzZyY]i?[Bb]?)?)
- $
- ''' % '|'.join(map(re.escape, OPERATORS.keys())))
- m = operator_rex.search(filter_spec)
- if m:
- try:
- comparison_value = int(m.group('value'))
- except ValueError:
- comparison_value = parse_filesize(m.group('value'))
- if comparison_value is None:
- comparison_value = parse_filesize(m.group('value') + 'B')
- if comparison_value is None:
- raise ValueError(
- 'Invalid value %r in format specification %r' % (
- m.group('value'), filter_spec))
- op = OPERATORS[m.group('op')]
-
- if not m:
- STR_OPERATORS = {
- '=': operator.eq,
- '!=': operator.ne,
- '^=': lambda attr, value: attr.startswith(value),
- '$=': lambda attr, value: attr.endswith(value),
- '*=': lambda attr, value: value in attr,
- }
- str_operator_rex = re.compile(r'''(?x)
- \s*(?P<key>ext|acodec|vcodec|container|protocol|format_id)
- \s*(?P<op>%s)(?P<none_inclusive>\s*\?)?
- \s*(?P<value>[a-zA-Z0-9._-]+)
- \s*$
- ''' % '|'.join(map(re.escape, STR_OPERATORS.keys())))
- m = str_operator_rex.search(filter_spec)
- if m:
- comparison_value = m.group('value')
- op = STR_OPERATORS[m.group('op')]
-
- if not m:
- raise ValueError('Invalid filter specification %r' % filter_spec)
-
- def _filter(f):
- actual_value = f.get(m.group('key'))
- if actual_value is None:
- return m.group('none_inclusive')
- return op(actual_value, comparison_value)
- return _filter
-
- def _default_format_spec(self, info_dict, download=True):
-
- def can_merge():
- merger = FFmpegMergerPP(self)
- return merger.available and merger.can_merge()
-
- def prefer_best():
- if self.params.get('simulate', False):
- return False
- if not download:
- return False
- if self.params.get('outtmpl', DEFAULT_OUTTMPL) == '-':
- return True
- if info_dict.get('is_live'):
- return True
- if not can_merge():
- return True
- return False
-
- req_format_list = ['bestvideo+bestaudio', 'best']
- if prefer_best():
- req_format_list.reverse()
- return '/'.join(req_format_list)
-
- def build_format_selector(self, format_spec):
- def syntax_error(note, start):
- message = (
- 'Invalid format specification: '
- '{0}\n\t{1}\n\t{2}^'.format(note, format_spec, ' ' * start[1]))
- return SyntaxError(message)
-
- PICKFIRST = 'PICKFIRST'
- MERGE = 'MERGE'
- SINGLE = 'SINGLE'
- GROUP = 'GROUP'
- FormatSelector = collections.namedtuple('FormatSelector', ['type', 'selector', 'filters'])
-
- def _parse_filter(tokens):
- filter_parts = []
- for type, string, start, _, _ in tokens:
- if type == tokenize.OP and string == ']':
- return ''.join(filter_parts)
- else:
- filter_parts.append(string)
-
- def _remove_unused_ops(tokens):
- # Remove operators that we don't use and join them with the surrounding strings
- # for example: 'mp4' '-' 'baseline' '-' '16x9' is converted to 'mp4-baseline-16x9'
- ALLOWED_OPS = ('/', '+', ',', '(', ')')
- last_string, last_start, last_end, last_line = None, None, None, None
- for type, string, start, end, line in tokens:
- if type == tokenize.OP and string == '[':
- if last_string:
- yield tokenize.NAME, last_string, last_start, last_end, last_line
- last_string = None
- yield type, string, start, end, line
- # everything inside brackets will be handled by _parse_filter
- for type, string, start, end, line in tokens:
- yield type, string, start, end, line
- if type == tokenize.OP and string == ']':
- break
- elif type == tokenize.OP and string in ALLOWED_OPS:
- if last_string:
- yield tokenize.NAME, last_string, last_start, last_end, last_line
- last_string = None
- yield type, string, start, end, line
- elif type in [tokenize.NAME, tokenize.NUMBER, tokenize.OP]:
- if not last_string:
- last_string = string
- last_start = start
- last_end = end
- else:
- last_string += string
- if last_string:
- yield tokenize.NAME, last_string, last_start, last_end, last_line
-
- def _parse_format_selection(tokens, inside_merge=False, inside_choice=False, inside_group=False):
- selectors = []
- current_selector = None
- for type, string, start, _, _ in tokens:
- # ENCODING is only defined in python 3.x
- if type == getattr(tokenize, 'ENCODING', None):
- continue
- elif type in [tokenize.NAME, tokenize.NUMBER]:
- current_selector = FormatSelector(SINGLE, string, [])
- elif type == tokenize.OP:
- if string == ')':
- if not inside_group:
- # ')' will be handled by the parentheses group
- tokens.restore_last_token()
- break
- elif inside_merge and string in ['/', ',']:
- tokens.restore_last_token()
- break
- elif inside_choice and string == ',':
- tokens.restore_last_token()
- break
- elif string == ',':
- if not current_selector:
- raise syntax_error('"," must follow a format selector', start)
- selectors.append(current_selector)
- current_selector = None
- elif string == '/':
- if not current_selector:
- raise syntax_error('"/" must follow a format selector', start)
- first_choice = current_selector
- second_choice = _parse_format_selection(tokens, inside_choice=True)
- current_selector = FormatSelector(PICKFIRST, (first_choice, second_choice), [])
- elif string == '[':
- if not current_selector:
- current_selector = FormatSelector(SINGLE, 'best', [])
- format_filter = _parse_filter(tokens)
- current_selector.filters.append(format_filter)
- elif string == '(':
- if current_selector:
- raise syntax_error('Unexpected "("', start)
- group = _parse_format_selection(tokens, inside_group=True)
- current_selector = FormatSelector(GROUP, group, [])
- elif string == '+':
- video_selector = current_selector
- audio_selector = _parse_format_selection(tokens, inside_merge=True)
- if not video_selector or not audio_selector:
- raise syntax_error('"+" must be between two format selectors', start)
- current_selector = FormatSelector(MERGE, (video_selector, audio_selector), [])
- else:
- raise syntax_error('Operator not recognized: "{0}"'.format(string), start)
- elif type == tokenize.ENDMARKER:
- break
- if current_selector:
- selectors.append(current_selector)
- return selectors
-
- def _build_selector_function(selector):
- if isinstance(selector, list):
- fs = [_build_selector_function(s) for s in selector]
-
- def selector_function(ctx):
- for f in fs:
- for format in f(ctx):
- yield format
- return selector_function
- elif selector.type == GROUP:
- selector_function = _build_selector_function(selector.selector)
- elif selector.type == PICKFIRST:
- fs = [_build_selector_function(s) for s in selector.selector]
-
- def selector_function(ctx):
- for f in fs:
- picked_formats = list(f(ctx))
- if picked_formats:
- return picked_formats
- return []
- elif selector.type == SINGLE:
- format_spec = selector.selector
-
- def selector_function(ctx):
- formats = list(ctx['formats'])
- if not formats:
- return
- if format_spec == 'all':
- for f in formats:
- yield f
- elif format_spec in ['best', 'worst', None]:
- format_idx = 0 if format_spec == 'worst' else -1
- audiovideo_formats = [
- f for f in formats
- if f.get('vcodec') != 'none' and f.get('acodec') != 'none']
- if audiovideo_formats:
- yield audiovideo_formats[format_idx]
- # for extractors with incomplete formats (audio only (soundcloud)
- # or video only (imgur)) we will fallback to best/worst
- # {video,audio}-only format
- elif ctx['incomplete_formats']:
- yield formats[format_idx]
- elif format_spec == 'bestaudio':
- audio_formats = [
- f for f in formats
- if f.get('vcodec') == 'none']
- if audio_formats:
- yield audio_formats[-1]
- elif format_spec == 'worstaudio':
- audio_formats = [
- f for f in formats
- if f.get('vcodec') == 'none']
- if audio_formats:
- yield audio_formats[0]
- elif format_spec == 'bestvideo':
- video_formats = [
- f for f in formats
- if f.get('acodec') == 'none']
- if video_formats:
- yield video_formats[-1]
- elif format_spec == 'worstvideo':
- video_formats = [
- f for f in formats
- if f.get('acodec') == 'none']
- if video_formats:
- yield video_formats[0]
- else:
- extensions = ['mp4', 'flv', 'webm', '3gp', 'm4a', 'mp3', 'ogg', 'aac', 'wav']
- if format_spec in extensions:
- filter_f = lambda f: f['ext'] == format_spec
- else:
- filter_f = lambda f: f['format_id'] == format_spec
- matches = list(filter(filter_f, formats))
- if matches:
- yield matches[-1]
- elif selector.type == MERGE:
- def _merge(formats_info):
- format_1, format_2 = [f['format_id'] for f in formats_info]
- # The first format must contain the video and the
- # second the audio
- if formats_info[0].get('vcodec') == 'none':
- self.report_error('The first format must '
- 'contain the video, try using '
- '"-f %s+%s"' % (format_2, format_1))
- return
- # Formats must be opposite (video+audio)
- if formats_info[0].get('acodec') == 'none' and formats_info[1].get('acodec') == 'none':
- self.report_error(
- 'Both formats %s and %s are video-only, you must specify "-f video+audio"'
- % (format_1, format_2))
- return
- output_ext = (
- formats_info[0]['ext']
- if self.params.get('merge_output_format') is None
- else self.params['merge_output_format'])
- return {
- 'requested_formats': formats_info,
- 'format': '%s+%s' % (formats_info[0].get('format'),
- formats_info[1].get('format')),
- 'format_id': '%s+%s' % (formats_info[0].get('format_id'),
- formats_info[1].get('format_id')),
- 'width': formats_info[0].get('width'),
- 'height': formats_info[0].get('height'),
- 'resolution': formats_info[0].get('resolution'),
- 'fps': formats_info[0].get('fps'),
- 'vcodec': formats_info[0].get('vcodec'),
- 'vbr': formats_info[0].get('vbr'),
- 'stretched_ratio': formats_info[0].get('stretched_ratio'),
- 'acodec': formats_info[1].get('acodec'),
- 'abr': formats_info[1].get('abr'),
- 'ext': output_ext,
- }
- video_selector, audio_selector = map(_build_selector_function, selector.selector)
-
- def selector_function(ctx):
- for pair in itertools.product(
- video_selector(copy.deepcopy(ctx)), audio_selector(copy.deepcopy(ctx))):
- yield _merge(pair)
-
- filters = [self._build_format_filter(f) for f in selector.filters]
-
- def final_selector(ctx):
- ctx_copy = copy.deepcopy(ctx)
- for _filter in filters:
- ctx_copy['formats'] = list(filter(_filter, ctx_copy['formats']))
- return selector_function(ctx_copy)
- return final_selector
-
- stream = io.BytesIO(format_spec.encode('utf-8'))
- try:
- tokens = list(_remove_unused_ops(compat_tokenize_tokenize(stream.readline)))
- except tokenize.TokenError:
- raise syntax_error('Missing closing/opening brackets or parenthesis', (0, len(format_spec)))
-
- class TokenIterator(object):
- def __init__(self, tokens):
- self.tokens = tokens
- self.counter = 0
-
- def __iter__(self):
- return self
-
- def __next__(self):
- if self.counter >= len(self.tokens):
- raise StopIteration()
- value = self.tokens[self.counter]
- self.counter += 1
- return value
-
- next = __next__
-
- def restore_last_token(self):
- self.counter -= 1
-
- parsed_selector = _parse_format_selection(iter(TokenIterator(tokens)))
- return _build_selector_function(parsed_selector)
-
- def _calc_headers(self, info_dict):
- res = std_headers.copy()
-
- add_headers = info_dict.get('http_headers')
- if add_headers:
- res.update(add_headers)
-
- cookies = self._calc_cookies(info_dict)
- if cookies:
- res['Cookie'] = cookies
-
- if 'X-Forwarded-For' not in res:
- x_forwarded_for_ip = info_dict.get('__x_forwarded_for_ip')
- if x_forwarded_for_ip:
- res['X-Forwarded-For'] = x_forwarded_for_ip
-
- return res
-
- def _calc_cookies(self, info_dict):
- pr = sanitized_Request(info_dict['url'])
- self.cookiejar.add_cookie_header(pr)
- return pr.get_header('Cookie')
-
- def process_video_result(self, info_dict, download=True):
- assert info_dict.get('_type', 'video') == 'video'
-
- if 'id' not in info_dict:
- raise ExtractorError('Missing "id" field in extractor result')
- if 'title' not in info_dict:
- raise ExtractorError('Missing "title" field in extractor result')
-
- def report_force_conversion(field, field_not, conversion):
- self.report_warning(
- '"%s" field is not %s - forcing %s conversion, there is an error in extractor'
- % (field, field_not, conversion))
-
- def sanitize_string_field(info, string_field):
- field = info.get(string_field)
- if field is None or isinstance(field, compat_str):
- return
- report_force_conversion(string_field, 'a string', 'string')
- info[string_field] = compat_str(field)
-
- def sanitize_numeric_fields(info):
- for numeric_field in self._NUMERIC_FIELDS:
- field = info.get(numeric_field)
- if field is None or isinstance(field, compat_numeric_types):
- continue
- report_force_conversion(numeric_field, 'numeric', 'int')
- info[numeric_field] = int_or_none(field)
-
- sanitize_string_field(info_dict, 'id')
- sanitize_numeric_fields(info_dict)
-
- if 'playlist' not in info_dict:
- # It isn't part of a playlist
- info_dict['playlist'] = None
- info_dict['playlist_index'] = None
-
- thumbnails = info_dict.get('thumbnails')
- if thumbnails is None:
- thumbnail = info_dict.get('thumbnail')
- if thumbnail:
- info_dict['thumbnails'] = thumbnails = [{'url': thumbnail}]
- if thumbnails:
- thumbnails.sort(key=lambda t: (
- t.get('preference') if t.get('preference') is not None else -1,
- t.get('width') if t.get('width') is not None else -1,
- t.get('height') if t.get('height') is not None else -1,
- t.get('id') if t.get('id') is not None else '', t.get('url')))
- for i, t in enumerate(thumbnails):
- t['url'] = sanitize_url(t['url'])
- if t.get('width') and t.get('height'):
- t['resolution'] = '%dx%d' % (t['width'], t['height'])
- if t.get('id') is None:
- t['id'] = '%d' % i
-
- if self.params.get('list_thumbnails'):
- self.list_thumbnails(info_dict)
- return
-
- thumbnail = info_dict.get('thumbnail')
- if thumbnail:
- info_dict['thumbnail'] = sanitize_url(thumbnail)
- elif thumbnails:
- info_dict['thumbnail'] = thumbnails[-1]['url']
-
- if 'display_id' not in info_dict and 'id' in info_dict:
- info_dict['display_id'] = info_dict['id']
-
- if info_dict.get('upload_date') is None and info_dict.get('timestamp') is not None:
- # Working around out-of-range timestamp values (e.g. negative ones on Windows,
- # see http://bugs.python.org/issue1646728)
- try:
- upload_date = datetime.datetime.utcfromtimestamp(info_dict['timestamp'])
- info_dict['upload_date'] = upload_date.strftime('%Y%m%d')
- except (ValueError, OverflowError, OSError):
- pass
-
- # Auto generate title fields corresponding to the *_number fields when missing
- # in order to always have clean titles. This is very common for TV series.
- for field in ('chapter', 'season', 'episode'):
- if info_dict.get('%s_number' % field) is not None and not info_dict.get(field):
- info_dict[field] = '%s %d' % (field.capitalize(), info_dict['%s_number' % field])
-
- for cc_kind in ('subtitles', 'automatic_captions'):
- cc = info_dict.get(cc_kind)
- if cc:
- for _, subtitle in cc.items():
- for subtitle_format in subtitle:
- if subtitle_format.get('url'):
- subtitle_format['url'] = sanitize_url(subtitle_format['url'])
- if subtitle_format.get('ext') is None:
- subtitle_format['ext'] = determine_ext(subtitle_format['url']).lower()
-
- automatic_captions = info_dict.get('automatic_captions')
- subtitles = info_dict.get('subtitles')
-
- if self.params.get('listsubtitles', False):
- if 'automatic_captions' in info_dict:
- self.list_subtitles(
- info_dict['id'], automatic_captions, 'automatic captions')
- self.list_subtitles(info_dict['id'], subtitles, 'subtitles')
- return
-
- info_dict['requested_subtitles'] = self.process_subtitles(
- info_dict['id'], subtitles, automatic_captions)
-
- # We now pick which formats have to be downloaded
- if info_dict.get('formats') is None:
- # There's only one format available
- formats = [info_dict]
- else:
- formats = info_dict['formats']
-
- if not formats:
- raise ExtractorError('No video formats found!')
-
- def is_wellformed(f):
- url = f.get('url')
- if not url:
- self.report_warning(
- '"url" field is missing or empty - skipping format, '
- 'there is an error in extractor')
- return False
- if isinstance(url, bytes):
- sanitize_string_field(f, 'url')
- return True
-
- # Filter out malformed formats for better extraction robustness
- formats = list(filter(is_wellformed, formats))
-
- formats_dict = {}
-
- # We check that all the formats have the format and format_id fields
- for i, format in enumerate(formats):
- sanitize_string_field(format, 'format_id')
- sanitize_numeric_fields(format)
- format['url'] = sanitize_url(format['url'])
- if not format.get('format_id'):
- format['format_id'] = compat_str(i)
- else:
- # Sanitize format_id from characters used in format selector expression
- format['format_id'] = re.sub(r'[\s,/+\[\]()]', '_', format['format_id'])
- format_id = format['format_id']
- if format_id not in formats_dict:
- formats_dict[format_id] = []
- formats_dict[format_id].append(format)
-
- # Make sure all formats have unique format_id
- for format_id, ambiguous_formats in formats_dict.items():
- if len(ambiguous_formats) > 1:
- for i, format in enumerate(ambiguous_formats):
- format['format_id'] = '%s-%d' % (format_id, i)
-
- for i, format in enumerate(formats):
- if format.get('format') is None:
- format['format'] = '{id} - {res}{note}'.format(
- id=format['format_id'],
- res=self.format_resolution(format),
- note=' ({0})'.format(format['format_note']) if format.get('format_note') is not None else '',
- )
- # Automatically determine file extension if missing
- if format.get('ext') is None:
- format['ext'] = determine_ext(format['url']).lower()
- # Automatically determine protocol if missing (useful for format
- # selection purposes)
- if format.get('protocol') is None:
- format['protocol'] = determine_protocol(format)
- # Add HTTP headers, so that external programs can use them from the
- # json output
- full_format_info = info_dict.copy()
- full_format_info.update(format)
- format['http_headers'] = self._calc_headers(full_format_info)
- # Remove private housekeeping stuff
- if '__x_forwarded_for_ip' in info_dict:
- del info_dict['__x_forwarded_for_ip']
-
- # TODO Central sorting goes here
-
- if formats[0] is not info_dict:
- # only set the 'formats' fields if the original info_dict list them
- # otherwise we end up with a circular reference, the first (and unique)
- # element in the 'formats' field in info_dict is info_dict itself,
- # which can't be exported to json
- info_dict['formats'] = formats
- if self.params.get('listformats'):
- self.list_formats(info_dict)
- return
-
- req_format = self.params.get('format')
- if req_format is None:
- req_format = self._default_format_spec(info_dict, download=download)
- if self.params.get('verbose'):
- self.to_stdout('[debug] Default format spec: %s' % req_format)
-
- format_selector = self.build_format_selector(req_format)
-
- # While in format selection we may need to have an access to the original
- # format set in order to calculate some metrics or do some processing.
- # For now we need to be able to guess whether original formats provided
- # by extractor are incomplete or not (i.e. whether extractor provides only
- # video-only or audio-only formats) for proper formats selection for
- # extractors with such incomplete formats (see
- # https://github.com/rg3/youtube-dl/pull/5556).
- # Since formats may be filtered during format selection and may not match
- # the original formats the results may be incorrect. Thus original formats
- # or pre-calculated metrics should be passed to format selection routines
- # as well.
- # We will pass a context object containing all necessary additional data
- # instead of just formats.
- # This fixes incorrect format selection issue (see
- # https://github.com/rg3/youtube-dl/issues/10083).
- incomplete_formats = (
- # All formats are video-only or
- all(f.get('vcodec') != 'none' and f.get('acodec') == 'none' for f in formats) or
- # all formats are audio-only
- all(f.get('vcodec') == 'none' and f.get('acodec') != 'none' for f in formats))
-
- ctx = {
- 'formats': formats,
- 'incomplete_formats': incomplete_formats,
- }
-
- formats_to_download = list(format_selector(ctx))
- if not formats_to_download:
- raise ExtractorError('requested format not available',
- expected=True)
-
- if download:
- if len(formats_to_download) > 1:
- self.to_screen('[info] %s: downloading video in %s formats' % (info_dict['id'], len(formats_to_download)))
- for format in formats_to_download:
- new_info = dict(info_dict)
- new_info.update(format)
- self.process_info(new_info)
- # We update the info dict with the best quality format (backwards compatibility)
- info_dict.update(formats_to_download[-1])
- return info_dict
-
- def process_subtitles(self, video_id, normal_subtitles, automatic_captions):
- """Select the requested subtitles and their format"""
- available_subs = {}
- if normal_subtitles and self.params.get('writesubtitles'):
- available_subs.update(normal_subtitles)
- if automatic_captions and self.params.get('writeautomaticsub'):
- for lang, cap_info in automatic_captions.items():
- if lang not in available_subs:
- available_subs[lang] = cap_info
-
- if (not self.params.get('writesubtitles') and not
- self.params.get('writeautomaticsub') or not
- available_subs):
- return None
-
- if self.params.get('allsubtitles', False):
- requested_langs = available_subs.keys()
- else:
- if self.params.get('subtitleslangs', False):
- requested_langs = self.params.get('subtitleslangs')
- elif 'en' in available_subs:
- requested_langs = ['en']
- else:
- requested_langs = [list(available_subs.keys())[0]]
-
- formats_query = self.params.get('subtitlesformat', 'best')
- formats_preference = formats_query.split('/') if formats_query else []
- subs = {}
- for lang in requested_langs:
- formats = available_subs.get(lang)
- if formats is None:
- self.report_warning('%s subtitles not available for %s' % (lang, video_id))
- continue
- for ext in formats_preference:
- if ext == 'best':
- f = formats[-1]
- break
- matches = list(filter(lambda f: f['ext'] == ext, formats))
- if matches:
- f = matches[-1]
- break
- else:
- f = formats[-1]
- self.report_warning(
- 'No subtitle format found matching "%s" for language %s, '
- 'using %s' % (formats_query, lang, f['ext']))
- subs[lang] = f
- return subs
-
- def process_info(self, info_dict):
- """Process a single resolved IE result."""
-
- assert info_dict.get('_type', 'video') == 'video'
-
- max_downloads = self.params.get('max_downloads')
- if max_downloads is not None:
- if self._num_downloads >= int(max_downloads):
- raise MaxDownloadsReached()
-
- info_dict['fulltitle'] = info_dict['title']
- if len(info_dict['title']) > 200:
- info_dict['title'] = info_dict['title'][:197] + '...'
-
- if 'format' not in info_dict:
- info_dict['format'] = info_dict['ext']
-
- reason = self._match_entry(info_dict, incomplete=False)
- if reason is not None:
- self.to_screen('[download] ' + reason)
- return
-
- self._num_downloads += 1
-
- info_dict['_filename'] = filename = self.prepare_filename(info_dict)
-
- # Forced printings
- if self.params.get('forcetitle', False):
- self.to_stdout(info_dict['fulltitle'])
- if self.params.get('forceid', False):
- self.to_stdout(info_dict['id'])
- if self.params.get('forceurl', False):
- if info_dict.get('requested_formats') is not None:
- for f in info_dict['requested_formats']:
- self.to_stdout(f['url'] + f.get('play_path', ''))
- else:
- # For RTMP URLs, also include the playpath
- self.to_stdout(info_dict['url'] + info_dict.get('play_path', ''))
- if self.params.get('forcethumbnail', False) and info_dict.get('thumbnail') is not None:
- self.to_stdout(info_dict['thumbnail'])
- if self.params.get('forcedescription', False) and info_dict.get('description') is not None:
- self.to_stdout(info_dict['description'])
- if self.params.get('forcefilename', False) and filename is not None:
- self.to_stdout(filename)
- if self.params.get('forceduration', False) and info_dict.get('duration') is not None:
- self.to_stdout(formatSeconds(info_dict['duration']))
- if self.params.get('forceformat', False):
- self.to_stdout(info_dict['format'])
- if self.params.get('forcejson', False):
- self.to_stdout(json.dumps(info_dict))
-
- # Do nothing else if in simulate mode
- if self.params.get('simulate', False):
- return
-
- if filename is None:
- return
-
- def ensure_dir_exists(path):
- try:
- dn = os.path.dirname(path)
- if dn and not os.path.exists(dn):
- os.makedirs(dn)
- return True
- except (OSError, IOError) as err:
- self.report_error('unable to create directory ' + error_to_compat_str(err))
- return False
-
- if not ensure_dir_exists(sanitize_path(encodeFilename(filename))):
- return
-
- if self.params.get('writedescription', False):
- descfn = replace_extension(filename, 'description', info_dict.get('ext'))
- if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(descfn)):
- self.to_screen('[info] Video description is already present')
- elif info_dict.get('description') is None:
- self.report_warning('There\'s no description to write.')
- else:
- try:
- self.to_screen('[info] Writing video description to: ' + descfn)
- with io.open(encodeFilename(descfn), 'w', encoding='utf-8') as descfile:
- descfile.write(info_dict['description'])
- except (OSError, IOError):
- self.report_error('Cannot write description file ' + descfn)
- return
-
- if self.params.get('writeannotations', False):
- annofn = replace_extension(filename, 'annotations.xml', info_dict.get('ext'))
- if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(annofn)):
- self.to_screen('[info] Video annotations are already present')
- else:
- try:
- self.to_screen('[info] Writing video annotations to: ' + annofn)
- with io.open(encodeFilename(annofn), 'w', encoding='utf-8') as annofile:
- annofile.write(info_dict['annotations'])
- except (KeyError, TypeError):
- self.report_warning('There are no annotations to write.')
- except (OSError, IOError):
- self.report_error('Cannot write annotations file: ' + annofn)
- return
-
- subtitles_are_requested = any([self.params.get('writesubtitles', False),
- self.params.get('writeautomaticsub')])
-
- if subtitles_are_requested and info_dict.get('requested_subtitles'):
- # subtitles download errors are already managed as troubles in relevant IE
- # that way it will silently go on when used with unsupporting IE
- subtitles = info_dict['requested_subtitles']
- ie = self.get_info_extractor(info_dict['extractor_key'])
- for sub_lang, sub_info in subtitles.items():
- sub_format = sub_info['ext']
- sub_filename = subtitles_filename(filename, sub_lang, sub_format)
- if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(sub_filename)):
- self.to_screen('[info] Video subtitle %s.%s is already present' % (sub_lang, sub_format))
- else:
- self.to_screen('[info] Writing video subtitles to: ' + sub_filename)
- if sub_info.get('data') is not None:
- try:
- # Use newline='' to prevent conversion of newline characters
- # See https://github.com/rg3/youtube-dl/issues/10268
- with io.open(encodeFilename(sub_filename), 'w', encoding='utf-8', newline='') as subfile:
- subfile.write(sub_info['data'])
- except (OSError, IOError):
- self.report_error('Cannot write subtitles file ' + sub_filename)
- return
- else:
- try:
- sub_data = ie._request_webpage(
- sub_info['url'], info_dict['id'], note=False).read()
- with io.open(encodeFilename(sub_filename), 'wb') as subfile:
- subfile.write(sub_data)
- except (ExtractorError, IOError, OSError, ValueError) as err:
- self.report_warning('Unable to download subtitle for "%s": %s' %
- (sub_lang, error_to_compat_str(err)))
- continue
-
- if self.params.get('writeinfojson', False):
- infofn = replace_extension(filename, 'info.json', info_dict.get('ext'))
- if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(infofn)):
- self.to_screen('[info] Video description metadata is already present')
- else:
- self.to_screen('[info] Writing video description metadata as JSON to: ' + infofn)
- try:
- write_json_file(self.filter_requested_info(info_dict), infofn)
- except (OSError, IOError):
- self.report_error('Cannot write metadata to JSON file ' + infofn)
- return
-
- self._write_thumbnails(info_dict, filename)
-
- if not self.params.get('skip_download', False):
- try:
- def dl(name, info):
- fd = get_suitable_downloader(info, self.params)(self, self.params)
- for ph in self._progress_hooks:
- fd.add_progress_hook(ph)
- if self.params.get('verbose'):
- self.to_stdout('[debug] Invoking downloader on %r' % info.get('url'))
- return fd.download(name, info)
-
- if info_dict.get('requested_formats') is not None:
- downloaded = []
- success = True
- merger = FFmpegMergerPP(self)
- if not merger.available:
- postprocessors = []
- self.report_warning('You have requested multiple '
- 'formats but ffmpeg or avconv are not installed.'
- ' The formats won\'t be merged.')
- else:
- postprocessors = [merger]
-
- def compatible_formats(formats):
- video, audio = formats
- # Check extension
- video_ext, audio_ext = video.get('ext'), audio.get('ext')
- if video_ext and audio_ext:
- COMPATIBLE_EXTS = (
- ('mp3', 'mp4', 'm4a', 'm4p', 'm4b', 'm4r', 'm4v', 'ismv', 'isma'),
- ('webm')
- )
- for exts in COMPATIBLE_EXTS:
- if video_ext in exts and audio_ext in exts:
- return True
- # TODO: Check acodec/vcodec
- return False
-
- filename_real_ext = os.path.splitext(filename)[1][1:]
- filename_wo_ext = (
- os.path.splitext(filename)[0]
- if filename_real_ext == info_dict['ext']
- else filename)
- requested_formats = info_dict['requested_formats']
- if self.params.get('merge_output_format') is None and not compatible_formats(requested_formats):
- info_dict['ext'] = 'mkv'
- self.report_warning(
- 'Requested formats are incompatible for merge and will be merged into mkv.')
- # Ensure filename always has a correct extension for successful merge
- filename = '%s.%s' % (filename_wo_ext, info_dict['ext'])
- if os.path.exists(encodeFilename(filename)):
- self.to_screen(
- '[download] %s has already been downloaded and '
- 'merged' % filename)
- else:
- for f in requested_formats:
- new_info = dict(info_dict)
- new_info.update(f)
- fname = prepend_extension(
- self.prepare_filename(new_info),
- 'f%s' % f['format_id'], new_info['ext'])
- if not ensure_dir_exists(fname):
- return
- downloaded.append(fname)
- partial_success = dl(fname, new_info)
- success = success and partial_success
- info_dict['__postprocessors'] = postprocessors
- info_dict['__files_to_merge'] = downloaded
- else:
- # Just a single file
- success = dl(filename, info_dict)
- except (compat_urllib_error.URLError, compat_http_client.HTTPException, socket.error) as err:
- self.report_error('unable to download video data: %s' % error_to_compat_str(err))
- return
- except (OSError, IOError) as err:
- raise UnavailableVideoError(err)
- except (ContentTooShortError, ) as err:
- self.report_error('content too short (expected %s bytes and served %s)' % (err.expected, err.downloaded))
- return
-
- if success and filename != '-':
- # Fixup content
- fixup_policy = self.params.get('fixup')
- if fixup_policy is None:
- fixup_policy = 'detect_or_warn'
-
- INSTALL_FFMPEG_MESSAGE = 'Install ffmpeg or avconv to fix this automatically.'
-
- stretched_ratio = info_dict.get('stretched_ratio')
- if stretched_ratio is not None and stretched_ratio != 1:
- if fixup_policy == 'warn':
- self.report_warning('%s: Non-uniform pixel ratio (%s)' % (
- info_dict['id'], stretched_ratio))
- elif fixup_policy == 'detect_or_warn':
- stretched_pp = FFmpegFixupStretchedPP(self)
- if stretched_pp.available:
- info_dict.setdefault('__postprocessors', [])
- info_dict['__postprocessors'].append(stretched_pp)
- else:
- self.report_warning(
- '%s: Non-uniform pixel ratio (%s). %s'
- % (info_dict['id'], stretched_ratio, INSTALL_FFMPEG_MESSAGE))
- else:
- assert fixup_policy in ('ignore', 'never')
-
- if (info_dict.get('requested_formats') is None and
- info_dict.get('container') == 'm4a_dash'):
- if fixup_policy == 'warn':
- self.report_warning(
- '%s: writing DASH m4a. '
- 'Only some players support this container.'
- % info_dict['id'])
- elif fixup_policy == 'detect_or_warn':
- fixup_pp = FFmpegFixupM4aPP(self)
- if fixup_pp.available:
- info_dict.setdefault('__postprocessors', [])
- info_dict['__postprocessors'].append(fixup_pp)
- else:
- self.report_warning(
- '%s: writing DASH m4a. '
- 'Only some players support this container. %s'
- % (info_dict['id'], INSTALL_FFMPEG_MESSAGE))
- else:
- assert fixup_policy in ('ignore', 'never')
-
- if (info_dict.get('protocol') == 'm3u8_native' or
- info_dict.get('protocol') == 'm3u8' and
- self.params.get('hls_prefer_native')):
- if fixup_policy == 'warn':
- self.report_warning('%s: malformed AAC bitstream detected.' % (
- info_dict['id']))
- elif fixup_policy == 'detect_or_warn':
- fixup_pp = FFmpegFixupM3u8PP(self)
- if fixup_pp.available:
- info_dict.setdefault('__postprocessors', [])
- info_dict['__postprocessors'].append(fixup_pp)
- else:
- self.report_warning(
- '%s: malformed AAC bitstream detected. %s'
- % (info_dict['id'], INSTALL_FFMPEG_MESSAGE))
- else:
- assert fixup_policy in ('ignore', 'never')
-
- try:
- self.post_process(filename, info_dict)
- except (PostProcessingError) as err:
- self.report_error('postprocessing: %s' % str(err))
- return
- self.record_download_archive(info_dict)
-
- def download(self, url_list):
- """Download a given list of URLs."""
- outtmpl = self.params.get('outtmpl', DEFAULT_OUTTMPL)
- if (len(url_list) > 1 and
- outtmpl != '-' and
- '%' not in outtmpl and
- self.params.get('max_downloads') != 1):
- raise SameFileError(outtmpl)
-
- for url in url_list:
- try:
- # It also downloads the videos
- res = self.extract_info(
- url, force_generic_extractor=self.params.get('force_generic_extractor', False))
- except UnavailableVideoError:
- self.report_error('unable to download video')
- except MaxDownloadsReached:
- self.to_screen('[info] Maximum number of downloaded files reached.')
- raise
- else:
- if self.params.get('dump_single_json', False):
- self.to_stdout(json.dumps(res))
-
- return self._download_retcode
-
- def download_with_info_file(self, info_filename):
- with contextlib.closing(fileinput.FileInput(
- [info_filename], mode='r',
- openhook=fileinput.hook_encoded('utf-8'))) as f:
- # FileInput doesn't have a read method, we can't call json.load
- info = self.filter_requested_info(json.loads('\n'.join(f)))
- try:
- self.process_ie_result(info, download=True)
- except DownloadError:
- webpage_url = info.get('webpage_url')
- if webpage_url is not None:
- self.report_warning('The info failed to download, trying with "%s"' % webpage_url)
- return self.download([webpage_url])
- else:
- raise
- return self._download_retcode
-
- @staticmethod
- def filter_requested_info(info_dict):
- return dict(
- (k, v) for k, v in info_dict.items()
- if k not in ['requested_formats', 'requested_subtitles'])
-
- def post_process(self, filename, ie_info):
- """Run all the postprocessors on the given file."""
- info = dict(ie_info)
- info['filepath'] = filename
- pps_chain = []
- if ie_info.get('__postprocessors') is not None:
- pps_chain.extend(ie_info['__postprocessors'])
- pps_chain.extend(self._pps)
- for pp in pps_chain:
- files_to_delete = []
- try:
- files_to_delete, info = pp.run(info)
- except PostProcessingError as e:
- self.report_error(e.msg)
- if files_to_delete and not self.params.get('keepvideo', False):
- for old_filename in files_to_delete:
- self.to_screen('Deleting original file %s (pass -k to keep)' % old_filename)
- try:
- os.remove(encodeFilename(old_filename))
- except (IOError, OSError):
- self.report_warning('Unable to remove downloaded original file')
-
- def _make_archive_id(self, info_dict):
- # Future-proof against any change in case
- # and backwards compatibility with prior versions
- extractor = info_dict.get('extractor_key')
- if extractor is None:
- if 'id' in info_dict:
- extractor = info_dict.get('ie_key') # key in a playlist
- if extractor is None:
- return None # Incomplete video information
- return extractor.lower() + ' ' + info_dict['id']
-
- def in_download_archive(self, info_dict):
- fn = self.params.get('download_archive')
- if fn is None:
- return False
-
- vid_id = self._make_archive_id(info_dict)
- if vid_id is None:
- return False # Incomplete video information
-
- try:
- with locked_file(fn, 'r', encoding='utf-8') as archive_file:
- for line in archive_file:
- if line.strip() == vid_id:
- return True
- except IOError as ioe:
- if ioe.errno != errno.ENOENT:
- raise
- return False
-
- def record_download_archive(self, info_dict):
- fn = self.params.get('download_archive')
- if fn is None:
- return
- vid_id = self._make_archive_id(info_dict)
- assert vid_id
- with locked_file(fn, 'a', encoding='utf-8') as archive_file:
- archive_file.write(vid_id + '\n')
-
- @staticmethod
- def format_resolution(format, default='unknown'):
- if format.get('vcodec') == 'none':
- return 'audio only'
- if format.get('resolution') is not None:
- return format['resolution']
- if format.get('height') is not None:
- if format.get('width') is not None:
- res = '%sx%s' % (format['width'], format['height'])
- else:
- res = '%sp' % format['height']
- elif format.get('width') is not None:
- res = '%dx?' % format['width']
- else:
- res = default
- return res
-
- def _format_note(self, fdict):
- res = ''
- if fdict.get('ext') in ['f4f', 'f4m']:
- res += '(unsupported) '
- if fdict.get('language'):
- if res:
- res += ' '
- res += '[%s] ' % fdict['language']
- if fdict.get('format_note') is not None:
- res += fdict['format_note'] + ' '
- if fdict.get('tbr') is not None:
- res += '%4dk ' % fdict['tbr']
- if fdict.get('container') is not None:
- if res:
- res += ', '
- res += '%s container' % fdict['container']
- if (fdict.get('vcodec') is not None and
- fdict.get('vcodec') != 'none'):
- if res:
- res += ', '
- res += fdict['vcodec']
- if fdict.get('vbr') is not None:
- res += '@'
- elif fdict.get('vbr') is not None and fdict.get('abr') is not None:
- res += 'video@'
- if fdict.get('vbr') is not None:
- res += '%4dk' % fdict['vbr']
- if fdict.get('fps') is not None:
- if res:
- res += ', '
- res += '%sfps' % fdict['fps']
- if fdict.get('acodec') is not None:
- if res:
- res += ', '
- if fdict['acodec'] == 'none':
- res += 'video only'
- else:
- res += '%-5s' % fdict['acodec']
- elif fdict.get('abr') is not None:
- if res:
- res += ', '
- res += 'audio'
- if fdict.get('abr') is not None:
- res += '@%3dk' % fdict['abr']
- if fdict.get('asr') is not None:
- res += ' (%5dHz)' % fdict['asr']
- if fdict.get('filesize') is not None:
- if res:
- res += ', '
- res += format_bytes(fdict['filesize'])
- elif fdict.get('filesize_approx') is not None:
- if res:
- res += ', '
- res += '~' + format_bytes(fdict['filesize_approx'])
- return res
-
- def list_formats(self, info_dict):
- formats = info_dict.get('formats', [info_dict])
- table = [
- [f['format_id'], f['ext'], self.format_resolution(f), self._format_note(f)]
- for f in formats
- if f.get('preference') is None or f['preference'] >= -1000]
- if len(formats) > 1:
- table[-1][-1] += (' ' if table[-1][-1] else '') + '(best)'
-
- header_line = ['format code', 'extension', 'resolution', 'note']
- self.to_screen(
- '[info] Available formats for %s:\n%s' %
- (info_dict['id'], render_table(header_line, table)))
-
- def list_thumbnails(self, info_dict):
- thumbnails = info_dict.get('thumbnails')
- if not thumbnails:
- self.to_screen('[info] No thumbnails present for %s' % info_dict['id'])
- return
-
- self.to_screen(
- '[info] Thumbnails for %s:' % info_dict['id'])
- self.to_screen(render_table(
- ['ID', 'width', 'height', 'URL'],
- [[t['id'], t.get('width', 'unknown'), t.get('height', 'unknown'), t['url']] for t in thumbnails]))
-
- def list_subtitles(self, video_id, subtitles, name='subtitles'):
- if not subtitles:
- self.to_screen('%s has no %s' % (video_id, name))
- return
- self.to_screen(
- 'Available %s for %s:' % (name, video_id))
- self.to_screen(render_table(
- ['Language', 'formats'],
- [[lang, ', '.join(f['ext'] for f in reversed(formats))]
- for lang, formats in subtitles.items()]))
-
- def urlopen(self, req):
- """ Start an HTTP download """
- if isinstance(req, compat_basestring):
- req = sanitized_Request(req)
- return self._opener.open(req, timeout=self._socket_timeout)
-
- def print_debug_header(self):
- if not self.params.get('verbose'):
- return
-
- if type('') is not compat_str:
- # Python 2.6 on SLES11 SP1 (https://github.com/rg3/youtube-dl/issues/3326)
- self.report_warning(
- 'Your Python is broken! Update to a newer and supported version')
-
- stdout_encoding = getattr(
- sys.stdout, 'encoding', 'missing (%s)' % type(sys.stdout).__name__)
- encoding_str = (
- '[debug] Encodings: locale %s, fs %s, out %s, pref %s\n' % (
- locale.getpreferredencoding(),
- sys.getfilesystemencoding(),
- stdout_encoding,
- self.get_encoding()))
- write_string(encoding_str, encoding=None)
-
- self._write_string('[debug] youtube-dl version ' + __version__ + '\n')
- if _LAZY_LOADER:
- self._write_string('[debug] Lazy loading extractors enabled' + '\n')
- try:
- sp = subprocess.Popen(
- ['git', 'rev-parse', '--short', 'HEAD'],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE,
- cwd=os.path.dirname(os.path.abspath(__file__)))
- out, err = sp.communicate()
- out = out.decode().strip()
- if re.match('[0-9a-f]+', out):
- self._write_string('[debug] Git HEAD: ' + out + '\n')
- except Exception:
- try:
- sys.exc_clear()
- except Exception:
- pass
-
- def python_implementation():
- impl_name = platform.python_implementation()
- if impl_name == 'PyPy' and hasattr(sys, 'pypy_version_info'):
- return impl_name + ' version %d.%d.%d' % sys.pypy_version_info[:3]
- return impl_name
-
- self._write_string('[debug] Python version %s (%s) - %s\n' % (
- platform.python_version(), python_implementation(),
- platform_name()))
-
- exe_versions = FFmpegPostProcessor.get_versions(self)
- exe_versions['rtmpdump'] = rtmpdump_version()
- exe_versions['phantomjs'] = PhantomJSwrapper._version()
- exe_str = ', '.join(
- '%s %s' % (exe, v)
- for exe, v in sorted(exe_versions.items())
- if v
- )
- if not exe_str:
- exe_str = 'none'
- self._write_string('[debug] exe versions: %s\n' % exe_str)
-
- proxy_map = {}
- for handler in self._opener.handlers:
- if hasattr(handler, 'proxies'):
- proxy_map.update(handler.proxies)
- self._write_string('[debug] Proxy map: ' + compat_str(proxy_map) + '\n')
-
- if self.params.get('call_home', False):
- ipaddr = self.urlopen('https://yt-dl.org/ip').read().decode('utf-8')
- self._write_string('[debug] Public IP address: %s\n' % ipaddr)
- latest_version = self.urlopen(
- 'https://yt-dl.org/latest/version').read().decode('utf-8')
- if version_tuple(latest_version) > version_tuple(__version__):
- self.report_warning(
- 'You are using an outdated version (newest version: %s)! '
- 'See https://yt-dl.org/update if you need help updating.' %
- latest_version)
-
- def _setup_opener(self):
- timeout_val = self.params.get('socket_timeout')
- self._socket_timeout = 600 if timeout_val is None else float(timeout_val)
-
- opts_cookiefile = self.params.get('cookiefile')
- opts_proxy = self.params.get('proxy')
-
- if opts_cookiefile is None:
- self.cookiejar = compat_cookiejar.CookieJar()
- else:
- opts_cookiefile = expand_path(opts_cookiefile)
- self.cookiejar = compat_cookiejar.MozillaCookieJar(
- opts_cookiefile)
- if os.access(opts_cookiefile, os.R_OK):
- self.cookiejar.load()
-
- cookie_processor = YoutubeDLCookieProcessor(self.cookiejar)
- if opts_proxy is not None:
- if opts_proxy == '':
- proxies = {}
- else:
- proxies = {'http': opts_proxy, 'https': opts_proxy}
- else:
- proxies = compat_urllib_request.getproxies()
- # Set HTTPS proxy to HTTP one if given (https://github.com/rg3/youtube-dl/issues/805)
- if 'http' in proxies and 'https' not in proxies:
- proxies['https'] = proxies['http']
- proxy_handler = PerRequestProxyHandler(proxies)
-
- debuglevel = 1 if self.params.get('debug_printtraffic') else 0
- https_handler = make_HTTPS_handler(self.params, debuglevel=debuglevel)
- ydlh = YoutubeDLHandler(self.params, debuglevel=debuglevel)
- data_handler = compat_urllib_request_DataHandler()
-
- # When passing our own FileHandler instance, build_opener won't add the
- # default FileHandler and allows us to disable the file protocol, which
- # can be used for malicious purposes (see
- # https://github.com/rg3/youtube-dl/issues/8227)
- file_handler = compat_urllib_request.FileHandler()
-
- def file_open(*args, **kwargs):
- raise compat_urllib_error.URLError('file:// scheme is explicitly disabled in youtube-dl for security reasons')
- file_handler.file_open = file_open
-
- opener = compat_urllib_request.build_opener(
- proxy_handler, https_handler, cookie_processor, ydlh, data_handler, file_handler)
-
- # Delete the default user-agent header, which would otherwise apply in
- # cases where our custom HTTP handler doesn't come into play
- # (See https://github.com/rg3/youtube-dl/issues/1309 for details)
- opener.addheaders = []
- self._opener = opener
-
- def encode(self, s):
- if isinstance(s, bytes):
- return s # Already encoded
-
- try:
- return s.encode(self.get_encoding())
- except UnicodeEncodeError as err:
- err.reason = err.reason + '. Check your system encoding configuration or use the --encoding option.'
- raise
-
- def get_encoding(self):
- encoding = self.params.get('encoding')
- if encoding is None:
- encoding = preferredencoding()
- return encoding
-
- def _write_thumbnails(self, info_dict, filename):
- if self.params.get('writethumbnail', False):
- thumbnails = info_dict.get('thumbnails')
- if thumbnails:
- thumbnails = [thumbnails[-1]]
- elif self.params.get('write_all_thumbnails', False):
- thumbnails = info_dict.get('thumbnails')
- else:
- return
-
- if not thumbnails:
- # No thumbnails present, so return immediately
- return
-
- for t in thumbnails:
- thumb_ext = determine_ext(t['url'], 'jpg')
- suffix = '_%s' % t['id'] if len(thumbnails) > 1 else ''
- thumb_display_id = '%s ' % t['id'] if len(thumbnails) > 1 else ''
- t['filename'] = thumb_filename = os.path.splitext(filename)[0] + suffix + '.' + thumb_ext
-
- if self.params.get('nooverwrites', False) and os.path.exists(encodeFilename(thumb_filename)):
- self.to_screen('[%s] %s: Thumbnail %sis already present' %
- (info_dict['extractor'], info_dict['id'], thumb_display_id))
- else:
- self.to_screen('[%s] %s: Downloading thumbnail %s...' %
- (info_dict['extractor'], info_dict['id'], thumb_display_id))
- try:
- uf = self.urlopen(t['url'])
- with open(encodeFilename(thumb_filename), 'wb') as thumbf:
- shutil.copyfileobj(uf, thumbf)
- self.to_screen('[%s] %s: Writing thumbnail %sto: %s' %
- (info_dict['extractor'], info_dict['id'], thumb_display_id, thumb_filename))
- except (compat_urllib_error.URLError, compat_http_client.HTTPException, socket.error) as err:
- self.report_warning('Unable to download thumbnail "%s": %s' %
- (t['url'], error_to_compat_str(err)))
diff --git a/youtube_dl/__init__.py b/youtube_dl/__init__.py
deleted file mode 100644
index ba435ea..0000000
--- a/youtube_dl/__init__.py
+++ /dev/null
@@ -1,481 +0,0 @@
-#!/usr/bin/env python
-# coding: utf-8
-
-from __future__ import unicode_literals
-
-__license__ = 'Public Domain'
-
-import codecs
-import io
-import os
-import random
-import sys
-
-
-from .options import (
- parseOpts,
-)
-from .compat import (
- compat_getpass,
- compat_shlex_split,
- workaround_optparse_bug9161,
-)
-from .utils import (
- DateRange,
- decodeOption,
- DEFAULT_OUTTMPL,
- DownloadError,
- expand_path,
- match_filter_func,
- MaxDownloadsReached,
- preferredencoding,
- read_batch_urls,
- SameFileError,
- setproctitle,
- std_headers,
- write_string,
- render_table,
-)
-from .update import update_self
-from .downloader import (
- FileDownloader,
-)
-from .extractor import gen_extractors, list_extractors
-from .extractor.adobepass import MSO_INFO
-from .YoutubeDL import YoutubeDL
-
-
-def _real_main(argv=None):
- # Compatibility fixes for Windows
- if sys.platform == 'win32':
- # https://github.com/rg3/youtube-dl/issues/820
- codecs.register(lambda name: codecs.lookup('utf-8') if name == 'cp65001' else None)
-
- workaround_optparse_bug9161()
-
- setproctitle('youtube-dl')
-
- parser, opts, args = parseOpts(argv)
-
- # Set user agent
- if opts.user_agent is not None:
- std_headers['User-Agent'] = opts.user_agent
-
- # Set referer
- if opts.referer is not None:
- std_headers['Referer'] = opts.referer
-
- # Custom HTTP headers
- if opts.headers is not None:
- for h in opts.headers:
- if ':' not in h:
- parser.error('wrong header formatting, it should be key:value, not "%s"' % h)
- key, value = h.split(':', 1)
- if opts.verbose:
- write_string('[debug] Adding header from command line option %s:%s\n' % (key, value))
- std_headers[key] = value
-
- # Dump user agent
- if opts.dump_user_agent:
- write_string(std_headers['User-Agent'] + '\n', out=sys.stdout)
- sys.exit(0)
-
- # Batch file verification
- batch_urls = []
- if opts.batchfile is not None:
- try:
- if opts.batchfile == '-':
- batchfd = sys.stdin
- else:
- batchfd = io.open(
- expand_path(opts.batchfile),
- 'r', encoding='utf-8', errors='ignore')
- batch_urls = read_batch_urls(batchfd)
- if opts.verbose:
- write_string('[debug] Batch file urls: ' + repr(batch_urls) + '\n')
- except IOError:
- sys.exit('ERROR: batch file could not be read')
- all_urls = batch_urls + [url.strip() for url in args] # batch_urls are already striped in read_batch_urls
- _enc = preferredencoding()
- all_urls = [url.decode(_enc, 'ignore') if isinstance(url, bytes) else url for url in all_urls]
-
- if opts.list_extractors:
- for ie in list_extractors(opts.age_limit):
- write_string(ie.IE_NAME + (' (CURRENTLY BROKEN)' if not ie._WORKING else '') + '\n', out=sys.stdout)
- matchedUrls = [url for url in all_urls if ie.suitable(url)]
- for mu in matchedUrls:
- write_string(' ' + mu + '\n', out=sys.stdout)
- sys.exit(0)
- if opts.list_extractor_descriptions:
- for ie in list_extractors(opts.age_limit):
- if not ie._WORKING:
- continue
- desc = getattr(ie, 'IE_DESC', ie.IE_NAME)
- if desc is False:
- continue
- if hasattr(ie, 'SEARCH_KEY'):
- _SEARCHES = ('cute kittens', 'slithering pythons', 'falling cat', 'angry poodle', 'purple fish', 'running tortoise', 'sleeping bunny', 'burping cow')
- _COUNTS = ('', '5', '10', 'all')
- desc += ' (Example: "%s%s:%s" )' % (ie.SEARCH_KEY, random.choice(_COUNTS), random.choice(_SEARCHES))
- write_string(desc + '\n', out=sys.stdout)
- sys.exit(0)
- if opts.ap_list_mso:
- table = [[mso_id, mso_info['name']] for mso_id, mso_info in MSO_INFO.items()]
- write_string('Supported TV Providers:\n' + render_table(['mso', 'mso name'], table) + '\n', out=sys.stdout)
- sys.exit(0)
-
- # Conflicting, missing and erroneous options
- if opts.usenetrc and (opts.username is not None or opts.password is not None):
- parser.error('using .netrc conflicts with giving username/password')
- if opts.password is not None and opts.username is None:
- parser.error('account username missing\n')
- if opts.ap_password is not None and opts.ap_username is None:
- parser.error('TV Provider account username missing\n')
- if opts.outtmpl is not None and (opts.usetitle or opts.autonumber or opts.useid):
- parser.error('using output template conflicts with using title, video ID or auto number')
- if opts.autonumber_size is not None:
- if opts.autonumber_size <= 0:
- parser.error('auto number size must be positive')
- if opts.autonumber_start is not None:
- if opts.autonumber_start < 0:
- parser.error('auto number start must be positive or 0')
- if opts.usetitle and opts.useid:
- parser.error('using title conflicts with using video ID')
- if opts.username is not None and opts.password is None:
- opts.password = compat_getpass('Type account password and press [Return]: ')
- if opts.ap_username is not None and opts.ap_password is None:
- opts.ap_password = compat_getpass('Type TV provider account password and press [Return]: ')
- if opts.ratelimit is not None:
- numeric_limit = FileDownloader.parse_bytes(opts.ratelimit)
- if numeric_limit is None:
- parser.error('invalid rate limit specified')
- opts.ratelimit = numeric_limit
- if opts.min_filesize is not None:
- numeric_limit = FileDownloader.parse_bytes(opts.min_filesize)
- if numeric_limit is None:
- parser.error('invalid min_filesize specified')
- opts.min_filesize = numeric_limit
- if opts.max_filesize is not None:
- numeric_limit = FileDownloader.parse_bytes(opts.max_filesize)
- if numeric_limit is None:
- parser.error('invalid max_filesize specified')
- opts.max_filesize = numeric_limit
- if opts.sleep_interval is not None:
- if opts.sleep_interval < 0:
- parser.error('sleep interval must be positive or 0')
- if opts.max_sleep_interval is not None:
- if opts.max_sleep_interval < 0:
- parser.error('max sleep interval must be positive or 0')
- if opts.max_sleep_interval < opts.sleep_interval:
- parser.error('max sleep interval must be greater than or equal to min sleep interval')
- else:
- opts.max_sleep_interval = opts.sleep_interval
- if opts.ap_mso and opts.ap_mso not in MSO_INFO:
- parser.error('Unsupported TV Provider, use --ap-list-mso to get a list of supported TV Providers')
-
- def parse_retries(retries):
- if retries in ('inf', 'infinite'):
- parsed_retries = float('inf')
- else:
- try:
- parsed_retries = int(retries)
- except (TypeError, ValueError):
- parser.error('invalid retry count specified')
- return parsed_retries
- if opts.retries is not None:
- opts.retries = parse_retries(opts.retries)
- if opts.fragment_retries is not None:
- opts.fragment_retries = parse_retries(opts.fragment_retries)
- if opts.buffersize is not None:
- numeric_buffersize = FileDownloader.parse_bytes(opts.buffersize)
- if numeric_buffersize is None:
- parser.error('invalid buffer size specified')
- opts.buffersize = numeric_buffersize
- if opts.http_chunk_size is not None:
- numeric_chunksize = FileDownloader.parse_bytes(opts.http_chunk_size)
- if not numeric_chunksize:
- parser.error('invalid http chunk size specified')
- opts.http_chunk_size = numeric_chunksize
- if opts.playliststart <= 0:
- raise ValueError('Playlist start must be positive')
- if opts.playlistend not in (-1, None) and opts.playlistend < opts.playliststart:
- raise ValueError('Playlist end must be greater than playlist start')
- if opts.extractaudio:
- if opts.audioformat not in ['best', 'aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav']:
- parser.error('invalid audio format specified')
- if opts.audioquality:
- opts.audioquality = opts.audioquality.strip('k').strip('K')
- if not opts.audioquality.isdigit():
- parser.error('invalid audio quality specified')
- if opts.recodevideo is not None:
- if opts.recodevideo not in ['mp4', 'flv', 'webm', 'ogg', 'mkv', 'avi']:
- parser.error('invalid video recode format specified')
- if opts.convertsubtitles is not None:
- if opts.convertsubtitles not in ['srt', 'vtt', 'ass', 'lrc']:
- parser.error('invalid subtitle format specified')
-
- if opts.date is not None:
- date = DateRange.day(opts.date)
- else:
- date = DateRange(opts.dateafter, opts.datebefore)
-
- # Do not download videos when there are audio-only formats
- if opts.extractaudio and not opts.keepvideo and opts.format is None:
- opts.format = 'bestaudio/best'
-
- # --all-sub automatically sets --write-sub if --write-auto-sub is not given
- # this was the old behaviour if only --all-sub was given.
- if opts.allsubtitles and not opts.writeautomaticsub:
- opts.writesubtitles = True
-
- outtmpl = ((opts.outtmpl is not None and opts.outtmpl) or
- (opts.format == '-1' and opts.usetitle and '%(title)s-%(id)s-%(format)s.%(ext)s') or
- (opts.format == '-1' and '%(id)s-%(format)s.%(ext)s') or
- (opts.usetitle and opts.autonumber and '%(autonumber)s-%(title)s-%(id)s.%(ext)s') or
- (opts.usetitle and '%(title)s-%(id)s.%(ext)s') or
- (opts.useid and '%(id)s.%(ext)s') or
- (opts.autonumber and '%(autonumber)s-%(id)s.%(ext)s') or
- DEFAULT_OUTTMPL)
- if not os.path.splitext(outtmpl)[1] and opts.extractaudio:
- parser.error('Cannot download a video and extract audio into the same'
- ' file! Use "{0}.%(ext)s" instead of "{0}" as the output'
- ' template'.format(outtmpl))
-
- any_getting = opts.geturl or opts.gettitle or opts.getid or opts.getthumbnail or opts.getdescription or opts.getfilename or opts.getformat or opts.getduration or opts.dumpjson or opts.dump_single_json
- any_printing = opts.print_json
- download_archive_fn = expand_path(opts.download_archive) if opts.download_archive is not None else opts.download_archive
-
- # PostProcessors
- postprocessors = []
- if opts.metafromtitle:
- postprocessors.append({
- 'key': 'MetadataFromTitle',
- 'titleformat': opts.metafromtitle
- })
- if opts.extractaudio:
- postprocessors.append({
- 'key': 'FFmpegExtractAudio',
- 'preferredcodec': opts.audioformat,
- 'preferredquality': opts.audioquality,
- 'nopostoverwrites': opts.nopostoverwrites,
- })
- if opts.recodevideo:
- postprocessors.append({
- 'key': 'FFmpegVideoConvertor',
- 'preferedformat': opts.recodevideo,
- })
- # FFmpegMetadataPP should be run after FFmpegVideoConvertorPP and
- # FFmpegExtractAudioPP as containers before conversion may not support
- # metadata (3gp, webm, etc.)
- # And this post-processor should be placed before other metadata
- # manipulating post-processors (FFmpegEmbedSubtitle) to prevent loss of
- # extra metadata. By default ffmpeg preserves metadata applicable for both
- # source and target containers. From this point the container won't change,
- # so metadata can be added here.
- if opts.addmetadata:
- postprocessors.append({'key': 'FFmpegMetadata'})
- if opts.convertsubtitles:
- postprocessors.append({
- 'key': 'FFmpegSubtitlesConvertor',
- 'format': opts.convertsubtitles,
- })
- if opts.embedsubtitles:
- postprocessors.append({
- 'key': 'FFmpegEmbedSubtitle',
- })
- if opts.embedthumbnail:
- already_have_thumbnail = opts.writethumbnail or opts.write_all_thumbnails
- postprocessors.append({
- 'key': 'EmbedThumbnail',
- 'already_have_thumbnail': already_have_thumbnail
- })
- if not already_have_thumbnail:
- opts.writethumbnail = True
- # XAttrMetadataPP should be run after post-processors that may change file
- # contents
- if opts.xattrs:
- postprocessors.append({'key': 'XAttrMetadata'})
- # Please keep ExecAfterDownload towards the bottom as it allows the user to modify the final file in any way.
- # So if the user is able to remove the file before your postprocessor runs it might cause a few problems.
- if opts.exec_cmd:
- postprocessors.append({
- 'key': 'ExecAfterDownload',
- 'exec_cmd': opts.exec_cmd,
- })
- external_downloader_args = None
- if opts.external_downloader_args:
- external_downloader_args = compat_shlex_split(opts.external_downloader_args)
- postprocessor_args = None
- if opts.postprocessor_args:
- postprocessor_args = compat_shlex_split(opts.postprocessor_args)
- match_filter = (
- None if opts.match_filter is None
- else match_filter_func(opts.match_filter))
-
- ydl_opts = {
- 'usenetrc': opts.usenetrc,
- 'username': opts.username,
- 'password': opts.password,
- 'twofactor': opts.twofactor,
- 'videopassword': opts.videopassword,
- 'ap_mso': opts.ap_mso,
- 'ap_username': opts.ap_username,
- 'ap_password': opts.ap_password,
- 'quiet': (opts.quiet or any_getting or any_printing),
- 'no_warnings': opts.no_warnings,
- 'forceurl': opts.geturl,
- 'forcetitle': opts.gettitle,
- 'forceid': opts.getid,
- 'forcethumbnail': opts.getthumbnail,
- 'forcedescription': opts.getdescription,
- 'forceduration': opts.getduration,
- 'forcefilename': opts.getfilename,
- 'forceformat': opts.getformat,
- 'forcejson': opts.dumpjson or opts.print_json,
- 'dump_single_json': opts.dump_single_json,
- 'simulate': opts.simulate or any_getting,
- 'skip_download': opts.skip_download,
- 'format': opts.format,
- 'listformats': opts.listformats,
- 'outtmpl': outtmpl,
- 'autonumber_size': opts.autonumber_size,
- 'autonumber_start': opts.autonumber_start,
- 'restrictfilenames': opts.restrictfilenames,
- 'ignoreerrors': opts.ignoreerrors,
- 'force_generic_extractor': opts.force_generic_extractor,
- 'ratelimit': opts.ratelimit,
- 'nooverwrites': opts.nooverwrites,
- 'retries': opts.retries,
- 'fragment_retries': opts.fragment_retries,
- 'skip_unavailable_fragments': opts.skip_unavailable_fragments,
- 'keep_fragments': opts.keep_fragments,
- 'buffersize': opts.buffersize,
- 'noresizebuffer': opts.noresizebuffer,
- 'http_chunk_size': opts.http_chunk_size,
- 'continuedl': opts.continue_dl,
- 'noprogress': opts.noprogress,
- 'progress_with_newline': opts.progress_with_newline,
- 'playliststart': opts.playliststart,
- 'playlistend': opts.playlistend,
- 'playlistreverse': opts.playlist_reverse,
- 'playlistrandom': opts.playlist_random,
- 'noplaylist': opts.noplaylist,
- 'logtostderr': opts.outtmpl == '-',
- 'consoletitle': opts.consoletitle,
- 'nopart': opts.nopart,
- 'updatetime': opts.updatetime,
- 'writedescription': opts.writedescription,
- 'writeannotations': opts.writeannotations,
- 'writeinfojson': opts.writeinfojson,
- 'writethumbnail': opts.writethumbnail,
- 'write_all_thumbnails': opts.write_all_thumbnails,
- 'writesubtitles': opts.writesubtitles,
- 'writeautomaticsub': opts.writeautomaticsub,
- 'allsubtitles': opts.allsubtitles,
- 'listsubtitles': opts.listsubtitles,
- 'subtitlesformat': opts.subtitlesformat,
- 'subtitleslangs': opts.subtitleslangs,
- 'matchtitle': decodeOption(opts.matchtitle),
- 'rejecttitle': decodeOption(opts.rejecttitle),
- 'max_downloads': opts.max_downloads,
- 'prefer_free_formats': opts.prefer_free_formats,
- 'verbose': opts.verbose,
- 'dump_intermediate_pages': opts.dump_intermediate_pages,
- 'write_pages': opts.write_pages,
- 'test': opts.test,
- 'keepvideo': opts.keepvideo,
- 'min_filesize': opts.min_filesize,
- 'max_filesize': opts.max_filesize,
- 'min_views': opts.min_views,
- 'max_views': opts.max_views,
- 'daterange': date,
- 'cachedir': opts.cachedir,
- 'youtube_print_sig_code': opts.youtube_print_sig_code,
- 'age_limit': opts.age_limit,
- 'download_archive': download_archive_fn,
- 'cookiefile': opts.cookiefile,
- 'nocheckcertificate': opts.no_check_certificate,
- 'prefer_insecure': opts.prefer_insecure,
- 'proxy': opts.proxy,
- 'socket_timeout': opts.socket_timeout,
- 'bidi_workaround': opts.bidi_workaround,
- 'debug_printtraffic': opts.debug_printtraffic,
- 'prefer_ffmpeg': opts.prefer_ffmpeg,
- 'include_ads': opts.include_ads,
- 'default_search': opts.default_search,
- 'youtube_include_dash_manifest': opts.youtube_include_dash_manifest,
- 'encoding': opts.encoding,
- 'extract_flat': opts.extract_flat,
- 'mark_watched': opts.mark_watched,
- 'merge_output_format': opts.merge_output_format,
- 'postprocessors': postprocessors,
- 'fixup': opts.fixup,
- 'source_address': opts.source_address,
- 'call_home': opts.call_home,
- 'sleep_interval': opts.sleep_interval,
- 'max_sleep_interval': opts.max_sleep_interval,
- 'external_downloader': opts.external_downloader,
- 'list_thumbnails': opts.list_thumbnails,
- 'playlist_items': opts.playlist_items,
- 'xattr_set_filesize': opts.xattr_set_filesize,
- 'match_filter': match_filter,
- 'no_color': opts.no_color,
- 'ffmpeg_location': opts.ffmpeg_location,
- 'hls_prefer_native': opts.hls_prefer_native,
- 'hls_use_mpegts': opts.hls_use_mpegts,
- 'external_downloader_args': external_downloader_args,
- 'postprocessor_args': postprocessor_args,
- 'cn_verification_proxy': opts.cn_verification_proxy,
- 'geo_verification_proxy': opts.geo_verification_proxy,
- 'config_location': opts.config_location,
- 'geo_bypass': opts.geo_bypass,
- 'geo_bypass_country': opts.geo_bypass_country,
- 'geo_bypass_ip_block': opts.geo_bypass_ip_block,
- # just for deprecation check
- 'autonumber': opts.autonumber if opts.autonumber is True else None,
- 'usetitle': opts.usetitle if opts.usetitle is True else None,
- }
-
- with YoutubeDL(ydl_opts) as ydl:
- # Update version
- if opts.update_self:
- update_self(ydl.to_screen, opts.verbose, ydl._opener)
-
- # Remove cache dir
- if opts.rm_cachedir:
- ydl.cache.remove()
-
- # Maybe do nothing
- if (len(all_urls) < 1) and (opts.load_info_filename is None):
- if opts.update_self or opts.rm_cachedir:
- sys.exit()
-
- ydl.warn_if_short_id(sys.argv[1:] if argv is None else argv)
- parser.error(
- 'You must provide at least one URL.\n'
- 'Type youtube-dl --help to see a list of all options.')
-
- try:
- if opts.load_info_filename is not None:
- retcode = ydl.download_with_info_file(expand_path(opts.load_info_filename))
- else:
- retcode = ydl.download(all_urls)
- except MaxDownloadsReached:
- ydl.to_screen('--max-download limit reached, aborting.')
- retcode = 101
-
- sys.exit(retcode)
-
-
-def main(argv=None):
- try:
- _real_main(argv)
- except DownloadError:
- sys.exit(1)
- except SameFileError:
- sys.exit('ERROR: fixed output name but more than one file to download')
- except KeyboardInterrupt:
- sys.exit('\nERROR: Interrupted by user')
-
-
-__all__ = ['main', 'YoutubeDL', 'gen_extractors', 'list_extractors']
diff --git a/youtube_dl/__main__.py b/youtube_dl/__main__.py
deleted file mode 100644
index 138f5fb..0000000
--- a/youtube_dl/__main__.py
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/usr/bin/env python
-from __future__ import unicode_literals
-
-# Execute with
-# $ python youtube_dl/__main__.py (2.6+)
-# $ python -m youtube_dl (2.7+)
-
-import sys
-
-if __package__ is None and not hasattr(sys, 'frozen'):
- # direct call of __main__.py
- import os.path
- path = os.path.realpath(os.path.abspath(__file__))
- sys.path.insert(0, os.path.dirname(os.path.dirname(path)))
-
-import youtube_dl
-
-if __name__ == '__main__':
- youtube_dl.main()
diff --git a/youtube_dl/aes.py b/youtube_dl/aes.py
deleted file mode 100644
index 461bb6d..0000000
--- a/youtube_dl/aes.py
+++ /dev/null
@@ -1,361 +0,0 @@
-from __future__ import unicode_literals
-
-from math import ceil
-
-from .compat import compat_b64decode
-from .utils import bytes_to_intlist, intlist_to_bytes
-
-BLOCK_SIZE_BYTES = 16
-
-
-def aes_ctr_decrypt(data, key, counter):
- """
- Decrypt with aes in counter mode
-
- @param {int[]} data cipher
- @param {int[]} key 16/24/32-Byte cipher key
- @param {instance} counter Instance whose next_value function (@returns {int[]} 16-Byte block)
- returns the next counter block
- @returns {int[]} decrypted data
- """
- expanded_key = key_expansion(key)
- block_count = int(ceil(float(len(data)) / BLOCK_SIZE_BYTES))
-
- decrypted_data = []
- for i in range(block_count):
- counter_block = counter.next_value()
- block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]
- block += [0] * (BLOCK_SIZE_BYTES - len(block))
-
- cipher_counter_block = aes_encrypt(counter_block, expanded_key)
- decrypted_data += xor(block, cipher_counter_block)
- decrypted_data = decrypted_data[:len(data)]
-
- return decrypted_data
-
-
-def aes_cbc_decrypt(data, key, iv):
- """
- Decrypt with aes in CBC mode
-
- @param {int[]} data cipher
- @param {int[]} key 16/24/32-Byte cipher key
- @param {int[]} iv 16-Byte IV
- @returns {int[]} decrypted data
- """
- expanded_key = key_expansion(key)
- block_count = int(ceil(float(len(data)) / BLOCK_SIZE_BYTES))
-
- decrypted_data = []
- previous_cipher_block = iv
- for i in range(block_count):
- block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]
- block += [0] * (BLOCK_SIZE_BYTES - len(block))
-
- decrypted_block = aes_decrypt(block, expanded_key)
- decrypted_data += xor(decrypted_block, previous_cipher_block)
- previous_cipher_block = block
- decrypted_data = decrypted_data[:len(data)]
-
- return decrypted_data
-
-
-def aes_cbc_encrypt(data, key, iv):
- """
- Encrypt with aes in CBC mode. Using PKCS#7 padding
-
- @param {int[]} data cleartext
- @param {int[]} key 16/24/32-Byte cipher key
- @param {int[]} iv 16-Byte IV
- @returns {int[]} encrypted data
- """
- expanded_key = key_expansion(key)
- block_count = int(ceil(float(len(data)) / BLOCK_SIZE_BYTES))
-
- encrypted_data = []
- previous_cipher_block = iv
- for i in range(block_count):
- block = data[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES]
- remaining_length = BLOCK_SIZE_BYTES - len(block)
- block += [remaining_length] * remaining_length
- mixed_block = xor(block, previous_cipher_block)
-
- encrypted_block = aes_encrypt(mixed_block, expanded_key)
- encrypted_data += encrypted_block
-
- previous_cipher_block = encrypted_block
-
- return encrypted_data
-
-
-def key_expansion(data):
- """
- Generate key schedule
-
- @param {int[]} data 16/24/32-Byte cipher key
- @returns {int[]} 176/208/240-Byte expanded key
- """
- data = data[:] # copy
- rcon_iteration = 1
- key_size_bytes = len(data)
- expanded_key_size_bytes = (key_size_bytes // 4 + 7) * BLOCK_SIZE_BYTES
-
- while len(data) < expanded_key_size_bytes:
- temp = data[-4:]
- temp = key_schedule_core(temp, rcon_iteration)
- rcon_iteration += 1
- data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])
-
- for _ in range(3):
- temp = data[-4:]
- data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])
-
- if key_size_bytes == 32:
- temp = data[-4:]
- temp = sub_bytes(temp)
- data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])
-
- for _ in range(3 if key_size_bytes == 32 else 2 if key_size_bytes == 24 else 0):
- temp = data[-4:]
- data += xor(temp, data[-key_size_bytes: 4 - key_size_bytes])
- data = data[:expanded_key_size_bytes]
-
- return data
-
-
-def aes_encrypt(data, expanded_key):
- """
- Encrypt one block with aes
-
- @param {int[]} data 16-Byte state
- @param {int[]} expanded_key 176/208/240-Byte expanded key
- @returns {int[]} 16-Byte cipher
- """
- rounds = len(expanded_key) // BLOCK_SIZE_BYTES - 1
-
- data = xor(data, expanded_key[:BLOCK_SIZE_BYTES])
- for i in range(1, rounds + 1):
- data = sub_bytes(data)
- data = shift_rows(data)
- if i != rounds:
- data = mix_columns(data)
- data = xor(data, expanded_key[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES])
-
- return data
-
-
-def aes_decrypt(data, expanded_key):
- """
- Decrypt one block with aes
-
- @param {int[]} data 16-Byte cipher
- @param {int[]} expanded_key 176/208/240-Byte expanded key
- @returns {int[]} 16-Byte state
- """
- rounds = len(expanded_key) // BLOCK_SIZE_BYTES - 1
-
- for i in range(rounds, 0, -1):
- data = xor(data, expanded_key[i * BLOCK_SIZE_BYTES: (i + 1) * BLOCK_SIZE_BYTES])
- if i != rounds:
- data = mix_columns_inv(data)
- data = shift_rows_inv(data)
- data = sub_bytes_inv(data)
- data = xor(data, expanded_key[:BLOCK_SIZE_BYTES])
-
- return data
-
-
-def aes_decrypt_text(data, password, key_size_bytes):
- """
- Decrypt text
- - The first 8 Bytes of decoded 'data' are the 8 high Bytes of the counter
- - The cipher key is retrieved by encrypting the first 16 Byte of 'password'
- with the first 'key_size_bytes' Bytes from 'password' (if necessary filled with 0's)
- - Mode of operation is 'counter'
-
- @param {str} data Base64 encoded string
- @param {str,unicode} password Password (will be encoded with utf-8)
- @param {int} key_size_bytes Possible values: 16 for 128-Bit, 24 for 192-Bit or 32 for 256-Bit
- @returns {str} Decrypted data
- """
- NONCE_LENGTH_BYTES = 8
-
- data = bytes_to_intlist(compat_b64decode(data))
- password = bytes_to_intlist(password.encode('utf-8'))
-
- key = password[:key_size_bytes] + [0] * (key_size_bytes - len(password))
- key = aes_encrypt(key[:BLOCK_SIZE_BYTES], key_expansion(key)) * (key_size_bytes // BLOCK_SIZE_BYTES)
-
- nonce = data[:NONCE_LENGTH_BYTES]
- cipher = data[NONCE_LENGTH_BYTES:]
-
- class Counter(object):
- __value = nonce + [0] * (BLOCK_SIZE_BYTES - NONCE_LENGTH_BYTES)
-
- def next_value(self):
- temp = self.__value
- self.__value = inc(self.__value)
- return temp
-
- decrypted_data = aes_ctr_decrypt(cipher, key, Counter())
- plaintext = intlist_to_bytes(decrypted_data)
-
- return plaintext
-
-
-RCON = (0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36)
-SBOX = (0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
- 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
- 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
- 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
- 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
- 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
- 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
- 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
- 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
- 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
- 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
- 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
- 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
- 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
- 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
- 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16)
-SBOX_INV = (0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
- 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
- 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
- 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
- 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
- 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
- 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
- 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
- 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
- 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
- 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
- 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
- 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
- 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
- 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
- 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d)
-MIX_COLUMN_MATRIX = ((0x2, 0x3, 0x1, 0x1),
- (0x1, 0x2, 0x3, 0x1),
- (0x1, 0x1, 0x2, 0x3),
- (0x3, 0x1, 0x1, 0x2))
-MIX_COLUMN_MATRIX_INV = ((0xE, 0xB, 0xD, 0x9),
- (0x9, 0xE, 0xB, 0xD),
- (0xD, 0x9, 0xE, 0xB),
- (0xB, 0xD, 0x9, 0xE))
-RIJNDAEL_EXP_TABLE = (0x01, 0x03, 0x05, 0x0F, 0x11, 0x33, 0x55, 0xFF, 0x1A, 0x2E, 0x72, 0x96, 0xA1, 0xF8, 0x13, 0x35,
- 0x5F, 0xE1, 0x38, 0x48, 0xD8, 0x73, 0x95, 0xA4, 0xF7, 0x02, 0x06, 0x0A, 0x1E, 0x22, 0x66, 0xAA,
- 0xE5, 0x34, 0x5C, 0xE4, 0x37, 0x59, 0xEB, 0x26, 0x6A, 0xBE, 0xD9, 0x70, 0x90, 0xAB, 0xE6, 0x31,
- 0x53, 0xF5, 0x04, 0x0C, 0x14, 0x3C, 0x44, 0xCC, 0x4F, 0xD1, 0x68, 0xB8, 0xD3, 0x6E, 0xB2, 0xCD,
- 0x4C, 0xD4, 0x67, 0xA9, 0xE0, 0x3B, 0x4D, 0xD7, 0x62, 0xA6, 0xF1, 0x08, 0x18, 0x28, 0x78, 0x88,
- 0x83, 0x9E, 0xB9, 0xD0, 0x6B, 0xBD, 0xDC, 0x7F, 0x81, 0x98, 0xB3, 0xCE, 0x49, 0xDB, 0x76, 0x9A,
- 0xB5, 0xC4, 0x57, 0xF9, 0x10, 0x30, 0x50, 0xF0, 0x0B, 0x1D, 0x27, 0x69, 0xBB, 0xD6, 0x61, 0xA3,
- 0xFE, 0x19, 0x2B, 0x7D, 0x87, 0x92, 0xAD, 0xEC, 0x2F, 0x71, 0x93, 0xAE, 0xE9, 0x20, 0x60, 0xA0,
- 0xFB, 0x16, 0x3A, 0x4E, 0xD2, 0x6D, 0xB7, 0xC2, 0x5D, 0xE7, 0x32, 0x56, 0xFA, 0x15, 0x3F, 0x41,
- 0xC3, 0x5E, 0xE2, 0x3D, 0x47, 0xC9, 0x40, 0xC0, 0x5B, 0xED, 0x2C, 0x74, 0x9C, 0xBF, 0xDA, 0x75,
- 0x9F, 0xBA, 0xD5, 0x64, 0xAC, 0xEF, 0x2A, 0x7E, 0x82, 0x9D, 0xBC, 0xDF, 0x7A, 0x8E, 0x89, 0x80,
- 0x9B, 0xB6, 0xC1, 0x58, 0xE8, 0x23, 0x65, 0xAF, 0xEA, 0x25, 0x6F, 0xB1, 0xC8, 0x43, 0xC5, 0x54,
- 0xFC, 0x1F, 0x21, 0x63, 0xA5, 0xF4, 0x07, 0x09, 0x1B, 0x2D, 0x77, 0x99, 0xB0, 0xCB, 0x46, 0xCA,
- 0x45, 0xCF, 0x4A, 0xDE, 0x79, 0x8B, 0x86, 0x91, 0xA8, 0xE3, 0x3E, 0x42, 0xC6, 0x51, 0xF3, 0x0E,
- 0x12, 0x36, 0x5A, 0xEE, 0x29, 0x7B, 0x8D, 0x8C, 0x8F, 0x8A, 0x85, 0x94, 0xA7, 0xF2, 0x0D, 0x17,
- 0x39, 0x4B, 0xDD, 0x7C, 0x84, 0x97, 0xA2, 0xFD, 0x1C, 0x24, 0x6C, 0xB4, 0xC7, 0x52, 0xF6, 0x01)
-RIJNDAEL_LOG_TABLE = (0x00, 0x00, 0x19, 0x01, 0x32, 0x02, 0x1a, 0xc6, 0x4b, 0xc7, 0x1b, 0x68, 0x33, 0xee, 0xdf, 0x03,
- 0x64, 0x04, 0xe0, 0x0e, 0x34, 0x8d, 0x81, 0xef, 0x4c, 0x71, 0x08, 0xc8, 0xf8, 0x69, 0x1c, 0xc1,
- 0x7d, 0xc2, 0x1d, 0xb5, 0xf9, 0xb9, 0x27, 0x6a, 0x4d, 0xe4, 0xa6, 0x72, 0x9a, 0xc9, 0x09, 0x78,
- 0x65, 0x2f, 0x8a, 0x05, 0x21, 0x0f, 0xe1, 0x24, 0x12, 0xf0, 0x82, 0x45, 0x35, 0x93, 0xda, 0x8e,
- 0x96, 0x8f, 0xdb, 0xbd, 0x36, 0xd0, 0xce, 0x94, 0x13, 0x5c, 0xd2, 0xf1, 0x40, 0x46, 0x83, 0x38,
- 0x66, 0xdd, 0xfd, 0x30, 0xbf, 0x06, 0x8b, 0x62, 0xb3, 0x25, 0xe2, 0x98, 0x22, 0x88, 0x91, 0x10,
- 0x7e, 0x6e, 0x48, 0xc3, 0xa3, 0xb6, 0x1e, 0x42, 0x3a, 0x6b, 0x28, 0x54, 0xfa, 0x85, 0x3d, 0xba,
- 0x2b, 0x79, 0x0a, 0x15, 0x9b, 0x9f, 0x5e, 0xca, 0x4e, 0xd4, 0xac, 0xe5, 0xf3, 0x73, 0xa7, 0x57,
- 0xaf, 0x58, 0xa8, 0x50, 0xf4, 0xea, 0xd6, 0x74, 0x4f, 0xae, 0xe9, 0xd5, 0xe7, 0xe6, 0xad, 0xe8,
- 0x2c, 0xd7, 0x75, 0x7a, 0xeb, 0x16, 0x0b, 0xf5, 0x59, 0xcb, 0x5f, 0xb0, 0x9c, 0xa9, 0x51, 0xa0,
- 0x7f, 0x0c, 0xf6, 0x6f, 0x17, 0xc4, 0x49, 0xec, 0xd8, 0x43, 0x1f, 0x2d, 0xa4, 0x76, 0x7b, 0xb7,
- 0xcc, 0xbb, 0x3e, 0x5a, 0xfb, 0x60, 0xb1, 0x86, 0x3b, 0x52, 0xa1, 0x6c, 0xaa, 0x55, 0x29, 0x9d,
- 0x97, 0xb2, 0x87, 0x90, 0x61, 0xbe, 0xdc, 0xfc, 0xbc, 0x95, 0xcf, 0xcd, 0x37, 0x3f, 0x5b, 0xd1,
- 0x53, 0x39, 0x84, 0x3c, 0x41, 0xa2, 0x6d, 0x47, 0x14, 0x2a, 0x9e, 0x5d, 0x56, 0xf2, 0xd3, 0xab,
- 0x44, 0x11, 0x92, 0xd9, 0x23, 0x20, 0x2e, 0x89, 0xb4, 0x7c, 0xb8, 0x26, 0x77, 0x99, 0xe3, 0xa5,
- 0x67, 0x4a, 0xed, 0xde, 0xc5, 0x31, 0xfe, 0x18, 0x0d, 0x63, 0x8c, 0x80, 0xc0, 0xf7, 0x70, 0x07)
-
-
-def sub_bytes(data):
- return [SBOX[x] for x in data]
-
-
-def sub_bytes_inv(data):
- return [SBOX_INV[x] for x in data]
-
-
-def rotate(data):
- return data[1:] + [data[0]]
-
-
-def key_schedule_core(data, rcon_iteration):
- data = rotate(data)
- data = sub_bytes(data)
- data[0] = data[0] ^ RCON[rcon_iteration]
-
- return data
-
-
-def xor(data1, data2):
- return [x ^ y for x, y in zip(data1, data2)]
-
-
-def rijndael_mul(a, b):
- if(a == 0 or b == 0):
- return 0
- return RIJNDAEL_EXP_TABLE[(RIJNDAEL_LOG_TABLE[a] + RIJNDAEL_LOG_TABLE[b]) % 0xFF]
-
-
-def mix_column(data, matrix):
- data_mixed = []
- for row in range(4):
- mixed = 0
- for column in range(4):
- # xor is (+) and (-)
- mixed ^= rijndael_mul(data[column], matrix[row][column])
- data_mixed.append(mixed)
- return data_mixed
-
-
-def mix_columns(data, matrix=MIX_COLUMN_MATRIX):
- data_mixed = []
- for i in range(4):
- column = data[i * 4: (i + 1) * 4]
- data_mixed += mix_column(column, matrix)
- return data_mixed
-
-
-def mix_columns_inv(data):
- return mix_columns(data, MIX_COLUMN_MATRIX_INV)
-
-
-def shift_rows(data):
- data_shifted = []
- for column in range(4):
- for row in range(4):
- data_shifted.append(data[((column + row) & 0b11) * 4 + row])
- return data_shifted
-
-
-def shift_rows_inv(data):
- data_shifted = []
- for column in range(4):
- for row in range(4):
- data_shifted.append(data[((column - row) & 0b11) * 4 + row])
- return data_shifted
-
-
-def inc(data):
- data = data[:] # copy
- for i in range(len(data) - 1, -1, -1):
- if data[i] == 255:
- data[i] = 0
- else:
- data[i] = data[i] + 1
- break
- return data
-
-
-__all__ = ['aes_encrypt', 'key_expansion', 'aes_ctr_decrypt', 'aes_cbc_decrypt', 'aes_decrypt_text']
diff --git a/youtube_dl/cache.py b/youtube_dl/cache.py
deleted file mode 100644
index 7bdade1..0000000
--- a/youtube_dl/cache.py
+++ /dev/null
@@ -1,96 +0,0 @@
-from __future__ import unicode_literals
-
-import errno
-import io
-import json
-import os
-import re
-import shutil
-import traceback
-
-from .compat import compat_getenv
-from .utils import (
- expand_path,
- write_json_file,
-)
-
-
-class Cache(object):
- def __init__(self, ydl):
- self._ydl = ydl
-
- def _get_root_dir(self):
- res = self._ydl.params.get('cachedir')
- if res is None:
- cache_root = compat_getenv('XDG_CACHE_HOME', '~/.cache')
- res = os.path.join(cache_root, 'youtube-dl')
- return expand_path(res)
-
- def _get_cache_fn(self, section, key, dtype):
- assert re.match(r'^[a-zA-Z0-9_.-]+$', section), \
- 'invalid section %r' % section
- assert re.match(r'^[a-zA-Z0-9_.-]+$', key), 'invalid key %r' % key
- return os.path.join(
- self._get_root_dir(), section, '%s.%s' % (key, dtype))
-
- @property
- def enabled(self):
- return self._ydl.params.get('cachedir') is not False
-
- def store(self, section, key, data, dtype='json'):
- assert dtype in ('json',)
-
- if not self.enabled:
- return
-
- fn = self._get_cache_fn(section, key, dtype)
- try:
- try:
- os.makedirs(os.path.dirname(fn))
- except OSError as ose:
- if ose.errno != errno.EEXIST:
- raise
- write_json_file(data, fn)
- except Exception:
- tb = traceback.format_exc()
- self._ydl.report_warning(
- 'Writing cache to %r failed: %s' % (fn, tb))
-
- def load(self, section, key, dtype='json', default=None):
- assert dtype in ('json',)
-
- if not self.enabled:
- return default
-
- cache_fn = self._get_cache_fn(section, key, dtype)
- try:
- try:
- with io.open(cache_fn, 'r', encoding='utf-8') as cachef:
- return json.load(cachef)
- except ValueError:
- try:
- file_size = os.path.getsize(cache_fn)
- except (OSError, IOError) as oe:
- file_size = str(oe)
- self._ydl.report_warning(
- 'Cache retrieval from %s failed (%s)' % (cache_fn, file_size))
- except IOError:
- pass # No cache available
-
- return default
-
- def remove(self):
- if not self.enabled:
- self._ydl.to_screen('Cache is disabled (Did you combine --no-cache-dir and --rm-cache-dir?)')
- return
-
- cachedir = self._get_root_dir()
- if not any((term in cachedir) for term in ('cache', 'tmp')):
- raise Exception('Not removing directory %s - this does not look like a cache dir' % cachedir)
-
- self._ydl.to_screen(
- 'Removing cache dir %s .' % cachedir, skip_eol=True)
- if os.path.exists(cachedir):
- self._ydl.to_screen('.', skip_eol=True)
- shutil.rmtree(cachedir)
- self._ydl.to_screen('.')
diff --git a/youtube_dl/compat.py b/youtube_dl/compat.py
deleted file mode 100644
index 7b77034..0000000
--- a/youtube_dl/compat.py
+++ /dev/null
@@ -1,3016 +0,0 @@
-# coding: utf-8
-from __future__ import unicode_literals
-
-import base64
-import binascii
-import collections
-import ctypes
-import email
-import getpass
-import io
-import itertools
-import optparse
-import os
-import platform
-import re
-import shlex
-import shutil
-import socket
-import struct
-import subprocess
-import sys
-import xml.etree.ElementTree
-
-
-try:
- import urllib.request as compat_urllib_request
-except ImportError: # Python 2
- import urllib2 as compat_urllib_request
-
-try:
- import urllib.error as compat_urllib_error
-except ImportError: # Python 2
- import urllib2 as compat_urllib_error
-
-try:
- import urllib.parse as compat_urllib_parse
-except ImportError: # Python 2
- import urllib as compat_urllib_parse
-
-try:
- from urllib.parse import urlparse as compat_urllib_parse_urlparse
-except ImportError: # Python 2
- from urlparse import urlparse as compat_urllib_parse_urlparse
-
-try:
- import urllib.parse as compat_urlparse
-except ImportError: # Python 2
- import urlparse as compat_urlparse
-
-try:
- import urllib.response as compat_urllib_response
-except ImportError: # Python 2
- import urllib as compat_urllib_response
-
-try:
- import http.cookiejar as compat_cookiejar
-except ImportError: # Python 2
- import cookielib as compat_cookiejar
-
-try:
- import http.cookies as compat_cookies
-except ImportError: # Python 2
- import Cookie as compat_cookies
-
-try:
- import html.entities as compat_html_entities
-except ImportError: # Python 2
- import htmlentitydefs as compat_html_entities
-
-try: # Python >= 3.3
- compat_html_entities_html5 = compat_html_entities.html5
-except AttributeError:
- # Copied from CPython 3.5.1 html/entities.py
- compat_html_entities_html5 = {
- 'Aacute': '\xc1',
- 'aacute': '\xe1',
- 'Aacute;': '\xc1',
- 'aacute;': '\xe1',
- 'Abreve;': '\u0102',
- 'abreve;': '\u0103',
- 'ac;': '\u223e',
- 'acd;': '\u223f',
- 'acE;': '\u223e\u0333',
- 'Acirc': '\xc2',
- 'acirc': '\xe2',
- 'Acirc;': '\xc2',
- 'acirc;': '\xe2',
- 'acute': '\xb4',
- 'acute;': '\xb4',
- 'Acy;': '\u0410',
- 'acy;': '\u0430',
- 'AElig': '\xc6',
- 'aelig': '\xe6',
- 'AElig;': '\xc6',
- 'aelig;': '\xe6',
- 'af;': '\u2061',
- 'Afr;': '\U0001d504',
- 'afr;': '\U0001d51e',
- 'Agrave': '\xc0',
- 'agrave': '\xe0',
- 'Agrave;': '\xc0',
- 'agrave;': '\xe0',
- 'alefsym;': '\u2135',
- 'aleph;': '\u2135',
- 'Alpha;': '\u0391',
- 'alpha;': '\u03b1',
- 'Amacr;': '\u0100',
- 'amacr;': '\u0101',
- 'amalg;': '\u2a3f',
- 'AMP': '&',
- 'amp': '&',
- 'AMP;': '&',
- 'amp;': '&',
- 'And;': '\u2a53',
- 'and;': '\u2227',
- 'andand;': '\u2a55',
- 'andd;': '\u2a5c',
- 'andslope;': '\u2a58',
- 'andv;': '\u2a5a',
- 'ang;': '\u2220',
- 'ange;': '\u29a4',
- 'angle;': '\u2220',
- 'angmsd;': '\u2221',
- 'angmsdaa;': '\u29a8',
- 'angmsdab;': '\u29a9',
- 'angmsdac;': '\u29aa',
- 'angmsdad;': '\u29ab',
- 'angmsdae;': '\u29ac',
- 'angmsdaf;': '\u29ad',
- 'angmsdag;': '\u29ae',
- 'angmsdah;': '\u29af',
- 'angrt;': '\u221f',
- 'angrtvb;': '\u22be',
- 'angrtvbd;': '\u299d',
- 'angsph;': '\u2222',
- 'angst;': '\xc5',
- 'angzarr;': '\u237c',
- 'Aogon;': '\u0104',
- 'aogon;': '\u0105',
- 'Aopf;': '\U0001d538',
- 'aopf;': '\U0001d552',
- 'ap;': '\u2248',
- 'apacir;': '\u2a6f',
- 'apE;': '\u2a70',
- 'ape;': '\u224a',
- 'apid;': '\u224b',
- 'apos;': "'",
- 'ApplyFunction;': '\u2061',
- 'approx;': '\u2248',
- 'approxeq;': '\u224a',
- 'Aring': '\xc5',
- 'aring': '\xe5',
- 'Aring;': '\xc5',
- 'aring;': '\xe5',
- 'Ascr;': '\U0001d49c',
- 'ascr;': '\U0001d4b6',
- 'Assign;': '\u2254',
- 'ast;': '*',
- 'asymp;': '\u2248',
- 'asympeq;': '\u224d',
- 'Atilde': '\xc3',
- 'atilde': '\xe3',
- 'Atilde;': '\xc3',
- 'atilde;': '\xe3',
- 'Auml': '\xc4',
- 'auml': '\xe4',
- 'Auml;': '\xc4',
- 'auml;': '\xe4',
- 'awconint;': '\u2233',
- 'awint;': '\u2a11',
- 'backcong;': '\u224c',
- 'backepsilon;': '\u03f6',
- 'backprime;': '\u2035',
- 'backsim;': '\u223d',
- 'backsimeq;': '\u22cd',
- 'Backslash;': '\u2216',
- 'Barv;': '\u2ae7',
- 'barvee;': '\u22bd',
- 'Barwed;': '\u2306',
- 'barwed;': '\u2305',
- 'barwedge;': '\u2305',
- 'bbrk;': '\u23b5',
- 'bbrktbrk;': '\u23b6',
- 'bcong;': '\u224c',
- 'Bcy;': '\u0411',
- 'bcy;': '\u0431',
- 'bdquo;': '\u201e',
- 'becaus;': '\u2235',
- 'Because;': '\u2235',
- 'because;': '\u2235',
- 'bemptyv;': '\u29b0',
- 'bepsi;': '\u03f6',
- 'bernou;': '\u212c',
- 'Bernoullis;': '\u212c',
- 'Beta;': '\u0392',
- 'beta;': '\u03b2',
- 'beth;': '\u2136',
- 'between;': '\u226c',
- 'Bfr;': '\U0001d505',
- 'bfr;': '\U0001d51f',
- 'bigcap;': '\u22c2',
- 'bigcirc;': '\u25ef',
- 'bigcup;': '\u22c3',
- 'bigodot;': '\u2a00',
- 'bigoplus;': '\u2a01',
- 'bigotimes;': '\u2a02',
- 'bigsqcup;': '\u2a06',
- 'bigstar;': '\u2605',
- 'bigtriangledown;': '\u25bd',
- 'bigtriangleup;': '\u25b3',
- 'biguplus;': '\u2a04',
- 'bigvee;': '\u22c1',
- 'bigwedge;': '\u22c0',
- 'bkarow;': '\u290d',
- 'blacklozenge;': '\u29eb',
- 'blacksquare;': '\u25aa',
- 'blacktriangle;': '\u25b4',
- 'blacktriangledown;': '\u25be',
- 'blacktriangleleft;': '\u25c2',
- 'blacktriangleright;': '\u25b8',
- 'blank;': '\u2423',
- 'blk12;': '\u2592',
- 'blk14;': '\u2591',
- 'blk34;': '\u2593',
- 'block;': '\u2588',
- 'bne;': '=\u20e5',
- 'bnequiv;': '\u2261\u20e5',
- 'bNot;': '\u2aed',
- 'bnot;': '\u2310',
- 'Bopf;': '\U0001d539',
- 'bopf;': '\U0001d553',
- 'bot;': '\u22a5',
- 'bottom;': '\u22a5',
- 'bowtie;': '\u22c8',
- 'boxbox;': '\u29c9',
- 'boxDL;': '\u2557',
- 'boxDl;': '\u2556',
- 'boxdL;': '\u2555',
- 'boxdl;': '\u2510',
- 'boxDR;': '\u2554',
- 'boxDr;': '\u2553',
- 'boxdR;': '\u2552',
- 'boxdr;': '\u250c',
- 'boxH;': '\u2550',
- 'boxh;': '\u2500',
- 'boxHD;': '\u2566',
- 'boxHd;': '\u2564',
- 'boxhD;': '\u2565',
- 'boxhd;': '\u252c',
- 'boxHU;': '\u2569',
- 'boxHu;': '\u2567',
- 'boxhU;': '\u2568',
- 'boxhu;': '\u2534',
- 'boxminus;': '\u229f',
- 'boxplus;': '\u229e',
- 'boxtimes;': '\u22a0',
- 'boxUL;': '\u255d',
- 'boxUl;': '\u255c',
- 'boxuL;': '\u255b',
- 'boxul;': '\u2518',
- 'boxUR;': '\u255a',
- 'boxUr;': '\u2559',
- 'boxuR;': '\u2558',
- 'boxur;': '\u2514',
- 'boxV;': '\u2551',
- 'boxv;': '\u2502',
- 'boxVH;': '\u256c',
- 'boxVh;': '\u256b',
- 'boxvH;': '\u256a',
- 'boxvh;': '\u253c',
- 'boxVL;': '\u2563',
- 'boxVl;': '\u2562',
- 'boxvL;': '\u2561',
- 'boxvl;': '\u2524',
- 'boxVR;': '\u2560',
- 'boxVr;': '\u255f',
- 'boxvR;': '\u255e',
- 'boxvr;': '\u251c',
- 'bprime;': '\u2035',
- 'Breve;': '\u02d8',
- 'breve;': '\u02d8',
- 'brvbar': '\xa6',
- 'brvbar;': '\xa6',
- 'Bscr;': '\u212c',
- 'bscr;': '\U0001d4b7',
- 'bsemi;': '\u204f',
- 'bsim;': '\u223d',
- 'bsime;': '\u22cd',
- 'bsol;': '\\',
- 'bsolb;': '\u29c5',
- 'bsolhsub;': '\u27c8',
- 'bull;': '\u2022',
- 'bullet;': '\u2022',
- 'bump;': '\u224e',
- 'bumpE;': '\u2aae',
- 'bumpe;': '\u224f',
- 'Bumpeq;': '\u224e',
- 'bumpeq;': '\u224f',
- 'Cacute;': '\u0106',
- 'cacute;': '\u0107',
- 'Cap;': '\u22d2',
- 'cap;': '\u2229',
- 'capand;': '\u2a44',
- 'capbrcup;': '\u2a49',
- 'capcap;': '\u2a4b',
- 'capcup;': '\u2a47',
- 'capdot;': '\u2a40',
- 'CapitalDifferentialD;': '\u2145',
- 'caps;': '\u2229\ufe00',
- 'caret;': '\u2041',
- 'caron;': '\u02c7',
- 'Cayleys;': '\u212d',
- 'ccaps;': '\u2a4d',
- 'Ccaron;': '\u010c',
- 'ccaron;': '\u010d',
- 'Ccedil': '\xc7',
- 'ccedil': '\xe7',
- 'Ccedil;': '\xc7',
- 'ccedil;': '\xe7',
- 'Ccirc;': '\u0108',
- 'ccirc;': '\u0109',
- 'Cconint;': '\u2230',
- 'ccups;': '\u2a4c',
- 'ccupssm;': '\u2a50',
- 'Cdot;': '\u010a',
- 'cdot;': '\u010b',
- 'cedil': '\xb8',
- 'cedil;': '\xb8',
- 'Cedilla;': '\xb8',
- 'cemptyv;': '\u29b2',
- 'cent': '\xa2',
- 'cent;': '\xa2',
- 'CenterDot;': '\xb7',
- 'centerdot;': '\xb7',
- 'Cfr;': '\u212d',
- 'cfr;': '\U0001d520',
- 'CHcy;': '\u0427',
- 'chcy;': '\u0447',
- 'check;': '\u2713',
- 'checkmark;': '\u2713',
- 'Chi;': '\u03a7',
- 'chi;': '\u03c7',
- 'cir;': '\u25cb',
- 'circ;': '\u02c6',
- 'circeq;': '\u2257',
- 'circlearrowleft;': '\u21ba',
- 'circlearrowright;': '\u21bb',
- 'circledast;': '\u229b',
- 'circledcirc;': '\u229a',
- 'circleddash;': '\u229d',
- 'CircleDot;': '\u2299',
- 'circledR;': '\xae',
- 'circledS;': '\u24c8',
- 'CircleMinus;': '\u2296',
- 'CirclePlus;': '\u2295',
- 'CircleTimes;': '\u2297',
- 'cirE;': '\u29c3',
- 'cire;': '\u2257',
- 'cirfnint;': '\u2a10',
- 'cirmid;': '\u2aef',
- 'cirscir;': '\u29c2',
- 'ClockwiseContourIntegral;': '\u2232',
- 'CloseCurlyDoubleQuote;': '\u201d',
- 'CloseCurlyQuote;': '\u2019',
- 'clubs;': '\u2663',
- 'clubsuit;': '\u2663',
- 'Colon;': '\u2237',
- 'colon;': ':',
- 'Colone;': '\u2a74',
- 'colone;': '\u2254',
- 'coloneq;': '\u2254',
- 'comma;': ',',
- 'commat;': '@',
- 'comp;': '\u2201',
- 'compfn;': '\u2218',
- 'complement;': '\u2201',
- 'complexes;': '\u2102',
- 'cong;': '\u2245',
- 'congdot;': '\u2a6d',
- 'Congruent;': '\u2261',
- 'Conint;': '\u222f',
- 'conint;': '\u222e',
- 'ContourIntegral;': '\u222e',
- 'Copf;': '\u2102',
- 'copf;': '\U0001d554',
- 'coprod;': '\u2210',
- 'Coproduct;': '\u2210',
- 'COPY': '\xa9',
- 'copy': '\xa9',
- 'COPY;': '\xa9',
- 'copy;': '\xa9',
- 'copysr;': '\u2117',
- 'CounterClockwiseContourIntegral;': '\u2233',
- 'crarr;': '\u21b5',
- 'Cross;': '\u2a2f',
- 'cross;': '\u2717',
- 'Cscr;': '\U0001d49e',
- 'cscr;': '\U0001d4b8',
- 'csub;': '\u2acf',
- 'csube;': '\u2ad1',
- 'csup;': '\u2ad0',
- 'csupe;': '\u2ad2',
- 'ctdot;': '\u22ef',
- 'cudarrl;': '\u2938',
- 'cudarrr;': '\u2935',
- 'cuepr;': '\u22de',
- 'cuesc;': '\u22df',
- 'cularr;': '\u21b6',
- 'cularrp;': '\u293d',
- 'Cup;': '\u22d3',
- 'cup;': '\u222a',
- 'cupbrcap;': '\u2a48',
- 'CupCap;': '\u224d',
- 'cupcap;': '\u2a46',
- 'cupcup;': '\u2a4a',
- 'cupdot;': '\u228d',
- 'cupor;': '\u2a45',
- 'cups;': '\u222a\ufe00',
- 'curarr;': '\u21b7',
- 'curarrm;': '\u293c',
- 'curlyeqprec;': '\u22de',
- 'curlyeqsucc;': '\u22df',
- 'curlyvee;': '\u22ce',
- 'curlywedge;': '\u22cf',
- 'curren': '\xa4',
- 'curren;': '\xa4',
- 'curvearrowleft;': '\u21b6',
- 'curvearrowright;': '\u21b7',
- 'cuvee;': '\u22ce',
- 'cuwed;': '\u22cf',
- 'cwconint;': '\u2232',
- 'cwint;': '\u2231',
- 'cylcty;': '\u232d',
- 'Dagger;': '\u2021',
- 'dagger;': '\u2020',
- 'daleth;': '\u2138',
- 'Darr;': '\u21a1',
- 'dArr;': '\u21d3',
- 'darr;': '\u2193',
- 'dash;': '\u2010',
- 'Dashv;': '\u2ae4',
- 'dashv;': '\u22a3',
- 'dbkarow;': '\u290f',
- 'dblac;': '\u02dd',
- 'Dcaron;': '\u010e',
- 'dcaron;': '\u010f',
- 'Dcy;': '\u0414',
- 'dcy;': '\u0434',
- 'DD;': '\u2145',
- 'dd;': '\u2146',
- 'ddagger;': '\u2021',
- 'ddarr;': '\u21ca',
- 'DDotrahd;': '\u2911',
- 'ddotseq;': '\u2a77',
- 'deg': '\xb0',
- 'deg;': '\xb0',
- 'Del;': '\u2207',
- 'Delta;': '\u0394',
- 'delta;': '\u03b4',
- 'demptyv;': '\u29b1',
- 'dfisht;': '\u297f',
- 'Dfr;': '\U0001d507',
- 'dfr;': '\U0001d521',
- 'dHar;': '\u2965',
- 'dharl;': '\u21c3',
- 'dharr;': '\u21c2',
- 'DiacriticalAcute;': '\xb4',
- 'DiacriticalDot;': '\u02d9',
- 'DiacriticalDoubleAcute;': '\u02dd',
- 'DiacriticalGrave;': '`',
- 'DiacriticalTilde;': '\u02dc',
- 'diam;': '\u22c4',
- 'Diamond;': '\u22c4',
- 'diamond;': '\u22c4',
- 'diamondsuit;': '\u2666',
- 'diams;': '\u2666',
- 'die;': '\xa8',
- 'DifferentialD;': '\u2146',
- 'digamma;': '\u03dd',
- 'disin;': '\u22f2',
- 'div;': '\xf7',
- 'divide': '\xf7',
- 'divide;': '\xf7',
- 'divideontimes;': '\u22c7',
- 'divonx;': '\u22c7',
- 'DJcy;': '\u0402',
- 'djcy;': '\u0452',
- 'dlcorn;': '\u231e',
- 'dlcrop;': '\u230d',
- 'dollar;': '$',
- 'Dopf;': '\U0001d53b',
- 'dopf;': '\U0001d555',
- 'Dot;': '\xa8',
- 'dot;': '\u02d9',
- 'DotDot;': '\u20dc',
- 'doteq;': '\u2250',
- 'doteqdot;': '\u2251',
- 'DotEqual;': '\u2250',
- 'dotminus;': '\u2238',
- 'dotplus;': '\u2214',
- 'dotsquare;': '\u22a1',
- 'doublebarwedge;': '\u2306',
- 'DoubleContourIntegral;': '\u222f',
- 'DoubleDot;': '\xa8',
- 'DoubleDownArrow;': '\u21d3',
- 'DoubleLeftArrow;': '\u21d0',
- 'DoubleLeftRightArrow;': '\u21d4',
- 'DoubleLeftTee;': '\u2ae4',
- 'DoubleLongLeftArrow;': '\u27f8',
- 'DoubleLongLeftRightArrow;': '\u27fa',
- 'DoubleLongRightArrow;': '\u27f9',
- 'DoubleRightArrow;': '\u21d2',
- 'DoubleRightTee;': '\u22a8',
- 'DoubleUpArrow;': '\u21d1',
- 'DoubleUpDownArrow;': '\u21d5',
- 'DoubleVerticalBar;': '\u2225',
- 'DownArrow;': '\u2193',
- 'Downarrow;': '\u21d3',
- 'downarrow;': '\u2193',
- 'DownArrowBar;': '\u2913',
- 'DownArrowUpArrow;': '\u21f5',
- 'DownBreve;': '\u0311',
- 'downdownarrows;': '\u21ca',
- 'downharpoonleft;': '\u21c3',
- 'downharpoonright;': '\u21c2',
- 'DownLeftRightVector;': '\u2950',
- 'DownLeftTeeVector;': '\u295e',
- 'DownLeftVector;': '\u21bd',
- 'DownLeftVectorBar;': '\u2956',
- 'DownRightTeeVector;': '\u295f',
- 'DownRightVector;': '\u21c1',
- 'DownRightVectorBar;': '\u2957',
- 'DownTee;': '\u22a4',
- 'DownTeeArrow;': '\u21a7',
- 'drbkarow;': '\u2910',
- 'drcorn;': '\u231f',
- 'drcrop;': '\u230c',
- 'Dscr;': '\U0001d49f',
- 'dscr;': '\U0001d4b9',
- 'DScy;': '\u0405',
- 'dscy;': '\u0455',
- 'dsol;': '\u29f6',
- 'Dstrok;': '\u0110',
- 'dstrok;': '\u0111',
- 'dtdot;': '\u22f1',
- 'dtri;': '\u25bf',
- 'dtrif;': '\u25be',
- 'duarr;': '\u21f5',
- 'duhar;': '\u296f',
- 'dwangle;': '\u29a6',
- 'DZcy;': '\u040f',
- 'dzcy;': '\u045f',
- 'dzigrarr;': '\u27ff',
- 'Eacute': '\xc9',
- 'eacute': '\xe9',
- 'Eacute;': '\xc9',
- 'eacute;': '\xe9',
- 'easter;': '\u2a6e',
- 'Ecaron;': '\u011a',
- 'ecaron;': '\u011b',
- 'ecir;': '\u2256',
- 'Ecirc': '\xca',
- 'ecirc': '\xea',
- 'Ecirc;': '\xca',
- 'ecirc;': '\xea',
- 'ecolon;': '\u2255',
- 'Ecy;': '\u042d',
- 'ecy;': '\u044d',
- 'eDDot;': '\u2a77',
- 'Edot;': '\u0116',
- 'eDot;': '\u2251',
- 'edot;': '\u0117',
- 'ee;': '\u2147',
- 'efDot;': '\u2252',
- 'Efr;': '\U0001d508',
- 'efr;': '\U0001d522',
- 'eg;': '\u2a9a',
- 'Egrave': '\xc8',
- 'egrave': '\xe8',
- 'Egrave;': '\xc8',
- 'egrave;': '\xe8',
- 'egs;': '\u2a96',
- 'egsdot;': '\u2a98',
- 'el;': '\u2a99',
- 'Element;': '\u2208',
- 'elinters;': '\u23e7',
- 'ell;': '\u2113',
- 'els;': '\u2a95',
- 'elsdot;': '\u2a97',
- 'Emacr;': '\u0112',
- 'emacr;': '\u0113',
- 'empty;': '\u2205',
- 'emptyset;': '\u2205',
- 'EmptySmallSquare;': '\u25fb',
- 'emptyv;': '\u2205',
- 'EmptyVerySmallSquare;': '\u25ab',
- 'emsp13;': '\u2004',
- 'emsp14;': '\u2005',
- 'emsp;': '\u2003',
- 'ENG;': '\u014a',
- 'eng;': '\u014b',
- 'ensp;': '\u2002',
- 'Eogon;': '\u0118',
- 'eogon;': '\u0119',
- 'Eopf;': '\U0001d53c',
- 'eopf;': '\U0001d556',
- 'epar;': '\u22d5',
- 'eparsl;': '\u29e3',
- 'eplus;': '\u2a71',
- 'epsi;': '\u03b5',
- 'Epsilon;': '\u0395',
- 'epsilon;': '\u03b5',
- 'epsiv;': '\u03f5',
- 'eqcirc;': '\u2256',
- 'eqcolon;': '\u2255',
- 'eqsim;': '\u2242',
- 'eqslantgtr;': '\u2a96',
- 'eqslantless;': '\u2a95',
- 'Equal;': '\u2a75',
- 'equals;': '=',
- 'EqualTilde;': '\u2242',
- 'equest;': '\u225f',
- 'Equilibrium;': '\u21cc',
- 'equiv;': '\u2261',
- 'equivDD;': '\u2a78',
- 'eqvparsl;': '\u29e5',
- 'erarr;': '\u2971',
- 'erDot;': '\u2253',
- 'Escr;': '\u2130',
- 'escr;': '\u212f',
- 'esdot;': '\u2250',
- 'Esim;': '\u2a73',
- 'esim;': '\u2242',
- 'Eta;': '\u0397',
- 'eta;': '\u03b7',
- 'ETH': '\xd0',
- 'eth': '\xf0',
- 'ETH;': '\xd0',
- 'eth;': '\xf0',
- 'Euml': '\xcb',
- 'euml': '\xeb',
- 'Euml;': '\xcb',
- 'euml;': '\xeb',
- 'euro;': '\u20ac',
- 'excl;': '!',
- 'exist;': '\u2203',
- 'Exists;': '\u2203',
- 'expectation;': '\u2130',
- 'ExponentialE;': '\u2147',
- 'exponentiale;': '\u2147',
- 'fallingdotseq;': '\u2252',
- 'Fcy;': '\u0424',
- 'fcy;': '\u0444',
- 'female;': '\u2640',
- 'ffilig;': '\ufb03',
- 'fflig;': '\ufb00',
- 'ffllig;': '\ufb04',
- 'Ffr;': '\U0001d509',
- 'ffr;': '\U0001d523',
- 'filig;': '\ufb01',
- 'FilledSmallSquare;': '\u25fc',
- 'FilledVerySmallSquare;': '\u25aa',
- 'fjlig;': 'fj',
- 'flat;': '\u266d',
- 'fllig;': '\ufb02',
- 'fltns;': '\u25b1',
- 'fnof;': '\u0192',
- 'Fopf;': '\U0001d53d',
- 'fopf;': '\U0001d557',
- 'ForAll;': '\u2200',
- 'forall;': '\u2200',
- 'fork;': '\u22d4',
- 'forkv;': '\u2ad9',
- 'Fouriertrf;': '\u2131',
- 'fpartint;': '\u2a0d',
- 'frac12': '\xbd',
- 'frac12;': '\xbd',
- 'frac13;': '\u2153',
- 'frac14': '\xbc',
- 'frac14;': '\xbc',
- 'frac15;': '\u2155',
- 'frac16;': '\u2159',
- 'frac18;': '\u215b',
- 'frac23;': '\u2154',
- 'frac25;': '\u2156',
- 'frac34': '\xbe',
- 'frac34;': '\xbe',
- 'frac35;': '\u2157',
- 'frac38;': '\u215c',
- 'frac45;': '\u2158',
- 'frac56;': '\u215a',
- 'frac58;': '\u215d',
- 'frac78;': '\u215e',
- 'frasl;': '\u2044',
- 'frown;': '\u2322',
- 'Fscr;': '\u2131',
- 'fscr;': '\U0001d4bb',
- 'gacute;': '\u01f5',
- 'Gamma;': '\u0393',
- 'gamma;': '\u03b3',
- 'Gammad;': '\u03dc',
- 'gammad;': '\u03dd',
- 'gap;': '\u2a86',
- 'Gbreve;': '\u011e',
- 'gbreve;': '\u011f',
- 'Gcedil;': '\u0122',
- 'Gcirc;': '\u011c',
- 'gcirc;': '\u011d',
- 'Gcy;': '\u0413',
- 'gcy;': '\u0433',
- 'Gdot;': '\u0120',
- 'gdot;': '\u0121',
- 'gE;': '\u2267',
- 'ge;': '\u2265',
- 'gEl;': '\u2a8c',
- 'gel;': '\u22db',
- 'geq;': '\u2265',
- 'geqq;': '\u2267',
- 'geqslant;': '\u2a7e',
- 'ges;': '\u2a7e',
- 'gescc;': '\u2aa9',
- 'gesdot;': '\u2a80',
- 'gesdoto;': '\u2a82',
- 'gesdotol;': '\u2a84',
- 'gesl;': '\u22db\ufe00',
- 'gesles;': '\u2a94',
- 'Gfr;': '\U0001d50a',
- 'gfr;': '\U0001d524',
- 'Gg;': '\u22d9',
- 'gg;': '\u226b',
- 'ggg;': '\u22d9',
- 'gimel;': '\u2137',
- 'GJcy;': '\u0403',
- 'gjcy;': '\u0453',
- 'gl;': '\u2277',
- 'gla;': '\u2aa5',
- 'glE;': '\u2a92',
- 'glj;': '\u2aa4',
- 'gnap;': '\u2a8a',
- 'gnapprox;': '\u2a8a',
- 'gnE;': '\u2269',
- 'gne;': '\u2a88',
- 'gneq;': '\u2a88',
- 'gneqq;': '\u2269',
- 'gnsim;': '\u22e7',
- 'Gopf;': '\U0001d53e',
- 'gopf;': '\U0001d558',
- 'grave;': '`',
- 'GreaterEqual;': '\u2265',
- 'GreaterEqualLess;': '\u22db',
- 'GreaterFullEqual;': '\u2267',
- 'GreaterGreater;': '\u2aa2',
- 'GreaterLess;': '\u2277',
- 'GreaterSlantEqual;': '\u2a7e',
- 'GreaterTilde;': '\u2273',
- 'Gscr;': '\U0001d4a2',
- 'gscr;': '\u210a',
- 'gsim;': '\u2273',
- 'gsime;': '\u2a8e',
- 'gsiml;': '\u2a90',
- 'GT': '>',
- 'gt': '>',
- 'GT;': '>',
- 'Gt;': '\u226b',
- 'gt;': '>',
- 'gtcc;': '\u2aa7',
- 'gtcir;': '\u2a7a',
- 'gtdot;': '\u22d7',
- 'gtlPar;': '\u2995',
- 'gtquest;': '\u2a7c',
- 'gtrapprox;': '\u2a86',
- 'gtrarr;': '\u2978',
- 'gtrdot;': '\u22d7',
- 'gtreqless;': '\u22db',
- 'gtreqqless;': '\u2a8c',
- 'gtrless;': '\u2277',
- 'gtrsim;': '\u2273',
- 'gvertneqq;': '\u2269\ufe00',
- 'gvnE;': '\u2269\ufe00',
- 'Hacek;': '\u02c7',
- 'hairsp;': '\u200a',
- 'half;': '\xbd',
- 'hamilt;': '\u210b',
- 'HARDcy;': '\u042a',
- 'hardcy;': '\u044a',
- 'hArr;': '\u21d4',
- 'harr;': '\u2194',
- 'harrcir;': '\u2948',
- 'harrw;': '\u21ad',
- 'Hat;': '^',
- 'hbar;': '\u210f',
- 'Hcirc;': '\u0124',
- 'hcirc;': '\u0125',
- 'hearts;': '\u2665',
- 'heartsuit;': '\u2665',
- 'hellip;': '\u2026',
- 'hercon;': '\u22b9',
- 'Hfr;': '\u210c',
- 'hfr;': '\U0001d525',
- 'HilbertSpace;': '\u210b',
- 'hksearow;': '\u2925',
- 'hkswarow;': '\u2926',
- 'hoarr;': '\u21ff',
- 'homtht;': '\u223b',
- 'hookleftarrow;': '\u21a9',
- 'hookrightarrow;': '\u21aa',
- 'Hopf;': '\u210d',
- 'hopf;': '\U0001d559',
- 'horbar;': '\u2015',
- 'HorizontalLine;': '\u2500',
- 'Hscr;': '\u210b',
- 'hscr;': '\U0001d4bd',
- 'hslash;': '\u210f',
- 'Hstrok;': '\u0126',
- 'hstrok;': '\u0127',
- 'HumpDownHump;': '\u224e',
- 'HumpEqual;': '\u224f',
- 'hybull;': '\u2043',
- 'hyphen;': '\u2010',
- 'Iacute': '\xcd',
- 'iacute': '\xed',
- 'Iacute;': '\xcd',
- 'iacute;': '\xed',
- 'ic;': '\u2063',
- 'Icirc': '\xce',
- 'icirc': '\xee',
- 'Icirc;': '\xce',
- 'icirc;': '\xee',
- 'Icy;': '\u0418',
- 'icy;': '\u0438',
- 'Idot;': '\u0130',
- 'IEcy;': '\u0415',
- 'iecy;': '\u0435',
- 'iexcl': '\xa1',
- 'iexcl;': '\xa1',
- 'iff;': '\u21d4',
- 'Ifr;': '\u2111',
- 'ifr;': '\U0001d526',
- 'Igrave': '\xcc',
- 'igrave': '\xec',
- 'Igrave;': '\xcc',
- 'igrave;': '\xec',
- 'ii;': '\u2148',
- 'iiiint;': '\u2a0c',
- 'iiint;': '\u222d',
- 'iinfin;': '\u29dc',
- 'iiota;': '\u2129',
- 'IJlig;': '\u0132',
- 'ijlig;': '\u0133',
- 'Im;': '\u2111',
- 'Imacr;': '\u012a',
- 'imacr;': '\u012b',
- 'image;': '\u2111',
- 'ImaginaryI;': '\u2148',
- 'imagline;': '\u2110',
- 'imagpart;': '\u2111',
- 'imath;': '\u0131',
- 'imof;': '\u22b7',
- 'imped;': '\u01b5',
- 'Implies;': '\u21d2',
- 'in;': '\u2208',
- 'incare;': '\u2105',
- 'infin;': '\u221e',
- 'infintie;': '\u29dd',
- 'inodot;': '\u0131',
- 'Int;': '\u222c',
- 'int;': '\u222b',
- 'intcal;': '\u22ba',
- 'integers;': '\u2124',
- 'Integral;': '\u222b',
- 'intercal;': '\u22ba',
- 'Intersection;': '\u22c2',
- 'intlarhk;': '\u2a17',
- 'intprod;': '\u2a3c',
- 'InvisibleComma;': '\u2063',
- 'InvisibleTimes;': '\u2062',
- 'IOcy;': '\u0401',
- 'iocy;': '\u0451',
- 'Iogon;': '\u012e',
- 'iogon;': '\u012f',
- 'Iopf;': '\U0001d540',
- 'iopf;': '\U0001d55a',
- 'Iota;': '\u0399',
- 'iota;': '\u03b9',
- 'iprod;': '\u2a3c',
- 'iquest': '\xbf',
- 'iquest;': '\xbf',
- 'Iscr;': '\u2110',
- 'iscr;': '\U0001d4be',
- 'isin;': '\u2208',
- 'isindot;': '\u22f5',
- 'isinE;': '\u22f9',
- 'isins;': '\u22f4',
- 'isinsv;': '\u22f3',
- 'isinv;': '\u2208',
- 'it;': '\u2062',
- 'Itilde;': '\u0128',
- 'itilde;': '\u0129',
- 'Iukcy;': '\u0406',
- 'iukcy;': '\u0456',
- 'Iuml': '\xcf',
- 'iuml': '\xef',
- 'Iuml;': '\xcf',
- 'iuml;': '\xef',
- 'Jcirc;': '\u0134',
- 'jcirc;': '\u0135',
- 'Jcy;': '\u0419',
- 'jcy;': '\u0439',
- 'Jfr;': '\U0001d50d',
- 'jfr;': '\U0001d527',
- 'jmath;': '\u0237',
- 'Jopf;': '\U0001d541',
- 'jopf;': '\U0001d55b',
- 'Jscr;': '\U0001d4a5',
- 'jscr;': '\U0001d4bf',
- 'Jsercy;': '\u0408',
- 'jsercy;': '\u0458',
- 'Jukcy;': '\u0404',
- 'jukcy;': '\u0454',
- 'Kappa;': '\u039a',
- 'kappa;': '\u03ba',
- 'kappav;': '\u03f0',
- 'Kcedil;': '\u0136',
- 'kcedil;': '\u0137',
- 'Kcy;': '\u041a',
- 'kcy;': '\u043a',
- 'Kfr;': '\U0001d50e',
- 'kfr;': '\U0001d528',
- 'kgreen;': '\u0138',
- 'KHcy;': '\u0425',
- 'khcy;': '\u0445',
- 'KJcy;': '\u040c',
- 'kjcy;': '\u045c',
- 'Kopf;': '\U0001d542',
- 'kopf;': '\U0001d55c',
- 'Kscr;': '\U0001d4a6',
- 'kscr;': '\U0001d4c0',
- 'lAarr;': '\u21da',
- 'Lacute;': '\u0139',
- 'lacute;': '\u013a',
- 'laemptyv;': '\u29b4',
- 'lagran;': '\u2112',
- 'Lambda;': '\u039b',
- 'lambda;': '\u03bb',
- 'Lang;': '\u27ea',
- 'lang;': '\u27e8',
- 'langd;': '\u2991',
- 'langle;': '\u27e8',
- 'lap;': '\u2a85',
- 'Laplacetrf;': '\u2112',
- 'laquo': '\xab',
- 'laquo;': '\xab',
- 'Larr;': '\u219e',
- 'lArr;': '\u21d0',
- 'larr;': '\u2190',
- 'larrb;': '\u21e4',
- 'larrbfs;': '\u291f',
- 'larrfs;': '\u291d',
- 'larrhk;': '\u21a9',
- 'larrlp;': '\u21ab',
- 'larrpl;': '\u2939',
- 'larrsim;': '\u2973',
- 'larrtl;': '\u21a2',
- 'lat;': '\u2aab',
- 'lAtail;': '\u291b',
- 'latail;': '\u2919',
- 'late;': '\u2aad',
- 'lates;': '\u2aad\ufe00',
- 'lBarr;': '\u290e',
- 'lbarr;': '\u290c',
- 'lbbrk;': '\u2772',
- 'lbrace;': '{',
- 'lbrack;': '[',
- 'lbrke;': '\u298b',
- 'lbrksld;': '\u298f',
- 'lbrkslu;': '\u298d',
- 'Lcaron;': '\u013d',
- 'lcaron;': '\u013e',
- 'Lcedil;': '\u013b',
- 'lcedil;': '\u013c',
- 'lceil;': '\u2308',
- 'lcub;': '{',
- 'Lcy;': '\u041b',
- 'lcy;': '\u043b',
- 'ldca;': '\u2936',
- 'ldquo;': '\u201c',
- 'ldquor;': '\u201e',
- 'ldrdhar;': '\u2967',
- 'ldrushar;': '\u294b',
- 'ldsh;': '\u21b2',
- 'lE;': '\u2266',
- 'le;': '\u2264',
- 'LeftAngleBracket;': '\u27e8',
- 'LeftArrow;': '\u2190',
- 'Leftarrow;': '\u21d0',
- 'leftarrow;': '\u2190',
- 'LeftArrowBar;': '\u21e4',
- 'LeftArrowRightArrow;': '\u21c6',
- 'leftarrowtail;': '\u21a2',
- 'LeftCeiling;': '\u2308',
- 'LeftDoubleBracket;': '\u27e6',
- 'LeftDownTeeVector;': '\u2961',
- 'LeftDownVector;': '\u21c3',
- 'LeftDownVectorBar;': '\u2959',
- 'LeftFloor;': '\u230a',
- 'leftharpoondown;': '\u21bd',
- 'leftharpoonup;': '\u21bc',
- 'leftleftarrows;': '\u21c7',
- 'LeftRightArrow;': '\u2194',
- 'Leftrightarrow;': '\u21d4',
- 'leftrightarrow;': '\u2194',
- 'leftrightarrows;': '\u21c6',
- 'leftrightharpoons;': '\u21cb',
- 'leftrightsquigarrow;': '\u21ad',
- 'LeftRightVector;': '\u294e',
- 'LeftTee;': '\u22a3',
- 'LeftTeeArrow;': '\u21a4',
- 'LeftTeeVector;': '\u295a',
- 'leftthreetimes;': '\u22cb',
- 'LeftTriangle;': '\u22b2',
- 'LeftTriangleBar;': '\u29cf',
- 'LeftTriangleEqual;': '\u22b4',
- 'LeftUpDownVector;': '\u2951',
- 'LeftUpTeeVector;': '\u2960',
- 'LeftUpVector;': '\u21bf',
- 'LeftUpVectorBar;': '\u2958',
- 'LeftVector;': '\u21bc',
- 'LeftVectorBar;': '\u2952',
- 'lEg;': '\u2a8b',
- 'leg;': '\u22da',
- 'leq;': '\u2264',
- 'leqq;': '\u2266',
- 'leqslant;': '\u2a7d',
- 'les;': '\u2a7d',
- 'lescc;': '\u2aa8',
- 'lesdot;': '\u2a7f',
- 'lesdoto;': '\u2a81',
- 'lesdotor;': '\u2a83',
- 'lesg;': '\u22da\ufe00',
- 'lesges;': '\u2a93',
- 'lessapprox;': '\u2a85',
- 'lessdot;': '\u22d6',
- 'lesseqgtr;': '\u22da',
- 'lesseqqgtr;': '\u2a8b',
- 'LessEqualGreater;': '\u22da',
- 'LessFullEqual;': '\u2266',
- 'LessGreater;': '\u2276',
- 'lessgtr;': '\u2276',
- 'LessLess;': '\u2aa1',
- 'lesssim;': '\u2272',
- 'LessSlantEqual;': '\u2a7d',
- 'LessTilde;': '\u2272',
- 'lfisht;': '\u297c',
- 'lfloor;': '\u230a',
- 'Lfr;': '\U0001d50f',
- 'lfr;': '\U0001d529',
- 'lg;': '\u2276',
- 'lgE;': '\u2a91',
- 'lHar;': '\u2962',
- 'lhard;': '\u21bd',
- 'lharu;': '\u21bc',
- 'lharul;': '\u296a',
- 'lhblk;': '\u2584',
- 'LJcy;': '\u0409',
- 'ljcy;': '\u0459',
- 'Ll;': '\u22d8',
- 'll;': '\u226a',
- 'llarr;': '\u21c7',
- 'llcorner;': '\u231e',
- 'Lleftarrow;': '\u21da',
- 'llhard;': '\u296b',
- 'lltri;': '\u25fa',
- 'Lmidot;': '\u013f',
- 'lmidot;': '\u0140',
- 'lmoust;': '\u23b0',
- 'lmoustache;': '\u23b0',
- 'lnap;': '\u2a89',
- 'lnapprox;': '\u2a89',
- 'lnE;': '\u2268',
- 'lne;': '\u2a87',
- 'lneq;': '\u2a87',
- 'lneqq;': '\u2268',
- 'lnsim;': '\u22e6',
- 'loang;': '\u27ec',
- 'loarr;': '\u21fd',
- 'lobrk;': '\u27e6',
- 'LongLeftArrow;': '\u27f5',
- 'Longleftarrow;': '\u27f8',
- 'longleftarrow;': '\u27f5',
- 'LongLeftRightArrow;': '\u27f7',
- 'Longleftrightarrow;': '\u27fa',
- 'longleftrightarrow;': '\u27f7',
- 'longmapsto;': '\u27fc',
- 'LongRightArrow;': '\u27f6',
- 'Longrightarrow;': '\u27f9',
- 'longrightarrow;': '\u27f6',
- 'looparrowleft;': '\u21ab',
- 'looparrowright;': '\u21ac',
- 'lopar;': '\u2985',
- 'Lopf;': '\U0001d543',
- 'lopf;': '\U0001d55d',
- 'loplus;': '\u2a2d',
- 'lotimes;': '\u2a34',
- 'lowast;': '\u2217',
- 'lowbar;': '_',
- 'LowerLeftArrow;': '\u2199',
- 'LowerRightArrow;': '\u2198',
- 'loz;': '\u25ca',
- 'lozenge;': '\u25ca',
- 'lozf;': '\u29eb',
- 'lpar;': '(',
- 'lparlt;': '\u2993',
- 'lrarr;': '\u21c6',
- 'lrcorner;': '\u231f',
- 'lrhar;': '\u21cb',
- 'lrhard;': '\u296d',
- 'lrm;': '\u200e',
- 'lrtri;': '\u22bf',
- 'lsaquo;': '\u2039',
- 'Lscr;': '\u2112',
- 'lscr;': '\U0001d4c1',
- 'Lsh;': '\u21b0',
- 'lsh;': '\u21b0',
- 'lsim;': '\u2272',
- 'lsime;': '\u2a8d',
- 'lsimg;': '\u2a8f',
- 'lsqb;': '[',
- 'lsquo;': '\u2018',
- 'lsquor;': '\u201a',
- 'Lstrok;': '\u0141',
- 'lstrok;': '\u0142',
- 'LT': '<',
- 'lt': '<',
- 'LT;': '<',
- 'Lt;': '\u226a',
- 'lt;': '<',
- 'ltcc;': '\u2aa6',
- 'ltcir;': '\u2a79',
- 'ltdot;': '\u22d6',
- 'lthree;': '\u22cb',
- 'ltimes;': '\u22c9',
- 'ltlarr;': '\u2976',
- 'ltquest;': '\u2a7b',
- 'ltri;': '\u25c3',
- 'ltrie;': '\u22b4',
- 'ltrif;': '\u25c2',
- 'ltrPar;': '\u2996',
- 'lurdshar;': '\u294a',
- 'luruhar;': '\u2966',
- 'lvertneqq;': '\u2268\ufe00',
- 'lvnE;': '\u2268\ufe00',
- 'macr': '\xaf',
- 'macr;': '\xaf',
- 'male;': '\u2642',
- 'malt;': '\u2720',
- 'maltese;': '\u2720',
- 'Map;': '\u2905',
- 'map;': '\u21a6',
- 'mapsto;': '\u21a6',
- 'mapstodown;': '\u21a7',
- 'mapstoleft;': '\u21a4',
- 'mapstoup;': '\u21a5',
- 'marker;': '\u25ae',
- 'mcomma;': '\u2a29',
- 'Mcy;': '\u041c',
- 'mcy;': '\u043c',
- 'mdash;': '\u2014',
- 'mDDot;': '\u223a',
- 'measuredangle;': '\u2221',
- 'MediumSpace;': '\u205f',
- 'Mellintrf;': '\u2133',
- 'Mfr;': '\U0001d510',
- 'mfr;': '\U0001d52a',
- 'mho;': '\u2127',
- 'micro': '\xb5',
- 'micro;': '\xb5',
- 'mid;': '\u2223',
- 'midast;': '*',
- 'midcir;': '\u2af0',
- 'middot': '\xb7',
- 'middot;': '\xb7',
- 'minus;': '\u2212',
- 'minusb;': '\u229f',
- 'minusd;': '\u2238',
- 'minusdu;': '\u2a2a',
- 'MinusPlus;': '\u2213',
- 'mlcp;': '\u2adb',
- 'mldr;': '\u2026',
- 'mnplus;': '\u2213',
- 'models;': '\u22a7',
- 'Mopf;': '\U0001d544',
- 'mopf;': '\U0001d55e',
- 'mp;': '\u2213',
- 'Mscr;': '\u2133',
- 'mscr;': '\U0001d4c2',
- 'mstpos;': '\u223e',
- 'Mu;': '\u039c',
- 'mu;': '\u03bc',
- 'multimap;': '\u22b8',
- 'mumap;': '\u22b8',
- 'nabla;': '\u2207',
- 'Nacute;': '\u0143',
- 'nacute;': '\u0144',
- 'nang;': '\u2220\u20d2',
- 'nap;': '\u2249',
- 'napE;': '\u2a70\u0338',
- 'napid;': '\u224b\u0338',
- 'napos;': '\u0149',
- 'napprox;': '\u2249',
- 'natur;': '\u266e',
- 'natural;': '\u266e',
- 'naturals;': '\u2115',
- 'nbsp': '\xa0',
- 'nbsp;': '\xa0',
- 'nbump;': '\u224e\u0338',
- 'nbumpe;': '\u224f\u0338',
- 'ncap;': '\u2a43',
- 'Ncaron;': '\u0147',
- 'ncaron;': '\u0148',
- 'Ncedil;': '\u0145',
- 'ncedil;': '\u0146',
- 'ncong;': '\u2247',
- 'ncongdot;': '\u2a6d\u0338',
- 'ncup;': '\u2a42',
- 'Ncy;': '\u041d',
- 'ncy;': '\u043d',
- 'ndash;': '\u2013',
- 'ne;': '\u2260',
- 'nearhk;': '\u2924',
- 'neArr;': '\u21d7',
- 'nearr;': '\u2197',
- 'nearrow;': '\u2197',
- 'nedot;': '\u2250\u0338',
- 'NegativeMediumSpace;': '\u200b',
- 'NegativeThickSpace;': '\u200b',
- 'NegativeThinSpace;': '\u200b',
- 'NegativeVeryThinSpace;': '\u200b',
- 'nequiv;': '\u2262',
- 'nesear;': '\u2928',
- 'nesim;': '\u2242\u0338',
- 'NestedGreaterGreater;': '\u226b',
- 'NestedLessLess;': '\u226a',
- 'NewLine;': '\n',
- 'nexist;': '\u2204',
- 'nexists;': '\u2204',
- 'Nfr;': '\U0001d511',
- 'nfr;': '\U0001d52b',
- 'ngE;': '\u2267\u0338',
- 'nge;': '\u2271',
- 'ngeq;': '\u2271',
- 'ngeqq;': '\u2267\u0338',
- 'ngeqslant;': '\u2a7e\u0338',
- 'nges;': '\u2a7e\u0338',
- 'nGg;': '\u22d9\u0338',
- 'ngsim;': '\u2275',
- 'nGt;': '\u226b\u20d2',
- 'ngt;': '\u226f',
- 'ngtr;': '\u226f',
- 'nGtv;': '\u226b\u0338',
- 'nhArr;': '\u21ce',
- 'nharr;': '\u21ae',
- 'nhpar;': '\u2af2',
- 'ni;': '\u220b',
- 'nis;': '\u22fc',
- 'nisd;': '\u22fa',
- 'niv;': '\u220b',
- 'NJcy;': '\u040a',
- 'njcy;': '\u045a',
- 'nlArr;': '\u21cd',
- 'nlarr;': '\u219a',
- 'nldr;': '\u2025',
- 'nlE;': '\u2266\u0338',
- 'nle;': '\u2270',
- 'nLeftarrow;': '\u21cd',
- 'nleftarrow;': '\u219a',
- 'nLeftrightarrow;': '\u21ce',
- 'nleftrightarrow;': '\u21ae',
- 'nleq;': '\u2270',
- 'nleqq;': '\u2266\u0338',
- 'nleqslant;': '\u2a7d\u0338',
- 'nles;': '\u2a7d\u0338',
- 'nless;': '\u226e',
- 'nLl;': '\u22d8\u0338',
- 'nlsim;': '\u2274',
- 'nLt;': '\u226a\u20d2',
- 'nlt;': '\u226e',
- 'nltri;': '\u22ea',
- 'nltrie;': '\u22ec',
- 'nLtv;': '\u226a\u0338',
- 'nmid;': '\u2224',
- 'NoBreak;': '\u2060',
- 'NonBreakingSpace;': '\xa0',
- 'Nopf;': '\u2115',
- 'nopf;': '\U0001d55f',
- 'not': '\xac',
- 'Not;': '\u2aec',
- 'not;': '\xac',
- 'NotCongruent;': '\u2262',
- 'NotCupCap;': '\u226d',
- 'NotDoubleVerticalBar;': '\u2226',
- 'NotElement;': '\u2209',
- 'NotEqual;': '\u2260',
- 'NotEqualTilde;': '\u2242\u0338',
- 'NotExists;': '\u2204',
- 'NotGreater;': '\u226f',
- 'NotGreaterEqual;': '\u2271',
- 'NotGreaterFullEqual;': '\u2267\u0338',
- 'NotGreaterGreater;': '\u226b\u0338',
- 'NotGreaterLess;': '\u2279',
- 'NotGreaterSlantEqual;': '\u2a7e\u0338',
- 'NotGreaterTilde;': '\u2275',
- 'NotHumpDownHump;': '\u224e\u0338',
- 'NotHumpEqual;': '\u224f\u0338',
- 'notin;': '\u2209',
- 'notindot;': '\u22f5\u0338',
- 'notinE;': '\u22f9\u0338',
- 'notinva;': '\u2209',
- 'notinvb;': '\u22f7',
- 'notinvc;': '\u22f6',
- 'NotLeftTriangle;': '\u22ea',
- 'NotLeftTriangleBar;': '\u29cf\u0338',
- 'NotLeftTriangleEqual;': '\u22ec',
- 'NotLess;': '\u226e',
- 'NotLessEqual;': '\u2270',
- 'NotLessGreater;': '\u2278',
- 'NotLessLess;': '\u226a\u0338',
- 'NotLessSlantEqual;': '\u2a7d\u0338',
- 'NotLessTilde;': '\u2274',
- 'NotNestedGreaterGreater;': '\u2aa2\u0338',
- 'NotNestedLessLess;': '\u2aa1\u0338',
- 'notni;': '\u220c',
- 'notniva;': '\u220c',
- 'notnivb;': '\u22fe',
- 'notnivc;': '\u22fd',
- 'NotPrecedes;': '\u2280',
- 'NotPrecedesEqual;': '\u2aaf\u0338',
- 'NotPrecedesSlantEqual;': '\u22e0',
- 'NotReverseElement;': '\u220c',
- 'NotRightTriangle;': '\u22eb',
- 'NotRightTriangleBar;': '\u29d0\u0338',
- 'NotRightTriangleEqual;': '\u22ed',
- 'NotSquareSubset;': '\u228f\u0338',
- 'NotSquareSubsetEqual;': '\u22e2',
- 'NotSquareSuperset;': '\u2290\u0338',
- 'NotSquareSupersetEqual;': '\u22e3',
- 'NotSubset;': '\u2282\u20d2',
- 'NotSubsetEqual;': '\u2288',
- 'NotSucceeds;': '\u2281',
- 'NotSucceedsEqual;': '\u2ab0\u0338',
- 'NotSucceedsSlantEqual;': '\u22e1',
- 'NotSucceedsTilde;': '\u227f\u0338',
- 'NotSuperset;': '\u2283\u20d2',
- 'NotSupersetEqual;': '\u2289',
- 'NotTilde;': '\u2241',
- 'NotTildeEqual;': '\u2244',
- 'NotTildeFullEqual;': '\u2247',
- 'NotTildeTilde;': '\u2249',
- 'NotVerticalBar;': '\u2224',
- 'npar;': '\u2226',
- 'nparallel;': '\u2226',
- 'nparsl;': '\u2afd\u20e5',
- 'npart;': '\u2202\u0338',
- 'npolint;': '\u2a14',
- 'npr;': '\u2280',
- 'nprcue;': '\u22e0',
- 'npre;': '\u2aaf\u0338',
- 'nprec;': '\u2280',
- 'npreceq;': '\u2aaf\u0338',
- 'nrArr;': '\u21cf',
- 'nrarr;': '\u219b',
- 'nrarrc;': '\u2933\u0338',
- 'nrarrw;': '\u219d\u0338',
- 'nRightarrow;': '\u21cf',
- 'nrightarrow;': '\u219b',
- 'nrtri;': '\u22eb',
- 'nrtrie;': '\u22ed',
- 'nsc;': '\u2281',
- 'nsccue;': '\u22e1',
- 'nsce;': '\u2ab0\u0338',
- 'Nscr;': '\U0001d4a9',
- 'nscr;': '\U0001d4c3',
- 'nshortmid;': '\u2224',
- 'nshortparallel;': '\u2226',
- 'nsim;': '\u2241',
- 'nsime;': '\u2244',
- 'nsimeq;': '\u2244',
- 'nsmid;': '\u2224',
- 'nspar;': '\u2226',
- 'nsqsube;': '\u22e2',
- 'nsqsupe;': '\u22e3',
- 'nsub;': '\u2284',
- 'nsubE;': '\u2ac5\u0338',
- 'nsube;': '\u2288',
- 'nsubset;': '\u2282\u20d2',
- 'nsubseteq;': '\u2288',
- 'nsubseteqq;': '\u2ac5\u0338',
- 'nsucc;': '\u2281',
- 'nsucceq;': '\u2ab0\u0338',
- 'nsup;': '\u2285',
- 'nsupE;': '\u2ac6\u0338',
- 'nsupe;': '\u2289',
- 'nsupset;': '\u2283\u20d2',
- 'nsupseteq;': '\u2289',
- 'nsupseteqq;': '\u2ac6\u0338',
- 'ntgl;': '\u2279',
- 'Ntilde': '\xd1',
- 'ntilde': '\xf1',
- 'Ntilde;': '\xd1',
- 'ntilde;': '\xf1',
- 'ntlg;': '\u2278',
- 'ntriangleleft;': '\u22ea',
- 'ntrianglelefteq;': '\u22ec',
- 'ntriangleright;': '\u22eb',
- 'ntrianglerighteq;': '\u22ed',
- 'Nu;': '\u039d',
- 'nu;': '\u03bd',
- 'num;': '#',
- 'numero;': '\u2116',
- 'numsp;': '\u2007',
- 'nvap;': '\u224d\u20d2',
- 'nVDash;': '\u22af',
- 'nVdash;': '\u22ae',
- 'nvDash;': '\u22ad',
- 'nvdash;': '\u22ac',
- 'nvge;': '\u2265\u20d2',
- 'nvgt;': '>\u20d2',
- 'nvHarr;': '\u2904',
- 'nvinfin;': '\u29de',
- 'nvlArr;': '\u2902',
- 'nvle;': '\u2264\u20d2',
- 'nvlt;': '<\u20d2',
- 'nvltrie;': '\u22b4\u20d2',
- 'nvrArr;': '\u2903',
- 'nvrtrie;': '\u22b5\u20d2',
- 'nvsim;': '\u223c\u20d2',
- 'nwarhk;': '\u2923',
- 'nwArr;': '\u21d6',
- 'nwarr;': '\u2196',
- 'nwarrow;': '\u2196',
- 'nwnear;': '\u2927',
- 'Oacute': '\xd3',
- 'oacute': '\xf3',
- 'Oacute;': '\xd3',
- 'oacute;': '\xf3',
- 'oast;': '\u229b',
- 'ocir;': '\u229a',
- 'Ocirc': '\xd4',
- 'ocirc': '\xf4',
- 'Ocirc;': '\xd4',
- 'ocirc;': '\xf4',
- 'Ocy;': '\u041e',
- 'ocy;': '\u043e',
- 'odash;': '\u229d',
- 'Odblac;': '\u0150',
- 'odblac;': '\u0151',
- 'odiv;': '\u2a38',
- 'odot;': '\u2299',
- 'odsold;': '\u29bc',
- 'OElig;': '\u0152',
- 'oelig;': '\u0153',
- 'ofcir;': '\u29bf',
- 'Ofr;': '\U0001d512',
- 'ofr;': '\U0001d52c',
- 'ogon;': '\u02db',
- 'Ograve': '\xd2',
- 'ograve': '\xf2',
- 'Ograve;': '\xd2',
- 'ograve;': '\xf2',
- 'ogt;': '\u29c1',
- 'ohbar;': '\u29b5',
- 'ohm;': '\u03a9',
- 'oint;': '\u222e',
- 'olarr;': '\u21ba',
- 'olcir;': '\u29be',
- 'olcross;': '\u29bb',
- 'oline;': '\u203e',
- 'olt;': '\u29c0',
- 'Omacr;': '\u014c',
- 'omacr;': '\u014d',
- 'Omega;': '\u03a9',
- 'omega;': '\u03c9',
- 'Omicron;': '\u039f',
- 'omicron;': '\u03bf',
- 'omid;': '\u29b6',
- 'ominus;': '\u2296',
- 'Oopf;': '\U0001d546',
- 'oopf;': '\U0001d560',
- 'opar;': '\u29b7',
- 'OpenCurlyDoubleQuote;': '\u201c',
- 'OpenCurlyQuote;': '\u2018',
- 'operp;': '\u29b9',
- 'oplus;': '\u2295',
- 'Or;': '\u2a54',
- 'or;': '\u2228',
- 'orarr;': '\u21bb',
- 'ord;': '\u2a5d',
- 'order;': '\u2134',
- 'orderof;': '\u2134',
- 'ordf': '\xaa',
- 'ordf;': '\xaa',
- 'ordm': '\xba',
- 'ordm;': '\xba',
- 'origof;': '\u22b6',
- 'oror;': '\u2a56',
- 'orslope;': '\u2a57',
- 'orv;': '\u2a5b',
- 'oS;': '\u24c8',
- 'Oscr;': '\U0001d4aa',
- 'oscr;': '\u2134',
- 'Oslash': '\xd8',
- 'oslash': '\xf8',
- 'Oslash;': '\xd8',
- 'oslash;': '\xf8',
- 'osol;': '\u2298',
- 'Otilde': '\xd5',
- 'otilde': '\xf5',
- 'Otilde;': '\xd5',
- 'otilde;': '\xf5',
- 'Otimes;': '\u2a37',
- 'otimes;': '\u2297',
- 'otimesas;': '\u2a36',
- 'Ouml': '\xd6',
- 'ouml': '\xf6',
- 'Ouml;': '\xd6',
- 'ouml;': '\xf6',
- 'ovbar;': '\u233d',
- 'OverBar;': '\u203e',
- 'OverBrace;': '\u23de',
- 'OverBracket;': '\u23b4',
- 'OverParenthesis;': '\u23dc',
- 'par;': '\u2225',
- 'para': '\xb6',
- 'para;': '\xb6',
- 'parallel;': '\u2225',
- 'parsim;': '\u2af3',
- 'parsl;': '\u2afd',
- 'part;': '\u2202',
- 'PartialD;': '\u2202',
- 'Pcy;': '\u041f',
- 'pcy;': '\u043f',
- 'percnt;': '%',
- 'period;': '.',
- 'permil;': '\u2030',
- 'perp;': '\u22a5',
- 'pertenk;': '\u2031',
- 'Pfr;': '\U0001d513',
- 'pfr;': '\U0001d52d',
- 'Phi;': '\u03a6',
- 'phi;': '\u03c6',
- 'phiv;': '\u03d5',
- 'phmmat;': '\u2133',
- 'phone;': '\u260e',
- 'Pi;': '\u03a0',
- 'pi;': '\u03c0',
- 'pitchfork;': '\u22d4',
- 'piv;': '\u03d6',
- 'planck;': '\u210f',
- 'planckh;': '\u210e',
- 'plankv;': '\u210f',
- 'plus;': '+',
- 'plusacir;': '\u2a23',
- 'plusb;': '\u229e',
- 'pluscir;': '\u2a22',
- 'plusdo;': '\u2214',
- 'plusdu;': '\u2a25',
- 'pluse;': '\u2a72',
- 'PlusMinus;': '\xb1',
- 'plusmn': '\xb1',
- 'plusmn;': '\xb1',
- 'plussim;': '\u2a26',
- 'plustwo;': '\u2a27',
- 'pm;': '\xb1',
- 'Poincareplane;': '\u210c',
- 'pointint;': '\u2a15',
- 'Popf;': '\u2119',
- 'popf;': '\U0001d561',
- 'pound': '\xa3',
- 'pound;': '\xa3',
- 'Pr;': '\u2abb',
- 'pr;': '\u227a',
- 'prap;': '\u2ab7',
- 'prcue;': '\u227c',
- 'prE;': '\u2ab3',
- 'pre;': '\u2aaf',
- 'prec;': '\u227a',
- 'precapprox;': '\u2ab7',
- 'preccurlyeq;': '\u227c',
- 'Precedes;': '\u227a',
- 'PrecedesEqual;': '\u2aaf',
- 'PrecedesSlantEqual;': '\u227c',
- 'PrecedesTilde;': '\u227e',
- 'preceq;': '\u2aaf',
- 'precnapprox;': '\u2ab9',
- 'precneqq;': '\u2ab5',
- 'precnsim;': '\u22e8',
- 'precsim;': '\u227e',
- 'Prime;': '\u2033',
- 'prime;': '\u2032',
- 'primes;': '\u2119',
- 'prnap;': '\u2ab9',
- 'prnE;': '\u2ab5',
- 'prnsim;': '\u22e8',
- 'prod;': '\u220f',
- 'Product;': '\u220f',
- 'profalar;': '\u232e',
- 'profline;': '\u2312',
- 'profsurf;': '\u2313',
- 'prop;': '\u221d',
- 'Proportion;': '\u2237',
- 'Proportional;': '\u221d',
- 'propto;': '\u221d',
- 'prsim;': '\u227e',
- 'prurel;': '\u22b0',
- 'Pscr;': '\U0001d4ab',
- 'pscr;': '\U0001d4c5',
- 'Psi;': '\u03a8',
- 'psi;': '\u03c8',
- 'puncsp;': '\u2008',
- 'Qfr;': '\U0001d514',
- 'qfr;': '\U0001d52e',
- 'qint;': '\u2a0c',
- 'Qopf;': '\u211a',
- 'qopf;': '\U0001d562',
- 'qprime;': '\u2057',
- 'Qscr;': '\U0001d4ac',
- 'qscr;': '\U0001d4c6',
- 'quaternions;': '\u210d',
- 'quatint;': '\u2a16',
- 'quest;': '?',
- 'questeq;': '\u225f',
- 'QUOT': '"',
- 'quot': '"',
- 'QUOT;': '"',
- 'quot;': '"',
- 'rAarr;': '\u21db',
- 'race;': '\u223d\u0331',
- 'Racute;': '\u0154',
- 'racute;': '\u0155',
- 'radic;': '\u221a',
- 'raemptyv;': '\u29b3',
- 'Rang;': '\u27eb',
- 'rang;': '\u27e9',
- 'rangd;': '\u2992',
- 'range;': '\u29a5',
- 'rangle;': '\u27e9',
- 'raquo': '\xbb',
- 'raquo;': '\xbb',
- 'Rarr;': '\u21a0',
- 'rArr;': '\u21d2',
- 'rarr;': '\u2192',
- 'rarrap;': '\u2975',
- 'rarrb;': '\u21e5',
- 'rarrbfs;': '\u2920',
- 'rarrc;': '\u2933',
- 'rarrfs;': '\u291e',
- 'rarrhk;': '\u21aa',
- 'rarrlp;': '\u21ac',
- 'rarrpl;': '\u2945',
- 'rarrsim;': '\u2974',
- 'Rarrtl;': '\u2916',
- 'rarrtl;': '\u21a3',
- 'rarrw;': '\u219d',
- 'rAtail;': '\u291c',
- 'ratail;': '\u291a',
- 'ratio;': '\u2236',
- 'rationals;': '\u211a',
- 'RBarr;': '\u2910',
- 'rBarr;': '\u290f',
- 'rbarr;': '\u290d',
- 'rbbrk;': '\u2773',
- 'rbrace;': '}',
- 'rbrack;': ']',
- 'rbrke;': '\u298c',
- 'rbrksld;': '\u298e',
- 'rbrkslu;': '\u2990',
- 'Rcaron;': '\u0158',
- 'rcaron;': '\u0159',
- 'Rcedil;': '\u0156',
- 'rcedil;': '\u0157',
- 'rceil;': '\u2309',
- 'rcub;': '}',
- 'Rcy;': '\u0420',
- 'rcy;': '\u0440',
- 'rdca;': '\u2937',
- 'rdldhar;': '\u2969',
- 'rdquo;': '\u201d',
- 'rdquor;': '\u201d',
- 'rdsh;': '\u21b3',
- 'Re;': '\u211c',
- 'real;': '\u211c',
- 'realine;': '\u211b',
- 'realpart;': '\u211c',
- 'reals;': '\u211d',
- 'rect;': '\u25ad',
- 'REG': '\xae',
- 'reg': '\xae',
- 'REG;': '\xae',
- 'reg;': '\xae',
- 'ReverseElement;': '\u220b',
- 'ReverseEquilibrium;': '\u21cb',
- 'ReverseUpEquilibrium;': '\u296f',
- 'rfisht;': '\u297d',
- 'rfloor;': '\u230b',
- 'Rfr;': '\u211c',
- 'rfr;': '\U0001d52f',
- 'rHar;': '\u2964',
- 'rhard;': '\u21c1',
- 'rharu;': '\u21c0',
- 'rharul;': '\u296c',
- 'Rho;': '\u03a1',
- 'rho;': '\u03c1',
- 'rhov;': '\u03f1',
- 'RightAngleBracket;': '\u27e9',
- 'RightArrow;': '\u2192',
- 'Rightarrow;': '\u21d2',
- 'rightarrow;': '\u2192',
- 'RightArrowBar;': '\u21e5',
- 'RightArrowLeftArrow;': '\u21c4',
- 'rightarrowtail;': '\u21a3',
- 'RightCeiling;': '\u2309',
- 'RightDoubleBracket;': '\u27e7',
- 'RightDownTeeVector;': '\u295d',
- 'RightDownVector;': '\u21c2',
- 'RightDownVectorBar;': '\u2955',
- 'RightFloor;': '\u230b',
- 'rightharpoondown;': '\u21c1',
- 'rightharpoonup;': '\u21c0',
- 'rightleftarrows;': '\u21c4',
- 'rightleftharpoons;': '\u21cc',
- 'rightrightarrows;': '\u21c9',
- 'rightsquigarrow;': '\u219d',
- 'RightTee;': '\u22a2',
- 'RightTeeArrow;': '\u21a6',
- 'RightTeeVector;': '\u295b',
- 'rightthreetimes;': '\u22cc',
- 'RightTriangle;': '\u22b3',
- 'RightTriangleBar;': '\u29d0',
- 'RightTriangleEqual;': '\u22b5',
- 'RightUpDownVector;': '\u294f',
- 'RightUpTeeVector;': '\u295c',
- 'RightUpVector;': '\u21be',
- 'RightUpVectorBar;': '\u2954',
- 'RightVector;': '\u21c0',
- 'RightVectorBar;': '\u2953',
- 'ring;': '\u02da',
- 'risingdotseq;': '\u2253',
- 'rlarr;': '\u21c4',
- 'rlhar;': '\u21cc',
- 'rlm;': '\u200f',
- 'rmoust;': '\u23b1',
- 'rmoustache;': '\u23b1',
- 'rnmid;': '\u2aee',
- 'roang;': '\u27ed',
- 'roarr;': '\u21fe',
- 'robrk;': '\u27e7',
- 'ropar;': '\u2986',
- 'Ropf;': '\u211d',
- 'ropf;': '\U0001d563',
- 'roplus;': '\u2a2e',
- 'rotimes;': '\u2a35',
- 'RoundImplies;': '\u2970',
- 'rpar;': ')',
- 'rpargt;': '\u2994',
- 'rppolint;': '\u2a12',
- 'rrarr;': '\u21c9',
- 'Rrightarrow;': '\u21db',
- 'rsaquo;': '\u203a',
- 'Rscr;': '\u211b',
- 'rscr;': '\U0001d4c7',
- 'Rsh;': '\u21b1',
- 'rsh;': '\u21b1',
- 'rsqb;': ']',
- 'rsquo;': '\u2019',
- 'rsquor;': '\u2019',
- 'rthree;': '\u22cc',
- 'rtimes;': '\u22ca',
- 'rtri;': '\u25b9',
- 'rtrie;': '\u22b5',
- 'rtrif;': '\u25b8',
- 'rtriltri;': '\u29ce',
- 'RuleDelayed;': '\u29f4',
- 'ruluhar;': '\u2968',
- 'rx;': '\u211e',
- 'Sacute;': '\u015a',
- 'sacute;': '\u015b',
- 'sbquo;': '\u201a',
- 'Sc;': '\u2abc',
- 'sc;': '\u227b',
- 'scap;': '\u2ab8',
- 'Scaron;': '\u0160',
- 'scaron;': '\u0161',
- 'sccue;': '\u227d',
- 'scE;': '\u2ab4',
- 'sce;': '\u2ab0',
- 'Scedil;': '\u015e',
- 'scedil;': '\u015f',
- 'Scirc;': '\u015c',
- 'scirc;': '\u015d',
- 'scnap;': '\u2aba',
- 'scnE;': '\u2ab6',
- 'scnsim;': '\u22e9',
- 'scpolint;': '\u2a13',
- 'scsim;': '\u227f',
- 'Scy;': '\u0421',
- 'scy;': '\u0441',
- 'sdot;': '\u22c5',
- 'sdotb;': '\u22a1',
- 'sdote;': '\u2a66',
- 'searhk;': '\u2925',
- 'seArr;': '\u21d8',
- 'searr;': '\u2198',
- 'searrow;': '\u2198',
- 'sect': '\xa7',
- 'sect;': '\xa7',
- 'semi;': ';',
- 'seswar;': '\u2929',
- 'setminus;': '\u2216',
- 'setmn;': '\u2216',
- 'sext;': '\u2736',
- 'Sfr;': '\U0001d516',
- 'sfr;': '\U0001d530',
- 'sfrown;': '\u2322',
- 'sharp;': '\u266f',
- 'SHCHcy;': '\u0429',
- 'shchcy;': '\u0449',
- 'SHcy;': '\u0428',
- 'shcy;': '\u0448',
- 'ShortDownArrow;': '\u2193',
- 'ShortLeftArrow;': '\u2190',
- 'shortmid;': '\u2223',
- 'shortparallel;': '\u2225',
- 'ShortRightArrow;': '\u2192',
- 'ShortUpArrow;': '\u2191',
- 'shy': '\xad',
- 'shy;': '\xad',
- 'Sigma;': '\u03a3',
- 'sigma;': '\u03c3',
- 'sigmaf;': '\u03c2',
- 'sigmav;': '\u03c2',
- 'sim;': '\u223c',
- 'simdot;': '\u2a6a',
- 'sime;': '\u2243',
- 'simeq;': '\u2243',
- 'simg;': '\u2a9e',
- 'simgE;': '\u2aa0',
- 'siml;': '\u2a9d',
- 'simlE;': '\u2a9f',
- 'simne;': '\u2246',
- 'simplus;': '\u2a24',
- 'simrarr;': '\u2972',
- 'slarr;': '\u2190',
- 'SmallCircle;': '\u2218',
- 'smallsetminus;': '\u2216',
- 'smashp;': '\u2a33',
- 'smeparsl;': '\u29e4',
- 'smid;': '\u2223',
- 'smile;': '\u2323',
- 'smt;': '\u2aaa',
- 'smte;': '\u2aac',
- 'smtes;': '\u2aac\ufe00',
- 'SOFTcy;': '\u042c',
- 'softcy;': '\u044c',
- 'sol;': '/',
- 'solb;': '\u29c4',
- 'solbar;': '\u233f',
- 'Sopf;': '\U0001d54a',
- 'sopf;': '\U0001d564',
- 'spades;': '\u2660',
- 'spadesuit;': '\u2660',
- 'spar;': '\u2225',
- 'sqcap;': '\u2293',
- 'sqcaps;': '\u2293\ufe00',
- 'sqcup;': '\u2294',
- 'sqcups;': '\u2294\ufe00',
- 'Sqrt;': '\u221a',
- 'sqsub;': '\u228f',
- 'sqsube;': '\u2291',
- 'sqsubset;': '\u228f',
- 'sqsubseteq;': '\u2291',
- 'sqsup;': '\u2290',
- 'sqsupe;': '\u2292',
- 'sqsupset;': '\u2290',
- 'sqsupseteq;': '\u2292',
- 'squ;': '\u25a1',
- 'Square;': '\u25a1',
- 'square;': '\u25a1',
- 'SquareIntersection;': '\u2293',
- 'SquareSubset;': '\u228f',
- 'SquareSubsetEqual;': '\u2291',
- 'SquareSuperset;': '\u2290',
- 'SquareSupersetEqual;': '\u2292',
- 'SquareUnion;': '\u2294',
- 'squarf;': '\u25aa',
- 'squf;': '\u25aa',
- 'srarr;': '\u2192',
- 'Sscr;': '\U0001d4ae',
- 'sscr;': '\U0001d4c8',
- 'ssetmn;': '\u2216',
- 'ssmile;': '\u2323',
- 'sstarf;': '\u22c6',
- 'Star;': '\u22c6',
- 'star;': '\u2606',
- 'starf;': '\u2605',
- 'straightepsilon;': '\u03f5',
- 'straightphi;': '\u03d5',
- 'strns;': '\xaf',
- 'Sub;': '\u22d0',
- 'sub;': '\u2282',
- 'subdot;': '\u2abd',
- 'subE;': '\u2ac5',
- 'sube;': '\u2286',
- 'subedot;': '\u2ac3',
- 'submult;': '\u2ac1',
- 'subnE;': '\u2acb',
- 'subne;': '\u228a',
- 'subplus;': '\u2abf',
- 'subrarr;': '\u2979',
- 'Subset;': '\u22d0',
- 'subset;': '\u2282',
- 'subseteq;': '\u2286',
- 'subseteqq;': '\u2ac5',
- 'SubsetEqual;': '\u2286',
- 'subsetneq;': '\u228a',
- 'subsetneqq;': '\u2acb',
- 'subsim;': '\u2ac7',
- 'subsub;': '\u2ad5',
- 'subsup;': '\u2ad3',
- 'succ;': '\u227b',
- 'succapprox;': '\u2ab8',
- 'succcurlyeq;': '\u227d',
- 'Succeeds;': '\u227b',
- 'SucceedsEqual;': '\u2ab0',
- 'SucceedsSlantEqual;': '\u227d',
- 'SucceedsTilde;': '\u227f',
- 'succeq;': '\u2ab0',
- 'succnapprox;': '\u2aba',
- 'succneqq;': '\u2ab6',
- 'succnsim;': '\u22e9',
- 'succsim;': '\u227f',
- 'SuchThat;': '\u220b',
- 'Sum;': '\u2211',
- 'sum;': '\u2211',
- 'sung;': '\u266a',
- 'sup1': '\xb9',
- 'sup1;': '\xb9',
- 'sup2': '\xb2',
- 'sup2;': '\xb2',
- 'sup3': '\xb3',
- 'sup3;': '\xb3',
- 'Sup;': '\u22d1',
- 'sup;': '\u2283',
- 'supdot;': '\u2abe',
- 'supdsub;': '\u2ad8',
- 'supE;': '\u2ac6',
- 'supe;': '\u2287',
- 'supedot;': '\u2ac4',
- 'Superset;': '\u2283',
- 'SupersetEqual;': '\u2287',
- 'suphsol;': '\u27c9',
- 'suphsub;': '\u2ad7',
- 'suplarr;': '\u297b',
- 'supmult;': '\u2ac2',
- 'supnE;': '\u2acc',
- 'supne;': '\u228b',
- 'supplus;': '\u2ac0',
- 'Supset;': '\u22d1',
- 'supset;': '\u2283',
- 'supseteq;': '\u2287',
- 'supseteqq;': '\u2ac6',
- 'supsetneq;': '\u228b',
- 'supsetneqq;': '\u2acc',
- 'supsim;': '\u2ac8',
- 'supsub;': '\u2ad4',
- 'supsup;': '\u2ad6',
- 'swarhk;': '\u2926',
- 'swArr;': '\u21d9',
- 'swarr;': '\u2199',
- 'swarrow;': '\u2199',
- 'swnwar;': '\u292a',
- 'szlig': '\xdf',
- 'szlig;': '\xdf',
- 'Tab;': '\t',
- 'target;': '\u2316',
- 'Tau;': '\u03a4',
- 'tau;': '\u03c4',
- 'tbrk;': '\u23b4',
- 'Tcaron;': '\u0164',
- 'tcaron;': '\u0165',
- 'Tcedil;': '\u0162',
- 'tcedil;': '\u0163',
- 'Tcy;': '\u0422',
- 'tcy;': '\u0442',
- 'tdot;': '\u20db',
- 'telrec;': '\u2315',
- 'Tfr;': '\U0001d517',
- 'tfr;': '\U0001d531',
- 'there4;': '\u2234',
- 'Therefore;': '\u2234',
- 'therefore;': '\u2234',
- 'Theta;': '\u0398',
- 'theta;': '\u03b8',
- 'thetasym;': '\u03d1',
- 'thetav;': '\u03d1',
- 'thickapprox;': '\u2248',
- 'thicksim;': '\u223c',
- 'ThickSpace;': '\u205f\u200a',
- 'thinsp;': '\u2009',
- 'ThinSpace;': '\u2009',
- 'thkap;': '\u2248',
- 'thksim;': '\u223c',
- 'THORN': '\xde',
- 'thorn': '\xfe',
- 'THORN;': '\xde',
- 'thorn;': '\xfe',
- 'Tilde;': '\u223c',
- 'tilde;': '\u02dc',
- 'TildeEqual;': '\u2243',
- 'TildeFullEqual;': '\u2245',
- 'TildeTilde;': '\u2248',
- 'times': '\xd7',
- 'times;': '\xd7',
- 'timesb;': '\u22a0',
- 'timesbar;': '\u2a31',
- 'timesd;': '\u2a30',
- 'tint;': '\u222d',
- 'toea;': '\u2928',
- 'top;': '\u22a4',
- 'topbot;': '\u2336',
- 'topcir;': '\u2af1',
- 'Topf;': '\U0001d54b',
- 'topf;': '\U0001d565',
- 'topfork;': '\u2ada',
- 'tosa;': '\u2929',
- 'tprime;': '\u2034',
- 'TRADE;': '\u2122',
- 'trade;': '\u2122',
- 'triangle;': '\u25b5',
- 'triangledown;': '\u25bf',
- 'triangleleft;': '\u25c3',
- 'trianglelefteq;': '\u22b4',
- 'triangleq;': '\u225c',
- 'triangleright;': '\u25b9',
- 'trianglerighteq;': '\u22b5',
- 'tridot;': '\u25ec',
- 'trie;': '\u225c',
- 'triminus;': '\u2a3a',
- 'TripleDot;': '\u20db',
- 'triplus;': '\u2a39',
- 'trisb;': '\u29cd',
- 'tritime;': '\u2a3b',
- 'trpezium;': '\u23e2',
- 'Tscr;': '\U0001d4af',
- 'tscr;': '\U0001d4c9',
- 'TScy;': '\u0426',
- 'tscy;': '\u0446',
- 'TSHcy;': '\u040b',
- 'tshcy;': '\u045b',
- 'Tstrok;': '\u0166',
- 'tstrok;': '\u0167',
- 'twixt;': '\u226c',
- 'twoheadleftarrow;': '\u219e',
- 'twoheadrightarrow;': '\u21a0',
- 'Uacute': '\xda',
- 'uacute': '\xfa',
- 'Uacute;': '\xda',
- 'uacute;': '\xfa',
- 'Uarr;': '\u219f',
- 'uArr;': '\u21d1',
- 'uarr;': '\u2191',
- 'Uarrocir;': '\u2949',
- 'Ubrcy;': '\u040e',
- 'ubrcy;': '\u045e',
- 'Ubreve;': '\u016c',
- 'ubreve;': '\u016d',
- 'Ucirc': '\xdb',
- 'ucirc': '\xfb',
- 'Ucirc;': '\xdb',
- 'ucirc;': '\xfb',
- 'Ucy;': '\u0423',
- 'ucy;': '\u0443',
- 'udarr;': '\u21c5',
- 'Udblac;': '\u0170',
- 'udblac;': '\u0171',
- 'udhar;': '\u296e',
- 'ufisht;': '\u297e',
- 'Ufr;': '\U0001d518',
- 'ufr;': '\U0001d532',
- 'Ugrave': '\xd9',
- 'ugrave': '\xf9',
- 'Ugrave;': '\xd9',
- 'ugrave;': '\xf9',
- 'uHar;': '\u2963',
- 'uharl;': '\u21bf',
- 'uharr;': '\u21be',
- 'uhblk;': '\u2580',
- 'ulcorn;': '\u231c',
- 'ulcorner;': '\u231c',
- 'ulcrop;': '\u230f',
- 'ultri;': '\u25f8',
- 'Umacr;': '\u016a',
- 'umacr;': '\u016b',
- 'uml': '\xa8',
- 'uml;': '\xa8',
- 'UnderBar;': '_',
- 'UnderBrace;': '\u23df',
- 'UnderBracket;': '\u23b5',
- 'UnderParenthesis;': '\u23dd',
- 'Union;': '\u22c3',
- 'UnionPlus;': '\u228e',
- 'Uogon;': '\u0172',
- 'uogon;': '\u0173',
- 'Uopf;': '\U0001d54c',
- 'uopf;': '\U0001d566',
- 'UpArrow;': '\u2191',
- 'Uparrow;': '\u21d1',
- 'uparrow;': '\u2191',
- 'UpArrowBar;': '\u2912',
- 'UpArrowDownArrow;': '\u21c5',
- 'UpDownArrow;': '\u2195',
- 'Updownarrow;': '\u21d5',
- 'updownarrow;': '\u2195',
- 'UpEquilibrium;': '\u296e',
- 'upharpoonleft;': '\u21bf',
- 'upharpoonright;': '\u21be',
- 'uplus;': '\u228e',
- 'UpperLeftArrow;': '\u2196',
- 'UpperRightArrow;': '\u2197',
- 'Upsi;': '\u03d2',
- 'upsi;': '\u03c5',
- 'upsih;': '\u03d2',
- 'Upsilon;': '\u03a5',
- 'upsilon;': '\u03c5',
- 'UpTee;': '\u22a5',
- 'UpTeeArrow;': '\u21a5',
- 'upuparrows;': '\u21c8',
- 'urcorn;': '\u231d',
- 'urcorner;': '\u231d',
- 'urcrop;': '\u230e',
- 'Uring;': '\u016e',
- 'uring;': '\u016f',
- 'urtri;': '\u25f9',
- 'Uscr;': '\U0001d4b0',
- 'uscr;': '\U0001d4ca',
- 'utdot;': '\u22f0',
- 'Utilde;': '\u0168',
- 'utilde;': '\u0169',
- 'utri;': '\u25b5',
- 'utrif;': '\u25b4',
- 'uuarr;': '\u21c8',
- 'Uuml': '\xdc',
- 'uuml': '\xfc',
- 'Uuml;': '\xdc',
- 'uuml;': '\xfc',
- 'uwangle;': '\u29a7',
- 'vangrt;': '\u299c',
- 'varepsilon;': '\u03f5',
- 'varkappa;': '\u03f0',
- 'varnothing;': '\u2205',
- 'varphi;': '\u03d5',
- 'varpi;': '\u03d6',
- 'varpropto;': '\u221d',
- 'vArr;': '\u21d5',
- 'varr;': '\u2195',
- 'varrho;': '\u03f1',
- 'varsigma;': '\u03c2',
- 'varsubsetneq;': '\u228a\ufe00',
- 'varsubsetneqq;': '\u2acb\ufe00',
- 'varsupsetneq;': '\u228b\ufe00',
- 'varsupsetneqq;': '\u2acc\ufe00',
- 'vartheta;': '\u03d1',
- 'vartriangleleft;': '\u22b2',
- 'vartriangleright;': '\u22b3',
- 'Vbar;': '\u2aeb',
- 'vBar;': '\u2ae8',
- 'vBarv;': '\u2ae9',
- 'Vcy;': '\u0412',
- 'vcy;': '\u0432',
- 'VDash;': '\u22ab',
- 'Vdash;': '\u22a9',
- 'vDash;': '\u22a8',
- 'vdash;': '\u22a2',
- 'Vdashl;': '\u2ae6',
- 'Vee;': '\u22c1',
- 'vee;': '\u2228',
- 'veebar;': '\u22bb',
- 'veeeq;': '\u225a',
- 'vellip;': '\u22ee',
- 'Verbar;': '\u2016',
- 'verbar;': '|',
- 'Vert;': '\u2016',
- 'vert;': '|',
- 'VerticalBar;': '\u2223',
- 'VerticalLine;': '|',
- 'VerticalSeparator;': '\u2758',
- 'VerticalTilde;': '\u2240',
- 'VeryThinSpace;': '\u200a',
- 'Vfr;': '\U0001d519',
- 'vfr;': '\U0001d533',
- 'vltri;': '\u22b2',
- 'vnsub;': '\u2282\u20d2',
- 'vnsup;': '\u2283\u20d2',
- 'Vopf;': '\U0001d54d',
- 'vopf;': '\U0001d567',
- 'vprop;': '\u221d',
- 'vrtri;': '\u22b3',
- 'Vscr;': '\U0001d4b1',
- 'vscr;': '\U0001d4cb',
- 'vsubnE;': '\u2acb\ufe00',
- 'vsubne;': '\u228a\ufe00',
- 'vsupnE;': '\u2acc\ufe00',
- 'vsupne;': '\u228b\ufe00',
- 'Vvdash;': '\u22aa',
- 'vzigzag;': '\u299a',
- 'Wcirc;': '\u0174',
- 'wcirc;': '\u0175',
- 'wedbar;': '\u2a5f',
- 'Wedge;': '\u22c0',
- 'wedge;': '\u2227',
- 'wedgeq;': '\u2259',
- 'weierp;': '\u2118',
- 'Wfr;': '\U0001d51a',
- 'wfr;': '\U0001d534',
- 'Wopf;': '\U0001d54e',
- 'wopf;': '\U0001d568',
- 'wp;': '\u2118',
- 'wr;': '\u2240',
- 'wreath;': '\u2240',
- 'Wscr;': '\U0001d4b2',
- 'wscr;': '\U0001d4cc',
- 'xcap;': '\u22c2',
- 'xcirc;': '\u25ef',
- 'xcup;': '\u22c3',
- 'xdtri;': '\u25bd',
- 'Xfr;': '\U0001d51b',
- 'xfr;': '\U0001d535',
- 'xhArr;': '\u27fa',
- 'xharr;': '\u27f7',
- 'Xi;': '\u039e',
- 'xi;': '\u03be',
- 'xlArr;': '\u27f8',
- 'xlarr;': '\u27f5',
- 'xmap;': '\u27fc',
- 'xnis;': '\u22fb',
- 'xodot;': '\u2a00',
- 'Xopf;': '\U0001d54f',
- 'xopf;': '\U0001d569',
- 'xoplus;': '\u2a01',
- 'xotime;': '\u2a02',
- 'xrArr;': '\u27f9',
- 'xrarr;': '\u27f6',
- 'Xscr;': '\U0001d4b3',
- 'xscr;': '\U0001d4cd',
- 'xsqcup;': '\u2a06',
- 'xuplus;': '\u2a04',
- 'xutri;': '\u25b3',
- 'xvee;': '\u22c1',
- 'xwedge;': '\u22c0',
- 'Yacute': '\xdd',
- 'yacute': '\xfd',
- 'Yacute;': '\xdd',
- 'yacute;': '\xfd',
- 'YAcy;': '\u042f',
- 'yacy;': '\u044f',
- 'Ycirc;': '\u0176',
- 'ycirc;': '\u0177',
- 'Ycy;': '\u042b',
- 'ycy;': '\u044b',
- 'yen': '\xa5',
- 'yen;': '\xa5',
- 'Yfr;': '\U0001d51c',
- 'yfr;': '\U0001d536',
- 'YIcy;': '\u0407',
- 'yicy;': '\u0457',
- 'Yopf;': '\U0001d550',
- 'yopf;': '\U0001d56a',
- 'Yscr;': '\U0001d4b4',
- 'yscr;': '\U0001d4ce',
- 'YUcy;': '\u042e',
- 'yucy;': '\u044e',
- 'yuml': '\xff',
- 'Yuml;': '\u0178',
- 'yuml;': '\xff',
- 'Zacute;': '\u0179',
- 'zacute;': '\u017a',
- 'Zcaron;': '\u017d',
- 'zcaron;': '\u017e',
- 'Zcy;': '\u0417',
- 'zcy;': '\u0437',
- 'Zdot;': '\u017b',
- 'zdot;': '\u017c',
- 'zeetrf;': '\u2128',
- 'ZeroWidthSpace;': '\u200b',
- 'Zeta;': '\u0396',
- 'zeta;': '\u03b6',
- 'Zfr;': '\u2128',
- 'zfr;': '\U0001d537',
- 'ZHcy;': '\u0416',
- 'zhcy;': '\u0436',
- 'zigrarr;': '\u21dd',
- 'Zopf;': '\u2124',
- 'zopf;': '\U0001d56b',
- 'Zscr;': '\U0001d4b5',
- 'zscr;': '\U0001d4cf',
- 'zwj;': '\u200d',
- 'zwnj;': '\u200c',
- }
-
-try:
- import http.client as compat_http_client
-except ImportError: # Python 2
- import httplib as compat_http_client
-
-try:
- from urllib.error import HTTPError as compat_HTTPError
-except ImportError: # Python 2
- from urllib2 import HTTPError as compat_HTTPError
-
-try:
- from urllib.request import urlretrieve as compat_urlretrieve
-except ImportError: # Python 2
- from urllib import urlretrieve as compat_urlretrieve
-
-try:
- from html.parser import HTMLParser as compat_HTMLParser
-except ImportError: # Python 2
- from HTMLParser import HTMLParser as compat_HTMLParser
-
-try: # Python 2
- from HTMLParser import HTMLParseError as compat_HTMLParseError
-except ImportError: # Python <3.4
- try:
- from html.parser import HTMLParseError as compat_HTMLParseError
- except ImportError: # Python >3.4
-
- # HTMLParseError has been deprecated in Python 3.3 and removed in
- # Python 3.5. Introducing dummy exception for Python >3.5 for compatible
- # and uniform cross-version exceptiong handling
- class compat_HTMLParseError(Exception):
- pass
-
-try:
- from subprocess import DEVNULL
- compat_subprocess_get_DEVNULL = lambda: DEVNULL
-except ImportError:
- compat_subprocess_get_DEVNULL = lambda: open(os.path.devnull, 'w')
-
-try:
- import http.server as compat_http_server
-except ImportError:
- import BaseHTTPServer as compat_http_server
-
-try:
- compat_str = unicode # Python 2
-except NameError:
- compat_str = str
-
-try:
- from urllib.parse import unquote_to_bytes as compat_urllib_parse_unquote_to_bytes
- from urllib.parse import unquote as compat_urllib_parse_unquote
- from urllib.parse import unquote_plus as compat_urllib_parse_unquote_plus
-except ImportError: # Python 2
- _asciire = (compat_urllib_parse._asciire if hasattr(compat_urllib_parse, '_asciire')
- else re.compile(r'([\x00-\x7f]+)'))
-
- # HACK: The following are the correct unquote_to_bytes, unquote and unquote_plus
- # implementations from cpython 3.4.3's stdlib. Python 2's version
- # is apparently broken (see https://github.com/rg3/youtube-dl/pull/6244)
-
- def compat_urllib_parse_unquote_to_bytes(string):
- """unquote_to_bytes('abc%20def') -> b'abc def'."""
- # Note: strings are encoded as UTF-8. This is only an issue if it contains
- # unescaped non-ASCII characters, which URIs should not.
- if not string:
- # Is it a string-like object?
- string.split
- return b''
- if isinstance(string, compat_str):
- string = string.encode('utf-8')
- bits = string.split(b'%')
- if len(bits) == 1:
- return string
- res = [bits[0]]
- append = res.append
- for item in bits[1:]:
- try:
- append(compat_urllib_parse._hextochr[item[:2]])
- append(item[2:])
- except KeyError:
- append(b'%')
- append(item)
- return b''.join(res)
-
- def compat_urllib_parse_unquote(string, encoding='utf-8', errors='replace'):
- """Replace %xx escapes by their single-character equivalent. The optional
- encoding and errors parameters specify how to decode percent-encoded
- sequences into Unicode characters, as accepted by the bytes.decode()
- method.
- By default, percent-encoded sequences are decoded with UTF-8, and invalid
- sequences are replaced by a placeholder character.
-
- unquote('abc%20def') -> 'abc def'.
- """
- if '%' not in string:
- string.split
- return string
- if encoding is None:
- encoding = 'utf-8'
- if errors is None:
- errors = 'replace'
- bits = _asciire.split(string)
- res = [bits[0]]
- append = res.append
- for i in range(1, len(bits), 2):
- append(compat_urllib_parse_unquote_to_bytes(bits[i]).decode(encoding, errors))
- append(bits[i + 1])
- return ''.join(res)
-
- def compat_urllib_parse_unquote_plus(string, encoding='utf-8', errors='replace'):
- """Like unquote(), but also replace plus signs by spaces, as required for
- unquoting HTML form values.
-
- unquote_plus('%7e/abc+def') -> '~/abc def'
- """
- string = string.replace('+', ' ')
- return compat_urllib_parse_unquote(string, encoding, errors)
-
-try:
- from urllib.parse import urlencode as compat_urllib_parse_urlencode
-except ImportError: # Python 2
- # Python 2 will choke in urlencode on mixture of byte and unicode strings.
- # Possible solutions are to either port it from python 3 with all
- # the friends or manually ensure input query contains only byte strings.
- # We will stick with latter thus recursively encoding the whole query.
- def compat_urllib_parse_urlencode(query, doseq=0, encoding='utf-8'):
- def encode_elem(e):
- if isinstance(e, dict):
- e = encode_dict(e)
- elif isinstance(e, (list, tuple,)):
- list_e = encode_list(e)
- e = tuple(list_e) if isinstance(e, tuple) else list_e
- elif isinstance(e, compat_str):
- e = e.encode(encoding)
- return e
-
- def encode_dict(d):
- return dict((encode_elem(k), encode_elem(v)) for k, v in d.items())
-
- def encode_list(l):
- return [encode_elem(e) for e in l]
-
- return compat_urllib_parse.urlencode(encode_elem(query), doseq=doseq)
-
-try:
- from urllib.request import DataHandler as compat_urllib_request_DataHandler
-except ImportError: # Python < 3.4
- # Ported from CPython 98774:1733b3bd46db, Lib/urllib/request.py
- class compat_urllib_request_DataHandler(compat_urllib_request.BaseHandler):
- def data_open(self, req):
- # data URLs as specified in RFC 2397.
- #
- # ignores POSTed data
- #
- # syntax:
- # dataurl := "data:" [ mediatype ] [ ";base64" ] "," data
- # mediatype := [ type "/" subtype ] *( ";" parameter )
- # data := *urlchar
- # parameter := attribute "=" value
- url = req.get_full_url()
-
- scheme, data = url.split(':', 1)
- mediatype, data = data.split(',', 1)
-
- # even base64 encoded data URLs might be quoted so unquote in any case:
- data = compat_urllib_parse_unquote_to_bytes(data)
- if mediatype.endswith(';base64'):
- data = binascii.a2b_base64(data)
- mediatype = mediatype[:-7]
-
- if not mediatype:
- mediatype = 'text/plain;charset=US-ASCII'
-
- headers = email.message_from_string(
- 'Content-type: %s\nContent-length: %d\n' % (mediatype, len(data)))
-
- return compat_urllib_response.addinfourl(io.BytesIO(data), headers, url)
-
-try:
- compat_basestring = basestring # Python 2
-except NameError:
- compat_basestring = str
-
-try:
- compat_chr = unichr # Python 2
-except NameError:
- compat_chr = chr
-
-try:
- from xml.etree.ElementTree import ParseError as compat_xml_parse_error
-except ImportError: # Python 2.6
- from xml.parsers.expat import ExpatError as compat_xml_parse_error
-
-
-etree = xml.etree.ElementTree
-
-
-class _TreeBuilder(etree.TreeBuilder):
- def doctype(self, name, pubid, system):
- pass
-
-
-if sys.version_info[0] >= 3:
- def compat_etree_fromstring(text):
- return etree.XML(text, parser=etree.XMLParser(target=_TreeBuilder()))
-else:
- # python 2.x tries to encode unicode strings with ascii (see the
- # XMLParser._fixtext method)
- try:
- _etree_iter = etree.Element.iter
- except AttributeError: # Python <=2.6
- def _etree_iter(root):
- for el in root.findall('*'):
- yield el
- for sub in _etree_iter(el):
- yield sub
-
- # on 2.6 XML doesn't have a parser argument, function copied from CPython
- # 2.7 source
- def _XML(text, parser=None):
- if not parser:
- parser = etree.XMLParser(target=_TreeBuilder())
- parser.feed(text)
- return parser.close()
-
- def _element_factory(*args, **kwargs):
- el = etree.Element(*args, **kwargs)
- for k, v in el.items():
- if isinstance(v, bytes):
- el.set(k, v.decode('utf-8'))
- return el
-
- def compat_etree_fromstring(text):
- doc = _XML(text, parser=etree.XMLParser(target=_TreeBuilder(element_factory=_element_factory)))
- for el in _etree_iter(doc):
- if el.text is not None and isinstance(el.text, bytes):
- el.text = el.text.decode('utf-8')
- return doc
-
-if hasattr(etree, 'register_namespace'):
- compat_etree_register_namespace = etree.register_namespace
-else:
- def compat_etree_register_namespace(prefix, uri):
- """Register a namespace prefix.
- The registry is global, and any existing mapping for either the
- given prefix or the namespace URI will be removed.
- *prefix* is the namespace prefix, *uri* is a namespace uri. Tags and
- attributes in this namespace will be serialized with prefix if possible.
- ValueError is raised if prefix is reserved or is invalid.
- """
- if re.match(r"ns\d+$", prefix):
- raise ValueError("Prefix format reserved for internal use")
- for k, v in list(etree._namespace_map.items()):
- if k == uri or v == prefix:
- del etree._namespace_map[k]
- etree._namespace_map[uri] = prefix
-
-if sys.version_info < (2, 7):
- # Here comes the crazy part: In 2.6, if the xpath is a unicode,
- # .//node does not match if a node is a direct child of . !
- def compat_xpath(xpath):
- if isinstance(xpath, compat_str):
- xpath = xpath.encode('ascii')
- return xpath
-else:
- compat_xpath = lambda xpath: xpath
-
-try:
- from urllib.parse import parse_qs as compat_parse_qs
-except ImportError: # Python 2
- # HACK: The following is the correct parse_qs implementation from cpython 3's stdlib.
- # Python 2's version is apparently totally broken
-
- def _parse_qsl(qs, keep_blank_values=False, strict_parsing=False,
- encoding='utf-8', errors='replace'):
- qs, _coerce_result = qs, compat_str
- pairs = [s2 for s1 in qs.split('&') for s2 in s1.split(';')]
- r = []
- for name_value in pairs:
- if not name_value and not strict_parsing:
- continue
- nv = name_value.split('=', 1)
- if len(nv) != 2:
- if strict_parsing:
- raise ValueError('bad query field: %r' % (name_value,))
- # Handle case of a control-name with no equal sign
- if keep_blank_values:
- nv.append('')
- else:
- continue
- if len(nv[1]) or keep_blank_values:
- name = nv[0].replace('+', ' ')
- name = compat_urllib_parse_unquote(
- name, encoding=encoding, errors=errors)
- name = _coerce_result(name)
- value = nv[1].replace('+', ' ')
- value = compat_urllib_parse_unquote(
- value, encoding=encoding, errors=errors)
- value = _coerce_result(value)
- r.append((name, value))
- return r
-
- def compat_parse_qs(qs, keep_blank_values=False, strict_parsing=False,
- encoding='utf-8', errors='replace'):
- parsed_result = {}
- pairs = _parse_qsl(qs, keep_blank_values, strict_parsing,
- encoding=encoding, errors=errors)
- for name, value in pairs:
- if name in parsed_result:
- parsed_result[name].append(value)
- else:
- parsed_result[name] = [value]
- return parsed_result
-
-
-compat_os_name = os._name if os.name == 'java' else os.name
-
-
-if compat_os_name == 'nt':
- def compat_shlex_quote(s):
- return s if re.match(r'^[-_\w./]+$', s) else '"%s"' % s.replace('"', '\\"')
-else:
- try:
- from shlex import quote as compat_shlex_quote
- except ImportError: # Python < 3.3
- def compat_shlex_quote(s):
- if re.match(r'^[-_\w./]+$', s):
- return s
- else:
- return "'" + s.replace("'", "'\"'\"'") + "'"
-
-
-try:
- args = shlex.split('中文')
- assert (isinstance(args, list) and
- isinstance(args[0], compat_str) and
- args[0] == '中文')
- compat_shlex_split = shlex.split
-except (AssertionError, UnicodeEncodeError):
- # Working around shlex issue with unicode strings on some python 2
- # versions (see http://bugs.python.org/issue1548891)
- def compat_shlex_split(s, comments=False, posix=True):
- if isinstance(s, compat_str):
- s = s.encode('utf-8')
- return list(map(lambda s: s.decode('utf-8'), shlex.split(s, comments, posix)))
-
-
-def compat_ord(c):
- if type(c) is int:
- return c
- else:
- return ord(c)
-
-
-if sys.version_info >= (3, 0):
- compat_getenv = os.getenv
- compat_expanduser = os.path.expanduser
-
- def compat_setenv(key, value, env=os.environ):
- env[key] = value
-else:
- # Environment variables should be decoded with filesystem encoding.
- # Otherwise it will fail if any non-ASCII characters present (see #3854 #3217 #2918)
-
- def compat_getenv(key, default=None):
- from .utils import get_filesystem_encoding
- env = os.getenv(key, default)
- if env:
- env = env.decode(get_filesystem_encoding())
- return env
-
- def compat_setenv(key, value, env=os.environ):
- def encode(v):
- from .utils import get_filesystem_encoding
- return v.encode(get_filesystem_encoding()) if isinstance(v, compat_str) else v
- env[encode(key)] = encode(value)
-
- # HACK: The default implementations of os.path.expanduser from cpython do not decode
- # environment variables with filesystem encoding. We will work around this by
- # providing adjusted implementations.
- # The following are os.path.expanduser implementations from cpython 2.7.8 stdlib
- # for different platforms with correct environment variables decoding.
-
- if compat_os_name == 'posix':
- def compat_expanduser(path):
- """Expand ~ and ~user constructions. If user or $HOME is unknown,
- do nothing."""
- if not path.startswith('~'):
- return path
- i = path.find('/', 1)
- if i < 0:
- i = len(path)
- if i == 1:
- if 'HOME' not in os.environ:
- import pwd
- userhome = pwd.getpwuid(os.getuid()).pw_dir
- else:
- userhome = compat_getenv('HOME')
- else:
- import pwd
- try:
- pwent = pwd.getpwnam(path[1:i])
- except KeyError:
- return path
- userhome = pwent.pw_dir
- userhome = userhome.rstrip('/')
- return (userhome + path[i:]) or '/'
- elif compat_os_name in ('nt', 'ce'):
- def compat_expanduser(path):
- """Expand ~ and ~user constructs.
-
- If user or $HOME is unknown, do nothing."""
- if path[:1] != '~':
- return path
- i, n = 1, len(path)
- while i < n and path[i] not in '/\\':
- i = i + 1
-
- if 'HOME' in os.environ:
- userhome = compat_getenv('HOME')
- elif 'USERPROFILE' in os.environ:
- userhome = compat_getenv('USERPROFILE')
- elif 'HOMEPATH' not in os.environ:
- return path
- else:
- try:
- drive = compat_getenv('HOMEDRIVE')
- except KeyError:
- drive = ''
- userhome = os.path.join(drive, compat_getenv('HOMEPATH'))
-
- if i != 1: # ~user
- userhome = os.path.join(os.path.dirname(userhome), path[1:i])
-
- return userhome + path[i:]
- else:
- compat_expanduser = os.path.expanduser
-
-
-if sys.version_info < (3, 0):
- def compat_print(s):
- from .utils import preferredencoding
- print(s.encode(preferredencoding(), 'xmlcharrefreplace'))
-else:
- def compat_print(s):
- assert isinstance(s, compat_str)
- print(s)
-
-
-if sys.version_info < (3, 0) and sys.platform == 'win32':
- def compat_getpass(prompt, *args, **kwargs):
- if isinstance(prompt, compat_str):
- from .utils import preferredencoding
- prompt = prompt.encode(preferredencoding())
- return getpass.getpass(prompt, *args, **kwargs)
-else:
- compat_getpass = getpass.getpass
-
-try:
- compat_input = raw_input
-except NameError: # Python 3
- compat_input = input
-
-# Python < 2.6.5 require kwargs to be bytes
-try:
- def _testfunc(x):
- pass
- _testfunc(**{'x': 0})
-except TypeError:
- def compat_kwargs(kwargs):
- return dict((bytes(k), v) for k, v in kwargs.items())
-else:
- compat_kwargs = lambda kwargs: kwargs
-
-
-try:
- compat_numeric_types = (int, float, long, complex)
-except NameError: # Python 3
- compat_numeric_types = (int, float, complex)
-
-
-try:
- compat_integer_types = (int, long)
-except NameError: # Python 3
- compat_integer_types = (int, )
-
-
-if sys.version_info < (2, 7):
- def compat_socket_create_connection(address, timeout, source_address=None):
- host, port = address
- err = None
- for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
- af, socktype, proto, canonname, sa = res
- sock = None
- try:
- sock = socket.socket(af, socktype, proto)
- sock.settimeout(timeout)
- if source_address:
- sock.bind(source_address)
- sock.connect(sa)
- return sock
- except socket.error as _:
- err = _
- if sock is not None:
- sock.close()
- if err is not None:
- raise err
- else:
- raise socket.error('getaddrinfo returns an empty list')
-else:
- compat_socket_create_connection = socket.create_connection
-
-
-# Fix https://github.com/rg3/youtube-dl/issues/4223
-# See http://bugs.python.org/issue9161 for what is broken
-def workaround_optparse_bug9161():
- op = optparse.OptionParser()
- og = optparse.OptionGroup(op, 'foo')
- try:
- og.add_option('-t')
- except TypeError:
- real_add_option = optparse.OptionGroup.add_option
-
- def _compat_add_option(self, *args, **kwargs):
- enc = lambda v: (
- v.encode('ascii', 'replace') if isinstance(v, compat_str)
- else v)
- bargs = [enc(a) for a in args]
- bkwargs = dict(
- (k, enc(v)) for k, v in kwargs.items())
- return real_add_option(self, *bargs, **bkwargs)
- optparse.OptionGroup.add_option = _compat_add_option
-
-
-if hasattr(shutil, 'get_terminal_size'): # Python >= 3.3
- compat_get_terminal_size = shutil.get_terminal_size
-else:
- _terminal_size = collections.namedtuple('terminal_size', ['columns', 'lines'])
-
- def compat_get_terminal_size(fallback=(80, 24)):
- columns = compat_getenv('COLUMNS')
- if columns:
- columns = int(columns)
- else:
- columns = None
- lines = compat_getenv('LINES')
- if lines:
- lines = int(lines)
- else:
- lines = None
-
- if columns is None or lines is None or columns <= 0 or lines <= 0:
- try:
- sp = subprocess.Popen(
- ['stty', 'size'],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- out, err = sp.communicate()
- _lines, _columns = map(int, out.split())
- except Exception:
- _columns, _lines = _terminal_size(*fallback)
-
- if columns is None or columns <= 0:
- columns = _columns
- if lines is None or lines <= 0:
- lines = _lines
- return _terminal_size(columns, lines)
-
-try:
- itertools.count(start=0, step=1)
- compat_itertools_count = itertools.count
-except TypeError: # Python 2.6
- def compat_itertools_count(start=0, step=1):
- n = start
- while True:
- yield n
- n += step
-
-if sys.version_info >= (3, 0):
- from tokenize import tokenize as compat_tokenize_tokenize
-else:
- from tokenize import generate_tokens as compat_tokenize_tokenize
-
-
-try:
- struct.pack('!I', 0)
-except TypeError:
- # In Python 2.6 and 2.7.x < 2.7.7, struct requires a bytes argument
- # See https://bugs.python.org/issue19099
- def compat_struct_pack(spec, *args):
- if isinstance(spec, compat_str):
- spec = spec.encode('ascii')
- return struct.pack(spec, *args)
-
- def compat_struct_unpack(spec, *args):
- if isinstance(spec, compat_str):
- spec = spec.encode('ascii')
- return struct.unpack(spec, *args)
-
- class compat_Struct(struct.Struct):
- def __init__(self, fmt):
- if isinstance(fmt, compat_str):
- fmt = fmt.encode('ascii')
- super(compat_Struct, self).__init__(fmt)
-else:
- compat_struct_pack = struct.pack
- compat_struct_unpack = struct.unpack
- if platform.python_implementation() == 'IronPython' and sys.version_info < (2, 7, 8):
- class compat_Struct(struct.Struct):
- def unpack(self, string):
- if not isinstance(string, buffer): # noqa: F821
- string = buffer(string) # noqa: F821
- return super(compat_Struct, self).unpack(string)
- else:
- compat_Struct = struct.Struct
-
-
-try:
- from future_builtins import zip as compat_zip
-except ImportError: # not 2.6+ or is 3.x
- try:
- from itertools import izip as compat_zip # < 2.5 or 3.x
- except ImportError:
- compat_zip = zip
-
-
-if sys.version_info < (3, 3):
- def compat_b64decode(s, *args, **kwargs):
- if isinstance(s, compat_str):
- s = s.encode('ascii')
- return base64.b64decode(s, *args, **kwargs)
-else:
- compat_b64decode = base64.b64decode
-
-
-if platform.python_implementation() == 'PyPy' and sys.pypy_version_info < (5, 4, 0):
- # PyPy2 prior to version 5.4.0 expects byte strings as Windows function
- # names, see the original PyPy issue [1] and the youtube-dl one [2].
- # 1. https://bitbucket.org/pypy/pypy/issues/2360/windows-ctypescdll-typeerror-function-name
- # 2. https://github.com/rg3/youtube-dl/pull/4392
- def compat_ctypes_WINFUNCTYPE(*args, **kwargs):
- real = ctypes.WINFUNCTYPE(*args, **kwargs)
-
- def resf(tpl, *args, **kwargs):
- funcname, dll = tpl
- return real((str(funcname), dll), *args, **kwargs)
-
- return resf
-else:
- def compat_ctypes_WINFUNCTYPE(*args, **kwargs):
- return ctypes.WINFUNCTYPE(*args, **kwargs)
-
-
-__all__ = [
- 'compat_HTMLParseError',
- 'compat_HTMLParser',
- 'compat_HTTPError',
- 'compat_Struct',
- 'compat_b64decode',
- 'compat_basestring',
- 'compat_chr',
- 'compat_cookiejar',
- 'compat_cookies',
- 'compat_ctypes_WINFUNCTYPE',
- 'compat_etree_fromstring',
- 'compat_etree_register_namespace',
- 'compat_expanduser',
- 'compat_get_terminal_size',
- 'compat_getenv',
- 'compat_getpass',
- 'compat_html_entities',
- 'compat_html_entities_html5',
- 'compat_http_client',
- 'compat_http_server',
- 'compat_input',
- 'compat_integer_types',
- 'compat_itertools_count',
- 'compat_kwargs',
- 'compat_numeric_types',
- 'compat_ord',
- 'compat_os_name',
- 'compat_parse_qs',
- 'compat_print',
- 'compat_setenv',
- 'compat_shlex_quote',
- 'compat_shlex_split',
- 'compat_socket_create_connection',
- 'compat_str',
- 'compat_struct_pack',
- 'compat_struct_unpack',
- 'compat_subprocess_get_DEVNULL',
- 'compat_tokenize_tokenize',
- 'compat_urllib_error',
- 'compat_urllib_parse',
- 'compat_urllib_parse_unquote',
- 'compat_urllib_parse_unquote_plus',
- 'compat_urllib_parse_unquote_to_bytes',
- 'compat_urllib_parse_urlencode',
- 'compat_urllib_parse_urlparse',
- 'compat_urllib_request',
- 'compat_urllib_request_DataHandler',
- 'compat_urllib_response',
- 'compat_urlparse',
- 'compat_urlretrieve',
- 'compat_xml_parse_error',
- 'compat_xpath',
- 'compat_zip',
- 'workaround_optparse_bug9161',
-]
diff --git a/youtube_dl/downloader/__init__.py b/youtube_dl/downloader/__init__.py
deleted file mode 100644
index 2e485df..0000000
--- a/youtube_dl/downloader/__init__.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from __future__ import unicode_literals
-
-from .common import FileDownloader
-from .f4m import F4mFD
-from .hls import HlsFD
-from .http import HttpFD
-from .rtmp import RtmpFD
-from .dash import DashSegmentsFD
-from .rtsp import RtspFD
-from .ism import IsmFD
-from .external import (
- get_external_downloader,
- FFmpegFD,
-)
-
-from ..utils import (
- determine_protocol,
-)
-
-PROTOCOL_MAP = {
- 'rtmp': RtmpFD,
- 'm3u8_native': HlsFD,
- 'm3u8': FFmpegFD,
- 'mms': RtspFD,
- 'rtsp': RtspFD,
- 'f4m': F4mFD,
- 'http_dash_segments': DashSegmentsFD,
- 'ism': IsmFD,
-}
-
-
-def get_suitable_downloader(info_dict, params={}):
- """Get the downloader class that can handle the info dict."""
- protocol = determine_protocol(info_dict)
- info_dict['protocol'] = protocol
-
- # if (info_dict.get('start_time') or info_dict.get('end_time')) and not info_dict.get('requested_formats') and FFmpegFD.can_download(info_dict):
- # return FFmpegFD
-
- external_downloader = params.get('external_downloader')
- if external_downloader is not None:
- ed = get_external_downloader(external_downloader)
- if ed.can_download(info_dict):
- return ed
-
- if protocol.startswith('m3u8') and info_dict.get('is_live'):
- return FFmpegFD
-
- if protocol == 'm3u8' and params.get('hls_prefer_native') is True:
- return HlsFD
-
- if protocol == 'm3u8_native' and params.get('hls_prefer_native') is False:
- return FFmpegFD
-
- return PROTOCOL_MAP.get(protocol, HttpFD)
-
-
-__all__ = [
- 'get_suitable_downloader',
- 'FileDownloader',
-]
diff --git a/youtube_dl/downloader/common.py b/youtube_dl/downloader/common.py
deleted file mode 100644
index 5979833..0000000
--- a/youtube_dl/downloader/common.py
+++ /dev/null
@@ -1,389 +0,0 @@
-from __future__ import division, unicode_literals
-
-import os
-import re
-import sys
-import time
-import random
-
-from ..compat import compat_os_name
-from ..utils import (
- decodeArgument,
- encodeFilename,
- error_to_compat_str,
- format_bytes,
- shell_quote,
- timeconvert,
-)
-
-
-class FileDownloader(object):
- """File Downloader class.
-
- File downloader objects are the ones responsible of downloading the
- actual video file and writing it to disk.
-
- File downloaders accept a lot of parameters. In order not to saturate
- the object constructor with arguments, it receives a dictionary of
- options instead.
-
- Available options:
-
- verbose: Print additional info to stdout.
- quiet: Do not print messages to stdout.
- ratelimit: Download speed limit, in bytes/sec.
- retries: Number of times to retry for HTTP error 5xx
- buffersize: Size of download buffer in bytes.
- noresizebuffer: Do not automatically resize the download buffer.
- continuedl: Try to continue downloads if possible.
- noprogress: Do not print the progress bar.
- logtostderr: Log messages to stderr instead of stdout.
- consoletitle: Display progress in console window's titlebar.
- nopart: Do not use temporary .part files.
- updatetime: Use the Last-modified header to set output file timestamps.
- test: Download only first bytes to test the downloader.
- min_filesize: Skip files smaller than this size
- max_filesize: Skip files larger than this size
- xattr_set_filesize: Set ytdl.filesize user xattribute with expected size.
- external_downloader_args: A list of additional command-line arguments for the
- external downloader.
- hls_use_mpegts: Use the mpegts container for HLS videos.
- http_chunk_size: Size of a chunk for chunk-based HTTP downloading. May be
- useful for bypassing bandwidth throttling imposed by
- a webserver (experimental)
-
- Subclasses of this one must re-define the real_download method.
- """
-
- _TEST_FILE_SIZE = 10241
- params = None
-
- def __init__(self, ydl, params):
- """Create a FileDownloader object with the given options."""
- self.ydl = ydl
- self._progress_hooks = []
- self.params = params
- self.add_progress_hook(self.report_progress)
-
- @staticmethod
- def format_seconds(seconds):
- (mins, secs) = divmod(seconds, 60)
- (hours, mins) = divmod(mins, 60)
- if hours > 99:
- return '--:--:--'
- if hours == 0:
- return '%02d:%02d' % (mins, secs)
- else:
- return '%02d:%02d:%02d' % (hours, mins, secs)
-
- @staticmethod
- def calc_percent(byte_counter, data_len):
- if data_len is None:
- return None
- return float(byte_counter) / float(data_len) * 100.0
-
- @staticmethod
- def format_percent(percent):
- if percent is None:
- return '---.-%'
- return '%6s' % ('%3.1f%%' % percent)
-
- @staticmethod
- def calc_eta(start, now, total, current):
- if total is None:
- return None
- if now is None:
- now = time.time()
- dif = now - start
- if current == 0 or dif < 0.001: # One millisecond
- return None
- rate = float(current) / dif
- return int((float(total) - float(current)) / rate)
-
- @staticmethod
- def format_eta(eta):
- if eta is None:
- return '--:--'
- return FileDownloader.format_seconds(eta)
-
- @staticmethod
- def calc_speed(start, now, bytes):
- dif = now - start
- if bytes == 0 or dif < 0.001: # One millisecond
- return None
- return float(bytes) / dif
-
- @staticmethod
- def format_speed(speed):
- if speed is None:
- return '%10s' % '---b/s'
- return '%10s' % ('%s/s' % format_bytes(speed))
-
- @staticmethod
- def format_retries(retries):
- return 'inf' if retries == float('inf') else '%.0f' % retries
-
- @staticmethod
- def best_block_size(elapsed_time, bytes):
- new_min = max(bytes / 2.0, 1.0)
- new_max = min(max(bytes * 2.0, 1.0), 4194304) # Do not surpass 4 MB
- if elapsed_time < 0.001:
- return int(new_max)
- rate = bytes / elapsed_time
- if rate > new_max:
- return int(new_max)
- if rate < new_min:
- return int(new_min)
- return int(rate)
-
- @staticmethod
- def parse_bytes(bytestr):
- """Parse a string indicating a byte quantity into an integer."""
- matchobj = re.match(r'(?i)^(\d+(?:\.\d+)?)([kMGTPEZY]?)$', bytestr)
- if matchobj is None:
- return None
- number = float(matchobj.group(1))
- multiplier = 1024.0 ** 'bkmgtpezy'.index(matchobj.group(2).lower())
- return int(round(number * multiplier))
-
- def to_screen(self, *args, **kargs):
- self.ydl.to_screen(*args, **kargs)
-
- def to_stderr(self, message):
- self.ydl.to_screen(message)
-
- def to_console_title(self, message):
- self.ydl.to_console_title(message)
-
- def trouble(self, *args, **kargs):
- self.ydl.trouble(*args, **kargs)
-
- def report_warning(self, *args, **kargs):
- self.ydl.report_warning(*args, **kargs)
-
- def report_error(self, *args, **kargs):
- self.ydl.report_error(*args, **kargs)
-
- def slow_down(self, start_time, now, byte_counter):
- """Sleep if the download speed is over the rate limit."""
- rate_limit = self.params.get('ratelimit')
- if rate_limit is None or byte_counter == 0:
- return
- if now is None:
- now = time.time()
- elapsed = now - start_time
- if elapsed <= 0.0:
- return
- speed = float(byte_counter) / elapsed
- if speed > rate_limit:
- time.sleep(max((byte_counter // rate_limit) - elapsed, 0))
-
- def temp_name(self, filename):
- """Returns a temporary filename for the given filename."""
- if self.params.get('nopart', False) or filename == '-' or \
- (os.path.exists(encodeFilename(filename)) and not os.path.isfile(encodeFilename(filename))):
- return filename
- return filename + '.part'
-
- def undo_temp_name(self, filename):
- if filename.endswith('.part'):
- return filename[:-len('.part')]
- return filename
-
- def ytdl_filename(self, filename):
- return filename + '.ytdl'
-
- def try_rename(self, old_filename, new_filename):
- try:
- if old_filename == new_filename:
- return
- os.rename(encodeFilename(old_filename), encodeFilename(new_filename))
- except (IOError, OSError) as err:
- self.report_error('unable to rename file: %s' % error_to_compat_str(err))
-
- def try_utime(self, filename, last_modified_hdr):
- """Try to set the last-modified time of the given file."""
- if last_modified_hdr is None:
- return
- if not os.path.isfile(encodeFilename(filename)):
- return
- timestr = last_modified_hdr
- if timestr is None:
- return
- filetime = timeconvert(timestr)
- if filetime is None:
- return filetime
- # Ignore obviously invalid dates
- if filetime == 0:
- return
- try:
- os.utime(filename, (time.time(), filetime))
- except Exception:
- pass
- return filetime
-
- def report_destination(self, filename):
- """Report destination filename."""
- self.to_screen('[download] Destination: ' + filename)
-
- def _report_progress_status(self, msg, is_last_line=False):
- fullmsg = '[download] ' + msg
- if self.params.get('progress_with_newline', False):
- self.to_screen(fullmsg)
- else:
- if compat_os_name == 'nt':
- prev_len = getattr(self, '_report_progress_prev_line_length',
- 0)
- if prev_len > len(fullmsg):
- fullmsg += ' ' * (prev_len - len(fullmsg))
- self._report_progress_prev_line_length = len(fullmsg)
- clear_line = '\r'
- else:
- clear_line = ('\r\x1b[K' if sys.stderr.isatty() else '\r')
- self.to_screen(clear_line + fullmsg, skip_eol=not is_last_line)
- self.to_console_title('youtube-dl ' + msg)
-
- def report_progress(self, s):
- if s['status'] == 'finished':
- if self.params.get('noprogress', False):
- self.to_screen('[download] Download completed')
- else:
- msg_template = '100%%'
- if s.get('total_bytes') is not None:
- s['_total_bytes_str'] = format_bytes(s['total_bytes'])
- msg_template += ' of %(_total_bytes_str)s'
- if s.get('elapsed') is not None:
- s['_elapsed_str'] = self.format_seconds(s['elapsed'])
- msg_template += ' in %(_elapsed_str)s'
- self._report_progress_status(
- msg_template % s, is_last_line=True)
-
- if self.params.get('noprogress'):
- return
-
- if s['status'] != 'downloading':
- return
-
- if s.get('eta') is not None:
- s['_eta_str'] = self.format_eta(s['eta'])
- else:
- s['_eta_str'] = 'Unknown ETA'
-
- if s.get('total_bytes') and s.get('downloaded_bytes') is not None:
- s['_percent_str'] = self.format_percent(100 * s['downloaded_bytes'] / s['total_bytes'])
- elif s.get('total_bytes_estimate') and s.get('downloaded_bytes') is not None:
- s['_percent_str'] = self.format_percent(100 * s['downloaded_bytes'] / s['total_bytes_estimate'])
- else:
- if s.get('downloaded_bytes') == 0:
- s['_percent_str'] = self.format_percent(0)
- else:
- s['_percent_str'] = 'Unknown %'
-
- if s.get('speed') is not None:
- s['_speed_str'] = self.format_speed(s['speed'])
- else:
- s['_speed_str'] = 'Unknown speed'
-
- if s.get('total_bytes') is not None:
- s['_total_bytes_str'] = format_bytes(s['total_bytes'])
- msg_template = '%(_percent_str)s of %(_total_bytes_str)s at %(_speed_str)s ETA %(_eta_str)s'
- elif s.get('total_bytes_estimate') is not None:
- s['_total_bytes_estimate_str'] = format_bytes(s['total_bytes_estimate'])
- msg_template = '%(_percent_str)s of ~%(_total_bytes_estimate_str)s at %(_speed_str)s ETA %(_eta_str)s'
- else:
- if s.get('downloaded_bytes') is not None:
- s['_downloaded_bytes_str'] = format_bytes(s['downloaded_bytes'])
- if s.get('elapsed'):
- s['_elapsed_str'] = self.format_seconds(s['elapsed'])
- msg_template = '%(_downloaded_bytes_str)s at %(_speed_str)s (%(_elapsed_str)s)'
- else:
- msg_template = '%(_downloaded_bytes_str)s at %(_speed_str)s'
- else:
- msg_template = '%(_percent_str)s % at %(_speed_str)s ETA %(_eta_str)s'
-
- self._report_progress_status(msg_template % s)
-
- def report_resuming_byte(self, resume_len):
- """Report attempt to resume at given byte."""
- self.to_screen('[download] Resuming download at byte %s' % resume_len)
-
- def report_retry(self, err, count, retries):
- """Report retry in case of HTTP error 5xx"""
- self.to_screen(
- '[download] Got server HTTP error: %s. Retrying (attempt %d of %s)...'
- % (error_to_compat_str(err), count, self.format_retries(retries)))
-
- def report_file_already_downloaded(self, file_name):
- """Report file has already been fully downloaded."""
- try:
- self.to_screen('[download] %s has already been downloaded' % file_name)
- except UnicodeEncodeError:
- self.to_screen('[download] The file has already been downloaded')
-
- def report_unable_to_resume(self):
- """Report it was impossible to resume download."""
- self.to_screen('[download] Unable to resume')
-
- def download(self, filename, info_dict):
- """Download to a filename using the info from info_dict
- Return True on success and False otherwise
- """
-
- nooverwrites_and_exists = (
- self.params.get('nooverwrites', False) and
- os.path.exists(encodeFilename(filename))
- )
-
- if not hasattr(filename, 'write'):
- continuedl_and_exists = (
- self.params.get('continuedl', True) and
- os.path.isfile(encodeFilename(filename)) and
- not self.params.get('nopart', False)
- )
-
- # Check file already present
- if filename != '-' and (nooverwrites_and_exists or continuedl_and_exists):
- self.report_file_already_downloaded(filename)
- self._hook_progress({
- 'filename': filename,
- 'status': 'finished',
- 'total_bytes': os.path.getsize(encodeFilename(filename)),
- })
- return True
-
- min_sleep_interval = self.params.get('sleep_interval')
- if min_sleep_interval:
- max_sleep_interval = self.params.get('max_sleep_interval', min_sleep_interval)
- sleep_interval = random.uniform(min_sleep_interval, max_sleep_interval)
- self.to_screen(
- '[download] Sleeping %s seconds...' % (
- int(sleep_interval) if sleep_interval.is_integer()
- else '%.2f' % sleep_interval))
- time.sleep(sleep_interval)
-
- return self.real_download(filename, info_dict)
-
- def real_download(self, filename, info_dict):
- """Real download process. Redefine in subclasses."""
- raise NotImplementedError('This method must be implemented by subclasses')
-
- def _hook_progress(self, status):
- for ph in self._progress_hooks:
- ph(status)
-
- def add_progress_hook(self, ph):
- # See YoutubeDl.py (search for progress_hooks) for a description of
- # this interface
- self._progress_hooks.append(ph)
-
- def _debug_cmd(self, args, exe=None):
- if not self.params.get('verbose', False):
- return
-
- str_args = [decodeArgument(a) for a in args]
-
- if exe is None:
- exe = os.path.basename(str_args[0])
-
- self.to_screen('[debug] %s command line: %s' % (
- exe, shell_quote(str_args)))
diff --git a/youtube_dl/downloader/dash.py b/youtube_dl/downloader/dash.py
deleted file mode 100644
index eaa7adf..0000000
--- a/youtube_dl/downloader/dash.py
+++ /dev/null
@@ -1,80 +0,0 @@
-from __future__ import unicode_literals
-
-from .fragment import FragmentFD
-from ..compat import compat_urllib_error
-from ..utils import (
- DownloadError,
- urljoin,
-)
-
-
-class DashSegmentsFD(FragmentFD):
- """
- Download segments in a DASH manifest
- """
-
- FD_NAME = 'dashsegments'
-
- def real_download(self, filename, info_dict):
- fragment_base_url = info_dict.get('fragment_base_url')
- fragments = info_dict['fragments'][:1] if self.params.get(
- 'test', False) else info_dict['fragments']
-
- ctx = {
- 'filename': filename,
- 'total_frags': len(fragments),
- }
-
- self._prepare_and_start_frag_download(ctx)
-
- fragment_retries = self.params.get('fragment_retries', 0)
- skip_unavailable_fragments = self.params.get('skip_unavailable_fragments', True)
-
- frag_index = 0
- for i, fragment in enumerate(fragments):
- frag_index += 1
- if frag_index <= ctx['fragment_index']:
- continue
- # In DASH, the first segment contains necessary headers to
- # generate a valid MP4 file, so always abort for the first segment
- fatal = i == 0 or not skip_unavailable_fragments
- count = 0
- while count <= fragment_retries:
- try:
- fragment_url = fragment.get('url')
- if not fragment_url:
- assert fragment_base_url
- fragment_url = urljoin(fragment_base_url, fragment['path'])
- success, frag_content = self._download_fragment(ctx, fragment_url, info_dict)
- if not success:
- return False
- self._append_fragment(ctx, frag_content)
- break
- except compat_urllib_error.HTTPError as err:
- # YouTube may often return 404 HTTP error for a fragment causing the
- # whole download to fail. However if the same fragment is immediately
- # retried with the same request data this usually succeeds (1-2 attemps
- # is usually enough) thus allowing to download the whole file successfully.
- # To be future-proof we will retry all fragments that fail with any
- # HTTP error.
- count += 1
- if count <= fragment_retries:
- self.report_retry_fragment(err, frag_index, count, fragment_retries)
- except DownloadError:
- # Don't retry fragment if error occurred during HTTP downloading
- # itself since it has own retry settings
- if not fatal:
- self.report_skip_fragment(frag_index)
- break
- raise
-
- if count > fragment_retries:
- if not fatal:
- self.report_skip_fragment(frag_index)
- continue
- self.report_error('giving up after %s fragment retries' % fragment_retries)
- return False
-
- self._finish_frag_download(ctx)
-
- return True
diff --git a/youtube_dl/downloader/external.py b/youtube_dl/downloader/external.py
deleted file mode 100644
index 958d00a..0000000
--- a/youtube_dl/downloader/external.py
+++ /dev/null
@@ -1,354 +0,0 @@
-from __future__ import unicode_literals
-
-import os.path
-import re
-import subprocess
-import sys
-import time
-
-from .common import FileDownloader
-from ..compat import (
- compat_setenv,
- compat_str,
-)
-from ..postprocessor.ffmpeg import FFmpegPostProcessor, EXT_TO_OUT_FORMATS
-from ..utils import (
- cli_option,
- cli_valueless_option,
- cli_bool_option,
- cli_configuration_args,
- encodeFilename,
- encodeArgument,
- handle_youtubedl_headers,
- check_executable,
- is_outdated_version,
-)
-
-
-class ExternalFD(FileDownloader):
- def real_download(self, filename, info_dict):
- self.report_destination(filename)
- tmpfilename = self.temp_name(filename)
-
- try:
- started = time.time()
- retval = self._call_downloader(tmpfilename, info_dict)
- except KeyboardInterrupt:
- if not info_dict.get('is_live'):
- raise
- # Live stream downloading cancellation should be considered as
- # correct and expected termination thus all postprocessing
- # should take place
- retval = 0
- self.to_screen('[%s] Interrupted by user' % self.get_basename())
-
- if retval == 0:
- status = {
- 'filename': filename,
- 'status': 'finished',
- 'elapsed': time.time() - started,
- }
- if filename != '-':
- fsize = os.path.getsize(encodeFilename(tmpfilename))
- self.to_screen('\r[%s] Downloaded %s bytes' % (self.get_basename(), fsize))
- self.try_rename(tmpfilename, filename)
- status.update({
- 'downloaded_bytes': fsize,
- 'total_bytes': fsize,
- })
- self._hook_progress(status)
- return True
- else:
- self.to_stderr('\n')
- self.report_error('%s exited with code %d' % (
- self.get_basename(), retval))
- return False
-
- @classmethod
- def get_basename(cls):
- return cls.__name__[:-2].lower()
-
- @property
- def exe(self):
- return self.params.get('external_downloader')
-
- @classmethod
- def available(cls):
- return check_executable(cls.get_basename(), [cls.AVAILABLE_OPT])
-
- @classmethod
- def supports(cls, info_dict):
- return info_dict['protocol'] in ('http', 'https', 'ftp', 'ftps')
-
- @classmethod
- def can_download(cls, info_dict):
- return cls.available() and cls.supports(info_dict)
-
- def _option(self, command_option, param):
- return cli_option(self.params, command_option, param)
-
- def _bool_option(self, command_option, param, true_value='true', false_value='false', separator=None):
- return cli_bool_option(self.params, command_option, param, true_value, false_value, separator)
-
- def _valueless_option(self, command_option, param, expected_value=True):
- return cli_valueless_option(self.params, command_option, param, expected_value)
-
- def _configuration_args(self, default=[]):
- return cli_configuration_args(self.params, 'external_downloader_args', default)
-
- def _call_downloader(self, tmpfilename, info_dict):
- """ Either overwrite this or implement _make_cmd """
- cmd = [encodeArgument(a) for a in self._make_cmd(tmpfilename, info_dict)]
-
- self._debug_cmd(cmd)
-
- p = subprocess.Popen(
- cmd, stderr=subprocess.PIPE)
- _, stderr = p.communicate()
- if p.returncode != 0:
- self.to_stderr(stderr.decode('utf-8', 'replace'))
- return p.returncode
-
-
-class CurlFD(ExternalFD):
- AVAILABLE_OPT = '-V'
-
- def _make_cmd(self, tmpfilename, info_dict):
- cmd = [self.exe, '--location', '-o', tmpfilename]
- for key, val in info_dict['http_headers'].items():
- cmd += ['--header', '%s: %s' % (key, val)]
- cmd += self._bool_option('--continue-at', 'continuedl', '-', '0')
- cmd += self._valueless_option('--silent', 'noprogress')
- cmd += self._valueless_option('--verbose', 'verbose')
- cmd += self._option('--limit-rate', 'ratelimit')
- cmd += self._option('--retry', 'retries')
- cmd += self._option('--max-filesize', 'max_filesize')
- cmd += self._option('--interface', 'source_address')
- cmd += self._option('--proxy', 'proxy')
- cmd += self._valueless_option('--insecure', 'nocheckcertificate')
- cmd += self._configuration_args()
- cmd += ['--', info_dict['url']]
- return cmd
-
- def _call_downloader(self, tmpfilename, info_dict):
- cmd = [encodeArgument(a) for a in self._make_cmd(tmpfilename, info_dict)]
-
- self._debug_cmd(cmd)
-
- # curl writes the progress to stderr so don't capture it.
- p = subprocess.Popen(cmd)
- p.communicate()
- return p.returncode
-
-
-class AxelFD(ExternalFD):
- AVAILABLE_OPT = '-V'
-
- def _make_cmd(self, tmpfilename, info_dict):
- cmd = [self.exe, '-o', tmpfilename]
- for key, val in info_dict['http_headers'].items():
- cmd += ['-H', '%s: %s' % (key, val)]
- cmd += self._configuration_args()
- cmd += ['--', info_dict['url']]
- return cmd
-
-
-class WgetFD(ExternalFD):
- AVAILABLE_OPT = '--version'
-
- def _make_cmd(self, tmpfilename, info_dict):
- cmd = [self.exe, '-O', tmpfilename, '-nv', '--no-cookies']
- for key, val in info_dict['http_headers'].items():
- cmd += ['--header', '%s: %s' % (key, val)]
- cmd += self._option('--bind-address', 'source_address')
- cmd += self._option('--proxy', 'proxy')
- cmd += self._valueless_option('--no-check-certificate', 'nocheckcertificate')
- cmd += self._configuration_args()
- cmd += ['--', info_dict['url']]
- return cmd
-
-
-class Aria2cFD(ExternalFD):
- AVAILABLE_OPT = '-v'
-
- def _make_cmd(self, tmpfilename, info_dict):
- cmd = [self.exe, '-c']
- cmd += self._configuration_args([
- '--min-split-size', '1M', '--max-connection-per-server', '4'])
- dn = os.path.dirname(tmpfilename)
- if dn:
- cmd += ['--dir', dn]
- cmd += ['--out', os.path.basename(tmpfilename)]
- for key, val in info_dict['http_headers'].items():
- cmd += ['--header', '%s: %s' % (key, val)]
- cmd += self._option('--interface', 'source_address')
- cmd += self._option('--all-proxy', 'proxy')
- cmd += self._bool_option('--check-certificate', 'nocheckcertificate', 'false', 'true', '=')
- cmd += ['--', info_dict['url']]
- return cmd
-
-
-class HttpieFD(ExternalFD):
- @classmethod
- def available(cls):
- return check_executable('http', ['--version'])
-
- def _make_cmd(self, tmpfilename, info_dict):
- cmd = ['http', '--download', '--output', tmpfilename, info_dict['url']]
- for key, val in info_dict['http_headers'].items():
- cmd += ['%s:%s' % (key, val)]
- return cmd
-
-
-class FFmpegFD(ExternalFD):
- @classmethod
- def supports(cls, info_dict):
- return info_dict['protocol'] in ('http', 'https', 'ftp', 'ftps', 'm3u8', 'rtsp', 'rtmp', 'mms')
-
- @classmethod
- def available(cls):
- return FFmpegPostProcessor().available
-
- def _call_downloader(self, tmpfilename, info_dict):
- url = info_dict['url']
- ffpp = FFmpegPostProcessor(downloader=self)
- if not ffpp.available:
- self.report_error('m3u8 download detected but ffmpeg or avconv could not be found. Please install one.')
- return False
- ffpp.check_version()
-
- args = [ffpp.executable, '-y']
-
- for log_level in ('quiet', 'verbose'):
- if self.params.get(log_level, False):
- args += ['-loglevel', log_level]
- break
-
- seekable = info_dict.get('_seekable')
- if seekable is not None:
- # setting -seekable prevents ffmpeg from guessing if the server
- # supports seeking(by adding the header `Range: bytes=0-`), which
- # can cause problems in some cases
- # https://github.com/rg3/youtube-dl/issues/11800#issuecomment-275037127
- # http://trac.ffmpeg.org/ticket/6125#comment:10
- args += ['-seekable', '1' if seekable else '0']
-
- args += self._configuration_args()
-
- # start_time = info_dict.get('start_time') or 0
- # if start_time:
- # args += ['-ss', compat_str(start_time)]
- # end_time = info_dict.get('end_time')
- # if end_time:
- # args += ['-t', compat_str(end_time - start_time)]
-
- if info_dict['http_headers'] and re.match(r'^https?://', url):
- # Trailing \r\n after each HTTP header is important to prevent warning from ffmpeg/avconv:
- # [http @ 00000000003d2fa0] No trailing CRLF found in HTTP header.
- headers = handle_youtubedl_headers(info_dict['http_headers'])
- args += [
- '-headers',
- ''.join('%s: %s\r\n' % (key, val) for key, val in headers.items())]
-
- env = None
- proxy = self.params.get('proxy')
- if proxy:
- if not re.match(r'^[\da-zA-Z]+://', proxy):
- proxy = 'http://%s' % proxy
-
- if proxy.startswith('socks'):
- self.report_warning(
- '%s does not support SOCKS proxies. Downloading is likely to fail. '
- 'Consider adding --hls-prefer-native to your command.' % self.get_basename())
-
- # Since December 2015 ffmpeg supports -http_proxy option (see
- # http://git.videolan.org/?p=ffmpeg.git;a=commit;h=b4eb1f29ebddd60c41a2eb39f5af701e38e0d3fd)
- # We could switch to the following code if we are able to detect version properly
- # args += ['-http_proxy', proxy]
- env = os.environ.copy()
- compat_setenv('HTTP_PROXY', proxy, env=env)
- compat_setenv('http_proxy', proxy, env=env)
-
- protocol = info_dict.get('protocol')
-
- if protocol == 'rtmp':
- player_url = info_dict.get('player_url')
- page_url = info_dict.get('page_url')
- app = info_dict.get('app')
- play_path = info_dict.get('play_path')
- tc_url = info_dict.get('tc_url')
- flash_version = info_dict.get('flash_version')
- live = info_dict.get('rtmp_live', False)
- if player_url is not None:
- args += ['-rtmp_swfverify', player_url]
- if page_url is not None:
- args += ['-rtmp_pageurl', page_url]
- if app is not None:
- args += ['-rtmp_app', app]
- if play_path is not None:
- args += ['-rtmp_playpath', play_path]
- if tc_url is not None:
- args += ['-rtmp_tcurl', tc_url]
- if flash_version is not None:
- args += ['-rtmp_flashver', flash_version]
- if live:
- args += ['-rtmp_live', 'live']
-
- args += ['-i', url, '-c', 'copy']
-
- if self.params.get('test', False):
- args += ['-fs', compat_str(self._TEST_FILE_SIZE)]
-
- if protocol in ('m3u8', 'm3u8_native'):
- if self.params.get('hls_use_mpegts', False) or tmpfilename == '-':
- args += ['-f', 'mpegts']
- else:
- args += ['-f', 'mp4']
- if (ffpp.basename == 'ffmpeg' and is_outdated_version(ffpp._versions['ffmpeg'], '3.2', False)) and (not info_dict.get('acodec') or info_dict['acodec'].split('.')[0] in ('aac', 'mp4a')):
- args += ['-bsf:a', 'aac_adtstoasc']
- elif protocol == 'rtmp':
- args += ['-f', 'flv']
- else:
- args += ['-f', EXT_TO_OUT_FORMATS.get(info_dict['ext'], info_dict['ext'])]
-
- args = [encodeArgument(opt) for opt in args]
- args.append(encodeFilename(ffpp._ffmpeg_filename_argument(tmpfilename), True))
-
- self._debug_cmd(args)
-
- proc = subprocess.Popen(args, stdin=subprocess.PIPE, env=env)
- try:
- retval = proc.wait()
- except KeyboardInterrupt:
- # subprocces.run would send the SIGKILL signal to ffmpeg and the
- # mp4 file couldn't be played, but if we ask ffmpeg to quit it
- # produces a file that is playable (this is mostly useful for live
- # streams). Note that Windows is not affected and produces playable
- # files (see https://github.com/rg3/youtube-dl/issues/8300).
- if sys.platform != 'win32':
- proc.communicate(b'q')
- raise
- return retval
-
-
-class AVconvFD(FFmpegFD):
- pass
-
-
-_BY_NAME = dict(
- (klass.get_basename(), klass)
- for name, klass in globals().items()
- if name.endswith('FD') and name != 'ExternalFD'
-)
-
-
-def list_external_downloaders():
- return sorted(_BY_NAME.keys())
-
-
-def get_external_downloader(external_downloader):
- """ Given the name of the executable, see whether we support the given
- downloader . """
- # Drop .exe extension on Windows
- bn = os.path.splitext(os.path.basename(external_downloader))[0]
- return _BY_NAME[bn]
diff --git a/youtube_dl/downloader/f4m.py b/youtube_dl/downloader/f4m.py
deleted file mode 100644
index 15e71be..0000000
--- a/youtube_dl/downloader/f4m.py
+++ /dev/null
@@ -1,438 +0,0 @@
-from __future__ import division, unicode_literals
-
-import io
-import itertools
-import time
-
-from .fragment import FragmentFD
-from ..compat import (
- compat_b64decode,
- compat_etree_fromstring,
- compat_urlparse,
- compat_urllib_error,
- compat_urllib_parse_urlparse,
- compat_struct_pack,
- compat_struct_unpack,
-)
-from ..utils import (
- fix_xml_ampersands,
- xpath_text,
-)
-
-
-class DataTruncatedError(Exception):
- pass
-
-
-class FlvReader(io.BytesIO):
- """
- Reader for Flv files
- The file format is documented in https://www.adobe.com/devnet/f4v.html
- """
-
- def read_bytes(self, n):
- data = self.read(n)
- if len(data) < n:
- raise DataTruncatedError(
- 'FlvReader error: need %d bytes while only %d bytes got' % (
- n, len(data)))
- return data
-
- # Utility functions for reading numbers and strings
- def read_unsigned_long_long(self):
- return compat_struct_unpack('!Q', self.read_bytes(8))[0]
-
- def read_unsigned_int(self):
- return compat_struct_unpack('!I', self.read_bytes(4))[0]
-
- def read_unsigned_char(self):
- return compat_struct_unpack('!B', self.read_bytes(1))[0]
-
- def read_string(self):
- res = b''
- while True:
- char = self.read_bytes(1)
- if char == b'\x00':
- break
- res += char
- return res
-
- def read_box_info(self):
- """
- Read a box and return the info as a tuple: (box_size, box_type, box_data)
- """
- real_size = size = self.read_unsigned_int()
- box_type = self.read_bytes(4)
- header_end = 8
- if size == 1:
- real_size = self.read_unsigned_long_long()
- header_end = 16
- return real_size, box_type, self.read_bytes(real_size - header_end)
-
- def read_asrt(self):
- # version
- self.read_unsigned_char()
- # flags
- self.read_bytes(3)
- quality_entry_count = self.read_unsigned_char()
- # QualityEntryCount
- for i in range(quality_entry_count):
- self.read_string()
-
- segment_run_count = self.read_unsigned_int()
- segments = []
- for i in range(segment_run_count):
- first_segment = self.read_unsigned_int()
- fragments_per_segment = self.read_unsigned_int()
- segments.append((first_segment, fragments_per_segment))
-
- return {
- 'segment_run': segments,
- }
-
- def read_afrt(self):
- # version
- self.read_unsigned_char()
- # flags
- self.read_bytes(3)
- # time scale
- self.read_unsigned_int()
-
- quality_entry_count = self.read_unsigned_char()
- # QualitySegmentUrlModifiers
- for i in range(quality_entry_count):
- self.read_string()
-
- fragments_count = self.read_unsigned_int()
- fragments = []
- for i in range(fragments_count):
- first = self.read_unsigned_int()
- first_ts = self.read_unsigned_long_long()
- duration = self.read_unsigned_int()
- if duration == 0:
- discontinuity_indicator = self.read_unsigned_char()
- else:
- discontinuity_indicator = None
- fragments.append({
- 'first': first,
- 'ts': first_ts,
- 'duration': duration,
- 'discontinuity_indicator': discontinuity_indicator,
- })
-
- return {
- 'fragments': fragments,
- }
-
- def read_abst(self):
- # version
- self.read_unsigned_char()
- # flags
- self.read_bytes(3)
-
- self.read_unsigned_int() # BootstrapinfoVersion
- # Profile,Live,Update,Reserved
- flags = self.read_unsigned_char()
- live = flags & 0x20 != 0
- # time scale
- self.read_unsigned_int()
- # CurrentMediaTime
- self.read_unsigned_long_long()
- # SmpteTimeCodeOffset
- self.read_unsigned_long_long()
-
- self.read_string() # MovieIdentifier
- server_count = self.read_unsigned_char()
- # ServerEntryTable
- for i in range(server_count):
- self.read_string()
- quality_count = self.read_unsigned_char()
- # QualityEntryTable
- for i in range(quality_count):
- self.read_string()
- # DrmData
- self.read_string()
- # MetaData
- self.read_string()
-
- segments_count = self.read_unsigned_char()
- segments = []
- for i in range(segments_count):
- box_size, box_type, box_data = self.read_box_info()
- assert box_type == b'asrt'
- segment = FlvReader(box_data).read_asrt()
- segments.append(segment)
- fragments_run_count = self.read_unsigned_char()
- fragments = []
- for i in range(fragments_run_count):
- box_size, box_type, box_data = self.read_box_info()
- assert box_type == b'afrt'
- fragments.append(FlvReader(box_data).read_afrt())
-
- return {
- 'segments': segments,
- 'fragments': fragments,
- 'live': live,
- }
-
- def read_bootstrap_info(self):
- total_size, box_type, box_data = self.read_box_info()
- assert box_type == b'abst'
- return FlvReader(box_data).read_abst()
-
-
-def read_bootstrap_info(bootstrap_bytes):
- return FlvReader(bootstrap_bytes).read_bootstrap_info()
-
-
-def build_fragments_list(boot_info):
- """ Return a list of (segment, fragment) for each fragment in the video """
- res = []
- segment_run_table = boot_info['segments'][0]
- fragment_run_entry_table = boot_info['fragments'][0]['fragments']
- first_frag_number = fragment_run_entry_table[0]['first']
- fragments_counter = itertools.count(first_frag_number)
- for segment, fragments_count in segment_run_table['segment_run']:
- # In some live HDS streams (for example Rai), `fragments_count` is
- # abnormal and causing out-of-memory errors. It's OK to change the
- # number of fragments for live streams as they are updated periodically
- if fragments_count == 4294967295 and boot_info['live']:
- fragments_count = 2
- for _ in range(fragments_count):
- res.append((segment, next(fragments_counter)))
-
- if boot_info['live']:
- res = res[-2:]
-
- return res
-
-
-def write_unsigned_int(stream, val):
- stream.write(compat_struct_pack('!I', val))
-
-
-def write_unsigned_int_24(stream, val):
- stream.write(compat_struct_pack('!I', val)[1:])
-
-
-def write_flv_header(stream):
- """Writes the FLV header to stream"""
- # FLV header
- stream.write(b'FLV\x01')
- stream.write(b'\x05')
- stream.write(b'\x00\x00\x00\x09')
- stream.write(b'\x00\x00\x00\x00')
-
-
-def write_metadata_tag(stream, metadata):
- """Writes optional metadata tag to stream"""
- SCRIPT_TAG = b'\x12'
- FLV_TAG_HEADER_LEN = 11
-
- if metadata:
- stream.write(SCRIPT_TAG)
- write_unsigned_int_24(stream, len(metadata))
- stream.write(b'\x00\x00\x00\x00\x00\x00\x00')
- stream.write(metadata)
- write_unsigned_int(stream, FLV_TAG_HEADER_LEN + len(metadata))
-
-
-def remove_encrypted_media(media):
- return list(filter(lambda e: 'drmAdditionalHeaderId' not in e.attrib and
- 'drmAdditionalHeaderSetId' not in e.attrib,
- media))
-
-
-def _add_ns(prop, ver=1):
- return '{http://ns.adobe.com/f4m/%d.0}%s' % (ver, prop)
-
-
-def get_base_url(manifest):
- base_url = xpath_text(
- manifest, [_add_ns('baseURL'), _add_ns('baseURL', 2)],
- 'base URL', default=None)
- if base_url:
- base_url = base_url.strip()
- return base_url
-
-
-class F4mFD(FragmentFD):
- """
- A downloader for f4m manifests or AdobeHDS.
- """
-
- FD_NAME = 'f4m'
-
- def _get_unencrypted_media(self, doc):
- media = doc.findall(_add_ns('media'))
- if not media:
- self.report_error('No media found')
- for e in (doc.findall(_add_ns('drmAdditionalHeader')) +
- doc.findall(_add_ns('drmAdditionalHeaderSet'))):
- # If id attribute is missing it's valid for all media nodes
- # without drmAdditionalHeaderId or drmAdditionalHeaderSetId attribute
- if 'id' not in e.attrib:
- self.report_error('Missing ID in f4m DRM')
- media = remove_encrypted_media(media)
- if not media:
- self.report_error('Unsupported DRM')
- return media
-
- def _get_bootstrap_from_url(self, bootstrap_url):
- bootstrap = self.ydl.urlopen(bootstrap_url).read()
- return read_bootstrap_info(bootstrap)
-
- def _update_live_fragments(self, bootstrap_url, latest_fragment):
- fragments_list = []
- retries = 30
- while (not fragments_list) and (retries > 0):
- boot_info = self._get_bootstrap_from_url(bootstrap_url)
- fragments_list = build_fragments_list(boot_info)
- fragments_list = [f for f in fragments_list if f[1] > latest_fragment]
- if not fragments_list:
- # Retry after a while
- time.sleep(5.0)
- retries -= 1
-
- if not fragments_list:
- self.report_error('Failed to update fragments')
-
- return fragments_list
-
- def _parse_bootstrap_node(self, node, base_url):
- # Sometimes non empty inline bootstrap info can be specified along
- # with bootstrap url attribute (e.g. dummy inline bootstrap info
- # contains whitespace characters in [1]). We will prefer bootstrap
- # url over inline bootstrap info when present.
- # 1. http://live-1-1.rutube.ru/stream/1024/HDS/SD/C2NKsS85HQNckgn5HdEmOQ/1454167650/S-s604419906/move/four/dirs/upper/1024-576p.f4m
- bootstrap_url = node.get('url')
- if bootstrap_url:
- bootstrap_url = compat_urlparse.urljoin(
- base_url, bootstrap_url)
- boot_info = self._get_bootstrap_from_url(bootstrap_url)
- else:
- bootstrap_url = None
- bootstrap = compat_b64decode(node.text)
- boot_info = read_bootstrap_info(bootstrap)
- return boot_info, bootstrap_url
-
- def real_download(self, filename, info_dict):
- man_url = info_dict['url']
- requested_bitrate = info_dict.get('tbr')
- self.to_screen('[%s] Downloading f4m manifest' % self.FD_NAME)
-
- urlh = self.ydl.urlopen(self._prepare_url(info_dict, man_url))
- man_url = urlh.geturl()
- # Some manifests may be malformed, e.g. prosiebensat1 generated manifests
- # (see https://github.com/rg3/youtube-dl/issues/6215#issuecomment-121704244
- # and https://github.com/rg3/youtube-dl/issues/7823)
- manifest = fix_xml_ampersands(urlh.read().decode('utf-8', 'ignore')).strip()
-
- doc = compat_etree_fromstring(manifest)
- formats = [(int(f.attrib.get('bitrate', -1)), f)
- for f in self._get_unencrypted_media(doc)]
- if requested_bitrate is None or len(formats) == 1:
- # get the best format
- formats = sorted(formats, key=lambda f: f[0])
- rate, media = formats[-1]
- else:
- rate, media = list(filter(
- lambda f: int(f[0]) == requested_bitrate, formats))[0]
-
- # Prefer baseURL for relative URLs as per 11.2 of F4M 3.0 spec.
- man_base_url = get_base_url(doc) or man_url
-
- base_url = compat_urlparse.urljoin(man_base_url, media.attrib['url'])
- bootstrap_node = doc.find(_add_ns('bootstrapInfo'))
- boot_info, bootstrap_url = self._parse_bootstrap_node(
- bootstrap_node, man_base_url)
- live = boot_info['live']
- metadata_node = media.find(_add_ns('metadata'))
- if metadata_node is not None:
- metadata = compat_b64decode(metadata_node.text)
- else:
- metadata = None
-
- fragments_list = build_fragments_list(boot_info)
- test = self.params.get('test', False)
- if test:
- # We only download the first fragment
- fragments_list = fragments_list[:1]
- total_frags = len(fragments_list)
- # For some akamai manifests we'll need to add a query to the fragment url
- akamai_pv = xpath_text(doc, _add_ns('pv-2.0'))
-
- ctx = {
- 'filename': filename,
- 'total_frags': total_frags,
- 'live': live,
- }
-
- self._prepare_frag_download(ctx)
-
- dest_stream = ctx['dest_stream']
-
- if ctx['complete_frags_downloaded_bytes'] == 0:
- write_flv_header(dest_stream)
- if not live:
- write_metadata_tag(dest_stream, metadata)
-
- base_url_parsed = compat_urllib_parse_urlparse(base_url)
-
- self._start_frag_download(ctx)
-
- frag_index = 0
- while fragments_list:
- seg_i, frag_i = fragments_list.pop(0)
- frag_index += 1
- if frag_index <= ctx['fragment_index']:
- continue
- name = 'Seg%d-Frag%d' % (seg_i, frag_i)
- query = []
- if base_url_parsed.query:
- query.append(base_url_parsed.query)
- if akamai_pv:
- query.append(akamai_pv.strip(';'))
- if info_dict.get('extra_param_to_segment_url'):
- query.append(info_dict['extra_param_to_segment_url'])
- url_parsed = base_url_parsed._replace(path=base_url_parsed.path + name, query='&'.join(query))
- try:
- success, down_data = self._download_fragment(ctx, url_parsed.geturl(), info_dict)
- if not success:
- return False
- reader = FlvReader(down_data)
- while True:
- try:
- _, box_type, box_data = reader.read_box_info()
- except DataTruncatedError:
- if test:
- # In tests, segments may be truncated, and thus
- # FlvReader may not be able to parse the whole
- # chunk. If so, write the segment as is
- # See https://github.com/rg3/youtube-dl/issues/9214
- dest_stream.write(down_data)
- break
- raise
- if box_type == b'mdat':
- self._append_fragment(ctx, box_data)
- break
- except (compat_urllib_error.HTTPError, ) as err:
- if live and (err.code == 404 or err.code == 410):
- # We didn't keep up with the live window. Continue
- # with the next available fragment.
- msg = 'Fragment %d unavailable' % frag_i
- self.report_warning(msg)
- fragments_list = []
- else:
- raise
-
- if not fragments_list and not test and live and bootstrap_url:
- fragments_list = self._update_live_fragments(bootstrap_url, frag_i)
- total_frags += len(fragments_list)
- if fragments_list and (fragments_list[0][1] > frag_i + 1):
- msg = 'Missed %d fragments' % (fragments_list[0][1] - (frag_i + 1))
- self.report_warning(msg)
-
- self._finish_frag_download(ctx)
-
- return True
diff --git a/youtube_dl/downloader/fragment.py b/youtube_dl/downloader/fragment.py
deleted file mode 100644
index 917f6dc..0000000
--- a/youtube_dl/downloader/fragment.py
+++ /dev/null
@@ -1,268 +0,0 @@
-from __future__ import division, unicode_literals
-
-import os
-import time
-import json
-
-from .common import FileDownloader
-from .http import HttpFD
-from ..utils import (
- error_to_compat_str,
- encodeFilename,
- sanitize_open,
- sanitized_Request,
-)
-
-
-class HttpQuietDownloader(HttpFD):
- def to_screen(self, *args, **kargs):
- pass
-
-
-class FragmentFD(FileDownloader):
- """
- A base file downloader class for fragmented media (e.g. f4m/m3u8 manifests).
-
- Available options:
-
- fragment_retries: Number of times to retry a fragment for HTTP error (DASH
- and hlsnative only)
- skip_unavailable_fragments:
- Skip unavailable fragments (DASH and hlsnative only)
- keep_fragments: Keep downloaded fragments on disk after downloading is
- finished
-
- For each incomplete fragment download youtube-dl keeps on disk a special
- bookkeeping file with download state and metadata (in future such files will
- be used for any incomplete download handled by youtube-dl). This file is
- used to properly handle resuming, check download file consistency and detect
- potential errors. The file has a .ytdl extension and represents a standard
- JSON file of the following format:
-
- extractor:
- Dictionary of extractor related data. TBD.
-
- downloader:
- Dictionary of downloader related data. May contain following data:
- current_fragment:
- Dictionary with current (being downloaded) fragment data:
- index: 0-based index of current fragment among all fragments
- fragment_count:
- Total count of fragments
-
- This feature is experimental and file format may change in future.
- """
-
- def report_retry_fragment(self, err, frag_index, count, retries):
- self.to_screen(
- '[download] Got server HTTP error: %s. Retrying fragment %d (attempt %d of %s)...'
- % (error_to_compat_str(err), frag_index, count, self.format_retries(retries)))
-
- def report_skip_fragment(self, frag_index):
- self.to_screen('[download] Skipping fragment %d...' % frag_index)
-
- def _prepare_url(self, info_dict, url):
- headers = info_dict.get('http_headers')
- return sanitized_Request(url, None, headers) if headers else url
-
- def _prepare_and_start_frag_download(self, ctx):
- self._prepare_frag_download(ctx)
- self._start_frag_download(ctx)
-
- @staticmethod
- def __do_ytdl_file(ctx):
- return not ctx['live'] and not ctx['tmpfilename'] == '-'
-
- def _read_ytdl_file(self, ctx):
- assert 'ytdl_corrupt' not in ctx
- stream, _ = sanitize_open(self.ytdl_filename(ctx['filename']), 'r')
- try:
- ctx['fragment_index'] = json.loads(stream.read())['downloader']['current_fragment']['index']
- except Exception:
- ctx['ytdl_corrupt'] = True
- finally:
- stream.close()
-
- def _write_ytdl_file(self, ctx):
- frag_index_stream, _ = sanitize_open(self.ytdl_filename(ctx['filename']), 'w')
- downloader = {
- 'current_fragment': {
- 'index': ctx['fragment_index'],
- },
- }
- if ctx.get('fragment_count') is not None:
- downloader['fragment_count'] = ctx['fragment_count']
- frag_index_stream.write(json.dumps({'downloader': downloader}))
- frag_index_stream.close()
-
- def _download_fragment(self, ctx, frag_url, info_dict, headers=None):
- fragment_filename = '%s-Frag%d' % (ctx['tmpfilename'], ctx['fragment_index'])
- success = ctx['dl'].download(fragment_filename, {
- 'url': frag_url,
- 'http_headers': headers or info_dict.get('http_headers'),
- })
- if not success:
- return False, None
- down, frag_sanitized = sanitize_open(fragment_filename, 'rb')
- ctx['fragment_filename_sanitized'] = frag_sanitized
- frag_content = down.read()
- down.close()
- return True, frag_content
-
- def _append_fragment(self, ctx, frag_content):
- try:
- ctx['dest_stream'].write(frag_content)
- ctx['dest_stream'].flush()
- finally:
- if self.__do_ytdl_file(ctx):
- self._write_ytdl_file(ctx)
- if not self.params.get('keep_fragments', False):
- os.remove(encodeFilename(ctx['fragment_filename_sanitized']))
- del ctx['fragment_filename_sanitized']
-
- def _prepare_frag_download(self, ctx):
- if 'live' not in ctx:
- ctx['live'] = False
- if not ctx['live']:
- total_frags_str = '%d' % ctx['total_frags']
- ad_frags = ctx.get('ad_frags', 0)
- if ad_frags:
- total_frags_str += ' (not including %d ad)' % ad_frags
- else:
- total_frags_str = 'unknown (live)'
- self.to_screen(
- '[%s] Total fragments: %s' % (self.FD_NAME, total_frags_str))
- self.report_destination(ctx['filename'])
- dl = HttpQuietDownloader(
- self.ydl,
- {
- 'continuedl': True,
- 'quiet': True,
- 'noprogress': True,
- 'ratelimit': self.params.get('ratelimit'),
- 'retries': self.params.get('retries', 0),
- 'nopart': self.params.get('nopart', False),
- 'test': self.params.get('test', False),
- }
- )
- tmpfilename = self.temp_name(ctx['filename'])
- open_mode = 'wb'
- resume_len = 0
-
- # Establish possible resume length
- if os.path.isfile(encodeFilename(tmpfilename)):
- open_mode = 'ab'
- resume_len = os.path.getsize(encodeFilename(tmpfilename))
-
- # Should be initialized before ytdl file check
- ctx.update({
- 'tmpfilename': tmpfilename,
- 'fragment_index': 0,
- })
-
- if self.__do_ytdl_file(ctx):
- if os.path.isfile(encodeFilename(self.ytdl_filename(ctx['filename']))):
- self._read_ytdl_file(ctx)
- is_corrupt = ctx.get('ytdl_corrupt') is True
- is_inconsistent = ctx['fragment_index'] > 0 and resume_len == 0
- if is_corrupt or is_inconsistent:
- message = (
- '.ytdl file is corrupt' if is_corrupt else
- 'Inconsistent state of incomplete fragment download')
- self.report_warning(
- '%s. Restarting from the beginning...' % message)
- ctx['fragment_index'] = resume_len = 0
- if 'ytdl_corrupt' in ctx:
- del ctx['ytdl_corrupt']
- self._write_ytdl_file(ctx)
- else:
- self._write_ytdl_file(ctx)
- assert ctx['fragment_index'] == 0
-
- dest_stream, tmpfilename = sanitize_open(tmpfilename, open_mode)
-
- ctx.update({
- 'dl': dl,
- 'dest_stream': dest_stream,
- 'tmpfilename': tmpfilename,
- # Total complete fragments downloaded so far in bytes
- 'complete_frags_downloaded_bytes': resume_len,
- })
-
- def _start_frag_download(self, ctx):
- total_frags = ctx['total_frags']
- # This dict stores the download progress, it's updated by the progress
- # hook
- state = {
- 'status': 'downloading',
- 'downloaded_bytes': ctx['complete_frags_downloaded_bytes'],
- 'fragment_index': ctx['fragment_index'],
- 'fragment_count': total_frags,
- 'filename': ctx['filename'],
- 'tmpfilename': ctx['tmpfilename'],
- }
-
- start = time.time()
- ctx.update({
- 'started': start,
- # Amount of fragment's bytes downloaded by the time of the previous
- # frag progress hook invocation
- 'prev_frag_downloaded_bytes': 0,
- })
-
- def frag_progress_hook(s):
- if s['status'] not in ('downloading', 'finished'):
- return
-
- time_now = time.time()
- state['elapsed'] = time_now - start
- frag_total_bytes = s.get('total_bytes') or 0
- if not ctx['live']:
- estimated_size = (
- (ctx['complete_frags_downloaded_bytes'] + frag_total_bytes) /
- (state['fragment_index'] + 1) * total_frags)
- state['total_bytes_estimate'] = estimated_size
-
- if s['status'] == 'finished':
- state['fragment_index'] += 1
- ctx['fragment_index'] = state['fragment_index']
- state['downloaded_bytes'] += frag_total_bytes - ctx['prev_frag_downloaded_bytes']
- ctx['complete_frags_downloaded_bytes'] = state['downloaded_bytes']
- ctx['prev_frag_downloaded_bytes'] = 0
- else:
- frag_downloaded_bytes = s['downloaded_bytes']
- state['downloaded_bytes'] += frag_downloaded_bytes - ctx['prev_frag_downloaded_bytes']
- if not ctx['live']:
- state['eta'] = self.calc_eta(
- start, time_now, estimated_size,
- state['downloaded_bytes'])
- state['speed'] = s.get('speed') or ctx.get('speed')
- ctx['speed'] = state['speed']
- ctx['prev_frag_downloaded_bytes'] = frag_downloaded_bytes
- self._hook_progress(state)
-
- ctx['dl'].add_progress_hook(frag_progress_hook)
-
- return start
-
- def _finish_frag_download(self, ctx):
- ctx['dest_stream'].close()
- if self.__do_ytdl_file(ctx):
- ytdl_filename = encodeFilename(self.ytdl_filename(ctx['filename']))
- if os.path.isfile(ytdl_filename):
- os.remove(ytdl_filename)
- elapsed = time.time() - ctx['started']
-
- if ctx['tmpfilename'] == '-':
- downloaded_bytes = ctx['complete_frags_downloaded_bytes']
- else:
- self.try_rename(ctx['tmpfilename'], ctx['filename'])
- downloaded_bytes = os.path.getsize(encodeFilename(ctx['filename']))
-
- self._hook_progress({
- 'downloaded_bytes': downloaded_bytes,
- 'total_bytes': downloaded_bytes,
- 'filename': ctx['filename'],
- 'status': 'finished',
- 'elapsed': elapsed,
- })
diff --git a/youtube_dl/downloader/hls.py b/youtube_dl/downloader/hls.py
deleted file mode 100644
index fd30452..0000000
--- a/youtube_dl/downloader/hls.py
+++ /dev/null
@@ -1,204 +0,0 @@
-from __future__ import unicode_literals
-
-import re
-import binascii
-try:
- from Crypto.Cipher import AES
- can_decrypt_frag = True
-except ImportError:
- can_decrypt_frag = False
-
-from .fragment import FragmentFD
-from .external import FFmpegFD
-
-from ..compat import (
- compat_urllib_error,
- compat_urlparse,
- compat_struct_pack,
-)
-from ..utils import (
- parse_m3u8_attributes,
- update_url_query,
-)
-
-
-class HlsFD(FragmentFD):
- """ A limited implementation that does not require ffmpeg """
-
- FD_NAME = 'hlsnative'
-
- @staticmethod
- def can_download(manifest, info_dict):
- UNSUPPORTED_FEATURES = (
- r'#EXT-X-KEY:METHOD=(?!NONE|AES-128)', # encrypted streams [1]
- # r'#EXT-X-BYTERANGE', # playlists composed of byte ranges of media files [2]
-
- # Live streams heuristic does not always work (e.g. geo restricted to Germany
- # http://hls-geo.daserste.de/i/videoportal/Film/c_620000/622873/format,716451,716457,716450,716458,716459,.mp4.csmil/index_4_av.m3u8?null=0)
- # r'#EXT-X-MEDIA-SEQUENCE:(?!0$)', # live streams [3]
-
- # This heuristic also is not correct since segments may not be appended as well.
- # Twitch vods of finished streams have EXT-X-PLAYLIST-TYPE:EVENT despite
- # no segments will definitely be appended to the end of the playlist.
- # r'#EXT-X-PLAYLIST-TYPE:EVENT', # media segments may be appended to the end of
- # # event media playlists [4]
-
- # 1. https://tools.ietf.org/html/draft-pantos-http-live-streaming-17#section-4.3.2.4
- # 2. https://tools.ietf.org/html/draft-pantos-http-live-streaming-17#section-4.3.2.2
- # 3. https://tools.ietf.org/html/draft-pantos-http-live-streaming-17#section-4.3.3.2
- # 4. https://tools.ietf.org/html/draft-pantos-http-live-streaming-17#section-4.3.3.5
- )
- check_results = [not re.search(feature, manifest) for feature in UNSUPPORTED_FEATURES]
- is_aes128_enc = '#EXT-X-KEY:METHOD=AES-128' in manifest
- check_results.append(can_decrypt_frag or not is_aes128_enc)
- check_results.append(not (is_aes128_enc and r'#EXT-X-BYTERANGE' in manifest))
- check_results.append(not info_dict.get('is_live'))
- return all(check_results)
-
- def real_download(self, filename, info_dict):
- man_url = info_dict['url']
- self.to_screen('[%s] Downloading m3u8 manifest' % self.FD_NAME)
-
- urlh = self.ydl.urlopen(self._prepare_url(info_dict, man_url))
- man_url = urlh.geturl()
- s = urlh.read().decode('utf-8', 'ignore')
-
- if not self.can_download(s, info_dict):
- if info_dict.get('extra_param_to_segment_url'):
- self.report_error('pycrypto not found. Please install it.')
- return False
- self.report_warning(
- 'hlsnative has detected features it does not support, '
- 'extraction will be delegated to ffmpeg')
- fd = FFmpegFD(self.ydl, self.params)
- for ph in self._progress_hooks:
- fd.add_progress_hook(ph)
- return fd.real_download(filename, info_dict)
-
- def is_ad_fragment(s):
- return (s.startswith('#ANVATO-SEGMENT-INFO') and 'type=ad' in s or
- s.startswith('#UPLYNK-SEGMENT') and s.endswith(',ad'))
-
- media_frags = 0
- ad_frags = 0
- ad_frag_next = False
- for line in s.splitlines():
- line = line.strip()
- if not line:
- continue
- if line.startswith('#'):
- if is_ad_fragment(line):
- ad_frags += 1
- ad_frag_next = True
- continue
- if ad_frag_next:
- ad_frag_next = False
- continue
- media_frags += 1
-
- ctx = {
- 'filename': filename,
- 'total_frags': media_frags,
- 'ad_frags': ad_frags,
- }
-
- self._prepare_and_start_frag_download(ctx)
-
- fragment_retries = self.params.get('fragment_retries', 0)
- skip_unavailable_fragments = self.params.get('skip_unavailable_fragments', True)
- test = self.params.get('test', False)
-
- extra_query = None
- extra_param_to_segment_url = info_dict.get('extra_param_to_segment_url')
- if extra_param_to_segment_url:
- extra_query = compat_urlparse.parse_qs(extra_param_to_segment_url)
- i = 0
- media_sequence = 0
- decrypt_info = {'METHOD': 'NONE'}
- byte_range = {}
- frag_index = 0
- ad_frag_next = False
- for line in s.splitlines():
- line = line.strip()
- if line:
- if not line.startswith('#'):
- if ad_frag_next:
- ad_frag_next = False
- continue
- frag_index += 1
- if frag_index <= ctx['fragment_index']:
- continue
- frag_url = (
- line
- if re.match(r'^https?://', line)
- else compat_urlparse.urljoin(man_url, line))
- if extra_query:
- frag_url = update_url_query(frag_url, extra_query)
- count = 0
- headers = info_dict.get('http_headers', {})
- if byte_range:
- headers['Range'] = 'bytes=%d-%d' % (byte_range['start'], byte_range['end'])
- while count <= fragment_retries:
- try:
- success, frag_content = self._download_fragment(
- ctx, frag_url, info_dict, headers)
- if not success:
- return False
- break
- except compat_urllib_error.HTTPError as err:
- # Unavailable (possibly temporary) fragments may be served.
- # First we try to retry then either skip or abort.
- # See https://github.com/rg3/youtube-dl/issues/10165,
- # https://github.com/rg3/youtube-dl/issues/10448).
- count += 1
- if count <= fragment_retries:
- self.report_retry_fragment(err, frag_index, count, fragment_retries)
- if count > fragment_retries:
- if skip_unavailable_fragments:
- i += 1
- media_sequence += 1
- self.report_skip_fragment(frag_index)
- continue
- self.report_error(
- 'giving up after %s fragment retries' % fragment_retries)
- return False
- if decrypt_info['METHOD'] == 'AES-128':
- iv = decrypt_info.get('IV') or compat_struct_pack('>8xq', media_sequence)
- decrypt_info['KEY'] = decrypt_info.get('KEY') or self.ydl.urlopen(
- self._prepare_url(info_dict, decrypt_info['URI'])).read()
- frag_content = AES.new(
- decrypt_info['KEY'], AES.MODE_CBC, iv).decrypt(frag_content)
- self._append_fragment(ctx, frag_content)
- # We only download the first fragment during the test
- if test:
- break
- i += 1
- media_sequence += 1
- elif line.startswith('#EXT-X-KEY'):
- decrypt_url = decrypt_info.get('URI')
- decrypt_info = parse_m3u8_attributes(line[11:])
- if decrypt_info['METHOD'] == 'AES-128':
- if 'IV' in decrypt_info:
- decrypt_info['IV'] = binascii.unhexlify(decrypt_info['IV'][2:].zfill(32))
- if not re.match(r'^https?://', decrypt_info['URI']):
- decrypt_info['URI'] = compat_urlparse.urljoin(
- man_url, decrypt_info['URI'])
- if extra_query:
- decrypt_info['URI'] = update_url_query(decrypt_info['URI'], extra_query)
- if decrypt_url != decrypt_info['URI']:
- decrypt_info['KEY'] = None
- elif line.startswith('#EXT-X-MEDIA-SEQUENCE'):
- media_sequence = int(line[22:])
- elif line.startswith('#EXT-X-BYTERANGE'):
- splitted_byte_range = line[17:].split('@')
- sub_range_start = int(splitted_byte_range[1]) if len(splitted_byte_range) == 2 else byte_range['end']
- byte_range = {
- 'start': sub_range_start,
- 'end': sub_range_start + int(splitted_byte_range[0]),
- }
- elif is_ad_fragment(line):
- ad_frag_next = True
-
- self._finish_frag_download(ctx)
-
- return True
diff --git a/youtube_dl/downloader/http.py b/youtube_dl/downloader/http.py
deleted file mode 100644
index 5b1e960..0000000
--- a/youtube_dl/downloader/http.py
+++ /dev/null
@@ -1,354 +0,0 @@
-from __future__ import unicode_literals
-
-import errno
-import os
-import socket
-import time
-import random
-import re
-
-from .common import FileDownloader
-from ..compat import (
- compat_str,
- compat_urllib_error,
-)
-from ..utils import (
- ContentTooShortError,
- encodeFilename,
- int_or_none,
- sanitize_open,
- sanitized_Request,
- write_xattr,
- XAttrMetadataError,
- XAttrUnavailableError,
-)
-
-
-class HttpFD(FileDownloader):
- def real_download(self, filename, info_dict):
- url = info_dict['url']
-
- class DownloadContext(dict):
- __getattr__ = dict.get
- __setattr__ = dict.__setitem__
- __delattr__ = dict.__delitem__
-
- ctx = DownloadContext()
- ctx.filename = filename
- ctx.tmpfilename = self.temp_name(filename)
- ctx.stream = None
-
- # Do not include the Accept-Encoding header
- headers = {'Youtubedl-no-compression': 'True'}
- add_headers = info_dict.get('http_headers')
- if add_headers:
- headers.update(add_headers)
-
- is_test = self.params.get('test', False)
- chunk_size = self._TEST_FILE_SIZE if is_test else (
- info_dict.get('downloader_options', {}).get('http_chunk_size') or
- self.params.get('http_chunk_size') or 0)
-
- ctx.open_mode = 'wb'
- ctx.resume_len = 0
- ctx.data_len = None
- ctx.block_size = self.params.get('buffersize', 1024)
- ctx.start_time = time.time()
- ctx.chunk_size = None
-
- if self.params.get('continuedl', True):
- # Establish possible resume length
- if os.path.isfile(encodeFilename(ctx.tmpfilename)):
- ctx.resume_len = os.path.getsize(
- encodeFilename(ctx.tmpfilename))
-
- ctx.is_resume = ctx.resume_len > 0
-
- count = 0
- retries = self.params.get('retries', 0)
-
- class SucceedDownload(Exception):
- pass
-
- class RetryDownload(Exception):
- def __init__(self, source_error):
- self.source_error = source_error
-
- class NextFragment(Exception):
- pass
-
- def set_range(req, start, end):
- range_header = 'bytes=%d-' % start
- if end:
- range_header += compat_str(end)
- req.add_header('Range', range_header)
-
- def establish_connection():
- ctx.chunk_size = (random.randint(int(chunk_size * 0.95), chunk_size)
- if not is_test and chunk_size else chunk_size)
- if ctx.resume_len > 0:
- range_start = ctx.resume_len
- if ctx.is_resume:
- self.report_resuming_byte(ctx.resume_len)
- ctx.open_mode = 'ab'
- elif ctx.chunk_size > 0:
- range_start = 0
- else:
- range_start = None
- ctx.is_resume = False
- range_end = range_start + ctx.chunk_size - 1 if ctx.chunk_size else None
- if range_end and ctx.data_len is not None and range_end >= ctx.data_len:
- range_end = ctx.data_len - 1
- has_range = range_start is not None
- ctx.has_range = has_range
- request = sanitized_Request(url, None, headers)
- if has_range:
- set_range(request, range_start, range_end)
- # Establish connection
- try:
- ctx.data = self.ydl.urlopen(request)
- # When trying to resume, Content-Range HTTP header of response has to be checked
- # to match the value of requested Range HTTP header. This is due to a webservers
- # that don't support resuming and serve a whole file with no Content-Range
- # set in response despite of requested Range (see
- # https://github.com/rg3/youtube-dl/issues/6057#issuecomment-126129799)
- if has_range:
- content_range = ctx.data.headers.get('Content-Range')
- if content_range:
- content_range_m = re.search(r'bytes (\d+)-(\d+)?(?:/(\d+))?', content_range)
- # Content-Range is present and matches requested Range, resume is possible
- if content_range_m:
- if range_start == int(content_range_m.group(1)):
- content_range_end = int_or_none(content_range_m.group(2))
- content_len = int_or_none(content_range_m.group(3))
- accept_content_len = (
- # Non-chunked download
- not ctx.chunk_size or
- # Chunked download and requested piece or
- # its part is promised to be served
- content_range_end == range_end or
- content_len < range_end)
- if accept_content_len:
- ctx.data_len = content_len
- return
- # Content-Range is either not present or invalid. Assuming remote webserver is
- # trying to send the whole file, resume is not possible, so wiping the local file
- # and performing entire redownload
- self.report_unable_to_resume()
- ctx.resume_len = 0
- ctx.open_mode = 'wb'
- ctx.data_len = int_or_none(ctx.data.info().get('Content-length', None))
- return
- except (compat_urllib_error.HTTPError, ) as err:
- if err.code == 416:
- # Unable to resume (requested range not satisfiable)
- try:
- # Open the connection again without the range header
- ctx.data = self.ydl.urlopen(
- sanitized_Request(url, None, headers))
- content_length = ctx.data.info()['Content-Length']
- except (compat_urllib_error.HTTPError, ) as err:
- if err.code < 500 or err.code >= 600:
- raise
- else:
- # Examine the reported length
- if (content_length is not None and
- (ctx.resume_len - 100 < int(content_length) < ctx.resume_len + 100)):
- # The file had already been fully downloaded.
- # Explanation to the above condition: in issue #175 it was revealed that
- # YouTube sometimes adds or removes a few bytes from the end of the file,
- # changing the file size slightly and causing problems for some users. So
- # I decided to implement a suggested change and consider the file
- # completely downloaded if the file size differs less than 100 bytes from
- # the one in the hard drive.
- self.report_file_already_downloaded(ctx.filename)
- self.try_rename(ctx.tmpfilename, ctx.filename)
- self._hook_progress({
- 'filename': ctx.filename,
- 'status': 'finished',
- 'downloaded_bytes': ctx.resume_len,
- 'total_bytes': ctx.resume_len,
- })
- raise SucceedDownload()
- else:
- # The length does not match, we start the download over
- self.report_unable_to_resume()
- ctx.resume_len = 0
- ctx.open_mode = 'wb'
- return
- elif err.code < 500 or err.code >= 600:
- # Unexpected HTTP error
- raise
- raise RetryDownload(err)
- except socket.error as err:
- if err.errno != errno.ECONNRESET:
- # Connection reset is no problem, just retry
- raise
- raise RetryDownload(err)
-
- def download():
- data_len = ctx.data.info().get('Content-length', None)
-
- # Range HTTP header may be ignored/unsupported by a webserver
- # (e.g. extractor/scivee.py, extractor/bambuser.py).
- # However, for a test we still would like to download just a piece of a file.
- # To achieve this we limit data_len to _TEST_FILE_SIZE and manually control
- # block size when downloading a file.
- if is_test and (data_len is None or int(data_len) > self._TEST_FILE_SIZE):
- data_len = self._TEST_FILE_SIZE
-
- if data_len is not None:
- data_len = int(data_len) + ctx.resume_len
- min_data_len = self.params.get('min_filesize')
- max_data_len = self.params.get('max_filesize')
- if min_data_len is not None and data_len < min_data_len:
- self.to_screen('\r[download] File is smaller than min-filesize (%s bytes < %s bytes). Aborting.' % (data_len, min_data_len))
- return False
- if max_data_len is not None and data_len > max_data_len:
- self.to_screen('\r[download] File is larger than max-filesize (%s bytes > %s bytes). Aborting.' % (data_len, max_data_len))
- return False
-
- byte_counter = 0 + ctx.resume_len
- block_size = ctx.block_size
- start = time.time()
-
- # measure time over whole while-loop, so slow_down() and best_block_size() work together properly
- now = None # needed for slow_down() in the first loop run
- before = start # start measuring
-
- def retry(e):
- to_stdout = ctx.tmpfilename == '-'
- if not to_stdout:
- ctx.stream.close()
- ctx.stream = None
- ctx.resume_len = byte_counter if to_stdout else os.path.getsize(encodeFilename(ctx.tmpfilename))
- raise RetryDownload(e)
-
- while True:
- try:
- # Download and write
- data_block = ctx.data.read(block_size if not is_test else min(block_size, data_len - byte_counter))
- # socket.timeout is a subclass of socket.error but may not have
- # errno set
- except socket.timeout as e:
- retry(e)
- except socket.error as e:
- if e.errno not in (errno.ECONNRESET, errno.ETIMEDOUT):
- raise
- retry(e)
-
- byte_counter += len(data_block)
-
- # exit loop when download is finished
- if len(data_block) == 0:
- break
-
- # Open destination file just in time
- if ctx.stream is None:
- try:
- ctx.stream, ctx.tmpfilename = sanitize_open(
- ctx.tmpfilename, ctx.open_mode)
- assert ctx.stream is not None
- ctx.filename = self.undo_temp_name(ctx.tmpfilename)
- self.report_destination(ctx.filename)
- except (OSError, IOError) as err:
- self.report_error('unable to open for writing: %s' % str(err))
- return False
-
- if self.params.get('xattr_set_filesize', False) and data_len is not None:
- try:
- write_xattr(ctx.tmpfilename, 'user.ytdl.filesize', str(data_len).encode('utf-8'))
- except (XAttrUnavailableError, XAttrMetadataError) as err:
- self.report_error('unable to set filesize xattr: %s' % str(err))
-
- try:
- ctx.stream.write(data_block)
- except (IOError, OSError) as err:
- self.to_stderr('\n')
- self.report_error('unable to write data: %s' % str(err))
- return False
-
- # Apply rate limit
- self.slow_down(start, now, byte_counter - ctx.resume_len)
-
- # end measuring of one loop run
- now = time.time()
- after = now
-
- # Adjust block size
- if not self.params.get('noresizebuffer', False):
- block_size = self.best_block_size(after - before, len(data_block))
-
- before = after
-
- # Progress message
- speed = self.calc_speed(start, now, byte_counter - ctx.resume_len)
- if ctx.data_len is None:
- eta = None
- else:
- eta = self.calc_eta(start, time.time(), ctx.data_len - ctx.resume_len, byte_counter - ctx.resume_len)
-
- self._hook_progress({
- 'status': 'downloading',
- 'downloaded_bytes': byte_counter,
- 'total_bytes': ctx.data_len,
- 'tmpfilename': ctx.tmpfilename,
- 'filename': ctx.filename,
- 'eta': eta,
- 'speed': speed,
- 'elapsed': now - ctx.start_time,
- })
-
- if is_test and byte_counter == data_len:
- break
-
- if not is_test and ctx.chunk_size and ctx.data_len is not None and byte_counter < ctx.data_len:
- ctx.resume_len = byte_counter
- # ctx.block_size = block_size
- raise NextFragment()
-
- if ctx.stream is None:
- self.to_stderr('\n')
- self.report_error('Did not get any data blocks')
- return False
- if ctx.tmpfilename != '-':
- ctx.stream.close()
-
- if data_len is not None and byte_counter != data_len:
- err = ContentTooShortError(byte_counter, int(data_len))
- if count <= retries:
- retry(err)
- raise err
-
- self.try_rename(ctx.tmpfilename, ctx.filename)
-
- # Update file modification time
- if self.params.get('updatetime', True):
- info_dict['filetime'] = self.try_utime(ctx.filename, ctx.data.info().get('last-modified', None))
-
- self._hook_progress({
- 'downloaded_bytes': byte_counter,
- 'total_bytes': byte_counter,
- 'filename': ctx.filename,
- 'status': 'finished',
- 'elapsed': time.time() - ctx.start_time,
- })
-
- return True
-
- while count <= retries:
- try:
- establish_connection()
- return download()
- except RetryDownload as e:
- count += 1
- if count <= retries:
- self.report_retry(e.source_error, count, retries)
- continue
- except NextFragment:
- continue
- except SucceedDownload:
- return True
-
- self.report_error('giving up after %s retries' % retries)
- return False
diff --git a/youtube_dl/downloader/ism.py b/youtube_dl/downloader/ism.py
deleted file mode 100644
index 063fcf4..0000000
--- a/youtube_dl/downloader/ism.py
+++ /dev/null
@@ -1,259 +0,0 @@
-from __future__ import unicode_literals
-
-import time
-import binascii
-import io
-
-from .fragment import FragmentFD
-from ..compat import (
- compat_Struct,
- compat_urllib_error,
-)
-
-
-u8 = compat_Struct('>B')
-u88 = compat_Struct('>Bx')
-u16 = compat_Struct('>H')
-u1616 = compat_Struct('>Hxx')
-u32 = compat_Struct('>I')
-u64 = compat_Struct('>Q')
-
-s88 = compat_Struct('>bx')
-s16 = compat_Struct('>h')
-s1616 = compat_Struct('>hxx')
-s32 = compat_Struct('>i')
-
-unity_matrix = (s32.pack(0x10000) + s32.pack(0) * 3) * 2 + s32.pack(0x40000000)
-
-TRACK_ENABLED = 0x1
-TRACK_IN_MOVIE = 0x2
-TRACK_IN_PREVIEW = 0x4
-
-SELF_CONTAINED = 0x1
-
-
-def box(box_type, payload):
- return u32.pack(8 + len(payload)) + box_type + payload
-
-
-def full_box(box_type, version, flags, payload):
- return box(box_type, u8.pack(version) + u32.pack(flags)[1:] + payload)
-
-
-def write_piff_header(stream, params):
- track_id = params['track_id']
- fourcc = params['fourcc']
- duration = params['duration']
- timescale = params.get('timescale', 10000000)
- language = params.get('language', 'und')
- height = params.get('height', 0)
- width = params.get('width', 0)
- is_audio = width == 0 and height == 0
- creation_time = modification_time = int(time.time())
-
- ftyp_payload = b'isml' # major brand
- ftyp_payload += u32.pack(1) # minor version
- ftyp_payload += b'piff' + b'iso2' # compatible brands
- stream.write(box(b'ftyp', ftyp_payload)) # File Type Box
-
- mvhd_payload = u64.pack(creation_time)
- mvhd_payload += u64.pack(modification_time)
- mvhd_payload += u32.pack(timescale)
- mvhd_payload += u64.pack(duration)
- mvhd_payload += s1616.pack(1) # rate
- mvhd_payload += s88.pack(1) # volume
- mvhd_payload += u16.pack(0) # reserved
- mvhd_payload += u32.pack(0) * 2 # reserved
- mvhd_payload += unity_matrix
- mvhd_payload += u32.pack(0) * 6 # pre defined
- mvhd_payload += u32.pack(0xffffffff) # next track id
- moov_payload = full_box(b'mvhd', 1, 0, mvhd_payload) # Movie Header Box
-
- tkhd_payload = u64.pack(creation_time)
- tkhd_payload += u64.pack(modification_time)
- tkhd_payload += u32.pack(track_id) # track id
- tkhd_payload += u32.pack(0) # reserved
- tkhd_payload += u64.pack(duration)
- tkhd_payload += u32.pack(0) * 2 # reserved
- tkhd_payload += s16.pack(0) # layer
- tkhd_payload += s16.pack(0) # alternate group
- tkhd_payload += s88.pack(1 if is_audio else 0) # volume
- tkhd_payload += u16.pack(0) # reserved
- tkhd_payload += unity_matrix
- tkhd_payload += u1616.pack(width)
- tkhd_payload += u1616.pack(height)
- trak_payload = full_box(b'tkhd', 1, TRACK_ENABLED | TRACK_IN_MOVIE | TRACK_IN_PREVIEW, tkhd_payload) # Track Header Box
-
- mdhd_payload = u64.pack(creation_time)
- mdhd_payload += u64.pack(modification_time)
- mdhd_payload += u32.pack(timescale)
- mdhd_payload += u64.pack(duration)
- mdhd_payload += u16.pack(((ord(language[0]) - 0x60) << 10) | ((ord(language[1]) - 0x60) << 5) | (ord(language[2]) - 0x60))
- mdhd_payload += u16.pack(0) # pre defined
- mdia_payload = full_box(b'mdhd', 1, 0, mdhd_payload) # Media Header Box
-
- hdlr_payload = u32.pack(0) # pre defined
- hdlr_payload += b'soun' if is_audio else b'vide' # handler type
- hdlr_payload += u32.pack(0) * 3 # reserved
- hdlr_payload += (b'Sound' if is_audio else b'Video') + b'Handler\0' # name
- mdia_payload += full_box(b'hdlr', 0, 0, hdlr_payload) # Handler Reference Box
-
- if is_audio:
- smhd_payload = s88.pack(0) # balance
- smhd_payload += u16.pack(0) # reserved
- media_header_box = full_box(b'smhd', 0, 0, smhd_payload) # Sound Media Header
- else:
- vmhd_payload = u16.pack(0) # graphics mode
- vmhd_payload += u16.pack(0) * 3 # opcolor
- media_header_box = full_box(b'vmhd', 0, 1, vmhd_payload) # Video Media Header
- minf_payload = media_header_box
-
- dref_payload = u32.pack(1) # entry count
- dref_payload += full_box(b'url ', 0, SELF_CONTAINED, b'') # Data Entry URL Box
- dinf_payload = full_box(b'dref', 0, 0, dref_payload) # Data Reference Box
- minf_payload += box(b'dinf', dinf_payload) # Data Information Box
-
- stsd_payload = u32.pack(1) # entry count
-
- sample_entry_payload = u8.pack(0) * 6 # reserved
- sample_entry_payload += u16.pack(1) # data reference index
- if is_audio:
- sample_entry_payload += u32.pack(0) * 2 # reserved
- sample_entry_payload += u16.pack(params.get('channels', 2))
- sample_entry_payload += u16.pack(params.get('bits_per_sample', 16))
- sample_entry_payload += u16.pack(0) # pre defined
- sample_entry_payload += u16.pack(0) # reserved
- sample_entry_payload += u1616.pack(params['sampling_rate'])
-
- if fourcc == 'AACL':
- sample_entry_box = box(b'mp4a', sample_entry_payload)
- else:
- sample_entry_payload += u16.pack(0) # pre defined
- sample_entry_payload += u16.pack(0) # reserved
- sample_entry_payload += u32.pack(0) * 3 # pre defined
- sample_entry_payload += u16.pack(width)
- sample_entry_payload += u16.pack(height)
- sample_entry_payload += u1616.pack(0x48) # horiz resolution 72 dpi
- sample_entry_payload += u1616.pack(0x48) # vert resolution 72 dpi
- sample_entry_payload += u32.pack(0) # reserved
- sample_entry_payload += u16.pack(1) # frame count
- sample_entry_payload += u8.pack(0) * 32 # compressor name
- sample_entry_payload += u16.pack(0x18) # depth
- sample_entry_payload += s16.pack(-1) # pre defined
-
- codec_private_data = binascii.unhexlify(params['codec_private_data'].encode('utf-8'))
- if fourcc in ('H264', 'AVC1'):
- sps, pps = codec_private_data.split(u32.pack(1))[1:]
- avcc_payload = u8.pack(1) # configuration version
- avcc_payload += sps[1:4] # avc profile indication + profile compatibility + avc level indication
- avcc_payload += u8.pack(0xfc | (params.get('nal_unit_length_field', 4) - 1)) # complete represenation (1) + reserved (11111) + length size minus one
- avcc_payload += u8.pack(1) # reserved (0) + number of sps (0000001)
- avcc_payload += u16.pack(len(sps))
- avcc_payload += sps
- avcc_payload += u8.pack(1) # number of pps
- avcc_payload += u16.pack(len(pps))
- avcc_payload += pps
- sample_entry_payload += box(b'avcC', avcc_payload) # AVC Decoder Configuration Record
- sample_entry_box = box(b'avc1', sample_entry_payload) # AVC Simple Entry
- stsd_payload += sample_entry_box
-
- stbl_payload = full_box(b'stsd', 0, 0, stsd_payload) # Sample Description Box
-
- stts_payload = u32.pack(0) # entry count
- stbl_payload += full_box(b'stts', 0, 0, stts_payload) # Decoding Time to Sample Box
-
- stsc_payload = u32.pack(0) # entry count
- stbl_payload += full_box(b'stsc', 0, 0, stsc_payload) # Sample To Chunk Box
-
- stco_payload = u32.pack(0) # entry count
- stbl_payload += full_box(b'stco', 0, 0, stco_payload) # Chunk Offset Box
-
- minf_payload += box(b'stbl', stbl_payload) # Sample Table Box
-
- mdia_payload += box(b'minf', minf_payload) # Media Information Box
-
- trak_payload += box(b'mdia', mdia_payload) # Media Box
-
- moov_payload += box(b'trak', trak_payload) # Track Box
-
- mehd_payload = u64.pack(duration)
- mvex_payload = full_box(b'mehd', 1, 0, mehd_payload) # Movie Extends Header Box
-
- trex_payload = u32.pack(track_id) # track id
- trex_payload += u32.pack(1) # default sample description index
- trex_payload += u32.pack(0) # default sample duration
- trex_payload += u32.pack(0) # default sample size
- trex_payload += u32.pack(0) # default sample flags
- mvex_payload += full_box(b'trex', 0, 0, trex_payload) # Track Extends Box
-
- moov_payload += box(b'mvex', mvex_payload) # Movie Extends Box
- stream.write(box(b'moov', moov_payload)) # Movie Box
-
-
-def extract_box_data(data, box_sequence):
- data_reader = io.BytesIO(data)
- while True:
- box_size = u32.unpack(data_reader.read(4))[0]
- box_type = data_reader.read(4)
- if box_type == box_sequence[0]:
- box_data = data_reader.read(box_size - 8)
- if len(box_sequence) == 1:
- return box_data
- return extract_box_data(box_data, box_sequence[1:])
- data_reader.seek(box_size - 8, 1)
-
-
-class IsmFD(FragmentFD):
- """
- Download segments in a ISM manifest
- """
-
- FD_NAME = 'ism'
-
- def real_download(self, filename, info_dict):
- segments = info_dict['fragments'][:1] if self.params.get(
- 'test', False) else info_dict['fragments']
-
- ctx = {
- 'filename': filename,
- 'total_frags': len(segments),
- }
-
- self._prepare_and_start_frag_download(ctx)
-
- fragment_retries = self.params.get('fragment_retries', 0)
- skip_unavailable_fragments = self.params.get('skip_unavailable_fragments', True)
-
- track_written = False
- frag_index = 0
- for i, segment in enumerate(segments):
- frag_index += 1
- if frag_index <= ctx['fragment_index']:
- continue
- count = 0
- while count <= fragment_retries:
- try:
- success, frag_content = self._download_fragment(ctx, segment['url'], info_dict)
- if not success:
- return False
- if not track_written:
- tfhd_data = extract_box_data(frag_content, [b'moof', b'traf', b'tfhd'])
- info_dict['_download_params']['track_id'] = u32.unpack(tfhd_data[4:8])[0]
- write_piff_header(ctx['dest_stream'], info_dict['_download_params'])
- track_written = True
- self._append_fragment(ctx, frag_content)
- break
- except compat_urllib_error.HTTPError as err:
- count += 1
- if count <= fragment_retries:
- self.report_retry_fragment(err, frag_index, count, fragment_retries)
- if count > fragment_retries:
- if skip_unavailable_fragments:
- self.report_skip_fragment(frag_index)
- continue
- self.report_error('giving up after %s fragment retries' % fragment_retries)
- return False
-
- self._finish_frag_download(ctx)
-
- return True
diff --git a/youtube_dl/downloader/rtmp.py b/youtube_dl/downloader/rtmp.py
deleted file mode 100644
index fbb7f51..0000000
--- a/youtube_dl/downloader/rtmp.py
+++ /dev/null
@@ -1,214 +0,0 @@
-from __future__ import unicode_literals
-
-import os
-import re
-import subprocess
-import time
-
-from .common import FileDownloader
-from ..compat import compat_str
-from ..utils import (
- check_executable,
- encodeFilename,
- encodeArgument,
- get_exe_version,
-)
-
-
-def rtmpdump_version():
- return get_exe_version(
- 'rtmpdump', ['--help'], r'(?i)RTMPDump\s*v?([0-9a-zA-Z._-]+)')
-
-
-class RtmpFD(FileDownloader):
- def real_download(self, filename, info_dict):
- def run_rtmpdump(args):
- start = time.time()
- resume_percent = None
- resume_downloaded_data_len = None
- proc = subprocess.Popen(args, stderr=subprocess.PIPE)
- cursor_in_new_line = True
- proc_stderr_closed = False
- try:
- while not proc_stderr_closed:
- # read line from stderr
- line = ''
- while True:
- char = proc.stderr.read(1)
- if not char:
- proc_stderr_closed = True
- break
- if char in [b'\r', b'\n']:
- break
- line += char.decode('ascii', 'replace')
- if not line:
- # proc_stderr_closed is True
- continue
- mobj = re.search(r'([0-9]+\.[0-9]{3}) kB / [0-9]+\.[0-9]{2} sec \(([0-9]{1,2}\.[0-9])%\)', line)
- if mobj:
- downloaded_data_len = int(float(mobj.group(1)) * 1024)
- percent = float(mobj.group(2))
- if not resume_percent:
- resume_percent = percent
- resume_downloaded_data_len = downloaded_data_len
- time_now = time.time()
- eta = self.calc_eta(start, time_now, 100 - resume_percent, percent - resume_percent)
- speed = self.calc_speed(start, time_now, downloaded_data_len - resume_downloaded_data_len)
- data_len = None
- if percent > 0:
- data_len = int(downloaded_data_len * 100 / percent)
- self._hook_progress({
- 'status': 'downloading',
- 'downloaded_bytes': downloaded_data_len,
- 'total_bytes_estimate': data_len,
- 'tmpfilename': tmpfilename,
- 'filename': filename,
- 'eta': eta,
- 'elapsed': time_now - start,
- 'speed': speed,
- })
- cursor_in_new_line = False
- else:
- # no percent for live streams
- mobj = re.search(r'([0-9]+\.[0-9]{3}) kB / [0-9]+\.[0-9]{2} sec', line)
- if mobj:
- downloaded_data_len = int(float(mobj.group(1)) * 1024)
- time_now = time.time()
- speed = self.calc_speed(start, time_now, downloaded_data_len)
- self._hook_progress({
- 'downloaded_bytes': downloaded_data_len,
- 'tmpfilename': tmpfilename,
- 'filename': filename,
- 'status': 'downloading',
- 'elapsed': time_now - start,
- 'speed': speed,
- })
- cursor_in_new_line = False
- elif self.params.get('verbose', False):
- if not cursor_in_new_line:
- self.to_screen('')
- cursor_in_new_line = True
- self.to_screen('[rtmpdump] ' + line)
- finally:
- proc.wait()
- if not cursor_in_new_line:
- self.to_screen('')
- return proc.returncode
-
- url = info_dict['url']
- player_url = info_dict.get('player_url')
- page_url = info_dict.get('page_url')
- app = info_dict.get('app')
- play_path = info_dict.get('play_path')
- tc_url = info_dict.get('tc_url')
- flash_version = info_dict.get('flash_version')
- live = info_dict.get('rtmp_live', False)
- conn = info_dict.get('rtmp_conn')
- protocol = info_dict.get('rtmp_protocol')
- real_time = info_dict.get('rtmp_real_time', False)
- no_resume = info_dict.get('no_resume', False)
- continue_dl = self.params.get('continuedl', True)
-
- self.report_destination(filename)
- tmpfilename = self.temp_name(filename)
- test = self.params.get('test', False)
-
- # Check for rtmpdump first
- if not check_executable('rtmpdump', ['-h']):
- self.report_error('RTMP download detected but "rtmpdump" could not be run. Please install it.')
- return False
-
- # Download using rtmpdump. rtmpdump returns exit code 2 when
- # the connection was interrupted and resuming appears to be
- # possible. This is part of rtmpdump's normal usage, AFAIK.
- basic_args = [
- 'rtmpdump', '--verbose', '-r', url,
- '-o', tmpfilename]
- if player_url is not None:
- basic_args += ['--swfVfy', player_url]
- if page_url is not None:
- basic_args += ['--pageUrl', page_url]
- if app is not None:
- basic_args += ['--app', app]
- if play_path is not None:
- basic_args += ['--playpath', play_path]
- if tc_url is not None:
- basic_args += ['--tcUrl', tc_url]
- if test:
- basic_args += ['--stop', '1']
- if flash_version is not None:
- basic_args += ['--flashVer', flash_version]
- if live:
- basic_args += ['--live']
- if isinstance(conn, list):
- for entry in conn:
- basic_args += ['--conn', entry]
- elif isinstance(conn, compat_str):
- basic_args += ['--conn', conn]
- if protocol is not None:
- basic_args += ['--protocol', protocol]
- if real_time:
- basic_args += ['--realtime']
-
- args = basic_args
- if not no_resume and continue_dl and not live:
- args += ['--resume']
- if not live and continue_dl:
- args += ['--skip', '1']
-
- args = [encodeArgument(a) for a in args]
-
- self._debug_cmd(args, exe='rtmpdump')
-
- RD_SUCCESS = 0
- RD_FAILED = 1
- RD_INCOMPLETE = 2
- RD_NO_CONNECT = 3
-
- started = time.time()
-
- try:
- retval = run_rtmpdump(args)
- except KeyboardInterrupt:
- if not info_dict.get('is_live'):
- raise
- retval = RD_SUCCESS
- self.to_screen('\n[rtmpdump] Interrupted by user')
-
- if retval == RD_NO_CONNECT:
- self.report_error('[rtmpdump] Could not connect to RTMP server.')
- return False
-
- while retval in (RD_INCOMPLETE, RD_FAILED) and not test and not live:
- prevsize = os.path.getsize(encodeFilename(tmpfilename))
- self.to_screen('[rtmpdump] Downloaded %s bytes' % prevsize)
- time.sleep(5.0) # This seems to be needed
- args = basic_args + ['--resume']
- if retval == RD_FAILED:
- args += ['--skip', '1']
- args = [encodeArgument(a) for a in args]
- retval = run_rtmpdump(args)
- cursize = os.path.getsize(encodeFilename(tmpfilename))
- if prevsize == cursize and retval == RD_FAILED:
- break
- # Some rtmp streams seem abort after ~ 99.8%. Don't complain for those
- if prevsize == cursize and retval == RD_INCOMPLETE and cursize > 1024:
- self.to_screen('[rtmpdump] Could not download the whole video. This can happen for some advertisements.')
- retval = RD_SUCCESS
- break
- if retval == RD_SUCCESS or (test and retval == RD_INCOMPLETE):
- fsize = os.path.getsize(encodeFilename(tmpfilename))
- self.to_screen('[rtmpdump] Downloaded %s bytes' % fsize)
- self.try_rename(tmpfilename, filename)
- self._hook_progress({
- 'downloaded_bytes': fsize,
- 'total_bytes': fsize,
- 'filename': filename,
- 'status': 'finished',
- 'elapsed': time.time() - started,
- })
- return True
- else:
- self.to_stderr('\n')
- self.report_error('rtmpdump exited with code %d' % retval)
- return False
diff --git a/youtube_dl/downloader/rtsp.py b/youtube_dl/downloader/rtsp.py
deleted file mode 100644
index 939358b..0000000
--- a/youtube_dl/downloader/rtsp.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from __future__ import unicode_literals
-
-import os
-import subprocess
-
-from .common import FileDownloader
-from ..utils import (
- check_executable,
- encodeFilename,
-)
-
-
-class RtspFD(FileDownloader):
- def real_download(self, filename, info_dict):
- url = info_dict['url']
- self.report_destination(filename)
- tmpfilename = self.temp_name(filename)
-
- if check_executable('mplayer', ['-h']):
- args = [
- 'mplayer', '-really-quiet', '-vo', 'null', '-vc', 'dummy',
- '-dumpstream', '-dumpfile', tmpfilename, url]
- elif check_executable('mpv', ['-h']):
- args = [
- 'mpv', '-really-quiet', '--vo=null', '--stream-dump=' + tmpfilename, url]
- else:
- self.report_error('MMS or RTSP download detected but neither "mplayer" nor "mpv" could be run. Please install any.')
- return False
-
- self._debug_cmd(args)
-
- retval = subprocess.call(args)
- if retval == 0:
- fsize = os.path.getsize(encodeFilename(tmpfilename))
- self.to_screen('\r[%s] %s bytes' % (args[0], fsize))
- self.try_rename(tmpfilename, filename)
- self._hook_progress({
- 'downloaded_bytes': fsize,
- 'total_bytes': fsize,
- 'filename': filename,
- 'status': 'finished',
- })
- return True
- else:
- self.to_stderr('\n')
- self.report_error('%s exited with code %d' % (args[0], retval))
- return False
diff --git a/youtube_dl/extractor/__init__.py b/youtube_dl/extractor/__init__.py
deleted file mode 100644
index d5a4418..0000000
--- a/youtube_dl/extractor/__init__.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from __future__ import unicode_literals
-
-try:
- from .lazy_extractors import *
- from .lazy_extractors import _ALL_CLASSES
- _LAZY_LOADER = True
-except ImportError:
- _LAZY_LOADER = False
- from .extractors import *
-
- _ALL_CLASSES = [
- klass
- for name, klass in globals().items()
- if name.endswith('IE') and name != 'GenericIE'
- ]
- #_ALL_CLASSES.append(GenericIE)
-
-
-def gen_extractor_classes():
- """ Return a list of supported extractors.
- The order does matter; the first extractor matched is the one handling the URL.
- """
- return _ALL_CLASSES
-
-
-def gen_extractors():
- """ Return a list of an instance of every supported extractor.
- The order does matter; the first extractor matched is the one handling the URL.
- """
- return [klass() for klass in gen_extractor_classes()]
-
-
-def list_extractors(age_limit):
- """
- Return a list of extractors that are suitable for the given age,
- sorted by extractor ID.
- """
-
- return sorted(
- filter(lambda ie: ie.is_suitable(age_limit), gen_extractors()),
- key=lambda ie: ie.IE_NAME.lower())
-
-
-def get_info_extractor(ie_name):
- """Returns the info extractor class with the given ie_name"""
- return globals()[ie_name + 'IE']
diff --git a/youtube_dl/extractor/adobepass.py b/youtube_dl/extractor/adobepass.py
deleted file mode 100644
index b83b51e..0000000
--- a/youtube_dl/extractor/adobepass.py
+++ /dev/null
@@ -1,1567 +0,0 @@
-# coding: utf-8
-from __future__ import unicode_literals
-
-import re
-import time
-import xml.etree.ElementTree as etree
-
-from .common import InfoExtractor
-from ..compat import (
- compat_kwargs,
- compat_urlparse,
-)
-from ..utils import (
- unescapeHTML,
- urlencode_postdata,
- unified_timestamp,
- ExtractorError,
- NO_DEFAULT,
-)
-
-
-MSO_INFO = {
- 'DTV': {
- 'name': 'DIRECTV',
- 'username_field': 'username',
- 'password_field': 'password',
- },
- 'ATTOTT': {
- 'name': 'DIRECTV NOW',
- 'username_field': 'email',
- 'password_field': 'loginpassword',
- },
- 'Rogers': {
- 'name': 'Rogers',
- 'username_field': 'UserName',
- 'password_field': 'UserPassword',
- },
- 'Comcast_SSO': {
- 'name': 'Comcast XFINITY',
- 'username_field': 'user',
- 'password_field': 'passwd',
- },
- 'TWC': {
- 'name': 'Time Warner Cable | Spectrum',
- 'username_field': 'Ecom_User_ID',
- 'password_field': 'Ecom_Password',
- },
- 'Brighthouse': {
- 'name': 'Bright House Networks | Spectrum',
- 'username_field': 'j_username',
- 'password_field': 'j_password',
- },
- 'Charter_Direct': {
- 'name': 'Charter Spectrum',
- 'username_field': 'IDToken1',
- 'password_field': 'IDToken2',
- },
- 'Verizon': {
- 'name': 'Verizon FiOS',
- 'username_field': 'IDToken1',
- 'password_field': 'IDToken2',
- },
- 'thr030': {
- 'name': '3 Rivers Communications'
- },
- 'com140': {
- 'name': 'Access Montana'
- },
- 'acecommunications': {
- 'name': 'AcenTek'
- },
- 'acm010': {
- 'name': 'Acme Communications'
- },
- 'ada020': {
- 'name': 'Adams Cable Service'
- },
- 'alb020': {
- 'name': 'Albany Mutual Telephone'
- },
- 'algona': {
- 'name': 'Algona Municipal Utilities'
- },
- 'allwest': {
- 'name': 'All West Communications'
- },
- 'all025': {
- 'name': 'Allen\'s Communications'
- },
- 'spl010': {
- 'name': 'Alliance Communications'
- },
- 'all070': {
- 'name': 'ALLO Communications'
- },
- 'alpine': {
- 'name': 'Alpine Communications'
- },
- 'hun015': {
- 'name': 'American Broadband'
- },
- 'nwc010': {
- 'name': 'American Broadband Missouri'
- },
- 'com130-02': {
- 'name': 'American Community Networks'
- },
- 'com130-01': {
- 'name': 'American Warrior Networks'
- },
- 'tom020': {
- 'name': 'Amherst Telephone/Tomorrow Valley'
- },
- 'tvc020': {
- 'name': 'Andycable'
- },
- 'arkwest': {
- 'name': 'Arkwest Communications'
- },
- 'art030': {
- 'name': 'Arthur Mutual Telephone Company'
- },
- 'arvig': {
- 'name': 'Arvig'
- },
- 'nttcash010': {
- 'name': 'Ashland Home Net'
- },
- 'astound': {
- 'name': 'Astound (now Wave)'
- },
- 'dix030': {
- 'name': 'ATC Broadband'
- },
- 'ara010': {
- 'name': 'ATC Communications'
- },
- 'she030-02': {
- 'name': 'Ayersville Communications'
- },
- 'baldwin': {
- 'name': 'Baldwin Lightstream'
- },
- 'bal040': {
- 'name': 'Ballard TV'
- },
- 'cit025': {
- 'name': 'Bardstown Cable TV'
- },
- 'bay030': {
- 'name': 'Bay Country Communications'
- },
- 'tel095': {
- 'name': 'Beaver Creek Cooperative Telephone'
- },
- 'bea020': {
- 'name': 'Beaver Valley Cable'
- },
- 'bee010': {
- 'name': 'Bee Line Cable'
- },
- 'wir030': {
- 'name': 'Beehive Broadband'
- },
- 'bra020': {
- 'name': 'BELD'
- },
- 'bel020': {
- 'name': 'Bellevue Municipal Cable'
- },
- 'vol040-01': {
- 'name': 'Ben Lomand Connect / BLTV'
- },
- 'bev010': {
- 'name': 'BEVCOMM'
- },
- 'big020': {
- 'name': 'Big Sandy Broadband'
- },
- 'ble020': {
- 'name': 'Bledsoe Telephone Cooperative'
- },
- 'bvt010': {
- 'name': 'Blue Valley Tele-Communications'
- },
- 'bra050': {
- 'name': 'Brandenburg Telephone Co.'
- },
- 'bte010': {
- 'name': 'Bristol Tennessee Essential Services'
- },
- 'annearundel': {
- 'name': 'Broadstripe'
- },
- 'btc010': {
- 'name': 'BTC Communications'
- },
- 'btc040': {
- 'name': 'BTC Vision - Nahunta'
- },
- 'bul010': {
- 'name': 'Bulloch Telephone Cooperative'
- },
- 'but010': {
- 'name': 'Butler-Bremer Communications'
- },
- 'tel160-csp': {
- 'name': 'C Spire SNAP'
- },
- 'csicable': {
- 'name': 'Cable Services Inc.'
- },
- 'cableamerica': {
- 'name': 'CableAmerica'
- },
- 'cab038': {
- 'name': 'CableSouth Media 3'
- },
- 'weh010-camtel': {
- 'name': 'Cam-Tel Company'
- },
- 'car030': {
- 'name': 'Cameron Communications'
- },
- 'canbytel': {
- 'name': 'Canby Telcom'
- },
- 'crt020': {
- 'name': 'CapRock Tv'
- },
- 'car050': {
- 'name': 'Carnegie Cable'
- },
- 'cas': {
- 'name': 'CAS Cable'
- },
- 'casscomm': {
- 'name': 'CASSCOMM'
- },
- 'mid180-02': {
- 'name': 'Catalina Broadband Solutions'
- },
- 'cccomm': {
- 'name': 'CC Communications'
- },
- 'nttccde010': {
- 'name': 'CDE Lightband'
- },
- 'cfunet': {
- 'name': 'Cedar Falls Utilities'
- },
- 'dem010-01': {
- 'name': 'Celect-Bloomer Telephone Area'
- },
- 'dem010-02': {
- 'name': 'Celect-Bruce Telephone Area'
- },
- 'dem010-03': {
- 'name': 'Celect-Citizens Connected Area'
- },
- 'dem010-04': {
- 'name': 'Celect-Elmwood/Spring Valley Area'
- },
- 'dem010-06': {
- 'name': 'Celect-Mosaic Telecom'
- },
- 'dem010-05': {
- 'name': 'Celect-West WI Telephone Area'
- },
- 'net010-02': {
- 'name': 'Cellcom/Nsight Telservices'
- },
- 'cen100': {
- 'name': 'CentraCom'
- },
- 'nttccst010': {
- 'name': 'Central Scott / CSTV'
- },
- 'cha035': {
- 'name': 'Chaparral CableVision'
- },
- 'cha050': {
- 'name': 'Chariton Valley Communication Corporation, Inc.'
- },
- 'cha060': {
- 'name': 'Chatmoss Cablevision'
- },
- 'nttcche010': {
- 'name': 'Cherokee Communications'
- },
- 'che050': {
- 'name': 'Chesapeake Bay Communications'
- },
- 'cimtel': {
- 'name': 'Cim-Tel Cable, LLC.'
- },
- 'cit180': {
- 'name': 'Citizens Cablevision - Floyd, VA'
- },
- 'cit210': {
- 'name': 'Citizens Cablevision, Inc.'
- },
- 'cit040': {
- 'name': 'Citizens Fiber'
- },
- 'cit250': {
- 'name': 'Citizens Mutual'
- },
- 'war040': {
- 'name': 'Citizens Telephone Corporation'
- },
- 'wat025': {
- 'name': 'City Of Monroe'
- },
- 'wadsworth': {
- 'name': 'CityLink'
- },
- 'nor100': {
- 'name': 'CL Tel'
- },
- 'cla010': {
- 'name': 'Clarence Telephone and Cedar Communications'
- },
- 'ser060': {
- 'name': 'Clear Choice Communications'
- },
- 'tac020': {
- 'name': 'Click! Cable TV'
- },
- 'war020': {
- 'name': 'CLICK1.NET'
- },
- 'cml010': {
- 'name': 'CML Telephone Cooperative Association'
- },
- 'cns': {
- 'name': 'CNS'
- },
- 'com160': {
- 'name': 'Co-Mo Connect'
- },
- 'coa020': {
- 'name': 'Coast Communications'
- },
- 'coa030': {
- 'name': 'Coaxial Cable TV'
- },
- 'mid055': {
- 'name': 'Cobalt TV (Mid-State Community TV)'
- },
- 'col070': {
- 'name': 'Columbia Power & Water Systems'
- },
- 'col080': {
- 'name': 'Columbus Telephone'
- },
- 'nor105': {
- 'name': 'Communications 1 Cablevision, Inc.'
- },
- 'com150': {
- 'name': 'Community Cable & Broadband'
- },
- 'com020': {
- 'name': 'Community Communications Company'
- },
- 'coy010': {
- 'name': 'commZoom'
- },
- 'com025': {
- 'name': 'Complete Communication Services'
- },
- 'cat020': {
- 'name': 'Comporium'
- },
- 'com071': {
- 'name': 'ComSouth Telesys'
- },
- 'consolidatedcable': {
- 'name': 'Consolidated'
- },
- 'conwaycorp': {
- 'name': 'Conway Corporation'
- },
- 'coo050': {
- 'name': 'Coon Valley Telecommunications Inc'
- },
- 'coo080': {
- 'name': 'Cooperative Telephone Company'
- },
- 'cpt010': {
- 'name': 'CP-TEL'
- },
- 'cra010': {
- 'name': 'Craw-Kan Telephone'
- },
- 'crestview': {
- 'name': 'Crestview Cable Communications'
- },
- 'cross': {
- 'name': 'Cross TV'
- },
- 'cro030': {
- 'name': 'Crosslake Communications'
- },
- 'ctc040': {
- 'name': 'CTC - Brainerd MN'
- },
- 'phe030': {
- 'name': 'CTV-Beam - East Alabama'
- },
- 'cun010': {
- 'name': 'Cunningham Telephone & Cable'
- },
- 'dpc010': {
- 'name': 'D & P Communications'
- },
- 'dak030': {
- 'name': 'Dakota Central Telecommunications'
- },
- 'nttcdel010': {
- 'name': 'Delcambre Telephone LLC'
- },
- 'tel160-del': {
- 'name': 'Delta Telephone Company'
- },
- 'sal040': {
- 'name': 'DiamondNet'
- },
- 'ind060-dc': {
- 'name': 'Direct Communications'
- },
- 'doy010': {
- 'name': 'Doylestown Cable TV'
- },
- 'dic010': {
- 'name': 'DRN'
- },
- 'dtc020': {
- 'name': 'DTC'
- },
- 'dtc010': {
- 'name': 'DTC Cable (Delhi)'
- },
- 'dum010': {
- 'name': 'Dumont Telephone Company'
- },
- 'dun010': {
- 'name': 'Dunkerton Telephone Cooperative'
- },
- 'cci010': {
- 'name': 'Duo County Telecom'
- },
- 'eagle': {
- 'name': 'Eagle Communications'
- },
- 'weh010-east': {
- 'name': 'East Arkansas Cable TV'
- },
- 'eatel': {
- 'name': 'EATEL Video, LLC'
- },
- 'ell010': {
- 'name': 'ECTA'
- },
- 'emerytelcom': {
- 'name': 'Emery Telcom Video LLC'
- },
- 'nor200': {
- 'name': 'Empire Access'
- },
- 'endeavor': {
- 'name': 'Endeavor Communications'
- },
- 'sun045': {
- 'name': 'Enhanced Telecommunications Corporation'
- },
- 'mid030': {
- 'name': 'enTouch'
- },
- 'epb020': {
- 'name': 'EPB Smartnet'
- },
- 'jea010': {
- 'name': 'EPlus Broadband'
- },
- 'com065': {
- 'name': 'ETC'
- },
- 'ete010': {
- 'name': 'Etex Communications'
- },
- 'fbc-tele': {
- 'name': 'F&B Communications'
- },
- 'fal010': {
- 'name': 'Falcon Broadband'
- },
- 'fam010': {
- 'name': 'FamilyView CableVision'
- },
- 'far020': {
- 'name': 'Farmers Mutual Telephone Company'
- },
- 'fay010': {
- 'name': 'Fayetteville Public Utilities'
- },
- 'sal060': {
- 'name': 'fibrant'
- },
- 'fid010': {
- 'name': 'Fidelity Communications'
- },
- 'for030': {
- 'name': 'FJ Communications'
- },
- 'fli020': {
- 'name': 'Flint River Communications'
- },
- 'far030': {
- 'name': 'FMT - Jesup'
- },
- 'foo010': {
- 'name': 'Foothills Communications'
- },
- 'for080': {
- 'name': 'Forsyth CableNet'
- },
- 'fbcomm': {
- 'name': 'Frankfort Plant Board'
- },
- 'tel160-fra': {
- 'name': 'Franklin Telephone Company'
- },
- 'nttcftc010': {
- 'name': 'FTC'
- },
- 'fullchannel': {
- 'name': 'Full Channel, Inc.'
- },
- 'gar040': {
- 'name': 'Gardonville Cooperative Telephone Association'
- },
- 'gbt010': {
- 'name': 'GBT Communications, Inc.'
- },
- 'tec010': {
- 'name': 'Genuine Telecom'
- },
- 'clr010': {
- 'name': 'Giant Communications'
- },
- 'gla010': {
- 'name': 'Glasgow EPB'
- },
- 'gle010': {
- 'name': 'Glenwood Telecommunications'
- },
- 'gra060': {
- 'name': 'GLW Broadband Inc.'
- },
- 'goldenwest': {
- 'name': 'Golden West Cablevision'
- },
- 'vis030': {
- 'name': 'Grantsburg Telcom'
- },
- 'gpcom': {
- 'name': 'Great Plains Communications'
- },
- 'gri010': {
- 'name': 'Gridley Cable Inc'
- },
- 'hbc010': {
- 'name': 'H&B Cable Services'
- },
- 'hae010': {
- 'name': 'Haefele TV Inc.'
- },
- 'htc010': {
- 'name': 'Halstad Telephone Company'
- },
- 'har005': {
- 'name': 'Harlan Municipal Utilities'
- },
- 'har020': {
- 'name': 'Hart Communications'
- },
- 'ced010': {
- 'name': 'Hartelco TV'
- },
- 'hea040': {
- 'name': 'Heart of Iowa Communications Cooperative'
- },
- 'htc020': {
- 'name': 'Hickory Telephone Company'
- },
- 'nttchig010': {
- 'name': 'Highland Communication Services'
- },
- 'hig030': {
- 'name': 'Highland Media'
- },
- 'spc010': {
- 'name': 'Hilliary Communications'
- },
- 'hin020': {
- 'name': 'Hinton CATV Co.'
- },
- 'hometel': {
- 'name': 'HomeTel Entertainment, Inc.'
- },
- 'hoodcanal': {
- 'name': 'Hood Canal Communications'
- },
- 'weh010-hope': {
- 'name': 'Hope - Prescott Cable TV'
- },
- 'horizoncable': {
- 'name': 'Horizon Cable TV, Inc.'
- },
- 'hor040': {
- 'name': 'Horizon Chillicothe Telephone'
- },
- 'htc030': {
- 'name': 'HTC Communications Co. - IL'
- },
- 'htccomm': {
- 'name': 'HTC Communications, Inc. - IA'
- },
- 'wal005': {
- 'name': 'Huxley Communications'
- },
- 'imon': {
- 'name': 'ImOn Communications'
- },
- 'ind040': {
- 'name': 'Independence Telecommunications'
- },
- 'rrc010': {
- 'name': 'Inland Networks'
- },
- 'stc020': {
- 'name': 'Innovative Cable TV St Croix'
- },
- 'car100': {
- 'name': 'Innovative Cable TV St Thomas-St John'
- },
- 'icc010': {
- 'name': 'Inside Connect Cable'
- },
- 'int100': {
- 'name': 'Integra Telecom'
- },
- 'int050': {
- 'name': 'Interstate Telecommunications Coop'
- },
- 'irv010': {
- 'name': 'Irvine Cable'
- },
- 'k2c010': {
- 'name': 'K2 Communications'
- },
- 'kal010': {
- 'name': 'Kalida Telephone Company, Inc.'
- },
- 'kal030': {
- 'name': 'Kalona Cooperative Telephone Company'
- },
- 'kmt010': {
- 'name': 'KMTelecom'
- },
- 'kpu010': {
- 'name': 'KPU Telecommunications'
- },
- 'kuh010': {
- 'name': 'Kuhn Communications, Inc.'
- },
- 'lak130': {
- 'name': 'Lakeland Communications'
- },
- 'lan010': {
- 'name': 'Langco'
- },
- 'lau020': {
- 'name': 'Laurel Highland Total Communications, Inc.'
- },
- 'leh010': {
- 'name': 'Lehigh Valley Cooperative Telephone'
- },
- 'bra010': {
- 'name': 'Limestone Cable/Bracken Cable'
- },
- 'loc020': {
- 'name': 'LISCO'
- },
- 'lit020': {
- 'name': 'Litestream'
- },
- 'tel140': {
- 'name': 'LivCom'
- },
- 'loc010': {
- 'name': 'LocalTel Communications'
- },
- 'weh010-longview': {
- 'name': 'Longview - Kilgore Cable TV'
- },
- 'lon030': {
- 'name': 'Lonsdale Video Ventures, LLC'
- },
- 'lns010': {
- 'name': 'Lost Nation-Elwood Telephone Co.'
- },
- 'nttclpc010': {
- 'name': 'LPC Connect'
- },
- 'lumos': {
- 'name': 'Lumos Networks'
- },
- 'madison': {
- 'name': 'Madison Communications'
- },
- 'mad030': {
- 'name': 'Madison County Cable Inc.'
- },
- 'nttcmah010': {
- 'name': 'Mahaska Communication Group'
- },
- 'mar010': {
- 'name': 'Marne & Elk Horn Telephone Company'
- },
- 'mcc040': {
- 'name': 'McClure Telephone Co.'
- },
- 'mctv': {
- 'name': 'MCTV'
- },
- 'merrimac': {
- 'name': 'Merrimac Communications Ltd.'
- },
- 'metronet': {
- 'name': 'Metronet'
- },
- 'mhtc': {
- 'name': 'MHTC'
- },
- 'midhudson': {
- 'name': 'Mid-Hudson Cable'
- },
- 'midrivers': {
- 'name': 'Mid-Rivers Communications'
- },
- 'mid045': {
- 'name': 'Midstate Communications'
- },
- 'mil080': {
- 'name': 'Milford Communications'
- },
- 'min030': {
- 'name': 'MINET'
- },
- 'nttcmin010': {
- 'name': 'Minford TV'
- },
- 'san040-02': {
- 'name': 'Mitchell Telecom'
- },
- 'mlg010': {
- 'name': 'MLGC'
- },
- 'mon060': {
- 'name': 'Mon-Cre TVE'
- },
- 'mou110': {
- 'name': 'Mountain Telephone'
- },
- 'mou050': {
- 'name': 'Mountain Village Cable'
- },
- 'mtacomm': {
- 'name': 'MTA Communications, LLC'
- },
- 'mtc010': {
- 'name': 'MTC Cable'
- },
- 'med040': {
- 'name': 'MTC Technologies'
- },
- 'man060': {
- 'name': 'MTCC'
- },
- 'mtc030': {
- 'name': 'MTCO Communications'
- },
- 'mul050': {
- 'name': 'Mulberry Telecommunications'
- },
- 'mur010': {
- 'name': 'Murray Electric System'
- },
- 'musfiber': {
- 'name': 'MUS FiberNET'
- },
- 'mpw': {
- 'name': 'Muscatine Power & Water'
- },
- 'nttcsli010': {
- 'name': 'myEVTV.com'
- },
- 'nor115': {
- 'name': 'NCC'
- },
- 'nor260': {
- 'name': 'NDTC'
- },
- 'nctc': {
- 'name': 'Nebraska Central Telecom, Inc.'
- },
- 'nel020': {
- 'name': 'Nelsonville TV Cable'
- },
- 'nem010': {
- 'name': 'Nemont'
- },
- 'new075': {
- 'name': 'New Hope Telephone Cooperative'
- },
- 'nor240': {
- 'name': 'NICP'
- },
- 'cic010': {
- 'name': 'NineStar Connect'
- },
- 'nktelco': {
- 'name': 'NKTelco'
- },
- 'nortex': {
- 'name': 'Nortex Communications'
- },
- 'nor140': {
- 'name': 'North Central Telephone Cooperative'
- },
- 'nor030': {
- 'name': 'Northland Communications'
- },
- 'nor075': {
- 'name': 'Northwest Communications'
- },
- 'nor125': {
- 'name': 'Norwood Light Broadband'
- },
- 'net010': {
- 'name': 'Nsight Telservices'
- },
- 'dur010': {
- 'name': 'Ntec'
- },
- 'nts010': {
- 'name': 'NTS Communications'
- },
- 'new045': {
- 'name': 'NU-Telecom'
- },
- 'nulink': {
- 'name': 'NuLink'
- },
- 'jam030': {
- 'name': 'NVC'
- },
- 'far035': {
- 'name': 'OmniTel Communications'
- },
- 'onesource': {
- 'name': 'OneSource Communications'
- },
- 'cit230': {
- 'name': 'Opelika Power Services'
- },
- 'daltonutilities': {
- 'name': 'OptiLink'
- },
- 'mid140': {
- 'name': 'OPTURA'
- },
- 'ote010': {
- 'name': 'OTEC Communication Company'
- },
- 'cci020': {
- 'name': 'Packerland Broadband'
- },
- 'pan010': {
- 'name': 'Panora Telco/Guthrie Center Communications'
- },
- 'otter': {
- 'name': 'Park Region Telephone & Otter Tail Telcom'
- },
- 'mid050': {
- 'name': 'Partner Communications Cooperative'
- },
- 'fib010': {
- 'name': 'Pathway'
- },
- 'paulbunyan': {
- 'name': 'Paul Bunyan Communications'
- },
- 'pem020': {
- 'name': 'Pembroke Telephone Company'
- },
- 'mck010': {
- 'name': 'Peoples Rural Telephone Cooperative'
- },
- 'pul010': {
- 'name': 'PES Energize'
- },
- 'phi010': {
- 'name': 'Philippi Communications System'
- },
- 'phonoscope': {
- 'name': 'Phonoscope Cable'
- },
- 'pin070': {
- 'name': 'Pine Belt Communications, Inc.'
- },
- 'weh010-pine': {
- 'name': 'Pine Bluff Cable TV'
- },
- 'pin060': {
- 'name': 'Pineland Telephone Cooperative'
- },
- 'cam010': {
- 'name': 'Pinpoint Communications'
- },
- 'pio060': {
- 'name': 'Pioneer Broadband'
- },
- 'pioncomm': {
- 'name': 'Pioneer Communications'
- },
- 'pioneer': {
- 'name': 'Pioneer DTV'
- },
- 'pla020': {
- 'name': 'Plant TiftNet, Inc.'
- },
- 'par010': {
- 'name': 'PLWC'
- },
- 'pro035': {
- 'name': 'PMT'
- },
- 'vik011': {
- 'name': 'Polar Cablevision'
- },
- 'pottawatomie': {
- 'name': 'Pottawatomie Telephone Co.'
- },
- 'premiercomm': {
- 'name': 'Premier Communications'
- },
- 'psc010': {
- 'name': 'PSC'
- },
- 'pan020': {
- 'name': 'PTCI'
- },
- 'qco010': {
- 'name': 'QCOL'
- },
- 'qua010': {
- 'name': 'Quality Cablevision'
- },
- 'rad010': {
- 'name': 'Radcliffe Telephone Company'
- },
- 'car040': {
- 'name': 'Rainbow Communications'
- },
- 'rai030': {
- 'name': 'Rainier Connect'
- },
- 'ral010': {
- 'name': 'Ralls Technologies'
- },
- 'rct010': {
- 'name': 'RC Technologies'
- },
- 'red040': {
- 'name': 'Red River Communications'
- },
- 'ree010': {
- 'name': 'Reedsburg Utility Commission'
- },
- 'mol010': {
- 'name': 'Reliance Connects- Oregon'
- },
- 'res020': {
- 'name': 'Reserve Telecommunications'
- },
- 'weh010-resort': {
- 'name': 'Resort TV Cable'
- },
- 'rld010': {
- 'name': 'Richland Grant Telephone Cooperative, Inc.'
- },
- 'riv030': {
- 'name': 'River Valley Telecommunications Coop'
- },
- 'rockportcable': {
- 'name': 'Rock Port Cablevision'
- },
- 'rsf010': {
- 'name': 'RS Fiber'
- },
- 'rtc': {
- 'name': 'RTC Communication Corp'
- },
- 'res040': {
- 'name': 'RTC-Reservation Telephone Coop.'
- },
- 'rte010': {
- 'name': 'RTEC Communications'
- },
- 'stc010': {
- 'name': 'S&T'
- },
- 'san020': {
- 'name': 'San Bruno Cable TV'
- },
- 'san040-01': {
- 'name': 'Santel'
- },
- 'sav010': {
- 'name': 'SCI Broadband-Savage Communications Inc.'
- },
- 'sco050': {
- 'name': 'Scottsboro Electric Power Board'
- },
- 'scr010': {
- 'name': 'Scranton Telephone Company'
- },
- 'selco': {
- 'name': 'SELCO'
- },
- 'she010': {
- 'name': 'Shentel'
- },
- 'she030': {
- 'name': 'Sherwood Mutual Telephone Association, Inc.'
- },
- 'ind060-ssc': {
- 'name': 'Silver Star Communications'
- },
- 'sjoberg': {
- 'name': 'Sjoberg\'s Inc.'
- },
- 'sou025': {
- 'name': 'SKT'
- },
- 'sky050': {
- 'name': 'SkyBest TV'
- },
- 'nttcsmi010': {
- 'name': 'Smithville Communications'
- },
- 'woo010': {
- 'name': 'Solarus'
- },
- 'sou075': {
- 'name': 'South Central Rural Telephone Cooperative'
- },
- 'sou065': {
- 'name': 'South Holt Cablevision, Inc.'
- },
- 'sou035': {
- 'name': 'South Slope Cooperative Communications'
- },
- 'spa020': {
- 'name': 'Spanish Fork Community Network'
- },
- 'spe010': {
- 'name': 'Spencer Municipal Utilities'
- },
- 'spi005': {
- 'name': 'Spillway Communications, Inc.'
- },
- 'srt010': {
- 'name': 'SRT'
- },
- 'cccsmc010': {
- 'name': 'St. Maarten Cable TV'
- },
- 'sta025': {
- 'name': 'Star Communications'
- },
- 'sco020': {
- 'name': 'STE'
- },
- 'uin010': {
- 'name': 'STRATA Networks'
- },
- 'sum010': {
- 'name': 'Sumner Cable TV'
- },
- 'pie010': {
- 'name': 'Surry TV/PCSI TV'
- },
- 'swa010': {
- 'name': 'Swayzee Communications'
- },
- 'sweetwater': {
- 'name': 'Sweetwater Cable Television Co'
- },
- 'weh010-talequah': {
- 'name': 'Tahlequah Cable TV'
- },
- 'tct': {
- 'name': 'TCT'
- },
- 'tel050': {
- 'name': 'Tele-Media Company'
- },
- 'com050': {
- 'name': 'The Community Agency'
- },
- 'thr020': {
- 'name': 'Three River'
- },
- 'cab140': {
- 'name': 'Town & Country Technologies'
- },
- 'tra010': {
- 'name': 'Trans-Video'
- },
- 'tre010': {
- 'name': 'Trenton TV Cable Company'
- },
- 'tcc': {
- 'name': 'Tri County Communications Cooperative'
- },
- 'tri025': {
- 'name': 'TriCounty Telecom'
- },
- 'tri110': {
- 'name': 'TrioTel Communications, Inc.'
- },
- 'tro010': {
- 'name': 'Troy Cablevision, Inc.'
- },
- 'tsc': {
- 'name': 'TSC'
- },
- 'cit220': {
- 'name': 'Tullahoma Utilities Board'
- },
- 'tvc030': {
- 'name': 'TV Cable of Rensselaer'
- },
- 'tvc015': {
- 'name': 'TVC Cable'
- },
- 'cab180': {
- 'name': 'TVision'
- },
- 'twi040': {
- 'name': 'Twin Lakes'
- },
- 'tvtinc': {
- 'name': 'Twin Valley'
- },
- 'uis010': {
- 'name': 'Union Telephone Company'
- },
- 'uni110': {
- 'name': 'United Communications - TN'
- },
- 'uni120': {
- 'name': 'United Services'
- },
- 'uss020': {
- 'name': 'US Sonet'
- },
- 'cab060': {
- 'name': 'USA Communications'
- },
- 'she005': {
- 'name': 'USA Communications/Shellsburg, IA'
- },
- 'val040': {
- 'name': 'Valley TeleCom Group'
- },
- 'val025': {
- 'name': 'Valley Telecommunications'
- },
- 'val030': {
- 'name': 'Valparaiso Broadband'
- },
- 'cla050': {
- 'name': 'Vast Broadband'
- },
- 'sul015': {
- 'name': 'Venture Communications Cooperative, Inc.'
- },
- 'ver025': {
- 'name': 'Vernon Communications Co-op'
- },
- 'weh010-vicksburg': {
- 'name': 'Vicksburg Video'
- },
- 'vis070': {
- 'name': 'Vision Communications'
- },
- 'volcanotel': {
- 'name': 'Volcano Vision, Inc.'
- },
- 'vol040-02': {
- 'name': 'VolFirst / BLTV'
- },
- 'ver070': {
- 'name': 'VTel'
- },
- 'nttcvtx010': {
- 'name': 'VTX1'
- },
- 'bci010-02': {
- 'name': 'Vyve Broadband'
- },
- 'wab020': {
- 'name': 'Wabash Mutual Telephone'
- },
- 'waitsfield': {
- 'name': 'Waitsfield Cable'
- },
- 'wal010': {
- 'name': 'Walnut Communications'
- },
- 'wavebroadband': {
- 'name': 'Wave'
- },
- 'wav030': {
- 'name': 'Waverly Communications Utility'
- },
- 'wbi010': {
- 'name': 'WBI'
- },
- 'web020': {
- 'name': 'Webster-Calhoun Cooperative Telephone Association'
- },
- 'wes005': {
- 'name': 'West Alabama TV Cable'
- },
- 'carolinata': {
- 'name': 'West Carolina Communications'
- },
- 'wct010': {
- 'name': 'West Central Telephone Association'
- },
- 'wes110': {
- 'name': 'West River Cooperative Telephone Company'
- },
- 'ani030': {
- 'name': 'WesTel Systems'
- },
- 'westianet': {
- 'name': 'Western Iowa Networks'
- },
- 'nttcwhi010': {
- 'name': 'Whidbey Telecom'
- },
- 'weh010-white': {
- 'name': 'White County Cable TV'
- },
- 'wes130': {
- 'name': 'Wiatel'
- },
- 'wik010': {
- 'name': 'Wiktel'
- },
- 'wil070': {
- 'name': 'Wilkes Communications, Inc./RiverStreet Networks'
- },
- 'wil015': {
- 'name': 'Wilson Communications'
- },
- 'win010': {
- 'name': 'Windomnet/SMBS'
- },
- 'win090': {
- 'name': 'Windstream Cable TV'
- },
- 'wcta': {
- 'name': 'Winnebago Cooperative Telecom Association'
- },
- 'wtc010': {
- 'name': 'WTC'
- },
- 'wil040': {
- 'name': 'WTC Communications, Inc.'
- },
- 'wya010': {
- 'name': 'Wyandotte Cable'
- },
- 'hin020-02': {
- 'name': 'X-Stream Services'
- },
- 'xit010': {
- 'name': 'XIT Communications'
- },
- 'yel010': {
- 'name': 'Yelcot Communications'
- },
- 'mid180-01': {
- 'name': 'yondoo'
- },
- 'cou060': {
- 'name': 'Zito Media'
- },
-}
-
-
-class AdobePassIE(InfoExtractor):
- _SERVICE_PROVIDER_TEMPLATE = 'https://sp.auth.adobe.com/adobe-services/%s'
- _USER_AGENT = 'Mozilla/5.0 (X11; Linux i686; rv:47.0) Gecko/20100101 Firefox/47.0'
- _MVPD_CACHE = 'ap-mvpd'
-
- _DOWNLOADING_LOGIN_PAGE = 'Downloading Provider Login Page'
-
- def _download_webpage_handle(self, *args, **kwargs):
- headers = kwargs.get('headers', {})
- headers.update(self.geo_verification_headers())
- kwargs['headers'] = headers
- return super(AdobePassIE, self)._download_webpage_handle(
- *args, **compat_kwargs(kwargs))
-
- @staticmethod
- def _get_mvpd_resource(provider_id, title, guid, rating):
- channel = etree.Element('channel')
- channel_title = etree.SubElement(channel, 'title')
- channel_title.text = provider_id
- item = etree.SubElement(channel, 'item')
- resource_title = etree.SubElement(item, 'title')
- resource_title.text = title
- resource_guid = etree.SubElement(item, 'guid')
- resource_guid.text = guid
- resource_rating = etree.SubElement(item, 'media:rating')
- resource_rating.attrib = {'scheme': 'urn:v-chip'}
- resource_rating.text = rating
- return '<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">' + etree.tostring(channel).decode() + '</rss>'
-
- def _extract_mvpd_auth(self, url, video_id, requestor_id, resource):
- def xml_text(xml_str, tag):
- return self._search_regex(
- '<%s>(.+?)</%s>' % (tag, tag), xml_str, tag)
-
- def is_expired(token, date_ele):
- token_expires = unified_timestamp(re.sub(r'[_ ]GMT', '', xml_text(token, date_ele)))
- return token_expires and token_expires <= int(time.time())
-
- def post_form(form_page_res, note, data={}):
- form_page, urlh = form_page_res
- post_url = self._html_search_regex(r'<form[^>]+action=(["\'])(?P<url>.+?)\1', form_page, 'post url', group='url')
- if not re.match(r'https?://', post_url):
- post_url = compat_urlparse.urljoin(urlh.geturl(), post_url)
- form_data = self._hidden_inputs(form_page)
- form_data.update(data)
- return self._download_webpage_handle(
- post_url, video_id, note, data=urlencode_postdata(form_data), headers={
- 'Content-Type': 'application/x-www-form-urlencoded',
- })
-
- def raise_mvpd_required():
- raise ExtractorError(
- 'This video is only available for users of participating TV providers. '
- 'Use --ap-mso to specify Adobe Pass Multiple-system operator Identifier '
- 'and --ap-username and --ap-password or --netrc to provide account credentials.', expected=True)
-
- def extract_redirect_url(html, url=None, fatal=False):
- # TODO: eliminate code duplication with generic extractor and move
- # redirection code into _download_webpage_handle
- REDIRECT_REGEX = r'[0-9]{,2};\s*(?:URL|url)=\'?([^\'"]+)'
- redirect_url = self._search_regex(
- r'(?i)<meta\s+(?=(?:[a-z-]+="[^"]+"\s+)*http-equiv="refresh")'
- r'(?:[a-z-]+="[^"]+"\s+)*?content="%s' % REDIRECT_REGEX,
- html, 'meta refresh redirect',
- default=NO_DEFAULT if fatal else None, fatal=fatal)
- if not redirect_url:
- return None
- if url:
- redirect_url = compat_urlparse.urljoin(url, unescapeHTML(redirect_url))
- return redirect_url
-
- mvpd_headers = {
- 'ap_42': 'anonymous',
- 'ap_11': 'Linux i686',
- 'ap_z': self._USER_AGENT,
- 'User-Agent': self._USER_AGENT,
- }
-
- guid = xml_text(resource, 'guid') if '<' in resource else resource
- count = 0
- while count < 2:
- requestor_info = self._downloader.cache.load(self._MVPD_CACHE, requestor_id) or {}
- authn_token = requestor_info.get('authn_token')
- if authn_token and is_expired(authn_token, 'simpleTokenExpires'):
- authn_token = None
- if not authn_token:
- # TODO add support for other TV Providers
- mso_id = self._downloader.params.get('ap_mso')
- if not mso_id:
- raise_mvpd_required()
- username, password = self._get_login_info('ap_username', 'ap_password', mso_id)
- if not username or not password:
- raise_mvpd_required()
- mso_info = MSO_INFO[mso_id]
-
- provider_redirect_page_res = self._download_webpage_handle(
- self._SERVICE_PROVIDER_TEMPLATE % 'authenticate/saml', video_id,
- 'Downloading Provider Redirect Page', query={
- 'noflash': 'true',
- 'mso_id': mso_id,
- 'requestor_id': requestor_id,
- 'no_iframe': 'false',
- 'domain_name': 'adobe.com',
- 'redirect_url': url,
- })
-
- if mso_id == 'Comcast_SSO':
- # Comcast page flow varies by video site and whether you
- # are on Comcast's network.
- provider_redirect_page, urlh = provider_redirect_page_res
- if 'automatically signing you in' in provider_redirect_page:
- oauth_redirect_url = self._html_search_regex(
- r'window\.location\s*=\s*[\'"]([^\'"]+)',
- provider_redirect_page, 'oauth redirect')
- self._download_webpage(
- oauth_redirect_url, video_id, 'Confirming auto login')
- else:
- if '<form name="signin"' in provider_redirect_page:
- provider_login_page_res = provider_redirect_page_res
- elif 'http-equiv="refresh"' in provider_redirect_page:
- oauth_redirect_url = extract_redirect_url(
- provider_redirect_page, fatal=True)
- provider_login_page_res = self._download_webpage_handle(
- oauth_redirect_url, video_id,
- self._DOWNLOADING_LOGIN_PAGE)
- else:
- provider_login_page_res = post_form(
- provider_redirect_page_res,
- self._DOWNLOADING_LOGIN_PAGE)
-
- mvpd_confirm_page_res = post_form(
- provider_login_page_res, 'Logging in', {
- mso_info['username_field']: username,
- mso_info['password_field']: password,
- })
- mvpd_confirm_page, urlh = mvpd_confirm_page_res
- if '<button class="submit" value="Resume">Resume</button>' in mvpd_confirm_page:
- post_form(mvpd_confirm_page_res, 'Confirming Login')
- elif mso_id == 'Verizon':
- # In general, if you're connecting from a Verizon-assigned IP,
- # you will not actually pass your credentials.
- provider_redirect_page, urlh = provider_redirect_page_res
- if 'Please wait ...' in provider_redirect_page:
- saml_redirect_url = self._html_search_regex(
- r'self\.parent\.location=(["\'])(?P<url>.+?)\1',
- provider_redirect_page,
- 'SAML Redirect URL', group='url')
- saml_login_page = self._download_webpage(
- saml_redirect_url, video_id,
- 'Downloading SAML Login Page')
- else:
- saml_login_page_res = post_form(
- provider_redirect_page_res, 'Logging in', {
- mso_info['username_field']: username,
- mso_info['password_field']: password,
- })
- saml_login_page, urlh = saml_login_page_res
- if 'Please try again.' in saml_login_page:
- raise ExtractorError(
- 'We\'re sorry, but either the User ID or Password entered is not correct.')
- saml_login_url = self._search_regex(
- r'xmlHttp\.open\("POST"\s*,\s*(["\'])(?P<url>.+?)\1',
- saml_login_page, 'SAML Login URL', group='url')
- saml_response_json = self._download_json(
- saml_login_url, video_id, 'Downloading SAML Response',
- headers={'Content-Type': 'text/xml'})
- self._download_webpage(
- saml_response_json['targetValue'], video_id,
- 'Confirming Login', data=urlencode_postdata({
- 'SAMLResponse': saml_response_json['SAMLResponse'],
- 'RelayState': saml_response_json['RelayState']
- }), headers={
- 'Content-Type': 'application/x-www-form-urlencoded'
- })
- else:
- # Some providers (e.g. DIRECTV NOW) have another meta refresh
- # based redirect that should be followed.
- provider_redirect_page, urlh = provider_redirect_page_res
- provider_refresh_redirect_url = extract_redirect_url(
- provider_redirect_page, url=urlh.geturl())
- if provider_refresh_redirect_url:
- provider_redirect_page_res = self._download_webpage_handle(
- provider_refresh_redirect_url, video_id,
- 'Downloading Provider Redirect Page (meta refresh)')
- provider_login_page_res = post_form(
- provider_redirect_page_res, self._DOWNLOADING_LOGIN_PAGE)
- mvpd_confirm_page_res = post_form(provider_login_page_res, 'Logging in', {
- mso_info.get('username_field', 'username'): username,
- mso_info.get('password_field', 'password'): password,
- })
- if mso_id != 'Rogers':
- post_form(mvpd_confirm_page_res, 'Confirming Login')
-
- session = self._download_webpage(
- self._SERVICE_PROVIDER_TEMPLATE % 'session', video_id,
- 'Retrieving Session', data=urlencode_postdata({
- '_method': 'GET',
- 'requestor_id': requestor_id,
- }), headers=mvpd_headers)
- if '<pendingLogout' in session:
- self._downloader.cache.store(self._MVPD_CACHE, requestor_id, {})
- count += 1
- continue
- authn_token = unescapeHTML(xml_text(session, 'authnToken'))
- requestor_info['authn_token'] = authn_token
- self._downloader.cache.store(self._MVPD_CACHE, requestor_id, requestor_info)
-
- authz_token = requestor_info.get(guid)
- if authz_token and is_expired(authz_token, 'simpleTokenTTL'):
- authz_token = None
- if not authz_token:
- authorize = self._download_webpage(
- self._SERVICE_PROVIDER_TEMPLATE % 'authorize', video_id,
- 'Retrieving Authorization Token', data=urlencode_postdata({
- 'resource_id': resource,
- 'requestor_id': requestor_id,
- 'authentication_token': authn_token,
- 'mso_id': xml_text(authn_token, 'simpleTokenMsoID'),
- 'userMeta': '1',
- }), headers=mvpd_headers)
- if '<pendingLogout' in authorize:
- self._downloader.cache.store(self._MVPD_CACHE, requestor_id, {})
- count += 1
- continue
- if '<error' in authorize:
- raise ExtractorError(xml_text(authorize, 'details'), expected=True)
- authz_token = unescapeHTML(xml_text(authorize, 'authzToken'))
- requestor_info[guid] = authz_token
- self._downloader.cache.store(self._MVPD_CACHE, requestor_id, requestor_info)
-
- mvpd_headers.update({
- 'ap_19': xml_text(authn_token, 'simpleSamlNameID'),
- 'ap_23': xml_text(authn_token, 'simpleSamlSessionIndex'),
- })
-
- short_authorize = self._download_webpage(
- self._SERVICE_PROVIDER_TEMPLATE % 'shortAuthorize',
- video_id, 'Retrieving Media Token', data=urlencode_postdata({
- 'authz_token': authz_token,
- 'requestor_id': requestor_id,
- 'session_guid': xml_text(authn_token, 'simpleTokenAuthenticationGuid'),
- 'hashed_guid': 'false',
- }), headers=mvpd_headers)
- if '<pendingLogout' in short_authorize:
- self._downloader.cache.store(self._MVPD_CACHE, requestor_id, {})
- count += 1
- continue
- return short_authorize
diff --git a/youtube_dl/extractor/common.py b/youtube_dl/extractor/common.py
deleted file mode 100644
index 5d4db54..0000000
--- a/youtube_dl/extractor/common.py
+++ /dev/null
@@ -1,2862 +0,0 @@
-# coding: utf-8
-from __future__ import unicode_literals
-
-import base64
-import datetime
-import hashlib
-import json
-import netrc
-import os
-import random
-import re
-import socket
-import sys
-import time
-import math
-
-from ..compat import (
- compat_cookiejar,
- compat_cookies,
- compat_etree_fromstring,
- compat_getpass,
- compat_integer_types,
- compat_http_client,
- compat_os_name,
- compat_str,
- compat_urllib_error,
- compat_urllib_parse_unquote,
- compat_urllib_parse_urlencode,
- compat_urllib_request,
- compat_urlparse,
- compat_xml_parse_error,
-)
-from ..downloader.f4m import (
- get_base_url,
- remove_encrypted_media,
-)
-from ..utils import (
- NO_DEFAULT,
- age_restricted,
- base_url,
- bug_reports_message,
- clean_html,
- compiled_regex_type,
- determine_ext,
- determine_protocol,
- error_to_compat_str,
- ExtractorError,
- extract_attributes,
- fix_xml_ampersands,
- float_or_none,
- GeoRestrictedError,
- GeoUtils,
- int_or_none,
- js_to_json,
- JSON_LD_RE,
- mimetype2ext,
- orderedSet,
- parse_codecs,
- parse_duration,
- parse_iso8601,
- parse_m3u8_attributes,
- RegexNotFoundError,
- sanitized_Request,
- sanitize_filename,
- unescapeHTML,
- unified_strdate,
- unified_timestamp,
- update_Request,
- update_url_query,
- urljoin,
- url_basename,
- xpath_element,
- xpath_text,
- xpath_with_ns,
-)
-
-
-class InfoExtractor(object):
- """Information Extractor class.
-
- Information extractors are the classes that, given a URL, extract
- information about the video (or videos) the URL refers to. This
- information includes the real video URL, the video title, author and
- others. The information is stored in a dictionary which is then
- passed to the YoutubeDL. The YoutubeDL processes this
- information possibly downloading the video to the file system, among
- other possible outcomes.
-
- The type field determines the type of the result.
- By far the most common value (and the default if _type is missing) is
- "video", which indicates a single video.
-
- For a video, the dictionaries must include the following fields:
-
- id: Video identifier.
- title: Video title, unescaped.
-
- Additionally, it must contain either a formats entry or a url one:
-
- formats: A list of dictionaries for each format available, ordered
- from worst to best quality.
-
- Potential fields:
- * url Mandatory. The URL of the video file
- * manifest_url
- The URL of the manifest file in case of
- fragmented media (DASH, hls, hds)
- * ext Will be calculated from URL if missing
- * format A human-readable description of the format
- ("mp4 container with h264/opus").
- Calculated from the format_id, width, height.
- and format_note fields if missing.
- * format_id A short description of the format
- ("mp4_h264_opus" or "19").
- Technically optional, but strongly recommended.
- * format_note Additional info about the format
- ("3D" or "DASH video")
- * width Width of the video, if known
- * height Height of the video, if known
- * resolution Textual description of width and height
- * tbr Average bitrate of audio and video in KBit/s
- * abr Average audio bitrate in KBit/s
- * acodec Name of the audio codec in use
- * asr Audio sampling rate in Hertz
- * vbr Average video bitrate in KBit/s
- * fps Frame rate
- * vcodec Name of the video codec in use
- * container Name of the container format
- * filesize The number of bytes, if known in advance
- * filesize_approx An estimate for the number of bytes
- * player_url SWF Player URL (used for rtmpdump).
- * protocol The protocol that will be used for the actual
- download, lower-case.
- "http", "https", "rtsp", "rtmp", "rtmpe",
- "m3u8", "m3u8_native" or "http_dash_segments".
- * fragment_base_url
- Base URL for fragments. Each fragment's path
- value (if present) will be relative to
- this URL.
- * fragments A list of fragments of a fragmented media.
- Each fragment entry must contain either an url
- or a path. If an url is present it should be
- considered by a client. Otherwise both path and
- fragment_base_url must be present. Here is
- the list of all potential fields:
- * "url" - fragment's URL
- * "path" - fragment's path relative to
- fragment_base_url
- * "duration" (optional, int or float)
- * "filesize" (optional, int)
- * preference Order number of this format. If this field is
- present and not None, the formats get sorted
- by this field, regardless of all other values.
- -1 for default (order by other properties),
- -2 or smaller for less than default.
- < -1000 to hide the format (if there is
- another one which is strictly better)
- * language Language code, e.g. "de" or "en-US".
- * language_preference Is this in the language mentioned in
- the URL?
- 10 if it's what the URL is about,
- -1 for default (don't know),
- -10 otherwise, other values reserved for now.
- * quality Order number of the video quality of this
- format, irrespective of the file format.
- -1 for default (order by other properties),
- -2 or smaller for less than default.
- * source_preference Order number for this video source
- (quality takes higher priority)
- -1 for default (order by other properties),
- -2 or smaller for less than default.
- * http_headers A dictionary of additional HTTP headers
- to add to the request.
- * stretched_ratio If given and not 1, indicates that the
- video's pixels are not square.
- width : height ratio as float.
- * no_resume The server does not support resuming the
- (HTTP or RTMP) download. Boolean.
- * downloader_options A dictionary of downloader options as
- described in FileDownloader
-
- url: Final video URL.
- ext: Video filename extension.
- format: The video format, defaults to ext (used for --get-format)
- player_url: SWF Player URL (used for rtmpdump).
-
- The following fields are optional:
-
- alt_title: A secondary title of the video.
- display_id An alternative identifier for the video, not necessarily
- unique, but available before title. Typically, id is
- something like "4234987", title "Dancing naked mole rats",
- and display_id "dancing-naked-mole-rats"
- thumbnails: A list of dictionaries, with the following entries:
- * "id" (optional, string) - Thumbnail format ID
- * "url"
- * "preference" (optional, int) - quality of the image
- * "width" (optional, int)
- * "height" (optional, int)
- * "resolution" (optional, string "{width}x{height"},
- deprecated)
- * "filesize" (optional, int)
- thumbnail: Full URL to a video thumbnail image.
- description: Full video description.
- uploader: Full name of the video uploader.
- license: License name the video is licensed under.
- creator: The creator of the video.
- release_date: The date (YYYYMMDD) when the video was released.
- timestamp: UNIX timestamp of the moment the video became available.
- upload_date: Video upload date (YYYYMMDD).
- If not explicitly set, calculated from timestamp.
- uploader_id: Nickname or id of the video uploader.
- uploader_url: Full URL to a personal webpage of the video uploader.
- location: Physical location where the video was filmed.
- subtitles: The available subtitles as a dictionary in the format
- {tag: subformats}. "tag" is usually a language code, and
- "subformats" is a list sorted from lower to higher
- preference, each element is a dictionary with the "ext"
- entry and one of:
- * "data": The subtitles file contents
- * "url": A URL pointing to the subtitles file
- "ext" will be calculated from URL if missing
- automatic_captions: Like 'subtitles', used by the YoutubeIE for
- automatically generated captions
- duration: Length of the video in seconds, as an integer or float.
- view_count: How many users have watched the video on the platform.
- like_count: Number of positive ratings of the video
- dislike_count: Number of negative ratings of the video
- repost_count: Number of reposts of the video
- average_rating: Average rating give by users, the scale used depends on the webpage
- comment_count: Number of comments on the video
- comments: A list of comments, each with one or more of the following
- properties (all but one of text or html optional):
- * "author" - human-readable name of the comment author
- * "author_id" - user ID of the comment author
- * "id" - Comment ID
- * "html" - Comment as HTML
- * "text" - Plain text of the comment
- * "timestamp" - UNIX timestamp of comment
- * "parent" - ID of the comment this one is replying to.
- Set to "root" to indicate that this is a
- comment to the original video.
- age_limit: Age restriction for the video, as an integer (years)
- webpage_url: The URL to the video webpage, if given to youtube-dl it
- should allow to get the same result again. (It will be set
- by YoutubeDL if it's missing)
- categories: A list of categories that the video falls in, for example
- ["Sports", "Berlin"]
- tags: A list of tags assigned to the video, e.g. ["sweden", "pop music"]
- is_live: True, False, or None (=unknown). Whether this video is a
- live stream that goes on instead of a fixed-length video.
- start_time: Time in seconds where the reproduction should start, as
- specified in the URL.
- end_time: Time in seconds where the reproduction should end, as
- specified in the URL.
- chapters: A list of dictionaries, with the following entries:
- * "start_time" - The start time of the chapter in seconds
- * "end_time" - The end time of the chapter in seconds
- * "title" (optional, string)
-
- The following fields should only be used when the video belongs to some logical
- chapter or section:
-
- chapter: Name or title of the chapter the video belongs to.
- chapter_number: Number of the chapter the video belongs to, as an integer.
- chapter_id: Id of the chapter the video belongs to, as a unicode string.
-
- The following fields should only be used when the video is an episode of some
- series, programme or podcast:
-
- series: Title of the series or programme the video episode belongs to.
- season: Title of the season the video episode belongs to.
- season_number: Number of the season the video episode belongs to, as an integer.
- season_id: Id of the season the video episode belongs to, as a unicode string.
- episode: Title of the video episode. Unlike mandatory video title field,
- this field should denote the exact title of the video episode
- without any kind of decoration.
- episode_number: Number of the video episode within a season, as an integer.
- episode_id: Id of the video episode, as a unicode string.
-
- The following fields should only be used when the media is a track or a part of
- a music album:
-
- track: Title of the track.
- track_number: Number of the track within an album or a disc, as an integer.
- track_id: Id of the track (useful in case of custom indexing, e.g. 6.iii),
- as a unicode string.
- artist: Artist(s) of the track.
- genre: Genre(s) of the track.
- album: Title of the album the track belongs to.
- album_type: Type of the album (e.g. "Demo", "Full-length", "Split", "Compilation", etc).
- album_artist: List of all artists appeared on the album (e.g.
- "Ash Borer / Fell Voices" or "Various Artists", useful for splits
- and compilations).
- disc_number: Number of the disc or other physical medium the track belongs to,
- as an integer.
- release_year: Year (YYYY) when the album was released.
-
- Unless mentioned otherwise, the fields should be Unicode strings.
-
- Unless mentioned otherwise, None is equivalent to absence of information.
-
-
- _type "playlist" indicates multiple videos.
- There must be a key "entries", which is a list, an iterable, or a PagedList
- object, each element of which is a valid dictionary by this specification.
-
- Additionally, playlists can have "id", "title", "description", "uploader",
- "uploader_id", "uploader_url" attributes with the same semantics as videos
- (see above).
-
-
- _type "multi_video" indicates that there are multiple videos that
- form a single show, for examples multiple acts of an opera or TV episode.
- It must have an entries key like a playlist and contain all the keys
- required for a video at the same time.
-
-
- _type "url" indicates that the video must be extracted from another
- location, possibly by a different extractor. Its only required key is:
- "url" - the next URL to extract.
- The key "ie_key" can be set to the class name (minus the trailing "IE",
- e.g. "Youtube") if the extractor class is known in advance.
- Additionally, the dictionary may have any properties of the resolved entity
- known in advance, for example "title" if the title of the referred video is
- known ahead of time.
-
-
- _type "url_transparent" entities have the same specification as "url", but
- indicate that the given additional information is more precise than the one
- associated with the resolved URL.
- This is useful when a site employs a video service that hosts the video and
- its technical metadata, but that video service does not embed a useful
- title, description etc.
-
-
- Subclasses of this one should re-define the _real_initialize() and
- _real_extract() methods and define a _VALID_URL regexp.
- Probably, they should also be added to the list of extractors.
-
- _GEO_BYPASS attribute may be set to False in order to disable
- geo restriction bypass mechanisms for a particular extractor.
- Though it won't disable explicit geo restriction bypass based on
- country code provided with geo_bypass_country.
-
- _GEO_COUNTRIES attribute may contain a list of presumably geo unrestricted
- countries for this extractor. One of these countries will be used by
- geo restriction bypass mechanism right away in order to bypass
- geo restriction, of course, if the mechanism is not disabled.
-
- _GEO_IP_BLOCKS attribute may contain a list of presumably geo unrestricted
- IP blocks in CIDR notation for this extractor. One of these IP blocks
- will be used by geo restriction bypass mechanism similarly
- to _GEO_COUNTRIES.
-
- Finally, the _WORKING attribute should be set to False for broken IEs
- in order to warn the users and skip the tests.
- """
-
- _ready = False
- _downloader = None
- _x_forwarded_for_ip = None
- _GEO_BYPASS = True
- _GEO_COUNTRIES = None
- _GEO_IP_BLOCKS = None
- _WORKING = True
-
- def __init__(self, downloader=None):
- """Constructor. Receives an optional downloader."""
- self._ready = False
- self._x_forwarded_for_ip = None
- self.set_downloader(downloader)
-
- @classmethod
- def suitable(cls, url):
- """Receives a URL and returns True if suitable for this IE."""
-
- # This does not use has/getattr intentionally - we want to know whether
- # we have cached the regexp for *this* class, whereas getattr would also
- # match the superclass
- if '_VALID_URL_RE' not in cls.__dict__:
- cls._VALID_URL_RE = re.compile(cls._VALID_URL)
- return cls._VALID_URL_RE.match(url) is not None
-
- @classmethod
- def _match_id(cls, url):
- if '_VALID_URL_RE' not in cls.__dict__:
- cls._VALID_URL_RE = re.compile(cls._VALID_URL)
- m = cls._VALID_URL_RE.match(url)
- assert m
- return compat_str(m.group('id'))
-
- @classmethod
- def working(cls):
- """Getter method for _WORKING."""
- return cls._WORKING
-
- def initialize(self):
- """Initializes an instance (authentication, etc)."""
- self._initialize_geo_bypass({
- 'countries': self._GEO_COUNTRIES,
- 'ip_blocks': self._GEO_IP_BLOCKS,
- })
- if not self._ready:
- self._real_initialize()
- self._ready = True
-
- def _initialize_geo_bypass(self, geo_bypass_context):
- """
- Initialize geo restriction bypass mechanism.
-
- This method is used to initialize geo bypass mechanism based on faking
- X-Forwarded-For HTTP header. A random country from provided country list
- is selected and a random IP belonging to this country is generated. This
- IP will be passed as X-Forwarded-For HTTP header in all subsequent
- HTTP requests.
-
- This method will be used for initial geo bypass mechanism initialization
- during the instance initialization with _GEO_COUNTRIES and
- _GEO_IP_BLOCKS.
-
- You may also manually call it from extractor's code if geo bypass
- information is not available beforehand (e.g. obtained during
- extraction) or due to some other reason. In this case you should pass
- this information in geo bypass context passed as first argument. It may
- contain following fields:
-
- countries: List of geo unrestricted countries (similar
- to _GEO_COUNTRIES)
- ip_blocks: List of geo unrestricted IP blocks in CIDR notation
- (similar to _GEO_IP_BLOCKS)
-
- """
- if not self._x_forwarded_for_ip:
-
- # Geo bypass mechanism is explicitly disabled by user
- if not self._downloader.params.get('geo_bypass', True):
- return
-
- if not geo_bypass_context:
- geo_bypass_context = {}
-
- # Backward compatibility: previously _initialize_geo_bypass
- # expected a list of countries, some 3rd party code may still use
- # it this way
- if isinstance(geo_bypass_context, (list, tuple)):
- geo_bypass_context = {
- 'countries': geo_bypass_context,
- }
-
- # The whole point of geo bypass mechanism is to fake IP
- # as X-Forwarded-For HTTP header based on some IP block or
- # country code.
-
- # Path 1: bypassing based on IP block in CIDR notation
-
- # Explicit IP block specified by user, use it right away
- # regardless of whether extractor is geo bypassable or not
- ip_block = self._downloader.params.get('geo_bypass_ip_block', None)
-
- # Otherwise use random IP block from geo bypass context but only
- # if extractor is known as geo bypassable
- if not ip_block:
- ip_blocks = geo_bypass_context.get('ip_blocks')
- if self._GEO_BYPASS and ip_blocks:
- ip_block = random.choice(ip_blocks)
-
- if ip_block:
- self._x_forwarded_for_ip = GeoUtils.random_ipv4(ip_block)
- if self._downloader.params.get('verbose', False):
- self._downloader.to_screen(
- '[debug] Using fake IP %s as X-Forwarded-For.'
- % self._x_forwarded_for_ip)
- return
-
- # Path 2: bypassing based on country code
-
- # Explicit country code specified by user, use it right away
- # regardless of whether extractor is geo bypassable or not
- country = self._downloader.params.get('geo_bypass_country', None)
-
- # Otherwise use random country code from geo bypass context but
- # only if extractor is known as geo bypassable
- if not country:
- countries = geo_bypass_context.get('countries')
- if self._GEO_BYPASS and countries:
- country = random.choice(countries)
-
- if country:
- self._x_forwarded_for_ip = GeoUtils.random_ipv4(country)
- if self._downloader.params.get('verbose', False):
- self._downloader.to_screen(
- '[debug] Using fake IP %s (%s) as X-Forwarded-For.'
- % (self._x_forwarded_for_ip, country.upper()))
-
- def extract(self, url):
- """Extracts URL information and returns it in list of dicts."""
- try:
- for _ in range(2):
- try:
- self.initialize()
- ie_result = self._real_extract(url)
- if self._x_forwarded_for_ip:
- ie_result['__x_forwarded_for_ip'] = self._x_forwarded_for_ip
- return ie_result
- except GeoRestrictedError as e:
- if self.__maybe_fake_ip_and_retry(e.countries):
- continue
- raise
- except ExtractorError:
- raise
- except compat_http_client.IncompleteRead as e:
- raise ExtractorError('A network error has occurred.', cause=e, expected=True)
- except (KeyError, StopIteration) as e:
- raise ExtractorError('An extractor error has occurred.', cause=e)
-
- def __maybe_fake_ip_and_retry(self, countries):
- if (not self._downloader.params.get('geo_bypass_country', None) and
- self._GEO_BYPASS and
- self._downloader.params.get('geo_bypass', True) and
- not self._x_forwarded_for_ip and
- countries):
- country_code = random.choice(countries)
- self._x_forwarded_for_ip = GeoUtils.random_ipv4(country_code)
- if self._x_forwarded_for_ip:
- self.report_warning(
- 'Video is geo restricted. Retrying extraction with fake IP %s (%s) as X-Forwarded-For.'
- % (self._x_forwarded_for_ip, country_code.upper()))
- return True
- return False
-
- def set_downloader(self, downloader):
- """Sets the downloader for this IE."""
- self._downloader = downloader
-
- def _real_initialize(self):
- """Real initialization process. Redefine in subclasses."""
- pass
-
- def _real_extract(self, url):
- """Real extraction process. Redefine in subclasses."""
- pass
-
- @classmethod
- def ie_key(cls):
- """A string for getting the InfoExtractor with get_info_extractor"""
- return compat_str(cls.__name__[:-2])
-
- @property
- def IE_NAME(self):
- return compat_str(type(self).__name__[:-2])
-
- @staticmethod
- def __can_accept_status_code(err, expected_status):
- assert isinstance(err, compat_urllib_error.HTTPError)
- if expected_status is None:
- return False
- if isinstance(expected_status, compat_integer_types):
- return err.code == expected_status
- elif isinstance(expected_status, (list, tuple)):
- return err.code in expected_status
- elif callable(expected_status):
- return expected_status(err.code) is True
- else:
- assert False
-
- def _request_webpage(self, url_or_request, video_id, note=None, errnote=None, fatal=True, data=None, headers={}, query={}, expected_status=None):
- """
- Return the response handle.
-
- See _download_webpage docstring for arguments specification.
- """
- if note is None:
- self.report_download_webpage(video_id)
- elif note is not False:
- if video_id is None:
- self.to_screen('%s' % (note,))
- else:
- self.to_screen('%s: %s' % (video_id, note))
-
- # Some sites check X-Forwarded-For HTTP header in order to figure out
- # the origin of the client behind proxy. This allows bypassing geo
- # restriction by faking this header's value to IP that belongs to some
- # geo unrestricted country. We will do so once we encounter any
- # geo restriction error.
- if self._x_forwarded_for_ip:
- if 'X-Forwarded-For' not in headers:
- headers['X-Forwarded-For'] = self._x_forwarded_for_ip
-
- if isinstance(url_or_request, compat_urllib_request.Request):
- url_or_request = update_Request(
- url_or_request, data=data, headers=headers, query=query)
- else:
- if query:
- url_or_request = update_url_query(url_or_request, query)
- if data is not None or headers:
- url_or_request = sanitized_Request(url_or_request, data, headers)
- try:
- return self._downloader.urlopen(url_or_request)
- except (compat_urllib_error.URLError, compat_http_client.HTTPException, socket.error) as err:
- if isinstance(err, compat_urllib_error.HTTPError):
- if self.__can_accept_status_code(err, expected_status):
- return err.fp
-
- if errnote is False:
- return False
- if errnote is None:
- errnote = 'Unable to download webpage'
-
- errmsg = '%s: %s' % (errnote, error_to_compat_str(err))
- if fatal:
- raise ExtractorError(errmsg, sys.exc_info()[2], cause=err)
- else:
- self._downloader.report_warning(errmsg)
- return False
-
- def _download_webpage_handle(self, url_or_request, video_id, note=None, errnote=None, fatal=True, encoding=None, data=None, headers={}, query={}, expected_status=None):
- """
- Return a tuple (page content as string, URL handle).
-
- See _download_webpage docstring for arguments specification.
- """
- # Strip hashes from the URL (#1038)
- if isinstance(url_or_request, (compat_str, str)):
- url_or_request = url_or_request.partition('#')[0]
-
- urlh = self._request_webpage(url_or_request, video_id, note, errnote, fatal, data=data, headers=headers, query=query, expected_status=expected_status)
- if urlh is False:
- assert not fatal
- return False
- content = self._webpage_read_content(urlh, url_or_request, video_id, note, errnote, fatal, encoding=encoding)
- return (content, urlh)
-
- @staticmethod
- def _guess_encoding_from_content(content_type, webpage_bytes):
- m = re.match(r'[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+\s*;\s*charset=(.+)', content_type)
- if m:
- encoding = m.group(1)
- else:
- m = re.search(br'<meta[^>]+charset=[\'"]?([^\'")]+)[ /\'">]',
- webpage_bytes[:1024])
- if m:
- encoding = m.group(1).decode('ascii')
- elif webpage_bytes.startswith(b'\xff\xfe'):
- encoding = 'utf-16'
- else:
- encoding = 'utf-8'
-
- return encoding
-
- def __check_blocked(self, content):
- first_block = content[:512]
- if ('<title>Access to this site is blocked</title>' in content and
- 'Websense' in first_block):
- msg = 'Access to this webpage has been blocked by Websense filtering software in your network.'
- blocked_iframe = self._html_search_regex(
- r'<iframe src="([^"]+)"', content,
- 'Websense information URL', default=None)
- if blocked_iframe:
- msg += ' Visit %s for more details' % blocked_iframe
- raise ExtractorError(msg, expected=True)
- if '<title>The URL you requested has been blocked</title>' in first_block:
- msg = (
- 'Access to this webpage has been blocked by Indian censorship. '
- 'Use a VPN or proxy server (with --proxy) to route around it.')
- block_msg = self._html_search_regex(
- r'</h1><p>(.*?)</p>',
- content, 'block message', default=None)
- if block_msg:
- msg += ' (Message: "%s")' % block_msg.replace('\n', ' ')
- raise ExtractorError(msg, expected=True)
- if ('<title>TTK :: Доступ к ресурсу ограничен</title>' in content and
- 'blocklist.rkn.gov.ru' in content):
- raise ExtractorError(
- 'Access to this webpage has been blocked by decision of the Russian government. '
- 'Visit http://blocklist.rkn.gov.ru/ for a block reason.',
- expected=True)
-
- def _webpage_read_content(self, urlh, url_or_request, video_id, note=None, errnote=None, fatal=True, prefix=None, encoding=None):
- content_type = urlh.headers.get('Content-Type', '')
- webpage_bytes = urlh.read()
- if prefix is not None:
- webpage_bytes = prefix + webpage_bytes
- if not encoding:
- encoding = self._guess_encoding_from_content(content_type, webpage_bytes)
- if self._downloader.params.get('dump_intermediate_pages', False):
- self.to_screen('Dumping request to ' + urlh.geturl())
- dump = base64.b64encode(webpage_bytes).decode('ascii')
- self._downloader.to_screen(dump)
- if self._downloader.params.get('write_pages', False):
- basen = '%s_%s' % (video_id, urlh.geturl())
- if len(basen) > 240:
- h = '___' + hashlib.md5(basen.encode('utf-8')).hexdigest()
- basen = basen[:240 - len(h)] + h
- raw_filename = basen + '.dump'
- filename = sanitize_filename(raw_filename, restricted=True)
- self.to_screen('Saving request to ' + filename)
- # Working around MAX_PATH limitation on Windows (see
- # http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx)
- if compat_os_name == 'nt':
- absfilepath = os.path.abspath(filename)
- if len(absfilepath) > 259:
- filename = '\\\\?\\' + absfilepath
- with open(filename, 'wb') as outf:
- outf.write(webpage_bytes)
-
- try:
- content = webpage_bytes.decode(encoding, 'replace')
- except LookupError:
- content = webpage_bytes.decode('utf-8', 'replace')
-
- self.__check_blocked(content)
-
- return content
-
- def _download_webpage(
- self, url_or_request, video_id, note=None, errnote=None,
- fatal=True, tries=1, timeout=5, encoding=None, data=None,
- headers={}, query={}, expected_status=None):
- """
- Return the data of the page as a string.
-
- Arguments:
- url_or_request -- plain text URL as a string or
- a compat_urllib_request.Requestobject
- video_id -- Video/playlist/item identifier (string)
-
- Keyword arguments:
- note -- note printed before downloading (string)
- errnote -- note printed in case of an error (string)
- fatal -- flag denoting whether error should be considered fatal,
- i.e. whether it should cause ExtractionError to be raised,
- otherwise a warning will be reported and extraction continued
- tries -- number of tries
- timeout -- sleep interval between tries
- encoding -- encoding for a page content decoding, guessed automatically
- when not explicitly specified
- data -- POST data (bytes)
- headers -- HTTP headers (dict)
- query -- URL query (dict)
- expected_status -- allows to accept failed HTTP requests (non 2xx
- status code) by explicitly specifying a set of accepted status
- codes. Can be any of the following entities:
- - an integer type specifying an exact failed status code to
- accept
- - a list or a tuple of integer types specifying a list of
- failed status codes to accept
- - a callable accepting an actual failed status code and
- returning True if it should be accepted
- Note that this argument does not affect success status codes (2xx)
- which are always accepted.
- """
-
- success = False
- try_count = 0
- while success is False:
- try:
- res = self._download_webpage_handle(
- url_or_request, video_id, note, errnote, fatal,
- encoding=encoding, data=data, headers=headers, query=query,
- expected_status=expected_status)
- success = True
- except compat_http_client.IncompleteRead as e:
- try_count += 1
- if try_count >= tries:
- raise e
- self._sleep(timeout, video_id)
- if res is False:
- return res
- else:
- content, _ = res
- return content
-
- def _download_xml_handle(
- self, url_or_request, video_id, note='Downloading XML',
- errnote='Unable to download XML', transform_source=None,
- fatal=True, encoding=None, data=None, headers={}, query={},
- expected_status=None):
- """
- Return a tuple (xml as an xml.etree.ElementTree.Element, URL handle).
-
- See _download_webpage docstring for arguments specification.
- """
- res = self._download_webpage_handle(
- url_or_request, video_id, note, errnote, fatal=fatal,
- encoding=encoding, data=data, headers=headers, query=query,
- expected_status=expected_status)
- if res is False:
- return res
- xml_string, urlh = res
- return self._parse_xml(
- xml_string, video_id, transform_source=transform_source,
- fatal=fatal), urlh
-
- def _download_xml(
- self, url_or_request, video_id,
- note='Downloading XML', errnote='Unable to download XML',
- transform_source=None, fatal=True, encoding=None,
- data=None, headers={}, query={}, expected_status=None):
- """
- Return the xml as an xml.etree.ElementTree.Element.
-
- See _download_webpage docstring for arguments specification.
- """
- res = self._download_xml_handle(
- url_or_request, video_id, note=note, errnote=errnote,
- transform_source=transform_source, fatal=fatal, encoding=encoding,
- data=data, headers=headers, query=query,
- expected_status=expected_status)
- return res if res is False else res[0]
-
- def _parse_xml(self, xml_string, video_id, transform_source=None, fatal=True):
- if transform_source:
- xml_string = transform_source(xml_string)
- try:
- return compat_etree_fromstring(xml_string.encode('utf-8'))
- except compat_xml_parse_error as ve:
- errmsg = '%s: Failed to parse XML ' % video_id
- if fatal:
- raise ExtractorError(errmsg, cause=ve)
- else:
- self.report_warning(errmsg + str(ve))
-
- def _download_json_handle(
- self, url_or_request, video_id, note='Downloading JSON metadata',
- errnote='Unable to download JSON metadata', transform_source=None,
- fatal=True, encoding=None, data=None, headers={}, query={},
- expected_status=None):
- """
- Return a tuple (JSON object, URL handle).
-
- See _download_webpage docstring for arguments specification.
- """
- res = self._download_webpage_handle(
- url_or_request, video_id, note, errnote, fatal=fatal,
- encoding=encoding, data=data, headers=headers, query=query,
- expected_status=expected_status)
- if res is False:
- return res
- json_string, urlh = res
- return self._parse_json(
- json_string, video_id, transform_source=transform_source,
- fatal=fatal), urlh
-
- def _download_json(
- self, url_or_request, video_id, note='Downloading JSON metadata',
- errnote='Unable to download JSON metadata', transform_source=None,
- fatal=True, encoding=None, data=None, headers={}, query={},
- expected_status=None):
- """
- Return the JSON object as a dict.
-
- See _download_webpage docstring for arguments specification.
- """
- res = self._download_json_handle(
- url_or_request, video_id, note=note, errnote=errnote,
- transform_source=transform_source, fatal=fatal, encoding=encoding,
- data=data, headers=headers, query=query,
- expected_status=expected_status)
- return res if res is False else res[0]
-
- def _parse_json(self, json_string, video_id, transform_source=None, fatal=True):
- if transform_source:
- json_string = transform_source(json_string)
- try:
- return json.loads(json_string)
- except ValueError as ve:
- errmsg = '%s: Failed to parse JSON ' % video_id
- if fatal:
- raise ExtractorError(errmsg, cause=ve)
- else:
- self.report_warning(errmsg + str(ve))
-
- def report_warning(self, msg, video_id=None):
- idstr = '' if video_id is None else '%s: ' % video_id
- self._downloader.report_warning(
- '[%s] %s%s' % (self.IE_NAME, idstr, msg))
-
- def to_screen(self, msg):
- """Print msg to screen, prefixing it with '[ie_name]'"""
- self._downloader.to_screen('[%s] %s' % (self.IE_NAME, msg))
-
- def report_extraction(self, id_or_name):
- """Report information extraction."""
- self.to_screen('%s: Extracting information' % id_or_name)
-
- def report_download_webpage(self, video_id):
- """Report webpage download."""
- self.to_screen('%s: Downloading webpage' % video_id)
-
- def report_age_confirmation(self):
- """Report attempt to confirm age."""
- self.to_screen('Confirming age')
-
- def report_login(self):
- """Report attempt to log in."""
- self.to_screen('Logging in')
-
- @staticmethod
- def raise_login_required(msg='This video is only available for registered users'):
- raise ExtractorError(
- '%s. Use --username and --password or --netrc to provide account credentials.' % msg,
- expected=True)
-
- @staticmethod
- def raise_geo_restricted(msg='This video is not available from your location due to geo restriction', countries=None):
- raise GeoRestrictedError(msg, countries=countries)
-
- # Methods for following #608
- @staticmethod
- def url_result(url, ie=None, video_id=None, video_title=None):
- """Returns a URL that points to a page that should be processed"""
- # TODO: ie should be the class used for getting the info
- video_info = {'_type': 'url',
- 'url': url,
- 'ie_key': ie}
- if video_id is not None:
- video_info['id'] = video_id
- if video_title is not None:
- video_info['title'] = video_title
- return video_info
-
- def playlist_from_matches(self, matches, playlist_id=None, playlist_title=None, getter=None, ie=None):
- urls = orderedSet(
- self.url_result(self._proto_relative_url(getter(m) if getter else m), ie)
- for m in matches)
- return self.playlist_result(
- urls, playlist_id=playlist_id, playlist_title=playlist_title)
-
- @staticmethod
- def playlist_result(entries, playlist_id=None, playlist_title=None, playlist_description=None):
- """Returns a playlist"""
- video_info = {'_type': 'playlist',
- 'entries': entries}
- if playlist_id:
- video_info['id'] = playlist_id
- if playlist_title:
- video_info['title'] = playlist_title
- if playlist_description:
- video_info['description'] = playlist_description
- return video_info
-
- def _search_regex(self, pattern, string, name, default=NO_DEFAULT, fatal=True, flags=0, group=None):
- """
- Perform a regex search on the given string, using a single or a list of
- patterns returning the first matching group.
- In case of failure return a default value or raise a WARNING or a
- RegexNotFoundError, depending on fatal, specifying the field name.
- """
- if isinstance(pattern, (str, compat_str, compiled_regex_type)):
- mobj = re.search(pattern, string, flags)
- else:
- for p in pattern:
- mobj = re.search(p, string, flags)
- if mobj:
- break
-
- if not self._downloader.params.get('no_color') and compat_os_name != 'nt' and sys.stderr.isatty():
- _name = '\033[0;34m%s\033[0m' % name
- else:
- _name = name
-
- if mobj:
- if group is None:
- # return the first matching group
- return next(g for g in mobj.groups() if g is not None)
- else:
- return mobj.group(group)
- elif default is not NO_DEFAULT:
- return default
- elif fatal:
- raise RegexNotFoundError('Unable to extract %s' % _name)
- else:
- self._downloader.report_warning('unable to extract %s' % _name + bug_reports_message())
- return None
-
- def _html_search_regex(self, pattern, string, name, default=NO_DEFAULT, fatal=True, flags=0, group=None):
- """
- Like _search_regex, but strips HTML tags and unescapes entities.
- """
- res = self._search_regex(pattern, string, name, default, fatal, flags, group)
- if res:
- return clean_html(res).strip()
- else:
- return res
-
- def _get_netrc_login_info(self, netrc_machine=None):
- username = None
- password = None
- netrc_machine = netrc_machine or self._NETRC_MACHINE
-
- if self._downloader.params.get('usenetrc', False):
- try:
- info = netrc.netrc().authenticators(netrc_machine)
- if info is not None:
- username = info[0]
- password = info[2]
- else:
- raise netrc.NetrcParseError(
- 'No authenticators for %s' % netrc_machine)
- except (IOError, netrc.NetrcParseError) as err:
- self._downloader.report_warning(
- 'parsing .netrc: %s' % error_to_compat_str(err))
-
- return username, password
-
- def _get_login_info(self, username_option='username', password_option='password', netrc_machine=None):
- """
- Get the login info as (username, password)
- First look for the manually specified credentials using username_option
- and password_option as keys in params dictionary. If no such credentials
- available look in the netrc file using the netrc_machine or _NETRC_MACHINE
- value.
- If there's no info available, return (None, None)
- """
- if self._downloader is None:
- return (None, None)
-
- downloader_params = self._downloader.params
-
- # Attempt to use provided username and password or .netrc data
- if downloader_params.get(username_option) is not None:
- username = downloader_params[username_option]
- password = downloader_params[password_option]
- else:
- username, password = self._get_netrc_login_info(netrc_machine)
-
- return username, password
-
- def _get_tfa_info(self, note='two-factor verification code'):
- """
- Get the two-factor authentication info
- TODO - asking the user will be required for sms/phone verify
- currently just uses the command line option
- If there's no info available, return None
- """
- if self._downloader is None:
- return None
- downloader_params = self._downloader.params
-
- if downloader_params.get('twofactor') is not None:
- return downloader_params['twofactor']
-
- return compat_getpass('Type %s and press [Return]: ' % note)
-
- # Helper functions for extracting OpenGraph info
- @staticmethod
- def _og_regexes(prop):
- content_re = r'content=(?:"([^"]+?)"|\'([^\']+?)\'|\s*([^\s"\'=<>`]+?))'
- property_re = (r'(?:name|property)=(?:\'og:%(prop)s\'|"og:%(prop)s"|\s*og:%(prop)s\b)'
- % {'prop': re.escape(prop)})
- template = r'<meta[^>]+?%s[^>]+?%s'
- return [
- template % (property_re, content_re),
- template % (content_re, property_re),
- ]
-
- @staticmethod
- def _meta_regex(prop):
- return r'''(?isx)<meta
- (?=[^>]+(?:itemprop|name|property|id|http-equiv)=(["\']?)%s\1)
- [^>]+?content=(["\'])(?P<content>.*?)\2''' % re.escape(prop)
-
- def _og_search_property(self, prop, html, name=None, **kargs):
- if not isinstance(prop, (list, tuple)):
- prop = [prop]
- if name is None:
- name = 'OpenGraph %s' % prop[0]
- og_regexes = []
- for p in prop:
- og_regexes.extend(self._og_regexes(p))
- escaped = self._search_regex(og_regexes, html, name, flags=re.DOTALL, **kargs)
- if escaped is None:
- return None
- return unescapeHTML(escaped)
-
- def _og_search_thumbnail(self, html, **kargs):
- return self._og_search_property('image', html, 'thumbnail URL', fatal=False, **kargs)
-
- def _og_search_description(self, html, **kargs):
- return self._og_search_property('description', html, fatal=False, **kargs)
-
- def _og_search_title(self, html, **kargs):
- return self._og_search_property('title', html, **kargs)
-
- def _og_search_video_url(self, html, name='video url', secure=True, **kargs):
- regexes = self._og_regexes('video') + self._og_regexes('video:url')
- if secure:
- regexes = self._og_regexes('video:secure_url') + regexes
- return self._html_search_regex(regexes, html, name, **kargs)
-
- def _og_search_url(self, html, **kargs):
- return self._og_search_property('url', html, **kargs)
-
- def _html_search_meta(self, name, html, display_name=None, fatal=False, **kwargs):
- if not isinstance(name, (list, tuple)):
- name = [name]
- if display_name is None:
- display_name = name[0]
- return self._html_search_regex(
- [self._meta_regex(n) for n in name],
- html, display_name, fatal=fatal, group='content', **kwargs)
-
- def _dc_search_uploader(self, html):
- return self._html_search_meta('dc.creator', html, 'uploader')
-
- def _rta_search(self, html):
- # See http://www.rtalabel.org/index.php?content=howtofaq#single
- if re.search(r'(?ix)<meta\s+name="rating"\s+'
- r' content="RTA-5042-1996-1400-1577-RTA"',
- html):
- return 18
- return 0
-
- def _media_rating_search(self, html):
- # See http://www.tjg-designs.com/WP/metadata-code-examples-adding-metadata-to-your-web-pages/
- rating = self._html_search_meta('rating', html)
-
- if not rating:
- return None
-
- RATING_TABLE = {
- 'safe for kids': 0,
- 'general': 8,
- '14 years': 14,
- 'mature': 17,
- 'restricted': 19,
- }
- return RATING_TABLE.get(rating.lower())
-
- def _family_friendly_search(self, html):
- # See http://schema.org/VideoObject
- family_friendly = self._html_search_meta(
- 'isFamilyFriendly', html, default=None)
-
- if not family_friendly:
- return None
-
- RATING_TABLE = {
- '1': 0,
- 'true': 0,
- '0': 18,
- 'false': 18,
- }
- return RATING_TABLE.get(family_friendly.lower())
-
- def _twitter_search_player(self, html):
- return self._html_search_meta('twitter:player', html,
- 'twitter card player')
-
- def _search_json_ld(self, html, video_id, expected_type=None, **kwargs):
- json_ld = self._search_regex(
- JSON_LD_RE, html, 'JSON-LD', group='json_ld', **kwargs)
- default = kwargs.get('default', NO_DEFAULT)
- if not json_ld:
- return default if default is not NO_DEFAULT else {}
- # JSON-LD may be malformed and thus `fatal` should be respected.
- # At the same time `default` may be passed that assumes `fatal=False`
- # for _search_regex. Let's simulate the same behavior here as well.
- fatal = kwargs.get('fatal', True) if default == NO_DEFAULT else False
- return self._json_ld(json_ld, video_id, fatal=fatal, expected_type=expected_type)
-
- def _json_ld(self, json_ld, video_id, fatal=True, expected_type=None):
- if isinstance(json_ld, compat_str):
- json_ld = self._parse_json(json_ld, video_id, fatal=fatal)
- if not json_ld:
- return {}
- info = {}
- if not isinstance(json_ld, (list, tuple, dict)):
- return info
- if isinstance(json_ld, dict):
- json_ld = [json_ld]
-
- INTERACTION_TYPE_MAP = {
- 'CommentAction': 'comment',
- 'AgreeAction': 'like',
- 'DisagreeAction': 'dislike',
- 'LikeAction': 'like',
- 'DislikeAction': 'dislike',
- 'ListenAction': 'view',
- 'WatchAction': 'view',
- 'ViewAction': 'view',
- }
-
- def extract_interaction_statistic(e):
- interaction_statistic = e.get('interactionStatistic')
- if not isinstance(interaction_statistic, list):
- return
- for is_e in interaction_statistic:
- if not isinstance(is_e, dict):
- continue
- if is_e.get('@type') != 'InteractionCounter':
- continue
- interaction_type = is_e.get('interactionType')
- if not isinstance(interaction_type, compat_str):
- continue
- interaction_count = int_or_none(is_e.get('userInteractionCount'))
- if interaction_count is None:
- continue
- count_kind = INTERACTION_TYPE_MAP.get(interaction_type.split('/')[-1])
- if not count_kind:
- continue
- count_key = '%s_count' % count_kind
- if info.get(count_key) is not None:
- continue
- info[count_key] = interaction_count
-
- def extract_video_object(e):
- assert e['@type'] == 'VideoObject'
- info.update({
- 'url': e.get('contentUrl'),
- 'title': unescapeHTML(e.get('name')),
- 'description': unescapeHTML(e.get('description')),
- 'thumbnail': e.get('thumbnailUrl') or e.get('thumbnailURL'),
- 'duration': parse_duration(e.get('duration')),
- 'timestamp': unified_timestamp(e.get('uploadDate')),
- 'filesize': float_or_none(e.get('contentSize')),
- 'tbr': int_or_none(e.get('bitrate')),
- 'width': int_or_none(e.get('width')),
- 'height': int_or_none(e.get('height')),
- 'view_count': int_or_none(e.get('interactionCount')),
- })
- extract_interaction_statistic(e)
-
- for e in json_ld:
- if isinstance(e.get('@context'), compat_str) and re.match(r'^https?://schema.org/?$', e.get('@context')):
- item_type = e.get('@type')
- if expected_type is not None and expected_type != item_type:
- return info
- if item_type in ('TVEpisode', 'Episode'):
- info.update({
- 'episode': unescapeHTML(e.get('name')),
- 'episode_number': int_or_none(e.get('episodeNumber')),
- 'description': unescapeHTML(e.get('description')),
- })
- part_of_season = e.get('partOfSeason')
- if isinstance(part_of_season, dict) and part_of_season.get('@type') in ('TVSeason', 'Season', 'CreativeWorkSeason'):
- info['season_number'] = int_or_none(part_of_season.get('seasonNumber'))
- part_of_series = e.get('partOfSeries') or e.get('partOfTVSeries')
- if isinstance(part_of_series, dict) and part_of_series.get('@type') in ('TVSeries', 'Series', 'CreativeWorkSeries'):
- info['series'] = unescapeHTML(part_of_series.get('name'))
- elif item_type in ('Article', 'NewsArticle'):
- info.update({
- 'timestamp': parse_iso8601(e.get('datePublished')),
- 'title': unescapeHTML(e.get('headline')),
- 'description': unescapeHTML(e.get('articleBody')),
- })
- elif item_type == 'VideoObject':
- extract_video_object(e)
- continue
- video = e.get('video')
- if isinstance(video, dict) and video.get('@type') == 'VideoObject':
- extract_video_object(video)
- break
- return dict((k, v) for k, v in info.items() if v is not None)
-
- @staticmethod
- def _hidden_inputs(html):
- html = re.sub(r'<!--(?:(?!<!--).)*-->', '', html)
- hidden_inputs = {}
- for input in re.findall(r'(?i)(<input[^>]+>)', html):
- attrs = extract_attributes(input)
- if not input:
- continue
- if attrs.get('type') not in ('hidden', 'submit'):
- continue
- name = attrs.get('name') or attrs.get('id')
- value = attrs.get('value')
- if name and value is not None:
- hidden_inputs[name] = value
- return hidden_inputs
-
- def _form_hidden_inputs(self, form_id, html):
- form = self._search_regex(
- r'(?is)<form[^>]+?id=(["\'])%s\1[^>]*>(?P<form>.+?)</form>' % form_id,
- html, '%s form' % form_id, group='form')
- return self._hidden_inputs(form)
-
- def _sort_formats(self, formats, field_preference=None):
- if not formats:
- raise ExtractorError('No video formats found')
-
- for f in formats:
- # Automatically determine tbr when missing based on abr and vbr (improves
- # formats sorting in some cases)
- if 'tbr' not in f and f.get('abr') is not None and f.get('vbr') is not None:
- f['tbr'] = f['abr'] + f['vbr']
-
- def _formats_key(f):
- # TODO remove the following workaround
- from ..utils import determine_ext
- if not f.get('ext') and 'url' in f:
- f['ext'] = determine_ext(f['url'])
-
- if isinstance(field_preference, (list, tuple)):
- return tuple(
- f.get(field)
- if f.get(field) is not None
- else ('' if field == 'format_id' else -1)
- for field in field_preference)
-
- preference = f.get('preference')
- if preference is None:
- preference = 0
- if f.get('ext') in ['f4f', 'f4m']: # Not yet supported
- preference -= 0.5
-
- protocol = f.get('protocol') or determine_protocol(f)
- proto_preference = 0 if protocol in ['http', 'https'] else (-0.5 if protocol == 'rtsp' else -0.1)
-
- if f.get('vcodec') == 'none': # audio only
- preference -= 50
- if self._downloader.params.get('prefer_free_formats'):
- ORDER = ['aac', 'mp3', 'm4a', 'webm', 'ogg', 'opus']
- else:
- ORDER = ['webm', 'opus', 'ogg', 'mp3', 'aac', 'm4a']
- ext_preference = 0
- try:
- audio_ext_preference = ORDER.index(f['ext'])
- except ValueError:
- audio_ext_preference = -1
- else:
- if f.get('acodec') == 'none': # video only
- preference -= 40
- if self._downloader.params.get('prefer_free_formats'):
- ORDER = ['flv', 'mp4', 'webm']
- else:
- ORDER = ['webm', 'flv', 'mp4']
- try:
- ext_preference = ORDER.index(f['ext'])
- except ValueError:
- ext_preference = -1
- audio_ext_preference = 0
-
- return (
- preference,
- f.get('language_preference') if f.get('language_preference') is not None else -1,
- f.get('quality') if f.get('quality') is not None else -1,
- f.get('tbr') if f.get('tbr') is not None else -1,
- f.get('filesize') if f.get('filesize') is not None else -1,
- f.get('vbr') if f.get('vbr') is not None else -1,
- f.get('height') if f.get('height') is not None else -1,
- f.get('width') if f.get('width') is not None else -1,
- proto_preference,
- ext_preference,
- f.get('abr') if f.get('abr') is not None else -1,
- audio_ext_preference,
- f.get('fps') if f.get('fps') is not None else -1,
- f.get('filesize_approx') if f.get('filesize_approx') is not None else -1,
- f.get('source_preference') if f.get('source_preference') is not None else -1,
- f.get('format_id') if f.get('format_id') is not None else '',
- )
- formats.sort(key=_formats_key)
-
- def _check_formats(self, formats, video_id):
- if formats:
- formats[:] = filter(
- lambda f: self._is_valid_url(
- f['url'], video_id,
- item='%s video format' % f.get('format_id') if f.get('format_id') else 'video'),
- formats)
-
- @staticmethod
- def _remove_duplicate_formats(formats):
- format_urls = set()
- unique_formats = []
- for f in formats:
- if f['url'] not in format_urls:
- format_urls.add(f['url'])
- unique_formats.append(f)
- formats[:] = unique_formats
-
- def _is_valid_url(self, url, video_id, item='video', headers={}):
- url = self._proto_relative_url(url, scheme='http:')
- # For now assume non HTTP(S) URLs always valid
- if not (url.startswith('http://') or url.startswith('https://')):
- return True
- try:
- self._request_webpage(url, video_id, 'Checking %s URL' % item, headers=headers)
- return True
- except ExtractorError as e:
- if isinstance(e.cause, compat_urllib_error.URLError):
- self.to_screen(
- '%s: %s URL is invalid, skipping' % (video_id, item))
- return False
- raise
-
- def http_scheme(self):
- """ Either "http:" or "https:", depending on the user's preferences """
- return (
- 'http:'
- if self._downloader.params.get('prefer_insecure', False)
- else 'https:')
-
- def _proto_relative_url(self, url, scheme=None):
- if url is None:
- return url
- if url.startswith('//'):
- if scheme is None:
- scheme = self.http_scheme()
- return scheme + url
- else:
- return url
-
- def _sleep(self, timeout, video_id, msg_template=None):
- if msg_template is None:
- msg_template = '%(video_id)s: Waiting for %(timeout)s seconds'
- msg = msg_template % {'video_id': video_id, 'timeout': timeout}
- self.to_screen(msg)
- time.sleep(timeout)
-
- def _extract_f4m_formats(self, manifest_url, video_id, preference=None, f4m_id=None,
- transform_source=lambda s: fix_xml_ampersands(s).strip(),
- fatal=True, m3u8_id=None):
- manifest = self._download_xml(
- manifest_url, video_id, 'Downloading f4m manifest',
- 'Unable to download f4m manifest',
- # Some manifests may be malformed, e.g. prosiebensat1 generated manifests
- # (see https://github.com/rg3/youtube-dl/issues/6215#issuecomment-121704244)
- transform_source=transform_source,
- fatal=fatal)
-
- if manifest is False:
- return []
-
- return self._parse_f4m_formats(
- manifest, manifest_url, video_id, preference=preference, f4m_id=f4m_id,
- transform_source=transform_source, fatal=fatal, m3u8_id=m3u8_id)
-
- def _parse_f4m_formats(self, manifest, manifest_url, video_id, preference=None, f4m_id=None,
- transform_source=lambda s: fix_xml_ampersands(s).strip(),
- fatal=True, m3u8_id=None):
- # currently youtube-dl cannot decode the playerVerificationChallenge as Akamai uses Adobe Alchemy
- akamai_pv = manifest.find('{http://ns.adobe.com/f4m/1.0}pv-2.0')
- if akamai_pv is not None and ';' in akamai_pv.text:
- playerVerificationChallenge = akamai_pv.text.split(';')[0]
- if playerVerificationChallenge.strip() != '':
- return []
-
- formats = []
- manifest_version = '1.0'
- media_nodes = manifest.findall('{http://ns.adobe.com/f4m/1.0}media')
- if not media_nodes:
- manifest_version = '2.0'
- media_nodes = manifest.findall('{http://ns.adobe.com/f4m/2.0}media')
- # Remove unsupported DRM protected media from final formats
- # rendition (see https://github.com/rg3/youtube-dl/issues/8573).
- media_nodes = remove_encrypted_media(media_nodes)
- if not media_nodes:
- return formats
-
- manifest_base_url = get_base_url(manifest)
-
- bootstrap_info = xpath_element(
- manifest, ['{http://ns.adobe.com/f4m/1.0}bootstrapInfo', '{http://ns.adobe.com/f4m/2.0}bootstrapInfo'],
- 'bootstrap info', default=None)
-
- vcodec = None
- mime_type = xpath_text(
- manifest, ['{http://ns.adobe.com/f4m/1.0}mimeType', '{http://ns.adobe.com/f4m/2.0}mimeType'],
- 'base URL', default=None)
- if mime_type and mime_type.startswith('audio/'):
- vcodec = 'none'
-
- for i, media_el in enumerate(media_nodes):
- tbr = int_or_none(media_el.attrib.get('bitrate'))
- width = int_or_none(media_el.attrib.get('width'))
- height = int_or_none(media_el.attrib.get('height'))
- format_id = '-'.join(filter(None, [f4m_id, compat_str(i if tbr is None else tbr)]))
- # If <bootstrapInfo> is present, the specified f4m is a
- # stream-level manifest, and only set-level manifests may refer to
- # external resources. See section 11.4 and section 4 of F4M spec
- if bootstrap_info is None:
- media_url = None
- # @href is introduced in 2.0, see section 11.6 of F4M spec
- if manifest_version == '2.0':
- media_url = media_el.attrib.get('href')
- if media_url is None:
- media_url = media_el.attrib.get('url')
- if not media_url:
- continue
- manifest_url = (
- media_url if media_url.startswith('http://') or media_url.startswith('https://')
- else ((manifest_base_url or '/'.join(manifest_url.split('/')[:-1])) + '/' + media_url))
- # If media_url is itself a f4m manifest do the recursive extraction
- # since bitrates in parent manifest (this one) and media_url manifest
- # may differ leading to inability to resolve the format by requested
- # bitrate in f4m downloader
- ext = determine_ext(manifest_url)
- if ext == 'f4m':
- f4m_formats = self._extract_f4m_formats(
- manifest_url, video_id, preference=preference, f4m_id=f4m_id,
- transform_source=transform_source, fatal=fatal)
- # Sometimes stream-level manifest contains single media entry that
- # does not contain any quality metadata (e.g. http://matchtv.ru/#live-player).
- # At the same time parent's media entry in set-level manifest may
- # contain it. We will copy it from parent in such cases.
- if len(f4m_formats) == 1:
- f = f4m_formats[0]
- f.update({
- 'tbr': f.get('tbr') or tbr,
- 'width': f.get('width') or width,
- 'height': f.get('height') or height,
- 'format_id': f.get('format_id') if not tbr else format_id,
- 'vcodec': vcodec,
- })
- formats.extend(f4m_formats)
- continue
- elif ext == 'm3u8':
- formats.extend(self._extract_m3u8_formats(
- manifest_url, video_id, 'mp4', preference=preference,
- m3u8_id=m3u8_id, fatal=fatal))
- continue
- formats.append({
- 'format_id': format_id,
- 'url': manifest_url,
- 'manifest_url': manifest_url,
- 'ext': 'flv' if bootstrap_info is not None else None,
- 'protocol': 'f4m',
- 'tbr': tbr,
- 'width': width,
- 'height': height,
- 'vcodec': vcodec,
- 'preference': preference,
- })
- return formats
-
- def _m3u8_meta_format(self, m3u8_url, ext=None, preference=None, m3u8_id=None):
- return {
- 'format_id': '-'.join(filter(None, [m3u8_id, 'meta'])),
- 'url': m3u8_url,
- 'ext': ext,
- 'protocol': 'm3u8',
- 'preference': preference - 100 if preference else -100,
- 'resolution': 'multiple',
- 'format_note': 'Quality selection URL',
- }
-
- def _extract_m3u8_formats(self, m3u8_url, video_id, ext=None,
- entry_protocol='m3u8', preference=None,
- m3u8_id=None, note=None, errnote=None,
- fatal=True, live=False):
- res = self._download_webpage_handle(
- m3u8_url, video_id,
- note=note or 'Downloading m3u8 information',
- errnote=errnote or 'Failed to download m3u8 information',
- fatal=fatal)
-
- if res is False:
- return []
-
- m3u8_doc, urlh = res
- m3u8_url = urlh.geturl()
-
- return self._parse_m3u8_formats(
- m3u8_doc, m3u8_url, ext=ext, entry_protocol=entry_protocol,
- preference=preference, m3u8_id=m3u8_id, live=live)
-
- def _parse_m3u8_formats(self, m3u8_doc, m3u8_url, ext=None,
- entry_protocol='m3u8', preference=None,
- m3u8_id=None, live=False):
- if '#EXT-X-FAXS-CM:' in m3u8_doc: # Adobe Flash Access
- return []
-
- if re.search(r'#EXT-X-SESSION-KEY:.*?URI="skd://', m3u8_doc): # Apple FairPlay
- return []
-
- formats = []
-
- format_url = lambda u: (
- u
- if re.match(r'^https?://', u)
- else compat_urlparse.urljoin(m3u8_url, u))
-
- # References:
- # 1. https://tools.ietf.org/html/draft-pantos-http-live-streaming-21
- # 2. https://github.com/rg3/youtube-dl/issues/12211
-
- # We should try extracting formats only from master playlists [1, 4.3.4],
- # i.e. playlists that describe available qualities. On the other hand
- # media playlists [1, 4.3.3] should be returned as is since they contain
- # just the media without qualities renditions.
- # Fortunately, master playlist can be easily distinguished from media
- # playlist based on particular tags availability. As of [1, 4.3.3, 4.3.4]
- # master playlist tags MUST NOT appear in a media playist and vice versa.
- # As of [1, 4.3.3.1] #EXT-X-TARGETDURATION tag is REQUIRED for every
- # media playlist and MUST NOT appear in master playlist thus we can
- # clearly detect media playlist with this criterion.
-
- if '#EXT-X-TARGETDURATION' in m3u8_doc: # media playlist, return as is
- return [{
- 'url': m3u8_url,
- 'format_id': m3u8_id,
- 'ext': ext,
- 'protocol': entry_protocol,
- 'preference': preference,
- }]
-
- groups = {}
- last_stream_inf = {}
-
- def extract_media(x_media_line):
- media = parse_m3u8_attributes(x_media_line)
- # As per [1, 4.3.4.1] TYPE, GROUP-ID and NAME are REQUIRED
- media_type, group_id, name = media.get('TYPE'), media.get('GROUP-ID'), media.get('NAME')
- if not (media_type and group_id and name):
- return
- groups.setdefault(group_id, []).append(media)
- if media_type not in ('VIDEO', 'AUDIO'):
- return
- media_url = media.get('URI')
- if media_url:
- format_id = []
- for v in (m3u8_id, group_id, name):
- if v:
- format_id.append(v)
- f = {
- 'format_id': '-'.join(format_id),
- 'url': format_url(media_url),
- 'manifest_url': m3u8_url,
- 'language': media.get('LANGUAGE'),
- 'ext': ext,
- 'protocol': entry_protocol,
- 'preference': preference,
- }
- if media_type == 'AUDIO':
- f['vcodec'] = 'none'
- formats.append(f)
-
- def build_stream_name():
- # Despite specification does not mention NAME attribute for
- # EXT-X-STREAM-INF tag it still sometimes may be present (see [1]
- # or vidio test in TestInfoExtractor.test_parse_m3u8_formats)
- # 1. http://www.vidio.com/watch/165683-dj_ambred-booyah-live-2015
- stream_name = last_stream_inf.get('NAME')
- if stream_name:
- return stream_name
- # If there is no NAME in EXT-X-STREAM-INF it will be obtained
- # from corresponding rendition group
- stream_group_id = last_stream_inf.get('VIDEO')
- if not stream_group_id:
- return
- stream_group = groups.get(stream_group_id)
- if not stream_group:
- return stream_group_id
- rendition = stream_group[0]
- return rendition.get('NAME') or stream_group_id
-
- for line in m3u8_doc.splitlines():
- if line.startswith('#EXT-X-STREAM-INF:'):
- last_stream_inf = parse_m3u8_attributes(line)
- elif line.startswith('#EXT-X-MEDIA:'):
- extract_media(line)
- elif line.startswith('#') or not line.strip():
- continue
- else:
- tbr = float_or_none(
- last_stream_inf.get('AVERAGE-BANDWIDTH') or
- last_stream_inf.get('BANDWIDTH'), scale=1000)
- format_id = []
- if m3u8_id:
- format_id.append(m3u8_id)
- stream_name = build_stream_name()
- # Bandwidth of live streams may differ over time thus making
- # format_id unpredictable. So it's better to keep provided
- # format_id intact.
- if not live:
- format_id.append(stream_name if stream_name else '%d' % (tbr if tbr else len(formats)))
- manifest_url = format_url(line.strip())
- f = {
- 'format_id': '-'.join(format_id),
- 'url': manifest_url,
- 'manifest_url': m3u8_url,
- 'tbr': tbr,
- 'ext': ext,
- 'fps': float_or_none(last_stream_inf.get('FRAME-RATE')),
- 'protocol': entry_protocol,
- 'preference': preference,
- }
- resolution = last_stream_inf.get('RESOLUTION')
- if resolution:
- mobj = re.search(r'(?P<width>\d+)[xX](?P<height>\d+)', resolution)
- if mobj:
- f['width'] = int(mobj.group('width'))
- f['height'] = int(mobj.group('height'))
- # Unified Streaming Platform
- mobj = re.search(
- r'audio.*?(?:%3D|=)(\d+)(?:-video.*?(?:%3D|=)(\d+))?', f['url'])
- if mobj:
- abr, vbr = mobj.groups()
- abr, vbr = float_or_none(abr, 1000), float_or_none(vbr, 1000)
- f.update({
- 'vbr': vbr,
- 'abr': abr,
- })
- codecs = parse_codecs(last_stream_inf.get('CODECS'))
- f.update(codecs)
- audio_group_id = last_stream_inf.get('AUDIO')
- # As per [1, 4.3.4.1.1] any EXT-X-STREAM-INF tag which
- # references a rendition group MUST have a CODECS attribute.
- # However, this is not always respected, for example, [2]
- # contains EXT-X-STREAM-INF tag which references AUDIO
- # rendition group but does not have CODECS and despite
- # referencing audio group an audio group, it represents
- # a complete (with audio and video) format. So, for such cases
- # we will ignore references to rendition groups and treat them
- # as complete formats.
- if audio_group_id and codecs and f.get('vcodec') != 'none':
- audio_group = groups.get(audio_group_id)
- if audio_group and audio_group[0].get('URI'):
- # TODO: update acodec for audio only formats with
- # the same GROUP-ID
- f['acodec'] = 'none'
- formats.append(f)
- last_stream_inf = {}
- return formats
-
- @staticmethod
- def _xpath_ns(path, namespace=None):
- if not namespace:
- return path
- out = []
- for c in path.split('/'):
- if not c or c == '.':
- out.append(c)
- else:
- out.append('{%s}%s' % (namespace, c))
- return '/'.join(out)
-
- def _extract_smil_formats(self, smil_url, video_id, fatal=True, f4m_params=None, transform_source=None):
- smil = self._download_smil(smil_url, video_id, fatal=fatal, transform_source=transform_source)
-
- if smil is False:
- assert not fatal
- return []
-
- namespace = self._parse_smil_namespace(smil)
-
- return self._parse_smil_formats(
- smil, smil_url, video_id, namespace=namespace, f4m_params=f4m_params)
-
- def _extract_smil_info(self, smil_url, video_id, fatal=True, f4m_params=None):
- smil = self._download_smil(smil_url, video_id, fatal=fatal)
- if smil is False:
- return {}
- return self._parse_smil(smil, smil_url, video_id, f4m_params=f4m_params)
-
- def _download_smil(self, smil_url, video_id, fatal=True, transform_source=None):
- return self._download_xml(
- smil_url, video_id, 'Downloading SMIL file',
- 'Unable to download SMIL file', fatal=fatal, transform_source=transform_source)
-
- def _parse_smil(self, smil, smil_url, video_id, f4m_params=None):
- namespace = self._parse_smil_namespace(smil)
-
- formats = self._parse_smil_formats(
- smil, smil_url, video_id, namespace=namespace, f4m_params=f4m_params)
- subtitles = self._parse_smil_subtitles(smil, namespace=namespace)
-
- video_id = os.path.splitext(url_basename(smil_url))[0]
- title = None
- description = None
- upload_date = None
- for meta in smil.findall(self._xpath_ns('./head/meta', namespace)):
- name = meta.attrib.get('name')
- content = meta.attrib.get('content')
- if not name or not content:
- continue
- if not title and name == 'title':
- title = content
- elif not description and name in ('description', 'abstract'):
- description = content
- elif not upload_date and name == 'date':
- upload_date = unified_strdate(content)
-
- thumbnails = [{
- 'id': image.get('type'),
- 'url': image.get('src'),
- 'width': int_or_none(image.get('width')),
- 'height': int_or_none(image.get('height')),
- } for image in smil.findall(self._xpath_ns('.//image', namespace)) if image.get('src')]
-
- return {
- 'id': video_id,
- 'title': title or video_id,
- 'description': description,
- 'upload_date': upload_date,
- 'thumbnails': thumbnails,
- 'formats': formats,
- 'subtitles': subtitles,
- }
-
- def _parse_smil_namespace(self, smil):
- return self._search_regex(
- r'(?i)^{([^}]+)?}smil$', smil.tag, 'namespace', default=None)
-
- def _parse_smil_formats(self, smil, smil_url, video_id, namespace=None, f4m_params=None, transform_rtmp_url=None):
- base = smil_url
- for meta in smil.findall(self._xpath_ns('./head/meta', namespace)):
- b = meta.get('base') or meta.get('httpBase')
- if b:
- base = b
- break
-
- formats = []
- rtmp_count = 0
- http_count = 0
- m3u8_count = 0
-
- srcs = []
- media = smil.findall(self._xpath_ns('.//video', namespace)) + smil.findall(self._xpath_ns('.//audio', namespace))
- for medium in media:
- src = medium.get('src')
- if not src or src in srcs:
- continue
- srcs.append(src)
-
- bitrate = float_or_none(medium.get('system-bitrate') or medium.get('systemBitrate'), 1000)
- filesize = int_or_none(medium.get('size') or medium.get('fileSize'))
- width = int_or_none(medium.get('width'))
- height = int_or_none(medium.get('height'))
- proto = medium.get('proto')
- ext = medium.get('ext')
- src_ext = determine_ext(src)
- streamer = medium.get('streamer') or base
-
- if proto == 'rtmp' or streamer.startswith('rtmp'):
- rtmp_count += 1
- formats.append({
- 'url': streamer,
- 'play_path': src,
- 'ext': 'flv',
- 'format_id': 'rtmp-%d' % (rtmp_count if bitrate is None else bitrate),
- 'tbr': bitrate,
- 'filesize': filesize,
- 'width': width,
- 'height': height,
- })
- if transform_rtmp_url:
- streamer, src = transform_rtmp_url(streamer, src)
- formats[-1].update({
- 'url': streamer,
- 'play_path': src,
- })
- continue
-
- src_url = src if src.startswith('http') else compat_urlparse.urljoin(base, src)
- src_url = src_url.strip()
-
- if proto == 'm3u8' or src_ext == 'm3u8':
- m3u8_formats = self._extract_m3u8_formats(
- src_url, video_id, ext or 'mp4', m3u8_id='hls', fatal=False)
- if len(m3u8_formats) == 1:
- m3u8_count += 1
- m3u8_formats[0].update({
- 'format_id': 'hls-%d' % (m3u8_count if bitrate is None else bitrate),
- 'tbr': bitrate,
- 'width': width,
- 'height': height,
- })
- formats.extend(m3u8_formats)
- continue
-
- if src_ext == 'f4m':
- f4m_url = src_url
- if not f4m_params:
- f4m_params = {
- 'hdcore': '3.2.0',
- 'plugin': 'flowplayer-3.2.0.1',
- }
- f4m_url += '&' if '?' in f4m_url else '?'
- f4m_url += compat_urllib_parse_urlencode(f4m_params)
- formats.extend(self._extract_f4m_formats(f4m_url, video_id, f4m_id='hds', fatal=False))
- continue
-
- if src_url.startswith('http') and self._is_valid_url(src, video_id):
- http_count += 1
- formats.append({
- 'url': src_url,
- 'ext': ext or src_ext or 'flv',
- 'format_id': 'http-%d' % (bitrate or http_count),
- 'tbr': bitrate,
- 'filesize': filesize,
- 'width': width,
- 'height': height,
- })
- continue
-
- return formats
-
- def _parse_smil_subtitles(self, smil, namespace=None, subtitles_lang='en'):
- urls = []
- subtitles = {}
- for num, textstream in enumerate(smil.findall(self._xpath_ns('.//textstream', namespace))):
- src = textstream.get('src')
- if not src or src in urls:
- continue
- urls.append(src)
- ext = textstream.get('ext') or mimetype2ext(textstream.get('type')) or determine_ext(src)
- lang = textstream.get('systemLanguage') or textstream.get('systemLanguageName') or textstream.get('lang') or subtitles_lang
- subtitles.setdefault(lang, []).append({
- 'url': src,
- 'ext': ext,
- })
- return subtitles
-
- def _extract_xspf_playlist(self, xspf_url, playlist_id, fatal=True):
- xspf = self._download_xml(
- xspf_url, playlist_id, 'Downloading xpsf playlist',
- 'Unable to download xspf manifest', fatal=fatal)
- if xspf is False:
- return []
- return self._parse_xspf(
- xspf, playlist_id, xspf_url=xspf_url,
- xspf_base_url=base_url(xspf_url))
-
- def _parse_xspf(self, xspf_doc, playlist_id, xspf_url=None, xspf_base_url=None):
- NS_MAP = {
- 'xspf': 'http://xspf.org/ns/0/',
- 's1': 'http://static.streamone.nl/player/ns/0',
- }
-
- entries = []
- for track in xspf_doc.findall(xpath_with_ns('./xspf:trackList/xspf:track', NS_MAP)):
- title = xpath_text(
- track, xpath_with_ns('./xspf:title', NS_MAP), 'title', default=playlist_id)
- description = xpath_text(
- track, xpath_with_ns('./xspf:annotation', NS_MAP), 'description')
- thumbnail = xpath_text(
- track, xpath_with_ns('./xspf:image', NS_MAP), 'thumbnail')
- duration = float_or_none(
- xpath_text(track, xpath_with_ns('./xspf:duration', NS_MAP), 'duration'), 1000)
-
- formats = []
- for location in track.findall(xpath_with_ns('./xspf:location', NS_MAP)):
- format_url = urljoin(xspf_base_url, location.text)
- if not format_url:
- continue
- formats.append({
- 'url': format_url,
- 'manifest_url': xspf_url,
- 'format_id': location.get(xpath_with_ns('s1:label', NS_MAP)),
- 'width': int_or_none(location.get(xpath_with_ns('s1:width', NS_MAP))),
- 'height': int_or_none(location.get(xpath_with_ns('s1:height', NS_MAP))),
- })
- self._sort_formats(formats)
-
- entries.append({
- 'id': playlist_id,
- 'title': title,
- 'description': description,
- 'thumbnail': thumbnail,
- 'duration': duration,
- 'formats': formats,
- })
- return entries
-
- def _extract_mpd_formats(self, mpd_url, video_id, mpd_id=None, note=None, errnote=None, fatal=True, formats_dict={}):
- res = self._download_xml_handle(
- mpd_url, video_id,
- note=note or 'Downloading MPD manifest',
- errnote=errnote or 'Failed to download MPD manifest',
- fatal=fatal)
- if res is False:
- return []
- mpd_doc, urlh = res
- mpd_base_url = base_url(urlh.geturl())
-
- return self._parse_mpd_formats(
- mpd_doc, mpd_id=mpd_id, mpd_base_url=mpd_base_url,
- formats_dict=formats_dict, mpd_url=mpd_url)
-
- def _parse_mpd_formats(self, mpd_doc, mpd_id=None, mpd_base_url='', formats_dict={}, mpd_url=None):
- """
- Parse formats from MPD manifest.
- References:
- 1. MPEG-DASH Standard, ISO/IEC 23009-1:2014(E),
- http://standards.iso.org/ittf/PubliclyAvailableStandards/c065274_ISO_IEC_23009-1_2014.zip
- 2. https://en.wikipedia.org/wiki/Dynamic_Adaptive_Streaming_over_HTTP
- """
- if mpd_doc.get('type') == 'dynamic':
- return []
-
- namespace = self._search_regex(r'(?i)^{([^}]+)?}MPD$', mpd_doc.tag, 'namespace', default=None)
-
- def _add_ns(path):
- return self._xpath_ns(path, namespace)
-
- def is_drm_protected(element):
- return element.find(_add_ns('ContentProtection')) is not None
-
- def extract_multisegment_info(element, ms_parent_info):
- ms_info = ms_parent_info.copy()
-
- # As per [1, 5.3.9.2.2] SegmentList and SegmentTemplate share some
- # common attributes and elements. We will only extract relevant
- # for us.
- def extract_common(source):
- segment_timeline = source.find(_add_ns('SegmentTimeline'))
- if segment_timeline is not None:
- s_e = segment_timeline.findall(_add_ns('S'))
- if s_e:
- ms_info['total_number'] = 0
- ms_info['s'] = []
- for s in s_e:
- r = int(s.get('r', 0))
- ms_info['total_number'] += 1 + r
- ms_info['s'].append({
- 't': int(s.get('t', 0)),
- # @d is mandatory (see [1, 5.3.9.6.2, Table 17, page 60])
- 'd': int(s.attrib['d']),
- 'r': r,
- })
- start_number = source.get('startNumber')
- if start_number:
- ms_info['start_number'] = int(start_number)
- timescale = source.get('timescale')
- if timescale:
- ms_info['timescale'] = int(timescale)
- segment_duration = source.get('duration')
- if segment_duration:
- ms_info['segment_duration'] = float(segment_duration)
-
- def extract_Initialization(source):
- initialization = source.find(_add_ns('Initialization'))
- if initialization is not None:
- ms_info['initialization_url'] = initialization.attrib['sourceURL']
-
- segment_list = element.find(_add_ns('SegmentList'))
- if segment_list is not None:
- extract_common(segment_list)
- extract_Initialization(segment_list)
- segment_urls_e = segment_list.findall(_add_ns('SegmentURL'))
- if segment_urls_e:
- ms_info['segment_urls'] = [segment.attrib['media'] for segment in segment_urls_e]
- else:
- segment_template = element.find(_add_ns('SegmentTemplate'))
- if segment_template is not None:
- extract_common(segment_template)
- media = segment_template.get('media')
- if media:
- ms_info['media'] = media
- initialization = segment_template.get('initialization')
- if initialization:
- ms_info['initialization'] = initialization
- else:
- extract_Initialization(segment_template)
- return ms_info
-
- mpd_duration = parse_duration(mpd_doc.get('mediaPresentationDuration'))
- formats = []
- for period in mpd_doc.findall(_add_ns('Period')):
- period_duration = parse_duration(period.get('duration')) or mpd_duration
- period_ms_info = extract_multisegment_info(period, {
- 'start_number': 1,
- 'timescale': 1,
- })
- for adaptation_set in period.findall(_add_ns('AdaptationSet')):
- if is_drm_protected(adaptation_set):
- continue
- adaption_set_ms_info = extract_multisegment_info(adaptation_set, period_ms_info)
- for representation in adaptation_set.findall(_add_ns('Representation')):
- if is_drm_protected(representation):
- continue
- representation_attrib = adaptation_set.attrib.copy()
- representation_attrib.update(representation.attrib)
- # According to [1, 5.3.7.2, Table 9, page 41], @mimeType is mandatory
- mime_type = representation_attrib['mimeType']
- content_type = mime_type.split('/')[0]
- if content_type == 'text':
- # TODO implement WebVTT downloading
- pass
- elif content_type in ('video', 'audio'):
- base_url = ''
- for element in (representation, adaptation_set, period, mpd_doc):
- base_url_e = element.find(_add_ns('BaseURL'))
- if base_url_e is not None:
- base_url = base_url_e.text + base_url
- if re.match(r'^https?://', base_url):
- break
- if mpd_base_url and not re.match(r'^https?://', base_url):
- if not mpd_base_url.endswith('/') and not base_url.startswith('/'):
- mpd_base_url += '/'
- base_url = mpd_base_url + base_url
- representation_id = representation_attrib.get('id')
- lang = representation_attrib.get('lang')
- url_el = representation.find(_add_ns('BaseURL'))
- filesize = int_or_none(url_el.attrib.get('{http://youtube.com/yt/2012/10/10}contentLength') if url_el is not None else None)
- bandwidth = int_or_none(representation_attrib.get('bandwidth'))
- f = {
- 'format_id': '%s-%s' % (mpd_id, representation_id) if mpd_id else representation_id,
- 'url': base_url,
- 'manifest_url': mpd_url,
- 'ext': mimetype2ext(mime_type),
- 'width': int_or_none(representation_attrib.get('width')),
- 'height': int_or_none(representation_attrib.get('height')),
- 'tbr': float_or_none(bandwidth, 1000),
- 'asr': int_or_none(representation_attrib.get('audioSamplingRate')),
- 'fps': int_or_none(representation_attrib.get('frameRate')),
- 'language': lang if lang not in ('mul', 'und', 'zxx', 'mis') else None,
- 'format_note': 'DASH %s' % content_type,
- 'filesize': filesize,
- 'container': mimetype2ext(mime_type) + '_dash',
- }
- f.update(parse_codecs(representation_attrib.get('codecs')))
- representation_ms_info = extract_multisegment_info(representation, adaption_set_ms_info)
-
- def prepare_template(template_name, identifiers):
- tmpl = representation_ms_info[template_name]
- # First of, % characters outside $...$ templates
- # must be escaped by doubling for proper processing
- # by % operator string formatting used further (see
- # https://github.com/rg3/youtube-dl/issues/16867).
- t = ''
- in_template = False
- for c in tmpl:
- t += c
- if c == '$':
- in_template = not in_template
- elif c == '%' and not in_template:
- t += c
- # Next, $...$ templates are translated to their
- # %(...) counterparts to be used with % operator
- t = t.replace('$RepresentationID$', representation_id)
- t = re.sub(r'\$(%s)\$' % '|'.join(identifiers), r'%(\1)d', t)
- t = re.sub(r'\$(%s)%%([^$]+)\$' % '|'.join(identifiers), r'%(\1)\2', t)
- t.replace('$$', '$')
- return t
-
- # @initialization is a regular template like @media one
- # so it should be handled just the same way (see
- # https://github.com/rg3/youtube-dl/issues/11605)
- if 'initialization' in representation_ms_info:
- initialization_template = prepare_template(
- 'initialization',
- # As per [1, 5.3.9.4.2, Table 15, page 54] $Number$ and
- # $Time$ shall not be included for @initialization thus
- # only $Bandwidth$ remains
- ('Bandwidth', ))
- representation_ms_info['initialization_url'] = initialization_template % {
- 'Bandwidth': bandwidth,
- }
-
- def location_key(location):
- return 'url' if re.match(r'^https?://', location) else 'path'
-
- if 'segment_urls' not in representation_ms_info and 'media' in representation_ms_info:
-
- media_template = prepare_template('media', ('Number', 'Bandwidth', 'Time'))
- media_location_key = location_key(media_template)
-
- # As per [1, 5.3.9.4.4, Table 16, page 55] $Number$ and $Time$
- # can't be used at the same time
- if '%(Number' in media_template and 's' not in representation_ms_info:
- segment_duration = None
- if 'total_number' not in representation_ms_info and 'segment_duration' in representation_ms_info:
- segment_duration = float_or_none(representation_ms_info['segment_duration'], representation_ms_info['timescale'])
- representation_ms_info['total_number'] = int(math.ceil(float(period_duration) / segment_duration))
- representation_ms_info['fragments'] = [{
- media_location_key: media_template % {
- 'Number': segment_number,
- 'Bandwidth': bandwidth,
- },
- 'duration': segment_duration,
- } for segment_number in range(
- representation_ms_info['start_number'],
- representation_ms_info['total_number'] + representation_ms_info['start_number'])]
- else:
- # $Number*$ or $Time$ in media template with S list available
- # Example $Number*$: http://www.svtplay.se/klipp/9023742/stopptid-om-bjorn-borg
- # Example $Time$: https://play.arkena.com/embed/avp/v2/player/media/b41dda37-d8e7-4d3f-b1b5-9a9db578bdfe/1/129411
- representation_ms_info['fragments'] = []
- segment_time = 0
- segment_d = None
- segment_number = representation_ms_info['start_number']
-
- def add_segment_url():
- segment_url = media_template % {
- 'Time': segment_time,
- 'Bandwidth': bandwidth,
- 'Number': segment_number,
- }
- representation_ms_info['fragments'].append({
- media_location_key: segment_url,
- 'duration': float_or_none(segment_d, representation_ms_info['timescale']),
- })
-
- for num, s in enumerate(representation_ms_info['s']):
- segment_time = s.get('t') or segment_time
- segment_d = s['d']
- add_segment_url()
- segment_number += 1
- for r in range(s.get('r', 0)):
- segment_time += segment_d
- add_segment_url()
- segment_number += 1
- segment_time += segment_d
- elif 'segment_urls' in representation_ms_info and 's' in representation_ms_info:
- # No media template
- # Example: https://www.youtube.com/watch?v=iXZV5uAYMJI
- # or any YouTube dashsegments video
- fragments = []
- segment_index = 0
- timescale = representation_ms_info['timescale']
- for s in representation_ms_info['s']:
- duration = float_or_none(s['d'], timescale)
- for r in range(s.get('r', 0) + 1):
- segment_uri = representation_ms_info['segment_urls'][segment_index]
- fragments.append({
- location_key(segment_uri): segment_uri,
- 'duration': duration,
- })
- segment_index += 1
- representation_ms_info['fragments'] = fragments
- elif 'segment_urls' in representation_ms_info:
- # Segment URLs with no SegmentTimeline
- # Example: https://www.seznam.cz/zpravy/clanek/cesko-zasahne-vitr-o-sile-vichrice-muze-byt-i-zivotu-nebezpecny-39091
- # https://github.com/rg3/youtube-dl/pull/14844
- fragments = []
- segment_duration = float_or_none(
- representation_ms_info['segment_duration'],
- representation_ms_info['timescale']) if 'segment_duration' in representation_ms_info else None
- for segment_url in representation_ms_info['segment_urls']:
- fragment = {
- location_key(segment_url): segment_url,
- }
- if segment_duration:
- fragment['duration'] = segment_duration
- fragments.append(fragment)
- representation_ms_info['fragments'] = fragments
- # NB: MPD manifest may contain direct URLs to unfragmented media.
- # No fragments key is present in this case.
- if 'fragments' in representation_ms_info:
- f.update({
- 'fragment_base_url': base_url,
- 'fragments': [],
- 'protocol': 'http_dash_segments',
- })
- if 'initialization_url' in representation_ms_info:
- initialization_url = representation_ms_info['initialization_url']
- if not f.get('url'):
- f['url'] = initialization_url
- f['fragments'].append({location_key(initialization_url): initialization_url})
- f['fragments'].extend(representation_ms_info['fragments'])
- # According to [1, 5.3.5.2, Table 7, page 35] @id of Representation
- # is not necessarily unique within a Period thus formats with
- # the same `format_id` are quite possible. There are numerous examples
- # of such manifests (see https://github.com/rg3/youtube-dl/issues/15111,
- # https://github.com/rg3/youtube-dl/issues/13919)
- full_info = formats_dict.get(representation_id, {}).copy()
- full_info.update(f)
- formats.append(full_info)
- else:
- self.report_warning('Unknown MIME type %s in DASH manifest' % mime_type)
- return formats
-
- def _extract_ism_formats(self, ism_url, video_id, ism_id=None, note=None, errnote=None, fatal=True):
- res = self._download_xml_handle(
- ism_url, video_id,
- note=note or 'Downloading ISM manifest',
- errnote=errnote or 'Failed to download ISM manifest',
- fatal=fatal)
- if res is False:
- return []
- ism_doc, urlh = res
-
- return self._parse_ism_formats(ism_doc, urlh.geturl(), ism_id)
-
- def _parse_ism_formats(self, ism_doc, ism_url, ism_id=None):
- """
- Parse formats from ISM manifest.
- References:
- 1. [MS-SSTR]: Smooth Streaming Protocol,
- https://msdn.microsoft.com/en-us/library/ff469518.aspx
- """
- if ism_doc.get('IsLive') == 'TRUE' or ism_doc.find('Protection') is not None:
- return []
-
- duration = int(ism_doc.attrib['Duration'])
- timescale = int_or_none(ism_doc.get('TimeScale')) or 10000000
-
- formats = []
- for stream in ism_doc.findall('StreamIndex'):
- stream_type = stream.get('Type')
- if stream_type not in ('video', 'audio'):
- continue
- url_pattern = stream.attrib['Url']
- stream_timescale = int_or_none(stream.get('TimeScale')) or timescale
- stream_name = stream.get('Name')
- for track in stream.findall('QualityLevel'):
- fourcc = track.get('FourCC', 'AACL' if track.get('AudioTag') == '255' else None)
- # TODO: add support for WVC1 and WMAP
- if fourcc not in ('H264', 'AVC1', 'AACL'):
- self.report_warning('%s is not a supported codec' % fourcc)
- continue
- tbr = int(track.attrib['Bitrate']) // 1000
- # [1] does not mention Width and Height attributes. However,
- # they're often present while MaxWidth and MaxHeight are
- # missing, so should be used as fallbacks
- width = int_or_none(track.get('MaxWidth') or track.get('Width'))
- height = int_or_none(track.get('MaxHeight') or track.get('Height'))
- sampling_rate = int_or_none(track.get('SamplingRate'))
-
- track_url_pattern = re.sub(r'{[Bb]itrate}', track.attrib['Bitrate'], url_pattern)
- track_url_pattern = compat_urlparse.urljoin(ism_url, track_url_pattern)
-
- fragments = []
- fragment_ctx = {
- 'time': 0,
- }
- stream_fragments = stream.findall('c')
- for stream_fragment_index, stream_fragment in enumerate(stream_fragments):
- fragment_ctx['time'] = int_or_none(stream_fragment.get('t')) or fragment_ctx['time']
- fragment_repeat = int_or_none(stream_fragment.get('r')) or 1
- fragment_ctx['duration'] = int_or_none(stream_fragment.get('d'))
- if not fragment_ctx['duration']:
- try:
- next_fragment_time = int(stream_fragment[stream_fragment_index + 1].attrib['t'])
- except IndexError:
- next_fragment_time = duration
- fragment_ctx['duration'] = (next_fragment_time - fragment_ctx['time']) / fragment_repeat
- for _ in range(fragment_repeat):
- fragments.append({
- 'url': re.sub(r'{start[ _]time}', compat_str(fragment_ctx['time']), track_url_pattern),
- 'duration': fragment_ctx['duration'] / stream_timescale,
- })
- fragment_ctx['time'] += fragment_ctx['duration']
-
- format_id = []
- if ism_id:
- format_id.append(ism_id)
- if stream_name:
- format_id.append(stream_name)
- format_id.append(compat_str(tbr))
-
- formats.append({
- 'format_id': '-'.join(format_id),
- 'url': ism_url,
- 'manifest_url': ism_url,
- 'ext': 'ismv' if stream_type == 'video' else 'isma',
- 'width': width,
- 'height': height,
- 'tbr': tbr,
- 'asr': sampling_rate,
- 'vcodec': 'none' if stream_type == 'audio' else fourcc,
- 'acodec': 'none' if stream_type == 'video' else fourcc,
- 'protocol': 'ism',
- 'fragments': fragments,
- '_download_params': {
- 'duration': duration,
- 'timescale': stream_timescale,
- 'width': width or 0,
- 'height': height or 0,
- 'fourcc': fourcc,
- 'codec_private_data': track.get('CodecPrivateData'),
- 'sampling_rate': sampling_rate,
- 'channels': int_or_none(track.get('Channels', 2)),
- 'bits_per_sample': int_or_none(track.get('BitsPerSample', 16)),
- 'nal_unit_length_field': int_or_none(track.get('NALUnitLengthField', 4)),
- },
- })
- return formats
-
- def _parse_html5_media_entries(self, base_url, webpage, video_id, m3u8_id=None, m3u8_entry_protocol='m3u8', mpd_id=None, preference=None):
- def absolute_url(item_url):
- return urljoin(base_url, item_url)
-
- def parse_content_type(content_type):
- if not content_type:
- return {}
- ctr = re.search(r'(?P<mimetype>[^/]+/[^;]+)(?:;\s*codecs="?(?P<codecs>[^"]+))?', content_type)
- if ctr:
- mimetype, codecs = ctr.groups()
- f = parse_codecs(codecs)
- f['ext'] = mimetype2ext(mimetype)
- return f
- return {}
-
- def _media_formats(src, cur_media_type, type_info={}):
- full_url = absolute_url(src)
- ext = type_info.get('ext') or determine_ext(full_url)
- if ext == 'm3u8':
- is_plain_url = False
- formats = self._extract_m3u8_formats(
- full_url, video_id, ext='mp4',
- entry_protocol=m3u8_entry_protocol, m3u8_id=m3u8_id,
- preference=preference, fatal=False)
- elif ext == 'mpd':
- is_plain_url = False
- formats = self._extract_mpd_formats(
- full_url, video_id, mpd_id=mpd_id, fatal=False)
- else:
- is_plain_url = True
- formats = [{
- 'url': full_url,
- 'vcodec': 'none' if cur_media_type == 'audio' else None,
- }]
- return is_plain_url, formats
-
- entries = []
- # amp-video and amp-audio are very similar to their HTML5 counterparts
- # so we wll include them right here (see
- # https://www.ampproject.org/docs/reference/components/amp-video)
- media_tags = [(media_tag, media_type, '')
- for media_tag, media_type
- in re.findall(r'(?s)(<(?:amp-)?(video|audio)[^>]*/>)', webpage)]
- media_tags.extend(re.findall(
- # We only allow video|audio followed by a whitespace or '>'.
- # Allowing more characters may end up in significant slow down (see
- # https://github.com/rg3/youtube-dl/issues/11979, example URL:
- # http://www.porntrex.com/maps/videositemap.xml).
- r'(?s)(<(?P<tag>(?:amp-)?(?:video|audio))(?:\s+[^>]*)?>)(.*?)</(?P=tag)>', webpage))
- for media_tag, media_type, media_content in media_tags:
- media_info = {
- 'formats': [],
- 'subtitles': {},
- }
- media_attributes = extract_attributes(media_tag)
- src = media_attributes.get('src')
- if src:
- _, formats = _media_formats(src, media_type)
- media_info['formats'].extend(formats)
- media_info['thumbnail'] = absolute_url(media_attributes.get('poster'))
- if media_content:
- for source_tag in re.findall(r'<source[^>]+>', media_content):
- source_attributes = extract_attributes(source_tag)
- src = source_attributes.get('src')
- if not src:
- continue
- f = parse_content_type(source_attributes.get('type'))
- is_plain_url, formats = _media_formats(src, media_type, f)
- if is_plain_url:
- # res attribute is not standard but seen several times
- # in the wild
- f.update({
- 'height': int_or_none(source_attributes.get('res')),
- 'format_id': source_attributes.get('label'),
- })
- f.update(formats[0])
- media_info['formats'].append(f)
- else:
- media_info['formats'].extend(formats)
- for track_tag in re.findall(r'<track[^>]+>', media_content):
- track_attributes = extract_attributes(track_tag)
- kind = track_attributes.get('kind')
- if not kind or kind in ('subtitles', 'captions'):
- src = track_attributes.get('src')
- if not src:
- continue
- lang = track_attributes.get('srclang') or track_attributes.get('lang') or track_attributes.get('label')
- media_info['subtitles'].setdefault(lang, []).append({
- 'url': absolute_url(src),
- })
- for f in media_info['formats']:
- f.setdefault('http_headers', {})['Referer'] = base_url
- if media_info['formats'] or media_info['subtitles']:
- entries.append(media_info)
- return entries
-
- def _extract_akamai_formats(self, manifest_url, video_id, hosts={}):
- formats = []
- hdcore_sign = 'hdcore=3.7.0'
- f4m_url = re.sub(r'(https?://[^/]+)/i/', r'\1/z/', manifest_url).replace('/master.m3u8', '/manifest.f4m')
- hds_host = hosts.get('hds')
- if hds_host:
- f4m_url = re.sub(r'(https?://)[^/]+', r'\1' + hds_host, f4m_url)
- if 'hdcore=' not in f4m_url:
- f4m_url += ('&' if '?' in f4m_url else '?') + hdcore_sign
- f4m_formats = self._extract_f4m_formats(
- f4m_url, video_id, f4m_id='hds', fatal=False)
- for entry in f4m_formats:
- entry.update({'extra_param_to_segment_url': hdcore_sign})
- formats.extend(f4m_formats)
- m3u8_url = re.sub(r'(https?://[^/]+)/z/', r'\1/i/', manifest_url).replace('/manifest.f4m', '/master.m3u8')
- hls_host = hosts.get('hls')
- if hls_host:
- m3u8_url = re.sub(r'(https?://)[^/]+', r'\1' + hls_host, m3u8_url)
- formats.extend(self._extract_m3u8_formats(
- m3u8_url, video_id, 'mp4', 'm3u8_native',
- m3u8_id='hls', fatal=False))
- return formats
-
- def _extract_wowza_formats(self, url, video_id, m3u8_entry_protocol='m3u8_native', skip_protocols=[]):
- query = compat_urlparse.urlparse(url).query
- url = re.sub(r'/(?:manifest|playlist|jwplayer)\.(?:m3u8|f4m|mpd|smil)', '', url)
- mobj = re.search(
- r'(?:(?:http|rtmp|rtsp)(?P<s>s)?:)?(?P<url>//[^?]+)', url)
- url_base = mobj.group('url')
- http_base_url = '%s%s:%s' % ('http', mobj.group('s') or '', url_base)
- formats = []
-
- def manifest_url(manifest):
- m_url = '%s/%s' % (http_base_url, manifest)
- if query:
- m_url += '?%s' % query
- return m_url
-
- if 'm3u8' not in skip_protocols:
- formats.extend(self._extract_m3u8_formats(
- manifest_url('playlist.m3u8'), video_id, 'mp4',
- m3u8_entry_protocol, m3u8_id='hls', fatal=False))
- if 'f4m' not in skip_protocols:
- formats.extend(self._extract_f4m_formats(
- manifest_url('manifest.f4m'),
- video_id, f4m_id='hds', fatal=False))
- if 'dash' not in skip_protocols:
- formats.extend(self._extract_mpd_formats(
- manifest_url('manifest.mpd'),
- video_id, mpd_id='dash', fatal=False))
- if re.search(r'(?:/smil:|\.smil)', url_base):
- if 'smil' not in skip_protocols:
- rtmp_formats = self._extract_smil_formats(
- manifest_url('jwplayer.smil'),
- video_id, fatal=False)
- for rtmp_format in rtmp_formats:
- rtsp_format = rtmp_format.copy()
- rtsp_format['url'] = '%s/%s' % (rtmp_format['url'], rtmp_format['play_path'])
- del rtsp_format['play_path']
- del rtsp_format['ext']
- rtsp_format.update({
- 'url': rtsp_format['url'].replace('rtmp://', 'rtsp://'),
- 'format_id': rtmp_format['format_id'].replace('rtmp', 'rtsp'),
- 'protocol': 'rtsp',
- })
- formats.extend([rtmp_format, rtsp_format])
- else:
- for protocol in ('rtmp', 'rtsp'):
- if protocol not in skip_protocols:
- formats.append({
- 'url': '%s:%s' % (protocol, url_base),
- 'format_id': protocol,
- 'protocol': protocol,
- })
- return formats
-
- def _find_jwplayer_data(self, webpage, video_id=None, transform_source=js_to_json):
- mobj = re.search(
- r'(?s)jwplayer\((?P<quote>[\'"])[^\'" ]+(?P=quote)\)(?!</script>).*?\.setup\s*\((?P<options>[^)]+)\)',
- webpage)
- if mobj:
- try:
- jwplayer_data = self._parse_json(mobj.group('options'),
- video_id=video_id,
- transform_source=transform_source)
- except ExtractorError:
- pass
- else:
- if isinstance(jwplayer_data, dict):
- return jwplayer_data
-
- def _extract_jwplayer_data(self, webpage, video_id, *args, **kwargs):
- jwplayer_data = self._find_jwplayer_data(
- webpage, video_id, transform_source=js_to_json)
- return self._parse_jwplayer_data(
- jwplayer_data, video_id, *args, **kwargs)
-
- def _parse_jwplayer_data(self, jwplayer_data, video_id=None, require_title=True,
- m3u8_id=None, mpd_id=None, rtmp_params=None, base_url=None):
- # JWPlayer backward compatibility: flattened playlists
- # https://github.com/jwplayer/jwplayer/blob/v7.4.3/src/js/api/config.js#L81-L96
- if 'playlist' not in jwplayer_data:
- jwplayer_data = {'playlist': [jwplayer_data]}
-
- entries = []
-
- # JWPlayer backward compatibility: single playlist item
- # https://github.com/jwplayer/jwplayer/blob/v7.7.0/src/js/playlist/playlist.js#L10
- if not isinstance(jwplayer_data['playlist'], list):
- jwplayer_data['playlist'] = [jwplayer_data['playlist']]
-
- for video_data in jwplayer_data['playlist']:
- # JWPlayer backward compatibility: flattened sources
- # https://github.com/jwplayer/jwplayer/blob/v7.4.3/src/js/playlist/item.js#L29-L35
- if 'sources' not in video_data:
- video_data['sources'] = [video_data]
-
- this_video_id = video_id or video_data['mediaid']
-
- formats = self._parse_jwplayer_formats(
- video_data['sources'], video_id=this_video_id, m3u8_id=m3u8_id,
- mpd_id=mpd_id, rtmp_params=rtmp_params, base_url=base_url)
-
- subtitles = {}
- tracks = video_data.get('tracks')
- if tracks and isinstance(tracks, list):
- for track in tracks:
- if not isinstance(track, dict):
- continue
- track_kind = track.get('kind')
- if not track_kind or not isinstance(track_kind, compat_str):
- continue
- if track_kind.lower() not in ('captions', 'subtitles'):
- continue
- track_url = urljoin(base_url, track.get('file'))
- if not track_url:
- continue
- subtitles.setdefault(track.get('label') or 'en', []).append({
- 'url': self._proto_relative_url(track_url)
- })
-
- entry = {
- 'id': this_video_id,
- 'title': unescapeHTML(video_data['title'] if require_title else video_data.get('title')),
- 'description': video_data.get('description'),
- 'thumbnail': self._proto_relative_url(video_data.get('image')),
- 'timestamp': int_or_none(video_data.get('pubdate')),
- 'duration': float_or_none(jwplayer_data.get('duration') or video_data.get('duration')),
- 'subtitles': subtitles,
- }
- # https://github.com/jwplayer/jwplayer/blob/master/src/js/utils/validator.js#L32
- if len(formats) == 1 and re.search(r'^(?:http|//).*(?:youtube\.com|youtu\.be)/.+', formats[0]['url']):
- entry.update({
- '_type': 'url_transparent',
- 'url': formats[0]['url'],
- })
- else:
- self._sort_formats(formats)
- entry['formats'] = formats
- entries.append(entry)
- if len(entries) == 1:
- return entries[0]
- else:
- return self.playlist_result(entries)
-
- def _parse_jwplayer_formats(self, jwplayer_sources_data, video_id=None,
- m3u8_id=None, mpd_id=None, rtmp_params=None, base_url=None):
- urls = []
- formats = []
- for source in jwplayer_sources_data:
- if not isinstance(source, dict):
- continue
- source_url = self._proto_relative_url(source.get('file'))
- if not source_url:
- continue
- if base_url:
- source_url = compat_urlparse.urljoin(base_url, source_url)
- if source_url in urls:
- continue
- urls.append(source_url)
- source_type = source.get('type') or ''
- ext = mimetype2ext(source_type) or determine_ext(source_url)
- if source_type == 'hls' or ext == 'm3u8':
- formats.extend(self._extract_m3u8_formats(
- source_url, video_id, 'mp4', entry_protocol='m3u8_native',
- m3u8_id=m3u8_id, fatal=False))
- elif source_type == 'dash' or ext == 'mpd':
- formats.extend(self._extract_mpd_formats(
- source_url, video_id, mpd_id=mpd_id, fatal=False))
- elif ext == 'smil':
- formats.extend(self._extract_smil_formats(
- source_url, video_id, fatal=False))
- # https://github.com/jwplayer/jwplayer/blob/master/src/js/providers/default.js#L67
- elif source_type.startswith('audio') or ext in (
- 'oga', 'aac', 'mp3', 'mpeg', 'vorbis'):
- formats.append({
- 'url': source_url,
- 'vcodec': 'none',
- 'ext': ext,
- })
- else:
- height = int_or_none(source.get('height'))
- if height is None:
- # Often no height is provided but there is a label in
- # format like "1080p", "720p SD", or 1080.
- height = int_or_none(self._search_regex(
- r'^(\d{3,4})[pP]?(?:\b|$)', compat_str(source.get('label') or ''),
- 'height', default=None))
- a_format = {
- 'url': source_url,
- 'width': int_or_none(source.get('width')),
- 'height': height,
- 'tbr': int_or_none(source.get('bitrate')),
- 'ext': ext,
- }
- if source_url.startswith('rtmp'):
- a_format['ext'] = 'flv'
- # See com/longtailvideo/jwplayer/media/RTMPMediaProvider.as
- # of jwplayer.flash.swf
- rtmp_url_parts = re.split(
- r'((?:mp4|mp3|flv):)', source_url, 1)
- if len(rtmp_url_parts) == 3:
- rtmp_url, prefix, play_path = rtmp_url_parts
- a_format.update({
- 'url': rtmp_url,
- 'play_path': prefix + play_path,
- })
- if rtmp_params:
- a_format.update(rtmp_params)
- formats.append(a_format)
- return formats
-
- def _live_title(self, name):
- """ Generate the title for a live video """
- now = datetime.datetime.now()
- now_str = now.strftime('%Y-%m-%d %H:%M')
- return name + ' ' + now_str
-
- def _int(self, v, name, fatal=False, **kwargs):
- res = int_or_none(v, **kwargs)
- if 'get_attr' in kwargs:
- print(getattr(v, kwargs['get_attr']))
- if res is None:
- msg = 'Failed to extract %s: Could not parse value %r' % (name, v)
- if fatal:
- raise ExtractorError(msg)
- else:
- self._downloader.report_warning(msg)
- return res
-
- def _float(self, v, name, fatal=False, **kwargs):
- res = float_or_none(v, **kwargs)
- if res is None:
- msg = 'Failed to extract %s: Could not parse value %r' % (name, v)
- if fatal:
- raise ExtractorError(msg)
- else:
- self._downloader.report_warning(msg)
- return res
-
- def _set_cookie(self, domain, name, value, expire_time=None, port=None,
- path='/', secure=False, discard=False, rest={}, **kwargs):
- cookie = compat_cookiejar.Cookie(
- 0, name, value, port, port is not None, domain, True,
- domain.startswith('.'), path, True, secure, expire_time,
- discard, None, None, rest)
- self._downloader.cookiejar.set_cookie(cookie)
-
- def _get_cookies(self, url):
- """ Return a compat_cookies.SimpleCookie with the cookies for the url """
- req = sanitized_Request(url)
- self._downloader.cookiejar.add_cookie_header(req)
- return compat_cookies.SimpleCookie(req.get_header('Cookie'))
-
- def get_testcases(self, include_onlymatching=False):
- t = getattr(self, '_TEST', None)
- if t:
- assert not hasattr(self, '_TESTS'), \
- '%s has _TEST and _TESTS' % type(self).__name__
- tests = [t]
- else:
- tests = getattr(self, '_TESTS', [])
- for t in tests:
- if not include_onlymatching and t.get('only_matching', False):
- continue
- t['name'] = type(self).__name__[:-len('IE')]
- yield t
-
- def is_suitable(self, age_limit):
- """ Test whether the extractor is generally suitable for the given
- age limit (i.e. pornographic sites are not, all others usually are) """
-
- any_restricted = False
- for tc in self.get_testcases(include_onlymatching=False):
- if tc.get('playlist', []):
- tc = tc['playlist'][0]
- is_restricted = age_restricted(
- tc.get('info_dict', {}).get('age_limit'), age_limit)
- if not is_restricted:
- return True
- any_restricted = any_restricted or is_restricted
- return not any_restricted
-
- def extract_subtitles(self, *args, **kwargs):
- if (self._downloader.params.get('writesubtitles', False) or
- self._downloader.params.get('listsubtitles')):
- return self._get_subtitles(*args, **kwargs)
- return {}
-
- def _get_subtitles(self, *args, **kwargs):
- raise NotImplementedError('This method must be implemented by subclasses')
-
- @staticmethod
- def _merge_subtitle_items(subtitle_list1, subtitle_list2):
- """ Merge subtitle items for one language. Items with duplicated URLs
- will be dropped. """
- list1_urls = set([item['url'] for item in subtitle_list1])
- ret = list(subtitle_list1)
- ret.extend([item for item in subtitle_list2 if item['url'] not in list1_urls])
- return ret
-
- @classmethod
- def _merge_subtitles(cls, subtitle_dict1, subtitle_dict2):
- """ Merge two subtitle dictionaries, language by language. """
- ret = dict(subtitle_dict1)
- for lang in subtitle_dict2:
- ret[lang] = cls._merge_subtitle_items(subtitle_dict1.get(lang, []), subtitle_dict2[lang])
- return ret
-
- def extract_automatic_captions(self, *args, **kwargs):
- if (self._downloader.params.get('writeautomaticsub', False) or
- self._downloader.params.get('listsubtitles')):
- return self._get_automatic_captions(*args, **kwargs)
- return {}
-
- def _get_automatic_captions(self, *args, **kwargs):
- raise NotImplementedError('This method must be implemented by subclasses')
-
- def mark_watched(self, *args, **kwargs):
- if (self._downloader.params.get('mark_watched', False) and
- (self._get_login_info()[0] is not None or
- self._downloader.params.get('cookiefile') is not None)):
- self._mark_watched(*args, **kwargs)
-
- def _mark_watched(self, *args, **kwargs):
- raise NotImplementedError('This method must be implemented by subclasses')
-
- def geo_verification_headers(self):
- headers = {}
- geo_verification_proxy = self._downloader.params.get('geo_verification_proxy')
- if geo_verification_proxy:
- headers['Ytdl-request-proxy'] = geo_verification_proxy
- return headers
-
- def _generic_id(self, url):
- return compat_urllib_parse_unquote(os.path.splitext(url.rstrip('/').split('/')[-1])[0])
-
- def _generic_title(self, url):
- return compat_urllib_parse_unquote(os.path.splitext(url_basename(url))[0])
-
-
-class SearchInfoExtractor(InfoExtractor):
- """
- Base class for paged search queries extractors.
- They accept URLs in the format _SEARCH_KEY(|all|[0-9]):{query}
- Instances should define _SEARCH_KEY and _MAX_RESULTS.
- """
-
- @classmethod
- def _make_valid_url(cls):
- return r'%s(?P<prefix>|[1-9][0-9]*|all):(?P<query>[\s\S]+)' % cls._SEARCH_KEY
-
- @classmethod
- def suitable(cls, url):
- return re.match(cls._make_valid_url(), url) is not None
-
- def _real_extract(self, query):
- mobj = re.match(self._make_valid_url(), query)
- if mobj is None:
- raise ExtractorError('Invalid search query "%s"' % query)
-
- prefix = mobj.group('prefix')
- query = mobj.group('query')
- if prefix == '':
- return self._get_n_results(query, 1)
- elif prefix == 'all':
- return self._get_n_results(query, self._MAX_RESULTS)
- else:
- n = int(prefix)
- if n <= 0:
- raise ExtractorError('invalid download number %s for query "%s"' % (n, query))
- elif n > self._MAX_RESULTS:
- self._downloader.report_warning('%s returns max %i results (you requested %i)' % (self._SEARCH_KEY, self._MAX_RESULTS, n))
- n = self._MAX_RESULTS
- return self._get_n_results(query, n)
-
- def _get_n_results(self, query, n):
- """Get a specified number of results for a query"""
- raise NotImplementedError('This method must be implemented by subclasses')
-
- @property
- def SEARCH_KEY(self):
- return self._SEARCH_KEY
diff --git a/youtube_dl/extractor/commonmistakes.py b/youtube_dl/extractor/commonmistakes.py
deleted file mode 100644
index 79f7a9c..0000000
--- a/youtube_dl/extractor/commonmistakes.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from __future__ import unicode_literals
-
-import sys
-
-from .common import InfoExtractor
-from ..utils import ExtractorError
-
-
-class CommonMistakesIE(InfoExtractor):
- IE_DESC = False # Do not list
- _VALID_URL = r'''(?x)
- (?:url|URL)$
- '''
-
- _TESTS = [{
- 'url': 'url',
- 'only_matching': True,
- }, {
- 'url': 'URL',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- msg = (
- 'You\'ve asked youtube-dl to download the URL "%s". '
- 'That doesn\'t make any sense. '
- 'Simply remove the parameter in your command or configuration.'
- ) % url
- if not self._downloader.params.get('verbose'):
- msg += ' Add -v to the command line to see what arguments and configuration youtube-dl got.'
- raise ExtractorError(msg, expected=True)
-
-
-class UnicodeBOMIE(InfoExtractor):
- IE_DESC = False
- _VALID_URL = r'(?P<bom>\ufeff)(?P<id>.*)$'
-
- # Disable test for python 3.2 since BOM is broken in re in this version
- # (see https://github.com/rg3/youtube-dl/issues/9751)
- _TESTS = [] if (3, 0) < sys.version_info <= (3, 3) else [{
- 'url': '\ufeffhttp://www.youtube.com/watch?v=BaW_jenozKc',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- real_url = self._match_id(url)
- self.report_warning(
- 'Your URL starts with a Byte Order Mark (BOM). '
- 'Removing the BOM and looking for "%s" ...' % real_url)
- return self.url_result(real_url)
diff --git a/youtube_dl/extractor/commonprotocols.py b/youtube_dl/extractor/commonprotocols.py
deleted file mode 100644
index d98331a..0000000
--- a/youtube_dl/extractor/commonprotocols.py
+++ /dev/null
@@ -1,60 +0,0 @@
-from __future__ import unicode_literals
-
-from .common import InfoExtractor
-from ..compat import (
- compat_urlparse,
-)
-
-
-class RtmpIE(InfoExtractor):
- IE_DESC = False # Do not list
- _VALID_URL = r'(?i)rtmp[est]?://.+'
-
- _TESTS = [{
- 'url': 'rtmp://cp44293.edgefcs.net/ondemand?auth=daEcTdydfdqcsb8cZcDbAaCbhamacbbawaS-bw7dBb-bWG-GqpGFqCpNCnGoyL&aifp=v001&slist=public/unsecure/audio/2c97899446428e4301471a8cb72b4b97--audio--pmg-20110908-0900a_flv_aac_med_int.mp4',
- 'only_matching': True,
- }, {
- 'url': 'rtmp://edge.live.hitbox.tv/live/dimak',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- video_id = self._generic_id(url)
- title = self._generic_title(url)
- return {
- 'id': video_id,
- 'title': title,
- 'formats': [{
- 'url': url,
- 'ext': 'flv',
- 'format_id': compat_urlparse.urlparse(url).scheme,
- }],
- }
-
-
-class MmsIE(InfoExtractor):
- IE_DESC = False # Do not list
- _VALID_URL = r'(?i)mms://.+'
-
- _TEST = {
- # Direct MMS link
- 'url': 'mms://kentro.kaist.ac.kr/200907/MilesReid(0709).wmv',
- 'info_dict': {
- 'id': 'MilesReid(0709)',
- 'ext': 'wmv',
- 'title': 'MilesReid(0709)',
- },
- 'params': {
- 'skip_download': True, # rtsp downloads, requiring mplayer or mpv
- },
- }
-
- def _real_extract(self, url):
- video_id = self._generic_id(url)
- title = self._generic_title(url)
-
- return {
- 'id': video_id,
- 'title': title,
- 'url': url,
- }
diff --git a/youtube_dl/extractor/extractors.py b/youtube_dl/extractor/extractors.py
deleted file mode 100644
index ceb72da..0000000
--- a/youtube_dl/extractor/extractors.py
+++ /dev/null
@@ -1,31 +0,0 @@
-# flake8: noqa
-from __future__ import unicode_literals
-
-
-from .commonmistakes import CommonMistakesIE, UnicodeBOMIE
-from .commonprotocols import (
- MmsIE,
- RtmpIE,
-)
-
-from .openload import OpenloadIE
-
-from .youtube import (
- YoutubeIE,
- YoutubeChannelIE,
- YoutubeFavouritesIE,
- YoutubeHistoryIE,
- YoutubeLiveIE,
- YoutubePlaylistIE,
- YoutubePlaylistsIE,
- YoutubeRecommendedIE,
- YoutubeSearchDateIE,
- YoutubeSearchIE,
- YoutubeSearchURLIE,
- YoutubeShowIE,
- YoutubeSubscriptionsIE,
- YoutubeTruncatedIDIE,
- YoutubeTruncatedURLIE,
- YoutubeUserIE,
- YoutubeWatchLaterIE,
-)
diff --git a/youtube_dl/extractor/generic.py b/youtube_dl/extractor/generic.py
deleted file mode 100644
index aa04905..0000000
--- a/youtube_dl/extractor/generic.py
+++ /dev/null
@@ -1,3335 +0,0 @@
-# coding: utf-8
-
-from __future__ import unicode_literals
-
-import os
-import re
-import sys
-
-from .common import InfoExtractor
-from .youtube import YoutubeIE
-from ..compat import (
- compat_etree_fromstring,
- compat_str,
- compat_urllib_parse_unquote,
- compat_urlparse,
- compat_xml_parse_error,
-)
-from ..utils import (
- determine_ext,
- ExtractorError,
- float_or_none,
- HEADRequest,
- is_html,
- js_to_json,
- KNOWN_EXTENSIONS,
- merge_dicts,
- mimetype2ext,
- orderedSet,
- sanitized_Request,
- smuggle_url,
- unescapeHTML,
- unified_strdate,
- unsmuggle_url,
- UnsupportedError,
- xpath_text,
-)
-from .commonprotocols import RtmpIE
-from .brightcove import (
- BrightcoveLegacyIE,
- BrightcoveNewIE,
-)
-from .nexx import (
- NexxIE,
- NexxEmbedIE,
-)
-from .nbc import NBCSportsVPlayerIE
-from .ooyala import OoyalaIE
-from .rutv import RUTVIE
-from .tvc import TVCIE
-from .sportbox import SportBoxEmbedIE
-from .smotri import SmotriIE
-from .myvi import MyviIE
-from .condenast import CondeNastIE
-from .udn import UDNEmbedIE
-from .senateisvp import SenateISVPIE
-from .svt import SVTIE
-from .pornhub import PornHubIE
-from .xhamster import XHamsterEmbedIE
-from .tnaflix import TNAFlixNetworkEmbedIE
-from .drtuber import DrTuberIE
-from .redtube import RedTubeIE
-from .tube8 import Tube8IE
-from .vimeo import VimeoIE
-from .dailymotion import DailymotionIE
-from .dailymail import DailyMailIE
-from .onionstudios import OnionStudiosIE
-from .viewlift import ViewLiftEmbedIE
-from .mtv import MTVServicesEmbeddedIE
-from .pladform import PladformIE
-from .videomore import VideomoreIE
-from .webcaster import WebcasterFeedIE
-from .googledrive import GoogleDriveIE
-from .jwplatform import JWPlatformIE
-from .digiteka import DigitekaIE
-from .arkena import ArkenaIE
-from .instagram import InstagramIE
-from .liveleak import LiveLeakIE
-from .threeqsdn import ThreeQSDNIE
-from .theplatform import ThePlatformIE
-from .vessel import VesselIE
-from .kaltura import KalturaIE
-from .eagleplatform import EaglePlatformIE
-from .facebook import FacebookIE
-from .soundcloud import SoundcloudIE
-from .tunein import TuneInBaseIE
-from .vbox7 import Vbox7IE
-from .dbtv import DBTVIE
-from .piksel import PikselIE
-from .videa import VideaIE
-from .twentymin import TwentyMinutenIE
-from .ustream import UstreamIE
-from .openload import OpenloadIE
-from .videopress import VideoPressIE
-from .rutube import RutubeIE
-from .limelight import LimelightBaseIE
-from .anvato import AnvatoIE
-from .washingtonpost import WashingtonPostIE
-from .wistia import WistiaIE
-from .mediaset import MediasetIE
-from .joj import JojIE
-from .megaphone import MegaphoneIE
-from .vzaar import VzaarIE
-from .channel9 import Channel9IE
-from .vshare import VShareIE
-from .mediasite import MediasiteIE
-from .springboardplatform import SpringboardPlatformIE
-from .yapfiles import YapFilesIE
-from .vice import ViceIE
-from .xfileshare import XFileShareIE
-from .cloudflarestream import CloudflareStreamIE
-from .peertube import PeerTubeIE
-from .indavideo import IndavideoEmbedIE
-from .apa import APAIE
-from .foxnews import FoxNewsIE
-
-
-class GenericIE(InfoExtractor):
- IE_DESC = 'Generic downloader that works on some sites'
- _VALID_URL = r'.*'
- IE_NAME = 'generic'
- _TESTS = [
- # Direct link to a video
- {
- 'url': 'http://media.w3.org/2010/05/sintel/trailer.mp4',
- 'md5': '67d406c2bcb6af27fa886f31aa934bbe',
- 'info_dict': {
- 'id': 'trailer',
- 'ext': 'mp4',
- 'title': 'trailer',
- 'upload_date': '20100513',
- }
- },
- # Direct link to media delivered compressed (until Accept-Encoding is *)
- {
- 'url': 'http://calimero.tk/muzik/FictionJunction-Parallel_Hearts.flac',
- 'md5': '128c42e68b13950268b648275386fc74',
- 'info_dict': {
- 'id': 'FictionJunction-Parallel_Hearts',
- 'ext': 'flac',
- 'title': 'FictionJunction-Parallel_Hearts',
- 'upload_date': '20140522',
- },
- 'expected_warnings': [
- 'URL could be a direct video link, returning it as such.'
- ],
- 'skip': 'URL invalid',
- },
- # Direct download with broken HEAD
- {
- 'url': 'http://ai-radio.org:8000/radio.opus',
- 'info_dict': {
- 'id': 'radio',
- 'ext': 'opus',
- 'title': 'radio',
- },
- 'params': {
- 'skip_download': True, # infinite live stream
- },
- 'expected_warnings': [
- r'501.*Not Implemented',
- r'400.*Bad Request',
- ],
- },
- # Direct link with incorrect MIME type
- {
- 'url': 'http://ftp.nluug.nl/video/nluug/2014-11-20_nj14/zaal-2/5_Lennart_Poettering_-_Systemd.webm',
- 'md5': '4ccbebe5f36706d85221f204d7eb5913',
- 'info_dict': {
- 'url': 'http://ftp.nluug.nl/video/nluug/2014-11-20_nj14/zaal-2/5_Lennart_Poettering_-_Systemd.webm',
- 'id': '5_Lennart_Poettering_-_Systemd',
- 'ext': 'webm',
- 'title': '5_Lennart_Poettering_-_Systemd',
- 'upload_date': '20141120',
- },
- 'expected_warnings': [
- 'URL could be a direct video link, returning it as such.'
- ]
- },
- # RSS feed
- {
- 'url': 'http://phihag.de/2014/youtube-dl/rss2.xml',
- 'info_dict': {
- 'id': 'http://phihag.de/2014/youtube-dl/rss2.xml',
- 'title': 'Zero Punctuation',
- 'description': 're:.*groundbreaking video review series.*'
- },
- 'playlist_mincount': 11,
- },
- # RSS feed with enclosure
- {
- 'url': 'http://podcastfeeds.nbcnews.com/audio/podcast/MSNBC-MADDOW-NETCAST-M4V.xml',
- 'info_dict': {
- 'id': 'pdv_maddow_netcast_m4v-02-27-2015-201624',
- 'ext': 'm4v',
- 'upload_date': '20150228',
- 'title': 'pdv_maddow_netcast_m4v-02-27-2015-201624',
- }
- },
- # RSS feed with enclosures and unsupported link URLs
- {
- 'url': 'http://www.hellointernet.fm/podcast?format=rss',
- 'info_dict': {
- 'id': 'http://www.hellointernet.fm/podcast?format=rss',
- 'description': 'CGP Grey and Brady Haran talk about YouTube, life, work, whatever.',
- 'title': 'Hello Internet',
- },
- 'playlist_mincount': 100,
- },
- # SMIL from http://videolectures.net/promogram_igor_mekjavic_eng
- {
- 'url': 'http://videolectures.net/promogram_igor_mekjavic_eng/video/1/smil.xml',
- 'info_dict': {
- 'id': 'smil',
- 'ext': 'mp4',
- 'title': 'Automatics, robotics and biocybernetics',
- 'description': 'md5:815fc1deb6b3a2bff99de2d5325be482',
- 'upload_date': '20130627',
- 'formats': 'mincount:16',
- 'subtitles': 'mincount:1',
- },
- 'params': {
- 'force_generic_extractor': True,
- 'skip_download': True,
- },
- },
- # SMIL from http://www1.wdr.de/mediathek/video/livestream/index.html
- {
- 'url': 'http://metafilegenerator.de/WDR/WDR_FS/hds/hds.smil',
- 'info_dict': {
- 'id': 'hds',
- 'ext': 'flv',
- 'title': 'hds',
- 'formats': 'mincount:1',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # SMIL from https://www.restudy.dk/video/play/id/1637
- {
- 'url': 'https://www.restudy.dk/awsmedia/SmilDirectory/video_1637.xml',
- 'info_dict': {
- 'id': 'video_1637',
- 'ext': 'flv',
- 'title': 'video_1637',
- 'formats': 'mincount:3',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # SMIL from http://adventure.howstuffworks.com/5266-cool-jobs-iditarod-musher-video.htm
- {
- 'url': 'http://services.media.howstuffworks.com/videos/450221/smil-service.smil',
- 'info_dict': {
- 'id': 'smil-service',
- 'ext': 'flv',
- 'title': 'smil-service',
- 'formats': 'mincount:1',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # SMIL from http://new.livestream.com/CoheedandCambria/WebsterHall/videos/4719370
- {
- 'url': 'http://api.new.livestream.com/accounts/1570303/events/1585861/videos/4719370.smil',
- 'info_dict': {
- 'id': '4719370',
- 'ext': 'mp4',
- 'title': '571de1fd-47bc-48db-abf9-238872a58d1f',
- 'formats': 'mincount:3',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # XSPF playlist from http://www.telegraaf.nl/tv/nieuws/binnenland/24353229/__Tikibad_ontruimd_wegens_brand__.html
- {
- 'url': 'http://www.telegraaf.nl/xml/playlist/2015/8/7/mZlp2ctYIUEB.xspf',
- 'info_dict': {
- 'id': 'mZlp2ctYIUEB',
- 'ext': 'mp4',
- 'title': 'Tikibad ontruimd wegens brand',
- 'description': 'md5:05ca046ff47b931f9b04855015e163a4',
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'duration': 33,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # MPD from http://dash-mse-test.appspot.com/media.html
- {
- 'url': 'http://yt-dash-mse-test.commondatastorage.googleapis.com/media/car-20120827-manifest.mpd',
- 'md5': '4b57baab2e30d6eb3a6a09f0ba57ef53',
- 'info_dict': {
- 'id': 'car-20120827-manifest',
- 'ext': 'mp4',
- 'title': 'car-20120827-manifest',
- 'formats': 'mincount:9',
- 'upload_date': '20130904',
- },
- 'params': {
- 'format': 'bestvideo',
- },
- },
- # m3u8 served with Content-Type: audio/x-mpegURL; charset=utf-8
- {
- 'url': 'http://once.unicornmedia.com/now/master/playlist/bb0b18ba-64f5-4b1b-a29f-0ac252f06b68/77a785f3-5188-4806-b788-0893a61634ed/93677179-2d99-4ef4-9e17-fe70d49abfbf/content.m3u8',
- 'info_dict': {
- 'id': 'content',
- 'ext': 'mp4',
- 'title': 'content',
- 'formats': 'mincount:8',
- },
- 'params': {
- # m3u8 downloads
- 'skip_download': True,
- },
- 'skip': 'video gone',
- },
- # m3u8 served with Content-Type: text/plain
- {
- 'url': 'http://www.nacentapps.com/m3u8/index.m3u8',
- 'info_dict': {
- 'id': 'index',
- 'ext': 'mp4',
- 'title': 'index',
- 'upload_date': '20140720',
- 'formats': 'mincount:11',
- },
- 'params': {
- # m3u8 downloads
- 'skip_download': True,
- },
- 'skip': 'video gone',
- },
- # google redirect
- {
- 'url': 'http://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=1&cad=rja&ved=0CCUQtwIwAA&url=http%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DcmQHVoWB5FY&ei=F-sNU-LLCaXk4QT52ICQBQ&usg=AFQjCNEw4hL29zgOohLXvpJ-Bdh2bils1Q&bvm=bv.61965928,d.bGE',
- 'info_dict': {
- 'id': 'cmQHVoWB5FY',
- 'ext': 'mp4',
- 'upload_date': '20130224',
- 'uploader_id': 'TheVerge',
- 'description': r're:^Chris Ziegler takes a look at the\.*',
- 'uploader': 'The Verge',
- 'title': 'First Firefox OS phones side-by-side',
- },
- 'params': {
- 'skip_download': False,
- }
- },
- {
- # redirect in Refresh HTTP header
- 'url': 'https://www.facebook.com/l.php?u=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DpO8h3EaFRdo&h=TAQHsoToz&enc=AZN16h-b6o4Zq9pZkCCdOLNKMN96BbGMNtcFwHSaazus4JHT_MFYkAA-WARTX2kvsCIdlAIyHZjl6d33ILIJU7Jzwk_K3mcenAXoAzBNoZDI_Q7EXGDJnIhrGkLXo_LJ_pAa2Jzbx17UHMd3jAs--6j2zaeto5w9RTn8T_1kKg3fdC5WPX9Dbb18vzH7YFX0eSJmoa6SP114rvlkw6pkS1-T&s=1',
- 'info_dict': {
- 'id': 'pO8h3EaFRdo',
- 'ext': 'mp4',
- 'title': 'Tripeo Boiler Room x Dekmantel Festival DJ Set',
- 'description': 'md5:6294cc1af09c4049e0652b51a2df10d5',
- 'upload_date': '20150917',
- 'uploader_id': 'brtvofficial',
- 'uploader': 'Boiler Room',
- },
- 'params': {
- 'skip_download': False,
- },
- },
- {
- 'url': 'http://www.hodiho.fr/2013/02/regis-plante-sa-jeep.html',
- 'md5': '85b90ccc9d73b4acd9138d3af4c27f89',
- 'info_dict': {
- 'id': '13601338388002',
- 'ext': 'mp4',
- 'uploader': 'www.hodiho.fr',
- 'title': 'R\u00e9gis plante sa Jeep',
- }
- },
- # bandcamp page with custom domain
- {
- 'add_ie': ['Bandcamp'],
- 'url': 'http://bronyrock.com/track/the-pony-mash',
- 'info_dict': {
- 'id': '3235767654',
- 'ext': 'mp3',
- 'title': 'The Pony Mash',
- 'uploader': 'M_Pallante',
- },
- 'skip': 'There is a limit of 200 free downloads / month for the test song',
- },
- {
- # embedded brightcove video
- # it also tests brightcove videos that need to set the 'Referer'
- # in the http requests
- 'add_ie': ['BrightcoveLegacy'],
- 'url': 'http://www.bfmtv.com/video/bfmbusiness/cours-bourse/cours-bourse-l-analyse-technique-154522/',
- 'info_dict': {
- 'id': '2765128793001',
- 'ext': 'mp4',
- 'title': 'Le cours de bourse : l’analyse technique',
- 'description': 'md5:7e9ad046e968cb2d1114004aba466fd9',
- 'uploader': 'BFM BUSINESS',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # embedded with itemprop embedURL and video id spelled as `idVideo`
- 'add_id': ['BrightcoveLegacy'],
- 'url': 'http://bfmbusiness.bfmtv.com/mediaplayer/chroniques/olivier-delamarche/',
- 'info_dict': {
- 'id': '5255628253001',
- 'ext': 'mp4',
- 'title': 'md5:37c519b1128915607601e75a87995fc0',
- 'description': 'md5:37f7f888b434bb8f8cc8dbd4f7a4cf26',
- 'uploader': 'BFM BUSINESS',
- 'uploader_id': '876450612001',
- 'timestamp': 1482255315,
- 'upload_date': '20161220',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # https://github.com/rg3/youtube-dl/issues/2253
- 'url': 'http://bcove.me/i6nfkrc3',
- 'md5': '0ba9446db037002366bab3b3eb30c88c',
- 'info_dict': {
- 'id': '3101154703001',
- 'ext': 'mp4',
- 'title': 'Still no power',
- 'uploader': 'thestar.com',
- 'description': 'Mississauga resident David Farmer is still out of power as a result of the ice storm a month ago. To keep the house warm, Farmer cuts wood from his property for a wood burning stove downstairs.',
- },
- 'add_ie': ['BrightcoveLegacy'],
- 'skip': 'video gone',
- },
- {
- 'url': 'http://www.championat.com/video/football/v/87/87499.html',
- 'md5': 'fb973ecf6e4a78a67453647444222983',
- 'info_dict': {
- 'id': '3414141473001',
- 'ext': 'mp4',
- 'title': 'Видео. Удаление Дзагоева (ЦСКА)',
- 'description': 'Онлайн-трансляция матча ЦСКА - "Волга"',
- 'uploader': 'Championat',
- },
- },
- {
- # https://github.com/rg3/youtube-dl/issues/3541
- 'add_ie': ['BrightcoveLegacy'],
- 'url': 'http://www.kijk.nl/sbs6/leermijvrouwenkennen/videos/jqMiXKAYan2S/aflevering-1',
- 'info_dict': {
- 'id': '3866516442001',
- 'ext': 'mp4',
- 'title': 'Leer mij vrouwen kennen: Aflevering 1',
- 'description': 'Leer mij vrouwen kennen: Aflevering 1',
- 'uploader': 'SBS Broadcasting',
- },
- 'skip': 'Restricted to Netherlands',
- 'params': {
- 'skip_download': True, # m3u8 download
- },
- },
- {
- # Brightcove video in <iframe>
- 'url': 'http://www.un.org/chinese/News/story.asp?NewsID=27724',
- 'md5': '36d74ef5e37c8b4a2ce92880d208b968',
- 'info_dict': {
- 'id': '5360463607001',
- 'ext': 'mp4',
- 'title': '叙利亚失明儿童在废墟上演唱《心跳》 呼吁获得正常童年生活',
- 'description': '联合国儿童基金会中东和北非区域大使、作曲家扎德·迪拉尼(Zade Dirani)在3月15日叙利亚冲突爆发7周年纪念日之际发布了为叙利亚谱写的歌曲《心跳》(HEARTBEAT),为受到六年冲突影响的叙利亚儿童发出强烈呐喊,呼吁世界做出共同努力,使叙利亚儿童重新获得享有正常童年生活的权利。',
- 'uploader': 'United Nations',
- 'uploader_id': '1362235914001',
- 'timestamp': 1489593889,
- 'upload_date': '20170315',
- },
- 'add_ie': ['BrightcoveLegacy'],
- },
- {
- # Brightcove with alternative playerID key
- 'url': 'http://www.nature.com/nmeth/journal/v9/n7/fig_tab/nmeth.2062_SV1.html',
- 'info_dict': {
- 'id': 'nmeth.2062_SV1',
- 'title': 'Simultaneous multiview imaging of the Drosophila syncytial blastoderm : Quantitative high-speed imaging of entire developing embryos with simultaneous multiview light-sheet microscopy : Nature Methods : Nature Research',
- },
- 'playlist': [{
- 'info_dict': {
- 'id': '2228375078001',
- 'ext': 'mp4',
- 'title': 'nmeth.2062-sv1',
- 'description': 'nmeth.2062-sv1',
- 'timestamp': 1363357591,
- 'upload_date': '20130315',
- 'uploader': 'Nature Publishing Group',
- 'uploader_id': '1964492299001',
- },
- }],
- },
- {
- # Brightcove with UUID in videoPlayer
- 'url': 'http://www8.hp.com/cn/zh/home.html',
- 'info_dict': {
- 'id': '5255815316001',
- 'ext': 'mp4',
- 'title': 'Sprocket Video - China',
- 'description': 'Sprocket Video - China',
- 'uploader': 'HP-Video Gallery',
- 'timestamp': 1482263210,
- 'upload_date': '20161220',
- 'uploader_id': '1107601872001',
- },
- 'params': {
- 'skip_download': True, # m3u8 download
- },
- 'skip': 'video rotates...weekly?',
- },
- {
- # Brightcove:new type [2].
- 'url': 'http://www.delawaresportszone.com/video-st-thomas-more-earns-first-trip-to-basketball-semis',
- 'md5': '2b35148fcf48da41c9fb4591650784f3',
- 'info_dict': {
- 'id': '5348741021001',
- 'ext': 'mp4',
- 'upload_date': '20170306',
- 'uploader_id': '4191638492001',
- 'timestamp': 1488769918,
- 'title': 'VIDEO: St. Thomas More earns first trip to basketball semis',
-
- },
- },
- {
- # Alternative brightcove <video> attributes
- 'url': 'http://www.programme-tv.net/videos/extraits/81095-guillaume-canet-evoque-les-rumeurs-d-infidelite-de-marion-cotillard-avec-brad-pitt-dans-vivement-dimanche/',
- 'info_dict': {
- 'id': '81095-guillaume-canet-evoque-les-rumeurs-d-infidelite-de-marion-cotillard-avec-brad-pitt-dans-vivement-dimanche',
- 'title': "Guillaume Canet évoque les rumeurs d'infidélité de Marion Cotillard avec Brad Pitt dans Vivement Dimanche, Extraits : toutes les vidéos avec Télé-Loisirs",
- },
- 'playlist': [{
- 'md5': '732d22ba3d33f2f3fc253c39f8f36523',
- 'info_dict': {
- 'id': '5311302538001',
- 'ext': 'mp4',
- 'title': "Guillaume Canet évoque les rumeurs d'infidélité de Marion Cotillard avec Brad Pitt dans Vivement Dimanche",
- 'description': "Guillaume Canet évoque les rumeurs d'infidélité de Marion Cotillard avec Brad Pitt dans Vivement Dimanche (France 2, 5 février 2017)",
- 'timestamp': 1486321708,
- 'upload_date': '20170205',
- 'uploader_id': '800000640001',
- },
- 'only_matching': True,
- }],
- },
- {
- # Brightcove with UUID in videoPlayer
- 'url': 'http://www8.hp.com/cn/zh/home.html',
- 'info_dict': {
- 'id': '5255815316001',
- 'ext': 'mp4',
- 'title': 'Sprocket Video - China',
- 'description': 'Sprocket Video - China',
- 'uploader': 'HP-Video Gallery',
- 'timestamp': 1482263210,
- 'upload_date': '20161220',
- 'uploader_id': '1107601872001',
- },
- 'params': {
- 'skip_download': True, # m3u8 download
- },
- },
- # ooyala video
- {
- 'url': 'http://www.rollingstone.com/music/videos/norwegian-dj-cashmere-cat-goes-spartan-on-with-me-premiere-20131219',
- 'md5': '166dd577b433b4d4ebfee10b0824d8ff',
- 'info_dict': {
- 'id': 'BwY2RxaTrTkslxOfcan0UCf0YqyvWysJ',
- 'ext': 'mp4',
- 'title': '2cc213299525360.mov', # that's what we get
- 'duration': 238.231,
- },
- 'add_ie': ['Ooyala'],
- },
- {
- # ooyala video embedded with http://player.ooyala.com/iframe.js
- 'url': 'http://www.macrumors.com/2015/07/24/steve-jobs-the-man-in-the-machine-first-trailer/',
- 'info_dict': {
- 'id': 'p0MGJndjoG5SOKqO_hZJuZFPB-Tr5VgB',
- 'ext': 'mp4',
- 'title': '"Steve Jobs: Man in the Machine" trailer',
- 'description': 'The first trailer for the Alex Gibney documentary "Steve Jobs: Man in the Machine."',
- 'duration': 135.427,
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'movie expired',
- },
- # ooyala video embedded with http://player.ooyala.com/static/v4/production/latest/core.min.js
- {
- 'url': 'http://wnep.com/2017/07/22/steampunk-fest-comes-to-honesdale/',
- 'info_dict': {
- 'id': 'lwYWYxYzE6V5uJMjNGyKtwwiw9ZJD7t2',
- 'ext': 'mp4',
- 'title': 'Steampunk Fest Comes to Honesdale',
- 'duration': 43.276,
- },
- 'params': {
- 'skip_download': True,
- }
- },
- # embed.ly video
- {
- 'url': 'http://www.tested.com/science/weird/460206-tested-grinding-coffee-2000-frames-second/',
- 'info_dict': {
- 'id': '9ODmcdjQcHQ',
- 'ext': 'mp4',
- 'title': 'Tested: Grinding Coffee at 2000 Frames Per Second',
- 'upload_date': '20140225',
- 'description': 'md5:06a40fbf30b220468f1e0957c0f558ff',
- 'uploader': 'Tested',
- 'uploader_id': 'testedcom',
- },
- # No need to test YoutubeIE here
- 'params': {
- 'skip_download': True,
- },
- },
- # funnyordie embed
- {
- 'url': 'http://www.theguardian.com/world/2014/mar/11/obama-zach-galifianakis-between-two-ferns',
- 'info_dict': {
- 'id': '18e820ec3f',
- 'ext': 'mp4',
- 'title': 'Between Two Ferns with Zach Galifianakis: President Barack Obama',
- 'description': 'Episode 18: President Barack Obama sits down with Zach Galifianakis for his most memorable interview yet.',
- },
- # HEAD requests lead to endless 301, while GET is OK
- 'expected_warnings': ['301'],
- },
- # RUTV embed
- {
- 'url': 'http://www.rg.ru/2014/03/15/reg-dfo/anklav-anons.html',
- 'info_dict': {
- 'id': '776940',
- 'ext': 'mp4',
- 'title': 'Охотское море стало целиком российским',
- 'description': 'md5:5ed62483b14663e2a95ebbe115eb8f43',
- },
- 'params': {
- # m3u8 download
- 'skip_download': True,
- },
- },
- # TVC embed
- {
- 'url': 'http://sch1298sz.mskobr.ru/dou_edu/karamel_ki/filial_galleries/video/iframe_src_http_tvc_ru_video_iframe_id_55304_isplay_false_acc_video_id_channel_brand_id_11_show_episodes_episode_id_32307_frameb/',
- 'info_dict': {
- 'id': '55304',
- 'ext': 'mp4',
- 'title': 'Дошкольное воспитание',
- },
- },
- # SportBox embed
- {
- 'url': 'http://www.vestifinance.ru/articles/25753',
- 'info_dict': {
- 'id': '25753',
- 'title': 'Прямые трансляции с Форума-выставки "Госзаказ-2013"',
- },
- 'playlist': [{
- 'info_dict': {
- 'id': '370908',
- 'title': 'Госзаказ. День 3',
- 'ext': 'mp4',
- }
- }, {
- 'info_dict': {
- 'id': '370905',
- 'title': 'Госзаказ. День 2',
- 'ext': 'mp4',
- }
- }, {
- 'info_dict': {
- 'id': '370902',
- 'title': 'Госзаказ. День 1',
- 'ext': 'mp4',
- }
- }],
- 'params': {
- # m3u8 download
- 'skip_download': True,
- },
- },
- # Myvi.ru embed
- {
- 'url': 'http://www.kinomyvi.tv/news/detail/Pervij-dublirovannij-trejler--Uzhastikov-_nOw1',
- 'info_dict': {
- 'id': 'f4dafcad-ff21-423d-89b5-146cfd89fa1e',
- 'ext': 'mp4',
- 'title': 'Ужастики, русский трейлер (2015)',
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'duration': 153,
- }
- },
- # XHamster embed
- {
- 'url': 'http://www.numisc.com/forum/showthread.php?11696-FM15-which-pumiscer-was-this-%28-vid-%29-%28-alfa-as-fuck-srx-%29&s=711f5db534502e22260dec8c5e2d66d8',
- 'info_dict': {
- 'id': 'showthread',
- 'title': '[NSFL] [FM15] which pumiscer was this ( vid ) ( alfa as fuck srx )',
- },
- 'playlist_mincount': 7,
- # This forum does not allow <iframe> syntaxes anymore
- # Now HTML tags are displayed as-is
- 'skip': 'No videos on this page',
- },
- # Embedded TED video
- {
- 'url': 'http://en.support.wordpress.com/videos/ted-talks/',
- 'md5': '65fdff94098e4a607385a60c5177c638',
- 'info_dict': {
- 'id': '1969',
- 'ext': 'mp4',
- 'title': 'Hidden miracles of the natural world',
- 'uploader': 'Louie Schwartzberg',
- 'description': 'md5:8145d19d320ff3e52f28401f4c4283b9',
- }
- },
- # nowvideo embed hidden behind percent encoding
- {
- 'url': 'http://www.waoanime.tv/the-super-dimension-fortress-macross-episode-1/',
- 'md5': '2baf4ddd70f697d94b1c18cf796d5107',
- 'info_dict': {
- 'id': '06e53103ca9aa',
- 'ext': 'flv',
- 'title': 'Macross Episode 001 Watch Macross Episode 001 onl',
- 'description': 'No description',
- },
- },
- # arte embed
- {
- 'url': 'http://www.tv-replay.fr/redirection/20-03-14/x-enius-arte-10753389.html',
- 'md5': '7653032cbb25bf6c80d80f217055fa43',
- 'info_dict': {
- 'id': '048195-004_PLUS7-F',
- 'ext': 'flv',
- 'title': 'X:enius',
- 'description': 'md5:d5fdf32ef6613cdbfd516ae658abf168',
- 'upload_date': '20140320',
- },
- 'params': {
- 'skip_download': 'Requires rtmpdump'
- },
- 'skip': 'video gone',
- },
- # francetv embed
- {
- 'url': 'http://www.tsprod.com/replay-du-concert-alcaline-de-calogero',
- 'info_dict': {
- 'id': 'EV_30231',
- 'ext': 'mp4',
- 'title': 'Alcaline, le concert avec Calogero',
- 'description': 'md5:61f08036dcc8f47e9cfc33aed08ffaff',
- 'upload_date': '20150226',
- 'timestamp': 1424989860,
- 'duration': 5400,
- },
- 'params': {
- # m3u8 downloads
- 'skip_download': True,
- },
- 'expected_warnings': [
- 'Forbidden'
- ]
- },
- # Condé Nast embed
- {
- 'url': 'http://www.wired.com/2014/04/honda-asimo/',
- 'md5': 'ba0dfe966fa007657bd1443ee672db0f',
- 'info_dict': {
- 'id': '53501be369702d3275860000',
- 'ext': 'mp4',
- 'title': 'Honda’s New Asimo Robot Is More Human Than Ever',
- }
- },
- # Dailymotion embed
- {
- 'url': 'http://www.spi0n.com/zap-spi0n-com-n216/',
- 'md5': '441aeeb82eb72c422c7f14ec533999cd',
- 'info_dict': {
- 'id': 'k2mm4bCdJ6CQ2i7c8o2',
- 'ext': 'mp4',
- 'title': 'Le Zap de Spi0n n°216 - Zapping du Web',
- 'description': 'md5:faf028e48a461b8b7fad38f1e104b119',
- 'uploader': 'Spi0n',
- 'uploader_id': 'xgditw',
- 'upload_date': '20140425',
- 'timestamp': 1398441542,
- },
- 'add_ie': ['Dailymotion'],
- },
- # DailyMail embed
- {
- 'url': 'http://www.bumm.sk/krimi/2017/07/05/biztonsagi-kamera-buktatta-le-az-agg-ferfit-utlegelo-apolot',
- 'info_dict': {
- 'id': '1495629',
- 'ext': 'mp4',
- 'title': 'Care worker punches elderly dementia patient in head 11 times',
- 'description': 'md5:3a743dee84e57e48ec68bf67113199a5',
- },
- 'add_ie': ['DailyMail'],
- 'params': {
- 'skip_download': True,
- },
- },
- # YouTube embed
- {
- 'url': 'http://www.badzine.de/ansicht/datum/2014/06/09/so-funktioniert-die-neue-englische-badminton-liga.html',
- 'info_dict': {
- 'id': 'FXRb4ykk4S0',
- 'ext': 'mp4',
- 'title': 'The NBL Auction 2014',
- 'uploader': 'BADMINTON England',
- 'uploader_id': 'BADMINTONEvents',
- 'upload_date': '20140603',
- 'description': 'md5:9ef128a69f1e262a700ed83edb163a73',
- },
- 'add_ie': ['Youtube'],
- 'params': {
- 'skip_download': True,
- }
- },
- # MTVSercices embed
- {
- 'url': 'http://www.vulture.com/2016/06/new-key-peele-sketches-released.html',
- 'md5': 'ca1aef97695ef2c1d6973256a57e5252',
- 'info_dict': {
- 'id': '769f7ec0-0692-4d62-9b45-0d88074bffc1',
- 'ext': 'mp4',
- 'title': 'Key and Peele|October 10, 2012|2|203|Liam Neesons - Uncensored',
- 'description': 'Two valets share their love for movie star Liam Neesons.',
- 'timestamp': 1349922600,
- 'upload_date': '20121011',
- },
- },
- # YouTube embed via <data-embed-url="">
- {
- 'url': 'https://play.google.com/store/apps/details?id=com.gameloft.android.ANMP.GloftA8HM',
- 'info_dict': {
- 'id': '4vAffPZIT44',
- 'ext': 'mp4',
- 'title': 'Asphalt 8: Airborne - Update - Welcome to Dubai!',
- 'uploader': 'Gameloft',
- 'uploader_id': 'gameloft',
- 'upload_date': '20140828',
- 'description': 'md5:c80da9ed3d83ae6d1876c834de03e1c4',
- },
- 'params': {
- 'skip_download': True,
- }
- },
- # YouTube <object> embed
- {
- 'url': 'http://www.improbable.com/2017/04/03/untrained-modern-youths-and-ancient-masters-in-selfie-portraits/',
- 'md5': '516718101ec834f74318df76259fb3cc',
- 'info_dict': {
- 'id': 'msN87y-iEx0',
- 'ext': 'webm',
- 'title': 'Feynman: Mirrors FUN TO IMAGINE 6',
- 'upload_date': '20080526',
- 'description': 'md5:0ffc78ea3f01b2e2c247d5f8d1d3c18d',
- 'uploader': 'Christopher Sykes',
- 'uploader_id': 'ChristopherJSykes',
- },
- 'add_ie': ['Youtube'],
- },
- # Camtasia studio
- {
- 'url': 'http://www.ll.mit.edu/workshops/education/videocourses/antennas/lecture1/video/',
- 'playlist': [{
- 'md5': '0c5e352edabf715d762b0ad4e6d9ee67',
- 'info_dict': {
- 'id': 'Fenn-AA_PA_Radar_Course_Lecture_1c_Final',
- 'title': 'Fenn-AA_PA_Radar_Course_Lecture_1c_Final - video1',
- 'ext': 'flv',
- 'duration': 2235.90,
- }
- }, {
- 'md5': '10e4bb3aaca9fd630e273ff92d9f3c63',
- 'info_dict': {
- 'id': 'Fenn-AA_PA_Radar_Course_Lecture_1c_Final_PIP',
- 'title': 'Fenn-AA_PA_Radar_Course_Lecture_1c_Final - pip',
- 'ext': 'flv',
- 'duration': 2235.93,
- }
- }],
- 'info_dict': {
- 'title': 'Fenn-AA_PA_Radar_Course_Lecture_1c_Final',
- }
- },
- # Flowplayer
- {
- 'url': 'http://www.handjobhub.com/video/busty-blonde-siri-tit-fuck-while-wank-6313.html',
- 'md5': '9d65602bf31c6e20014319c7d07fba27',
- 'info_dict': {
- 'id': '5123ea6d5e5a7',
- 'ext': 'mp4',
- 'age_limit': 18,
- 'uploader': 'www.handjobhub.com',
- 'title': 'Busty Blonde Siri Tit Fuck While Wank at HandjobHub.com',
- }
- },
- # Multiple brightcove videos
- # https://github.com/rg3/youtube-dl/issues/2283
- {
- 'url': 'http://www.newyorker.com/online/blogs/newsdesk/2014/01/always-never-nuclear-command-and-control.html',
- 'info_dict': {
- 'id': 'always-never',
- 'title': 'Always / Never - The New Yorker',
- },
- 'playlist_count': 3,
- 'params': {
- 'extract_flat': False,
- 'skip_download': True,
- }
- },
- # MLB embed
- {
- 'url': 'http://umpire-empire.com/index.php/topic/58125-laz-decides-no-thats-low/',
- 'md5': '96f09a37e44da40dd083e12d9a683327',
- 'info_dict': {
- 'id': '33322633',
- 'ext': 'mp4',
- 'title': 'Ump changes call to ball',
- 'description': 'md5:71c11215384298a172a6dcb4c2e20685',
- 'duration': 48,
- 'timestamp': 1401537900,
- 'upload_date': '20140531',
- 'thumbnail': r're:^https?://.*\.jpg$',
- },
- },
- # Wistia embed
- {
- 'url': 'http://study.com/academy/lesson/north-american-exploration-failed-colonies-of-spain-france-england.html#lesson',
- 'md5': '1953f3a698ab51cfc948ed3992a0b7ff',
- 'info_dict': {
- 'id': '6e2wtrbdaf',
- 'ext': 'mov',
- 'title': 'paywall_north-american-exploration-failed-colonies-of-spain-france-england',
- 'description': 'a Paywall Videos video from Remilon',
- 'duration': 644.072,
- 'uploader': 'study.com',
- 'timestamp': 1459678540,
- 'upload_date': '20160403',
- 'filesize': 24687186,
- },
- },
- {
- 'url': 'http://thoughtworks.wistia.com/medias/uxjb0lwrcz',
- 'md5': 'baf49c2baa8a7de5f3fc145a8506dcd4',
- 'info_dict': {
- 'id': 'uxjb0lwrcz',
- 'ext': 'mp4',
- 'title': 'Conversation about Hexagonal Rails Part 1',
- 'description': 'a Martin Fowler video from ThoughtWorks',
- 'duration': 1715.0,
- 'uploader': 'thoughtworks.wistia.com',
- 'timestamp': 1401832161,
- 'upload_date': '20140603',
- },
- },
- # Wistia standard embed (async)
- {
- 'url': 'https://www.getdrip.com/university/brennan-dunn-drip-workshop/',
- 'info_dict': {
- 'id': '807fafadvk',
- 'ext': 'mp4',
- 'title': 'Drip Brennan Dunn Workshop',
- 'description': 'a JV Webinars video from getdrip-1',
- 'duration': 4986.95,
- 'timestamp': 1463607249,
- 'upload_date': '20160518',
- },
- 'params': {
- 'skip_download': True,
- }
- },
- # Soundcloud embed
- {
- 'url': 'http://nakedsecurity.sophos.com/2014/10/29/sscc-171-are-you-sure-that-1234-is-a-bad-password-podcast/',
- 'info_dict': {
- 'id': '174391317',
- 'ext': 'mp3',
- 'description': 'md5:ff867d6b555488ad3c52572bb33d432c',
- 'uploader': 'Sophos Security',
- 'title': 'Chet Chat 171 - Oct 29, 2014',
- 'upload_date': '20141029',
- }
- },
- # Soundcloud multiple embeds
- {
- 'url': 'http://www.guitarplayer.com/lessons/1014/legato-workout-one-hour-to-more-fluid-performance---tab/52809',
- 'info_dict': {
- 'id': '52809',
- 'title': 'Guitar Essentials: Legato Workout—One-Hour to Fluid Performance | TAB + AUDIO',
- },
- 'playlist_mincount': 7,
- },
- # TuneIn station embed
- {
- 'url': 'http://radiocnrv.com/promouvoir-radio-cnrv/',
- 'info_dict': {
- 'id': '204146',
- 'ext': 'mp3',
- 'title': 'CNRV',
- 'location': 'Paris, France',
- 'is_live': True,
- },
- 'params': {
- # Live stream
- 'skip_download': True,
- },
- },
- # Livestream embed
- {
- 'url': 'http://www.esa.int/Our_Activities/Space_Science/Rosetta/Philae_comet_touch-down_webcast',
- 'info_dict': {
- 'id': '67864563',
- 'ext': 'flv',
- 'upload_date': '20141112',
- 'title': 'Rosetta #CometLanding webcast HL 10',
- }
- },
- # Another Livestream embed, without 'new.' in URL
- {
- 'url': 'https://www.freespeech.org/',
- 'info_dict': {
- 'id': '123537347',
- 'ext': 'mp4',
- 'title': 're:^FSTV [0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}$',
- },
- 'params': {
- # Live stream
- 'skip_download': True,
- },
- },
- # LazyYT
- {
- 'url': 'https://skiplagged.com/',
- 'info_dict': {
- 'id': 'skiplagged',
- 'title': 'Skiplagged: The smart way to find cheap flights',
- },
- 'playlist_mincount': 1,
- 'add_ie': ['Youtube'],
- },
- # Cinchcast embed
- {
- 'url': 'http://undergroundwellness.com/podcasts/306-5-steps-to-permanent-gut-healing/',
- 'info_dict': {
- 'id': '7141703',
- 'ext': 'mp3',
- 'upload_date': '20141126',
- 'title': 'Jack Tips: 5 Steps to Permanent Gut Healing',
- }
- },
- # Cinerama player
- {
- 'url': 'http://www.abc.net.au/7.30/content/2015/s4164797.htm',
- 'info_dict': {
- 'id': '730m_DandD_1901_512k',
- 'ext': 'mp4',
- 'uploader': 'www.abc.net.au',
- 'title': 'Game of Thrones with dice - Dungeons and Dragons fantasy role-playing game gets new life - 19/01/2015',
- }
- },
- # embedded viddler video
- {
- 'url': 'http://deadspin.com/i-cant-stop-watching-john-wall-chop-the-nuggets-with-th-1681801597',
- 'info_dict': {
- 'id': '4d03aad9',
- 'ext': 'mp4',
- 'uploader': 'deadspin',
- 'title': 'WALL-TO-GORTAT',
- 'timestamp': 1422285291,
- 'upload_date': '20150126',
- },
- 'add_ie': ['Viddler'],
- },
- # Libsyn embed
- {
- 'url': 'http://thedailyshow.cc.com/podcast/episodetwelve',
- 'info_dict': {
- 'id': '3377616',
- 'ext': 'mp3',
- 'title': "The Daily Show Podcast without Jon Stewart - Episode 12: Bassem Youssef: Egypt's Jon Stewart",
- 'description': 'md5:601cb790edd05908957dae8aaa866465',
- 'upload_date': '20150220',
- },
- 'skip': 'All The Daily Show URLs now redirect to http://www.cc.com/shows/',
- },
- # jwplayer YouTube
- {
- 'url': 'http://media.nationalarchives.gov.uk/index.php/webinar-using-discovery-national-archives-online-catalogue/',
- 'info_dict': {
- 'id': 'Mrj4DVp2zeA',
- 'ext': 'mp4',
- 'upload_date': '20150212',
- 'uploader': 'The National Archives UK',
- 'description': 'md5:8078af856dca76edc42910b61273dbbf',
- 'uploader_id': 'NationalArchives08',
- 'title': 'Webinar: Using Discovery, The National Archives’ online catalogue',
- },
- },
- # jwplayer rtmp
- {
- 'url': 'http://www.suffolk.edu/sjc/live.php',
- 'info_dict': {
- 'id': 'live',
- 'ext': 'flv',
- 'title': 'Massachusetts Supreme Judicial Court Oral Arguments',
- 'uploader': 'www.suffolk.edu',
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'Only has video a few mornings per month, see http://www.suffolk.edu/sjc/',
- },
- # Complex jwplayer
- {
- 'url': 'http://www.indiedb.com/games/king-machine/videos',
- 'info_dict': {
- 'id': 'videos',
- 'ext': 'mp4',
- 'title': 'king machine trailer 1',
- 'description': 'Browse King Machine videos & audio for sweet media. Your eyes will thank you.',
- 'thumbnail': r're:^https?://.*\.jpg$',
- },
- },
- {
- # JWPlayer config passed as variable
- 'url': 'http://www.txxx.com/videos/3326530/ariele/',
- 'info_dict': {
- 'id': '3326530_hq',
- 'ext': 'mp4',
- 'title': 'ARIELE | Tube Cup',
- 'uploader': 'www.txxx.com',
- 'age_limit': 18,
- },
- 'params': {
- 'skip_download': True,
- }
- },
- {
- # JWPlatform iframe
- 'url': 'https://www.mediaite.com/tv/dem-senator-claims-gary-cohn-faked-a-bad-connection-during-trump-call-to-get-him-off-the-phone/',
- 'md5': 'ca00a040364b5b439230e7ebfd02c4e9',
- 'info_dict': {
- 'id': 'O0c5JcKT',
- 'ext': 'mp4',
- 'upload_date': '20171122',
- 'timestamp': 1511366290,
- 'title': 'Dem Senator Claims Gary Cohn Faked a Bad Connection During Trump Call to Get Him Off the Phone',
- },
- 'add_ie': [JWPlatformIE.ie_key()],
- },
- {
- # Video.js embed, multiple formats
- 'url': 'http://ortcam.com/solidworks-урок-6-настройка-чертежа_33f9b7351.html',
- 'info_dict': {
- 'id': 'yygqldloqIk',
- 'ext': 'mp4',
- 'title': 'SolidWorks. Урок 6 Настройка чертежа',
- 'description': 'md5:baf95267792646afdbf030e4d06b2ab3',
- 'upload_date': '20130314',
- 'uploader': 'PROстое3D',
- 'uploader_id': 'PROstoe3D',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Video.js embed, single format
- 'url': 'https://www.vooplayer.com/v3/watch/watch.php?v=NzgwNTg=',
- 'info_dict': {
- 'id': 'watch',
- 'ext': 'mp4',
- 'title': 'Step 1 - Good Foundation',
- 'description': 'md5:d1e7ff33a29fc3eb1673d6c270d344f4',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # rtl.nl embed
- {
- 'url': 'http://www.rtlnieuws.nl/nieuws/buitenland/aanslagen-kopenhagen',
- 'playlist_mincount': 5,
- 'info_dict': {
- 'id': 'aanslagen-kopenhagen',
- 'title': 'Aanslagen Kopenhagen',
- }
- },
- # Zapiks embed
- {
- 'url': 'http://www.skipass.com/news/116090-bon-appetit-s5ep3-baqueira-mi-cor.html',
- 'info_dict': {
- 'id': '118046',
- 'ext': 'mp4',
- 'title': 'EP3S5 - Bon Appétit - Baqueira Mi Corazon !',
- }
- },
- # Kaltura embed (different embed code)
- {
- 'url': 'http://www.premierchristianradio.com/Shows/Saturday/Unbelievable/Conference-Videos/Os-Guinness-Is-It-Fools-Talk-Unbelievable-Conference-2014',
- 'info_dict': {
- 'id': '1_a52wc67y',
- 'ext': 'flv',
- 'upload_date': '20150127',
- 'uploader_id': 'PremierMedia',
- 'timestamp': int,
- 'title': 'Os Guinness // Is It Fools Talk? // Unbelievable? Conference 2014',
- },
- },
- # Kaltura embed with single quotes
- {
- 'url': 'http://fod.infobase.com/p_ViewPlaylist.aspx?AssignmentID=NUN8ZY',
- 'info_dict': {
- 'id': '0_izeg5utt',
- 'ext': 'mp4',
- 'title': '35871',
- 'timestamp': 1355743100,
- 'upload_date': '20121217',
- 'uploader_id': 'cplapp@learn360.com',
- },
- 'add_ie': ['Kaltura'],
- },
- {
- # Kaltura embedded via quoted entry_id
- 'url': 'https://www.oreilly.com/ideas/my-cloud-makes-pretty-pictures',
- 'info_dict': {
- 'id': '0_utuok90b',
- 'ext': 'mp4',
- 'title': '06_matthew_brender_raj_dutt',
- 'timestamp': 1466638791,
- 'upload_date': '20160622',
- },
- 'add_ie': ['Kaltura'],
- 'expected_warnings': [
- 'Could not send HEAD request'
- ],
- 'params': {
- 'skip_download': True,
- }
- },
- {
- # Kaltura embedded, some fileExt broken (#11480)
- 'url': 'http://www.cornell.edu/video/nima-arkani-hamed-standard-models-of-particle-physics',
- 'info_dict': {
- 'id': '1_sgtvehim',
- 'ext': 'mp4',
- 'title': 'Our "Standard Models" of particle physics and cosmology',
- 'description': 'md5:67ea74807b8c4fea92a6f38d6d323861',
- 'timestamp': 1321158993,
- 'upload_date': '20111113',
- 'uploader_id': 'kps1',
- },
- 'add_ie': ['Kaltura'],
- },
- {
- # Kaltura iframe embed
- 'url': 'http://www.gsd.harvard.edu/event/i-m-pei-a-centennial-celebration/',
- 'md5': 'ae5ace8eb09dc1a35d03b579a9c2cc44',
- 'info_dict': {
- 'id': '0_f2cfbpwy',
- 'ext': 'mp4',
- 'title': 'I. M. Pei: A Centennial Celebration',
- 'description': 'md5:1db8f40c69edc46ca180ba30c567f37c',
- 'upload_date': '20170403',
- 'uploader_id': 'batchUser',
- 'timestamp': 1491232186,
- },
- 'add_ie': ['Kaltura'],
- },
- {
- # Kaltura iframe embed, more sophisticated
- 'url': 'http://www.cns.nyu.edu/~eero/math-tools/Videos/lecture-05sep2017.html',
- 'info_dict': {
- 'id': '1_9gzouybz',
- 'ext': 'mp4',
- 'title': 'lecture-05sep2017',
- 'description': 'md5:40f347d91fd4ba047e511c5321064b49',
- 'upload_date': '20170913',
- 'uploader_id': 'eps2',
- 'timestamp': 1505340777,
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': ['Kaltura'],
- },
- {
- # meta twitter:player
- 'url': 'http://thechive.com/2017/12/08/all-i-want-for-christmas-is-more-twerk/',
- 'info_dict': {
- 'id': '0_01b42zps',
- 'ext': 'mp4',
- 'title': 'Main Twerk (Video)',
- 'upload_date': '20171208',
- 'uploader_id': 'sebastian.salinas@thechive.com',
- 'timestamp': 1512713057,
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': ['Kaltura'],
- },
- # referrer protected EaglePlatform embed
- {
- 'url': 'https://tvrain.ru/lite/teleshow/kak_vse_nachinalos/namin-418921/',
- 'info_dict': {
- 'id': '582306',
- 'ext': 'mp4',
- 'title': 'Стас Намин: «Мы нарушили девственность Кремля»',
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'duration': 3382,
- 'view_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # ClipYou (EaglePlatform) embed (custom URL)
- {
- 'url': 'http://muz-tv.ru/play/7129/',
- # Not checking MD5 as sometimes the direct HTTP link results in 404 and HLS is used
- 'info_dict': {
- 'id': '12820',
- 'ext': 'mp4',
- 'title': "'O Sole Mio",
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'duration': 216,
- 'view_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video is unavailable.',
- },
- # Pladform embed
- {
- 'url': 'http://muz-tv.ru/kinozal/view/7400/',
- 'info_dict': {
- 'id': '100183293',
- 'ext': 'mp4',
- 'title': 'Тайны перевала Дятлова • 1 серия 2 часть',
- 'description': 'Документальный сериал-расследование одной из самых жутких тайн ХХ века',
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'duration': 694,
- 'age_limit': 0,
- },
- 'skip': 'HTTP Error 404: Not Found',
- },
- # Playwire embed
- {
- 'url': 'http://www.cinemablend.com/new/First-Joe-Dirt-2-Trailer-Teaser-Stupid-Greatness-70874.html',
- 'info_dict': {
- 'id': '3519514',
- 'ext': 'mp4',
- 'title': 'Joe Dirt 2 Beautiful Loser Teaser Trailer',
- 'thumbnail': r're:^https?://.*\.png$',
- 'duration': 45.115,
- },
- },
- # 5min embed
- {
- 'url': 'http://techcrunch.com/video/facebook-creates-on-this-day-crunch-report/518726732/',
- 'md5': '4c6f127a30736b59b3e2c19234ee2bf7',
- 'info_dict': {
- 'id': '518726732',
- 'ext': 'mp4',
- 'title': 'Facebook Creates "On This Day" | Crunch Report',
- 'description': 'Amazon updates Fire TV line, Tesla\'s Model X spotted in the wild',
- 'timestamp': 1427237531,
- 'uploader': 'Crunch Report',
- 'upload_date': '20150324',
- },
- 'params': {
- # m3u8 download
- 'skip_download': True,
- },
- },
- # Crooks and Liars embed
- {
- 'url': 'http://crooksandliars.com/2015/04/fox-friends-says-protecting-atheists',
- 'info_dict': {
- 'id': '8RUoRhRi',
- 'ext': 'mp4',
- 'title': "Fox & Friends Says Protecting Atheists From Discrimination Is Anti-Christian!",
- 'description': 'md5:e1a46ad1650e3a5ec7196d432799127f',
- 'timestamp': 1428207000,
- 'upload_date': '20150405',
- 'uploader': 'Heather',
- },
- },
- # Crooks and Liars external embed
- {
- 'url': 'http://theothermccain.com/2010/02/02/video-proves-that-bill-kristol-has-been-watching-glenn-beck/comment-page-1/',
- 'info_dict': {
- 'id': 'MTE3MjUtMzQ2MzA',
- 'ext': 'mp4',
- 'title': 'md5:5e3662a81a4014d24c250d76d41a08d5',
- 'description': 'md5:9b8e9542d6c3c5de42d6451b7d780cec',
- 'timestamp': 1265032391,
- 'upload_date': '20100201',
- 'uploader': 'Heather',
- },
- },
- # NBC Sports vplayer embed
- {
- 'url': 'http://www.riderfans.com/forum/showthread.php?121827-Freeman&s=e98fa1ea6dc08e886b1678d35212494a',
- 'info_dict': {
- 'id': 'ln7x1qSThw4k',
- 'ext': 'flv',
- 'title': "PFT Live: New leader in the 'new-look' defense",
- 'description': 'md5:65a19b4bbfb3b0c0c5768bed1dfad74e',
- 'uploader': 'NBCU-SPORTS',
- 'upload_date': '20140107',
- 'timestamp': 1389118457,
- },
- 'skip': 'Invalid Page URL',
- },
- # NBC News embed
- {
- 'url': 'http://www.vulture.com/2016/06/letterman-couldnt-care-less-about-late-night.html',
- 'md5': '1aa589c675898ae6d37a17913cf68d66',
- 'info_dict': {
- 'id': 'x_dtl_oa_LettermanliftPR_160608',
- 'ext': 'mp4',
- 'title': 'David Letterman: A Preview',
- 'description': 'A preview of Tom Brokaw\'s interview with David Letterman as part of the On Assignment series powered by Dateline. Airs Sunday June 12 at 7/6c.',
- 'upload_date': '20160609',
- 'timestamp': 1465431544,
- 'uploader': 'NBCU-NEWS',
- },
- },
- # UDN embed
- {
- 'url': 'https://video.udn.com/news/300346',
- 'md5': 'fd2060e988c326991037b9aff9df21a6',
- 'info_dict': {
- 'id': '300346',
- 'ext': 'mp4',
- 'title': '中一中男師變性 全校師生力挺',
- 'thumbnail': r're:^https?://.*\.jpg$',
- },
- 'params': {
- # m3u8 download
- 'skip_download': True,
- },
- 'expected_warnings': ['Failed to parse JSON Expecting value'],
- },
- # Brightcove URL in single quotes
- {
- 'url': 'http://www.sportsnet.ca/baseball/mlb/sn-presents-russell-martin-world-citizen/',
- 'md5': '4ae374f1f8b91c889c4b9203c8c752af',
- 'info_dict': {
- 'id': '4255764656001',
- 'ext': 'mp4',
- 'title': 'SN Presents: Russell Martin, World Citizen',
- 'description': 'To understand why he was the Toronto Blue Jays’ top off-season priority is to appreciate his background and upbringing in Montreal, where he first developed his baseball skills. Written and narrated by Stephen Brunt.',
- 'uploader': 'Rogers Sportsnet',
- 'uploader_id': '1704050871',
- 'upload_date': '20150525',
- 'timestamp': 1432570283,
- },
- },
- # OnionStudios embed
- {
- 'url': 'http://www.clickhole.com/video/dont-understand-bitcoin-man-will-mumble-explanatio-2537',
- 'info_dict': {
- 'id': '2855',
- 'ext': 'mp4',
- 'title': 'Don’t Understand Bitcoin? This Man Will Mumble An Explanation At You',
- 'thumbnail': r're:^https?://.*\.jpe?g$',
- 'uploader': 'ClickHole',
- 'uploader_id': 'clickhole',
- }
- },
- # SnagFilms embed
- {
- 'url': 'http://whilewewatch.blogspot.ru/2012/06/whilewewatch-whilewewatch-gripping.html',
- 'info_dict': {
- 'id': '74849a00-85a9-11e1-9660-123139220831',
- 'ext': 'mp4',
- 'title': '#whilewewatch',
- }
- },
- # AdobeTVVideo embed
- {
- 'url': 'https://helpx.adobe.com/acrobat/how-to/new-experience-acrobat-dc.html?set=acrobat--get-started--essential-beginners',
- 'md5': '43662b577c018ad707a63766462b1e87',
- 'info_dict': {
- 'id': '2456',
- 'ext': 'mp4',
- 'title': 'New experience with Acrobat DC',
- 'description': 'New experience with Acrobat DC',
- 'duration': 248.667,
- },
- },
- # BrightcoveInPageEmbed embed
- {
- 'url': 'http://www.geekandsundry.com/tabletop-bonus-wils-final-thoughts-on-dread/',
- 'info_dict': {
- 'id': '4238694884001',
- 'ext': 'flv',
- 'title': 'Tabletop: Dread, Last Thoughts',
- 'description': 'Tabletop: Dread, Last Thoughts',
- 'duration': 51690,
- },
- },
- # Brightcove embed, with no valid 'renditions' but valid 'IOSRenditions'
- # This video can't be played in browsers if Flash disabled and UA set to iPhone, which is actually a false alarm
- {
- 'url': 'https://dl.dropboxusercontent.com/u/29092637/interview.html',
- 'info_dict': {
- 'id': '4785848093001',
- 'ext': 'mp4',
- 'title': 'The Cardinal Pell Interview',
- 'description': 'Sky News Contributor Andrew Bolt interviews George Pell in Rome, following the Cardinal\'s evidence before the Royal Commission into Child Abuse. ',
- 'uploader': 'GlobeCast Australia - GlobeStream',
- 'uploader_id': '2733773828001',
- 'upload_date': '20160304',
- 'timestamp': 1457083087,
- },
- 'params': {
- # m3u8 downloads
- 'skip_download': True,
- },
- },
- {
- # Brightcove embed with whitespace around attribute names
- 'url': 'http://www.stack.com/video/3167554373001/learn-to-hit-open-three-pointers-with-damian-lillard-s-baseline-drift-drill',
- 'info_dict': {
- 'id': '3167554373001',
- 'ext': 'mp4',
- 'title': "Learn to Hit Open Three-Pointers With Damian Lillard's Baseline Drift Drill",
- 'description': 'md5:57bacb0e0f29349de4972bfda3191713',
- 'uploader_id': '1079349493',
- 'upload_date': '20140207',
- 'timestamp': 1391810548,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # Another form of arte.tv embed
- {
- 'url': 'http://www.tv-replay.fr/redirection/09-04-16/arte-reportage-arte-11508975.html',
- 'md5': '850bfe45417ddf221288c88a0cffe2e2',
- 'info_dict': {
- 'id': '030273-562_PLUS7-F',
- 'ext': 'mp4',
- 'title': 'ARTE Reportage - Nulle part, en France',
- 'description': 'md5:e3a0e8868ed7303ed509b9e3af2b870d',
- 'upload_date': '20160409',
- },
- },
- # LiveLeak embed
- {
- 'url': 'http://www.wykop.pl/link/3088787/',
- 'md5': '7619da8c820e835bef21a1efa2a0fc71',
- 'info_dict': {
- 'id': '874_1459135191',
- 'ext': 'mp4',
- 'title': 'Man shows poor quality of new apartment building',
- 'description': 'The wall is like a sand pile.',
- 'uploader': 'Lake8737',
- },
- 'add_ie': [LiveLeakIE.ie_key()],
- },
- # Another LiveLeak embed pattern (#13336)
- {
- 'url': 'https://milo.yiannopoulos.net/2017/06/concealed-carry-robbery/',
- 'info_dict': {
- 'id': '2eb_1496309988',
- 'ext': 'mp4',
- 'title': 'Thief robs place where everyone was armed',
- 'description': 'md5:694d73ee79e535953cf2488562288eee',
- 'uploader': 'brazilwtf',
- },
- 'add_ie': [LiveLeakIE.ie_key()],
- },
- # Duplicated embedded video URLs
- {
- 'url': 'http://www.hudl.com/athlete/2538180/highlights/149298443',
- 'info_dict': {
- 'id': '149298443_480_16c25b74_2',
- 'ext': 'mp4',
- 'title': 'vs. Blue Orange Spring Game',
- 'uploader': 'www.hudl.com',
- },
- },
- # twitter:player:stream embed
- {
- 'url': 'http://www.rtl.be/info/video/589263.aspx?CategoryID=288',
- 'info_dict': {
- 'id': 'master',
- 'ext': 'mp4',
- 'title': 'Une nouvelle espèce de dinosaure découverte en Argentine',
- 'uploader': 'www.rtl.be',
- },
- 'params': {
- # m3u8 downloads
- 'skip_download': True,
- },
- },
- # twitter:player embed
- {
- 'url': 'http://www.theatlantic.com/video/index/484130/what-do-black-holes-sound-like/',
- 'md5': 'a3e0df96369831de324f0778e126653c',
- 'info_dict': {
- 'id': '4909620399001',
- 'ext': 'mp4',
- 'title': 'What Do Black Holes Sound Like?',
- 'description': 'what do black holes sound like',
- 'upload_date': '20160524',
- 'uploader_id': '29913724001',
- 'timestamp': 1464107587,
- 'uploader': 'TheAtlantic',
- },
- 'add_ie': ['BrightcoveLegacy'],
- },
- # Facebook <iframe> embed
- {
- 'url': 'https://www.hostblogger.de/blog/archives/6181-Auto-jagt-Betonmischer.html',
- 'md5': 'fbcde74f534176ecb015849146dd3aee',
- 'info_dict': {
- 'id': '599637780109885',
- 'ext': 'mp4',
- 'title': 'Facebook video #599637780109885',
- },
- },
- # Facebook <iframe> embed, plugin video
- {
- 'url': 'http://5pillarsuk.com/2017/06/07/tariq-ramadan-disagrees-with-pr-exercise-by-imams-refusing-funeral-prayers-for-london-attackers/',
- 'info_dict': {
- 'id': '1754168231264132',
- 'ext': 'mp4',
- 'title': 'About the Imams and Religious leaders refusing to perform funeral prayers for...',
- 'uploader': 'Tariq Ramadan (official)',
- 'timestamp': 1496758379,
- 'upload_date': '20170606',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- # Facebook API embed
- {
- 'url': 'http://www.lothype.com/blue-stars-2016-preview-standstill-full-show/',
- 'md5': 'a47372ee61b39a7b90287094d447d94e',
- 'info_dict': {
- 'id': '10153467542406923',
- 'ext': 'mp4',
- 'title': 'Facebook video #10153467542406923',
- },
- },
- # Wordpress "YouTube Video Importer" plugin
- {
- 'url': 'http://www.lothype.com/blue-devils-drumline-stanford-lot-2016/',
- 'md5': 'd16797741b560b485194eddda8121b48',
- 'info_dict': {
- 'id': 'HNTXWDXV9Is',
- 'ext': 'mp4',
- 'title': 'Blue Devils Drumline Stanford lot 2016',
- 'upload_date': '20160627',
- 'uploader_id': 'GENOCIDE8GENERAL10',
- 'uploader': 'cylus cyrus',
- },
- },
- {
- # video stored on custom kaltura server
- 'url': 'http://www.expansion.com/multimedia/videos.html?media=EQcM30NHIPv',
- 'md5': '537617d06e64dfed891fa1593c4b30cc',
- 'info_dict': {
- 'id': '0_1iotm5bh',
- 'ext': 'mp4',
- 'title': 'Elecciones británicas: 5 lecciones para Rajoy',
- 'description': 'md5:435a89d68b9760b92ce67ed227055f16',
- 'uploader_id': 'videos.expansion@el-mundo.net',
- 'upload_date': '20150429',
- 'timestamp': 1430303472,
- },
- 'add_ie': ['Kaltura'],
- },
- {
- # Non-standard Vimeo embed
- 'url': 'https://openclassrooms.com/courses/understanding-the-web',
- 'md5': '64d86f1c7d369afd9a78b38cbb88d80a',
- 'info_dict': {
- 'id': '148867247',
- 'ext': 'mp4',
- 'title': 'Understanding the web - Teaser',
- 'description': 'This is "Understanding the web - Teaser" by openclassrooms on Vimeo, the home for high quality videos and the people who love them.',
- 'upload_date': '20151214',
- 'uploader': 'OpenClassrooms',
- 'uploader_id': 'openclassrooms',
- },
- 'add_ie': ['Vimeo'],
- },
- {
- # generic vimeo embed that requires original URL passed as Referer
- 'url': 'http://racing4everyone.eu/2016/07/30/formula-1-2016-round12-germany/',
- 'only_matching': True,
- },
- {
- 'url': 'https://support.arkena.com/display/PLAY/Ways+to+embed+your+video',
- 'md5': 'b96f2f71b359a8ecd05ce4e1daa72365',
- 'info_dict': {
- 'id': 'b41dda37-d8e7-4d3f-b1b5-9a9db578bdfe',
- 'ext': 'mp4',
- 'title': 'Big Buck Bunny',
- 'description': 'Royalty free test video',
- 'timestamp': 1432816365,
- 'upload_date': '20150528',
- 'is_live': False,
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [ArkenaIE.ie_key()],
- },
- {
- 'url': 'http://nova.bg/news/view/2016/08/16/156543/%D0%BD%D0%B0-%D0%BA%D0%BE%D1%81%D1%8A%D0%BC-%D0%BE%D1%82-%D0%B2%D0%B7%D1%80%D0%B8%D0%B2-%D0%BE%D1%82%D1%86%D0%B5%D0%BF%D0%B8%D1%85%D0%B0-%D1%86%D1%8F%D0%BB-%D0%BA%D0%B2%D0%B0%D1%80%D1%82%D0%B0%D0%BB-%D0%B7%D0%B0%D1%80%D0%B0%D0%B4%D0%B8-%D0%B8%D0%B7%D1%82%D0%B8%D1%87%D0%B0%D0%BD%D0%B5-%D0%BD%D0%B0-%D0%B3%D0%B0%D0%B7-%D0%B2-%D0%BF%D0%BB%D0%BE%D0%B2%D0%B4%D0%B8%D0%B2/',
- 'info_dict': {
- 'id': '1c7141f46c',
- 'ext': 'mp4',
- 'title': 'НА КОСЪМ ОТ ВЗРИВ: Изтичане на газ на бензиностанция в Пловдив',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [Vbox7IE.ie_key()],
- },
- {
- # DBTV embeds
- 'url': 'http://www.dagbladet.no/2016/02/23/nyheter/nordlys/ski/troms/ver/43254897/',
- 'info_dict': {
- 'id': '43254897',
- 'title': 'Etter ett års planlegging, klaffet endelig alt: - Jeg måtte ta en liten dans',
- },
- 'playlist_mincount': 3,
- },
- {
- # Videa embeds
- 'url': 'http://forum.dvdtalk.com/movie-talk/623756-deleted-magic-star-wars-ot-deleted-alt-scenes-docu-style.html',
- 'info_dict': {
- 'id': '623756-deleted-magic-star-wars-ot-deleted-alt-scenes-docu-style',
- 'title': 'Deleted Magic - Star Wars: OT Deleted / Alt. Scenes Docu. Style - DVD Talk Forum',
- },
- 'playlist_mincount': 2,
- },
- {
- # 20 minuten embed
- 'url': 'http://www.20min.ch/schweiz/news/story/So-kommen-Sie-bei-Eis-und-Schnee-sicher-an-27032552',
- 'info_dict': {
- 'id': '523629',
- 'ext': 'mp4',
- 'title': 'So kommen Sie bei Eis und Schnee sicher an',
- 'description': 'md5:117c212f64b25e3d95747e5276863f7d',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [TwentyMinutenIE.ie_key()],
- },
- {
- # VideoPress embed
- 'url': 'https://en.support.wordpress.com/videopress/',
- 'info_dict': {
- 'id': 'OcobLTqC',
- 'ext': 'm4v',
- 'title': 'IMG_5786',
- 'timestamp': 1435711927,
- 'upload_date': '20150701',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [VideoPressIE.ie_key()],
- },
- {
- # Rutube embed
- 'url': 'http://magazzino.friday.ru/videos/vipuski/kazan-2',
- 'info_dict': {
- 'id': '9b3d5bee0a8740bf70dfd29d3ea43541',
- 'ext': 'flv',
- 'title': 'Магаззино: Казань 2',
- 'description': 'md5:99bccdfac2269f0e8fdbc4bbc9db184a',
- 'uploader': 'Магаззино',
- 'upload_date': '20170228',
- 'uploader_id': '996642',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [RutubeIE.ie_key()],
- },
- {
- # ThePlatform embedded with whitespaces in URLs
- 'url': 'http://www.golfchannel.com/topics/shows/golftalkcentral.htm',
- 'only_matching': True,
- },
- {
- # Senate ISVP iframe https
- 'url': 'https://www.hsgac.senate.gov/hearings/canadas-fast-track-refugee-plan-unanswered-questions-and-implications-for-us-national-security',
- 'md5': 'fb8c70b0b515e5037981a2492099aab8',
- 'info_dict': {
- 'id': 'govtaff020316',
- 'ext': 'mp4',
- 'title': 'Integrated Senate Video Player',
- },
- 'add_ie': [SenateISVPIE.ie_key()],
- },
- {
- # Limelight embeds (1 channel embed + 4 media embeds)
- 'url': 'http://www.sedona.com/FacilitatorTraining2017',
- 'info_dict': {
- 'id': 'FacilitatorTraining2017',
- 'title': 'Facilitator Training 2017',
- },
- 'playlist_mincount': 5,
- },
- {
- # Limelight embed (LimelightPlayerUtil.embed)
- 'url': 'https://tv5.ca/videos?v=xuu8qowr291ri',
- 'info_dict': {
- 'id': '95d035dc5c8a401588e9c0e6bd1e9c92',
- 'ext': 'mp4',
- 'title': '07448641',
- 'timestamp': 1499890639,
- 'upload_date': '20170712',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': ['LimelightMedia'],
- },
- {
- 'url': 'http://kron4.com/2017/04/28/standoff-with-walnut-creek-murder-suspect-ends-with-arrest/',
- 'info_dict': {
- 'id': 'standoff-with-walnut-creek-murder-suspect-ends-with-arrest',
- 'title': 'Standoff with Walnut Creek murder suspect ends',
- 'description': 'md5:3ccc48a60fc9441eeccfc9c469ebf788',
- },
- 'playlist_mincount': 4,
- },
- {
- # WashingtonPost embed
- 'url': 'http://www.vanityfair.com/hollywood/2017/04/donald-trump-tv-pitches',
- 'info_dict': {
- 'id': '8caf6e88-d0ec-11e5-90d3-34c2c42653ac',
- 'ext': 'mp4',
- 'title': "No one has seen the drama series based on Trump's life \u2014 until now",
- 'description': 'Donald Trump wanted a weekly TV drama based on his life. It never aired. But The Washington Post recently obtained a scene from the pilot script — and enlisted actors.',
- 'timestamp': 1455216756,
- 'uploader': 'The Washington Post',
- 'upload_date': '20160211',
- },
- 'add_ie': [WashingtonPostIE.ie_key()],
- },
- {
- # Mediaset embed
- 'url': 'http://www.tgcom24.mediaset.it/politica/serracchiani-voglio-vivere-in-una-societa-aperta-reazioni-sproporzionate-_3071354-201702a.shtml',
- 'info_dict': {
- 'id': '720642',
- 'ext': 'mp4',
- 'title': 'Serracchiani: "Voglio vivere in una società aperta, con tutela del patto di fiducia"',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [MediasetIE.ie_key()],
- },
- {
- # JOJ.sk embeds
- 'url': 'https://www.noviny.sk/slovensko/238543-slovenskom-sa-prehnala-vlna-silnych-burok',
- 'info_dict': {
- 'id': '238543-slovenskom-sa-prehnala-vlna-silnych-burok',
- 'title': 'Slovenskom sa prehnala vlna silných búrok',
- },
- 'playlist_mincount': 5,
- 'add_ie': [JojIE.ie_key()],
- },
- {
- # AMP embed (see https://www.ampproject.org/docs/reference/components/amp-video)
- 'url': 'https://tvrain.ru/amp/418921/',
- 'md5': 'cc00413936695987e8de148b67d14f1d',
- 'info_dict': {
- 'id': '418921',
- 'ext': 'mp4',
- 'title': 'Стас Намин: «Мы нарушили девственность Кремля»',
- },
- },
- {
- # vzaar embed
- 'url': 'http://help.vzaar.com/article/165-embedding-video',
- 'md5': '7e3919d9d2620b89e3e00bec7fe8c9d4',
- 'info_dict': {
- 'id': '8707641',
- 'ext': 'mp4',
- 'title': 'Building A Business Online: Principal Chairs Q & A',
- },
- },
- {
- # multiple HTML5 videos on one page
- 'url': 'https://www.paragon-software.com/home/rk-free/keyscenarios.html',
- 'info_dict': {
- 'id': 'keyscenarios',
- 'title': 'Rescue Kit 14 Free Edition - Getting started',
- },
- 'playlist_count': 4,
- },
- {
- # vshare embed
- 'url': 'https://youtube-dl-demo.neocities.org/vshare.html',
- 'md5': '17b39f55b5497ae8b59f5fbce8e35886',
- 'info_dict': {
- 'id': '0f64ce6',
- 'title': 'vl14062007715967',
- 'ext': 'mp4',
- }
- },
- {
- 'url': 'http://www.heidelberg-laureate-forum.org/blog/video/lecture-friday-september-23-2016-sir-c-antony-r-hoare/',
- 'md5': 'aecd089f55b1cb5a59032cb049d3a356',
- 'info_dict': {
- 'id': '90227f51a80c4d8f86c345a7fa62bd9a1d',
- 'ext': 'mp4',
- 'title': 'Lecture: Friday, September 23, 2016 - Sir Tony Hoare',
- 'description': 'md5:5a51db84a62def7b7054df2ade403c6c',
- 'timestamp': 1474354800,
- 'upload_date': '20160920',
- }
- },
- {
- 'url': 'http://www.kidzworld.com/article/30935-trolls-the-beat-goes-on-interview-skylar-astin-and-amanda-leighton',
- 'info_dict': {
- 'id': '1731611',
- 'ext': 'mp4',
- 'title': 'Official Trailer | TROLLS: THE BEAT GOES ON!',
- 'description': 'md5:eb5f23826a027ba95277d105f248b825',
- 'timestamp': 1516100691,
- 'upload_date': '20180116',
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [SpringboardPlatformIE.ie_key()],
- },
- {
- 'url': 'https://www.youtube.com/shared?ci=1nEzmT-M4fU',
- 'info_dict': {
- 'id': 'uPDB5I9wfp8',
- 'ext': 'webm',
- 'title': 'Pocoyo: 90 minutos de episódios completos Português para crianças - PARTE 3',
- 'description': 'md5:d9e4d9346a2dfff4c7dc4c8cec0f546d',
- 'upload_date': '20160219',
- 'uploader': 'Pocoyo - Português (BR)',
- 'uploader_id': 'PocoyoBrazil',
- },
- 'add_ie': [YoutubeIE.ie_key()],
- 'params': {
- 'skip_download': True,
- },
- },
- {
- 'url': 'https://www.yapfiles.ru/show/1872528/690b05d3054d2dbe1e69523aa21bb3b1.mp4.html',
- 'info_dict': {
- 'id': 'vMDE4NzI1Mjgt690b',
- 'ext': 'mp4',
- 'title': 'Котята',
- },
- 'add_ie': [YapFilesIE.ie_key()],
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # CloudflareStream embed
- 'url': 'https://www.cloudflare.com/products/cloudflare-stream/',
- 'info_dict': {
- 'id': '31c9291ab41fac05471db4e73aa11717',
- 'ext': 'mp4',
- 'title': '31c9291ab41fac05471db4e73aa11717',
- },
- 'add_ie': [CloudflareStreamIE.ie_key()],
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # PeerTube embed
- 'url': 'https://joinpeertube.org/fr/home/',
- 'info_dict': {
- 'id': 'home',
- 'title': 'Reprenez le contrôle de vos vidéos ! #JoinPeertube',
- },
- 'playlist_count': 2,
- },
- {
- # Indavideo embed
- 'url': 'https://streetkitchen.hu/receptek/igy_kell_otthon_hamburgert_sutni/',
- 'info_dict': {
- 'id': '1693903',
- 'ext': 'mp4',
- 'title': 'Így kell otthon hamburgert sütni',
- 'description': 'md5:f5a730ecf900a5c852e1e00540bbb0f7',
- 'timestamp': 1426330212,
- 'upload_date': '20150314',
- 'uploader': 'StreetKitchen',
- 'uploader_id': '546363',
- },
- 'add_ie': [IndavideoEmbedIE.ie_key()],
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # APA embed via JWPlatform embed
- 'url': 'http://www.vol.at/blue-man-group/5593454',
- 'info_dict': {
- 'id': 'jjv85FdZ',
- 'ext': 'mp4',
- 'title': '"Blau ist mysteriös": Die Blue Man Group im Interview',
- 'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'duration': 254,
- 'timestamp': 1519211149,
- 'upload_date': '20180221',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- 'url': 'http://share-videos.se/auto/video/83645793?uid=13',
- 'md5': 'b68d276de422ab07ee1d49388103f457',
- 'info_dict': {
- 'id': '83645793',
- 'title': 'Lock up and get excited',
- 'ext': 'mp4'
- },
- 'skip': 'TODO: fix nested playlists processing in tests',
- },
- # {
- # # TODO: find another test
- # # http://schema.org/VideoObject
- # 'url': 'https://flipagram.com/f/nyvTSJMKId',
- # 'md5': '888dcf08b7ea671381f00fab74692755',
- # 'info_dict': {
- # 'id': 'nyvTSJMKId',
- # 'ext': 'mp4',
- # 'title': 'Flipagram by sjuria101 featuring Midnight Memories by One Direction',
- # 'description': '#love for cats.',
- # 'timestamp': 1461244995,
- # 'upload_date': '20160421',
- # },
- # 'params': {
- # 'force_generic_extractor': True,
- # },
- # }
- ]
-
- def report_following_redirect(self, new_url):
- """Report information extraction."""
- self._downloader.to_screen('[redirect] Following redirect to %s' % new_url)
-
- def _extract_rss(self, url, video_id, doc):
- playlist_title = doc.find('./channel/title').text
- playlist_desc_el = doc.find('./channel/description')
- playlist_desc = None if playlist_desc_el is None else playlist_desc_el.text
-
- entries = []
- for it in doc.findall('./channel/item'):
- next_url = None
- enclosure_nodes = it.findall('./enclosure')
- for e in enclosure_nodes:
- next_url = e.attrib.get('url')
- if next_url:
- break
-
- if not next_url:
- next_url = xpath_text(it, 'link', fatal=False)
-
- if not next_url:
- continue
-
- entries.append({
- '_type': 'url_transparent',
- 'url': next_url,
- 'title': it.find('title').text,
- })
-
- return {
- '_type': 'playlist',
- 'id': url,
- 'title': playlist_title,
- 'description': playlist_desc,
- 'entries': entries,
- }
-
- def _extract_camtasia(self, url, video_id, webpage):
- """ Returns None if no camtasia video can be found. """
-
- camtasia_cfg = self._search_regex(
- r'fo\.addVariable\(\s*"csConfigFile",\s*"([^"]+)"\s*\);',
- webpage, 'camtasia configuration file', default=None)
- if camtasia_cfg is None:
- return None
-
- title = self._html_search_meta('DC.title', webpage, fatal=True)
-
- camtasia_url = compat_urlparse.urljoin(url, camtasia_cfg)
- camtasia_cfg = self._download_xml(
- camtasia_url, video_id,
- note='Downloading camtasia configuration',
- errnote='Failed to download camtasia configuration')
- fileset_node = camtasia_cfg.find('./playlist/array/fileset')
-
- entries = []
- for n in fileset_node.getchildren():
- url_n = n.find('./uri')
- if url_n is None:
- continue
-
- entries.append({
- 'id': os.path.splitext(url_n.text.rpartition('/')[2])[0],
- 'title': '%s - %s' % (title, n.tag),
- 'url': compat_urlparse.urljoin(url, url_n.text),
- 'duration': float_or_none(n.find('./duration').text),
- })
-
- return {
- '_type': 'playlist',
- 'entries': entries,
- 'title': title,
- }
-
- def _real_extract(self, url):
- if url.startswith('//'):
- return {
- '_type': 'url',
- 'url': self.http_scheme() + url,
- }
-
- parsed_url = compat_urlparse.urlparse(url)
- if not parsed_url.scheme:
- default_search = self._downloader.params.get('default_search')
- if default_search is None:
- default_search = 'fixup_error'
-
- if default_search in ('auto', 'auto_warning', 'fixup_error'):
- if '/' in url:
- self._downloader.report_warning('The url doesn\'t specify the protocol, trying with http')
- return self.url_result('http://' + url)
- elif default_search != 'fixup_error':
- if default_search == 'auto_warning':
- if re.match(r'^(?:url|URL)$', url):
- raise ExtractorError(
- 'Invalid URL: %r . Call youtube-dl like this: youtube-dl -v "https://www.youtube.com/watch?v=BaW_jenozKc" ' % url,
- expected=True)
- else:
- self._downloader.report_warning(
- 'Falling back to youtube search for %s . Set --default-search "auto" to suppress this warning.' % url)
- return self.url_result('ytsearch:' + url)
-
- if default_search in ('error', 'fixup_error'):
- raise ExtractorError(
- '%r is not a valid URL. '
- 'Set --default-search "ytsearch" (or run youtube-dl "ytsearch:%s" ) to search YouTube'
- % (url, url), expected=True)
- else:
- if ':' not in default_search:
- default_search += ':'
- return self.url_result(default_search + url)
-
- url, smuggled_data = unsmuggle_url(url)
- force_videoid = None
- is_intentional = smuggled_data and smuggled_data.get('to_generic')
- if smuggled_data and 'force_videoid' in smuggled_data:
- force_videoid = smuggled_data['force_videoid']
- video_id = force_videoid
- else:
- video_id = self._generic_id(url)
-
- self.to_screen('%s: Requesting header' % video_id)
-
- head_req = HEADRequest(url)
- head_response = self._request_webpage(
- head_req, video_id,
- note=False, errnote='Could not send HEAD request to %s' % url,
- fatal=False)
-
- if head_response is not False:
- # Check for redirect
- new_url = compat_str(head_response.geturl())
- if url != new_url:
- self.report_following_redirect(new_url)
- if force_videoid:
- new_url = smuggle_url(
- new_url, {'force_videoid': force_videoid})
- return self.url_result(new_url)
-
- full_response = None
- if head_response is False:
- request = sanitized_Request(url)
- request.add_header('Accept-Encoding', '*')
- full_response = self._request_webpage(request, video_id)
- head_response = full_response
-
- info_dict = {
- 'id': video_id,
- 'title': self._generic_title(url),
- 'upload_date': unified_strdate(head_response.headers.get('Last-Modified'))
- }
-
- # Check for direct link to a video
- content_type = head_response.headers.get('Content-Type', '').lower()
- m = re.match(r'^(?P<type>audio|video|application(?=/(?:ogg$|(?:vnd\.apple\.|x-)?mpegurl)))/(?P<format_id>[^;\s]+)', content_type)
- if m:
- format_id = compat_str(m.group('format_id'))
- if format_id.endswith('mpegurl'):
- formats = self._extract_m3u8_formats(url, video_id, 'mp4')
- elif format_id == 'f4m':
- formats = self._extract_f4m_formats(url, video_id)
- else:
- formats = [{
- 'format_id': format_id,
- 'url': url,
- 'vcodec': 'none' if m.group('type') == 'audio' else None
- }]
- info_dict['direct'] = True
- self._sort_formats(formats)
- info_dict['formats'] = formats
- return info_dict
-
- if not self._downloader.params.get('test', False) and not is_intentional:
- force = self._downloader.params.get('force_generic_extractor', False)
- self._downloader.report_warning(
- '%s on generic information extractor.' % ('Forcing' if force else 'Falling back'))
-
- if not full_response:
- request = sanitized_Request(url)
- # Some webservers may serve compressed content of rather big size (e.g. gzipped flac)
- # making it impossible to download only chunk of the file (yet we need only 512kB to
- # test whether it's HTML or not). According to youtube-dl default Accept-Encoding
- # that will always result in downloading the whole file that is not desirable.
- # Therefore for extraction pass we have to override Accept-Encoding to any in order
- # to accept raw bytes and being able to download only a chunk.
- # It may probably better to solve this by checking Content-Type for application/octet-stream
- # after HEAD request finishes, but not sure if we can rely on this.
- request.add_header('Accept-Encoding', '*')
- full_response = self._request_webpage(request, video_id)
-
- first_bytes = full_response.read(512)
-
- # Is it an M3U playlist?
- if first_bytes.startswith(b'#EXTM3U'):
- info_dict['formats'] = self._extract_m3u8_formats(url, video_id, 'mp4')
- self._sort_formats(info_dict['formats'])
- return info_dict
-
- # Maybe it's a direct link to a video?
- # Be careful not to download the whole thing!
- if not is_html(first_bytes):
- self._downloader.report_warning(
- 'URL could be a direct video link, returning it as such.')
- info_dict.update({
- 'direct': True,
- 'url': url,
- })
- return info_dict
-
- webpage = self._webpage_read_content(
- full_response, url, video_id, prefix=first_bytes)
-
- self.report_extraction(video_id)
-
- # Is it an RSS feed, a SMIL file, an XSPF playlist or a MPD manifest?
- try:
- doc = compat_etree_fromstring(webpage.encode('utf-8'))
- if doc.tag == 'rss':
- return self._extract_rss(url, video_id, doc)
- elif doc.tag == 'SmoothStreamingMedia':
- info_dict['formats'] = self._parse_ism_formats(doc, url)
- self._sort_formats(info_dict['formats'])
- return info_dict
- elif re.match(r'^(?:{[^}]+})?smil$', doc.tag):
- smil = self._parse_smil(doc, url, video_id)
- self._sort_formats(smil['formats'])
- return smil
- elif doc.tag == '{http://xspf.org/ns/0/}playlist':
- return self.playlist_result(
- self._parse_xspf(
- doc, video_id, xspf_url=url,
- xspf_base_url=compat_str(full_response.geturl())),
- video_id)
- elif re.match(r'(?i)^(?:{[^}]+})?MPD$', doc.tag):
- info_dict['formats'] = self._parse_mpd_formats(
- doc,
- mpd_base_url=compat_str(full_response.geturl()).rpartition('/')[0],
- mpd_url=url)
- self._sort_formats(info_dict['formats'])
- return info_dict
- elif re.match(r'^{http://ns\.adobe\.com/f4m/[12]\.0}manifest$', doc.tag):
- info_dict['formats'] = self._parse_f4m_formats(doc, url, video_id)
- self._sort_formats(info_dict['formats'])
- return info_dict
- except compat_xml_parse_error:
- pass
-
- # Is it a Camtasia project?
- camtasia_res = self._extract_camtasia(url, video_id, webpage)
- if camtasia_res is not None:
- return camtasia_res
-
- # Sometimes embedded video player is hidden behind percent encoding
- # (e.g. https://github.com/rg3/youtube-dl/issues/2448)
- # Unescaping the whole page allows to handle those cases in a generic way
- webpage = compat_urllib_parse_unquote(webpage)
-
- # it's tempting to parse this further, but you would
- # have to take into account all the variations like
- # Video Title - Site Name
- # Site Name | Video Title
- # Video Title - Tagline | Site Name
- # and so on and so forth; it's just not practical
- video_title = self._og_search_title(
- webpage, default=None) or self._html_search_regex(
- r'(?s)<title>(.*?)</title>', webpage, 'video title',
- default='video')
-
- # Try to detect age limit automatically
- age_limit = self._rta_search(webpage)
- # And then there are the jokers who advertise that they use RTA,
- # but actually don't.
- AGE_LIMIT_MARKERS = [
- r'Proudly Labeled <a href="http://www\.rtalabel\.org/" title="Restricted to Adults">RTA</a>',
- ]
- if any(re.search(marker, webpage) for marker in AGE_LIMIT_MARKERS):
- age_limit = 18
-
- # video uploader is domain name
- video_uploader = self._search_regex(
- r'^(?:https?://)?([^/]*)/.*', url, 'video uploader')
-
- video_description = self._og_search_description(webpage, default=None)
- video_thumbnail = self._og_search_thumbnail(webpage, default=None)
-
- info_dict.update({
- 'title': video_title,
- 'description': video_description,
- 'thumbnail': video_thumbnail,
- 'age_limit': age_limit,
- })
-
- # Look for Brightcove Legacy Studio embeds
- bc_urls = BrightcoveLegacyIE._extract_brightcove_urls(webpage)
- if bc_urls:
- entries = [{
- '_type': 'url',
- 'url': smuggle_url(bc_url, {'Referer': url}),
- 'ie_key': 'BrightcoveLegacy'
- } for bc_url in bc_urls]
-
- return {
- '_type': 'playlist',
- 'title': video_title,
- 'id': video_id,
- 'entries': entries,
- }
-
- # Look for Brightcove New Studio embeds
- bc_urls = BrightcoveNewIE._extract_urls(self, webpage)
- if bc_urls:
- return self.playlist_from_matches(
- bc_urls, video_id, video_title,
- getter=lambda x: smuggle_url(x, {'referrer': url}),
- ie='BrightcoveNew')
-
- # Look for Nexx embeds
- nexx_urls = NexxIE._extract_urls(webpage)
- if nexx_urls:
- return self.playlist_from_matches(nexx_urls, video_id, video_title, ie=NexxIE.ie_key())
-
- # Look for Nexx iFrame embeds
- nexx_embed_urls = NexxEmbedIE._extract_urls(webpage)
- if nexx_embed_urls:
- return self.playlist_from_matches(nexx_embed_urls, video_id, video_title, ie=NexxEmbedIE.ie_key())
-
- # Look for ThePlatform embeds
- tp_urls = ThePlatformIE._extract_urls(webpage)
- if tp_urls:
- return self.playlist_from_matches(tp_urls, video_id, video_title, ie='ThePlatform')
-
- # Look for Vessel embeds
- vessel_urls = VesselIE._extract_urls(webpage)
- if vessel_urls:
- return self.playlist_from_matches(vessel_urls, video_id, video_title, ie=VesselIE.ie_key())
-
- # Look for embedded rtl.nl player
- matches = re.findall(
- r'<iframe[^>]+?src="((?:https?:)?//(?:(?:www|static)\.)?rtl\.nl/(?:system/videoplayer/[^"]+(?:video_)?)?embed[^"]+)"',
- webpage)
- if matches:
- return self.playlist_from_matches(matches, video_id, video_title, ie='RtlNl')
-
- vimeo_urls = VimeoIE._extract_urls(url, webpage)
- if vimeo_urls:
- return self.playlist_from_matches(vimeo_urls, video_id, video_title, ie=VimeoIE.ie_key())
-
- vid_me_embed_url = self._search_regex(
- r'src=[\'"](https?://vid\.me/[^\'"]+)[\'"]',
- webpage, 'vid.me embed', default=None)
- if vid_me_embed_url is not None:
- return self.url_result(vid_me_embed_url, 'Vidme')
-
- # Look for YouTube embeds
- youtube_urls = YoutubeIE._extract_urls(webpage)
- if youtube_urls:
- return self.playlist_from_matches(
- youtube_urls, video_id, video_title, ie=YoutubeIE.ie_key())
-
- matches = DailymotionIE._extract_urls(webpage)
- if matches:
- return self.playlist_from_matches(matches, video_id, video_title)
-
- # Look for embedded Dailymotion playlist player (#3822)
- m = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>(?:https?:)?//(?:www\.)?dailymotion\.[a-z]{2,3}/widget/jukebox\?.+?)\1', webpage)
- if m:
- playlists = re.findall(
- r'list\[\]=/playlist/([^/]+)/', unescapeHTML(m.group('url')))
- if playlists:
- return self.playlist_from_matches(
- playlists, video_id, video_title, lambda p: '//dailymotion.com/playlist/%s' % p)
-
- # Look for DailyMail embeds
- dailymail_urls = DailyMailIE._extract_urls(webpage)
- if dailymail_urls:
- return self.playlist_from_matches(
- dailymail_urls, video_id, video_title, ie=DailyMailIE.ie_key())
-
- # Look for embedded Wistia player
- wistia_url = WistiaIE._extract_url(webpage)
- if wistia_url:
- return {
- '_type': 'url_transparent',
- 'url': self._proto_relative_url(wistia_url),
- 'ie_key': WistiaIE.ie_key(),
- 'uploader': video_uploader,
- }
-
- # Look for SVT player
- svt_url = SVTIE._extract_url(webpage)
- if svt_url:
- return self.url_result(svt_url, 'SVT')
-
- # Look for Bandcamp pages with custom domain
- mobj = re.search(r'<meta property="og:url"[^>]*?content="(.*?bandcamp\.com.*?)"', webpage)
- if mobj is not None:
- burl = unescapeHTML(mobj.group(1))
- # Don't set the extractor because it can be a track url or an album
- return self.url_result(burl)
-
- # Look for embedded Vevo player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>(?:https?:)?//(?:cache\.)?vevo\.com/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for embedded Viddler player
- mobj = re.search(
- r'<(?:iframe[^>]+?src|param[^>]+?value)=(["\'])(?P<url>(?:https?:)?//(?:www\.)?viddler\.com/(?:embed|player)/.+?)\1',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for NYTimes player
- mobj = re.search(
- r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//graphics8\.nytimes\.com/bcvideo/[^/]+/iframe/embed\.html.+?)\1>',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for Libsyn player
- mobj = re.search(
- r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//html5-player\.libsyn\.com/embed/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for Ooyala videos
- mobj = (re.search(r'player\.ooyala\.com/[^"?]+[?#][^"]*?(?:embedCode|ec)=(?P<ec>[^"&]+)', webpage) or
- re.search(r'OO\.Player\.create\([\'"].*?[\'"],\s*[\'"](?P<ec>.{32})[\'"]', webpage) or
- re.search(r'OO\.Player\.create\.apply\(\s*OO\.Player\s*,\s*op\(\s*\[\s*[\'"][^\'"]*[\'"]\s*,\s*[\'"](?P<ec>.{32})[\'"]', webpage) or
- re.search(r'SBN\.VideoLinkset\.ooyala\([\'"](?P<ec>.{32})[\'"]\)', webpage) or
- re.search(r'data-ooyala-video-id\s*=\s*[\'"](?P<ec>.{32})[\'"]', webpage))
- if mobj is not None:
- embed_token = self._search_regex(
- r'embedToken[\'"]?\s*:\s*[\'"]([^\'"]+)',
- webpage, 'ooyala embed token', default=None)
- return OoyalaIE._build_url_result(smuggle_url(
- mobj.group('ec'), {
- 'domain': url,
- 'embed_token': embed_token,
- }))
-
- # Look for multiple Ooyala embeds on SBN network websites
- mobj = re.search(r'SBN\.VideoLinkset\.entryGroup\((\[.*?\])', webpage)
- if mobj is not None:
- embeds = self._parse_json(mobj.group(1), video_id, fatal=False)
- if embeds:
- return self.playlist_from_matches(
- embeds, video_id, video_title,
- getter=lambda v: OoyalaIE._url_for_embed_code(smuggle_url(v['provider_video_id'], {'domain': url})), ie='Ooyala')
-
- # Look for Aparat videos
- mobj = re.search(r'<iframe .*?src="(http://www\.aparat\.com/video/[^"]+)"', webpage)
- if mobj is not None:
- return self.url_result(mobj.group(1), 'Aparat')
-
- # Look for MPORA videos
- mobj = re.search(r'<iframe .*?src="(http://mpora\.(?:com|de)/videos/[^"]+)"', webpage)
- if mobj is not None:
- return self.url_result(mobj.group(1), 'Mpora')
-
- # Look for embedded NovaMov-based player
- mobj = re.search(
- r'''(?x)<(?:pagespeed_)?iframe[^>]+?src=(["\'])
- (?P<url>http://(?:(?:embed|www)\.)?
- (?:novamov\.com|
- nowvideo\.(?:ch|sx|eu|at|ag|co)|
- videoweed\.(?:es|com)|
- movshare\.(?:net|sx|ag)|
- divxstage\.(?:eu|net|ch|co|at|ag))
- /embed\.php.+?)\1''', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for embedded Facebook player
- facebook_urls = FacebookIE._extract_urls(webpage)
- if facebook_urls:
- return self.playlist_from_matches(facebook_urls, video_id, video_title)
-
- # Look for embedded VK player
- mobj = re.search(r'<iframe[^>]+?src=(["\'])(?P<url>https?://vk\.com/video_ext\.php.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'VK')
-
- # Look for embedded Odnoklassniki player
- mobj = re.search(r'<iframe[^>]+?src=(["\'])(?P<url>https?://(?:odnoklassniki|ok)\.ru/videoembed/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Odnoklassniki')
-
- # Look for embedded ivi player
- mobj = re.search(r'<embed[^>]+?src=(["\'])(?P<url>https?://(?:www\.)?ivi\.ru/video/player.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Ivi')
-
- # Look for embedded Huffington Post player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>https?://embed\.live\.huffingtonpost\.com/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'HuffPost')
-
- # Look for embed.ly
- mobj = re.search(r'class=["\']embedly-card["\'][^>]href=["\'](?P<url>[^"\']+)', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
- mobj = re.search(r'class=["\']embedly-embed["\'][^>]src=["\'][^"\']*url=(?P<url>[^&]+)', webpage)
- if mobj is not None:
- return self.url_result(compat_urllib_parse_unquote(mobj.group('url')))
-
- # Look for funnyordie embed
- matches = re.findall(r'<iframe[^>]+?src="(https?://(?:www\.)?funnyordie\.com/embed/[^"]+)"', webpage)
- if matches:
- return self.playlist_from_matches(
- matches, video_id, video_title, getter=unescapeHTML, ie='FunnyOrDie')
-
- # Look for BBC iPlayer embed
- matches = re.findall(r'setPlaylist\("(https?://www\.bbc\.co\.uk/iplayer/[^/]+/[\da-z]{8})"\)', webpage)
- if matches:
- return self.playlist_from_matches(matches, video_id, video_title, ie='BBCCoUk')
-
- # Look for embedded RUTV player
- rutv_url = RUTVIE._extract_url(webpage)
- if rutv_url:
- return self.url_result(rutv_url, 'RUTV')
-
- # Look for embedded TVC player
- tvc_url = TVCIE._extract_url(webpage)
- if tvc_url:
- return self.url_result(tvc_url, 'TVC')
-
- # Look for embedded SportBox player
- sportbox_urls = SportBoxEmbedIE._extract_urls(webpage)
- if sportbox_urls:
- return self.playlist_from_matches(sportbox_urls, video_id, video_title, ie='SportBoxEmbed')
-
- # Look for embedded XHamster player
- xhamster_urls = XHamsterEmbedIE._extract_urls(webpage)
- if xhamster_urls:
- return self.playlist_from_matches(xhamster_urls, video_id, video_title, ie='XHamsterEmbed')
-
- # Look for embedded TNAFlixNetwork player
- tnaflix_urls = TNAFlixNetworkEmbedIE._extract_urls(webpage)
- if tnaflix_urls:
- return self.playlist_from_matches(tnaflix_urls, video_id, video_title, ie=TNAFlixNetworkEmbedIE.ie_key())
-
- # Look for embedded PornHub player
- pornhub_urls = PornHubIE._extract_urls(webpage)
- if pornhub_urls:
- return self.playlist_from_matches(pornhub_urls, video_id, video_title, ie=PornHubIE.ie_key())
-
- # Look for embedded DrTuber player
- drtuber_urls = DrTuberIE._extract_urls(webpage)
- if drtuber_urls:
- return self.playlist_from_matches(drtuber_urls, video_id, video_title, ie=DrTuberIE.ie_key())
-
- # Look for embedded RedTube player
- redtube_urls = RedTubeIE._extract_urls(webpage)
- if redtube_urls:
- return self.playlist_from_matches(redtube_urls, video_id, video_title, ie=RedTubeIE.ie_key())
-
- # Look for embedded Tube8 player
- tube8_urls = Tube8IE._extract_urls(webpage)
- if tube8_urls:
- return self.playlist_from_matches(tube8_urls, video_id, video_title, ie=Tube8IE.ie_key())
-
- # Look for embedded Tvigle player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>(?:https?:)?//cloud\.tvigle\.ru/video/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Tvigle')
-
- # Look for embedded TED player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>https?://embed(?:-ssl)?\.ted\.com/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'TED')
-
- # Look for embedded Ustream videos
- ustream_url = UstreamIE._extract_url(webpage)
- if ustream_url:
- return self.url_result(ustream_url, UstreamIE.ie_key())
-
- # Look for embedded arte.tv player
- mobj = re.search(
- r'<(?:script|iframe) [^>]*?src="(?P<url>http://www\.arte\.tv/(?:playerv2/embed|arte_vp/index)[^"]+)"',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'ArteTVEmbed')
-
- # Look for embedded francetv player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>(?:https?://)?embed\.francetv\.fr/\?ue=.+?)\1',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for embedded smotri.com player
- smotri_url = SmotriIE._extract_url(webpage)
- if smotri_url:
- return self.url_result(smotri_url, 'Smotri')
-
- # Look for embedded Myvi.ru player
- myvi_url = MyviIE._extract_url(webpage)
- if myvi_url:
- return self.url_result(myvi_url)
-
- # Look for embedded soundcloud player
- soundcloud_urls = SoundcloudIE._extract_urls(webpage)
- if soundcloud_urls:
- return self.playlist_from_matches(soundcloud_urls, video_id, video_title, getter=unescapeHTML, ie=SoundcloudIE.ie_key())
-
- # Look for tunein player
- tunein_urls = TuneInBaseIE._extract_urls(webpage)
- if tunein_urls:
- return self.playlist_from_matches(tunein_urls, video_id, video_title)
-
- # Look for embedded mtvservices player
- mtvservices_url = MTVServicesEmbeddedIE._extract_url(webpage)
- if mtvservices_url:
- return self.url_result(mtvservices_url, ie='MTVServicesEmbedded')
-
- # Look for embedded yahoo player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>https?://(?:screen|movies)\.yahoo\.com/.+?\.html\?format=embed)\1',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Yahoo')
-
- # Look for embedded sbs.com.au player
- mobj = re.search(
- r'''(?x)
- (?:
- <meta\s+property="og:video"\s+content=|
- <iframe[^>]+?src=
- )
- (["\'])(?P<url>https?://(?:www\.)?sbs\.com\.au/ondemand/video/.+?)\1''',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'SBS')
-
- # Look for embedded Cinchcast player
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>https?://player\.cinchcast\.com/.+?)\1',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Cinchcast')
-
- mobj = re.search(
- r'<iframe[^>]+?src=(["\'])(?P<url>https?://m(?:lb)?\.mlb\.com/shared/video/embed/embed\.html\?.+?)\1',
- webpage)
- if not mobj:
- mobj = re.search(
- r'data-video-link=["\'](?P<url>http://m.mlb.com/video/[^"\']+)',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'MLB')
-
- mobj = re.search(
- r'<(?:iframe|script)[^>]+?src=(["\'])(?P<url>%s)\1' % CondeNastIE.EMBED_URL,
- webpage)
- if mobj is not None:
- return self.url_result(self._proto_relative_url(mobj.group('url'), scheme='http:'), 'CondeNast')
-
- mobj = re.search(
- r'<iframe[^>]+src="(?P<url>https?://(?:new\.)?livestream\.com/[^"]+/player[^"]+)"',
- webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Livestream')
-
- # Look for Zapiks embed
- mobj = re.search(
- r'<iframe[^>]+src="(?P<url>https?://(?:www\.)?zapiks\.fr/index\.php\?.+?)"', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'), 'Zapiks')
-
- # Look for Kaltura embeds
- kaltura_url = KalturaIE._extract_url(webpage)
- if kaltura_url:
- return self.url_result(smuggle_url(kaltura_url, {'source_url': url}), KalturaIE.ie_key())
-
- # Look for EaglePlatform embeds
- eagleplatform_url = EaglePlatformIE._extract_url(webpage)
- if eagleplatform_url:
- return self.url_result(smuggle_url(eagleplatform_url, {'referrer': url}), EaglePlatformIE.ie_key())
-
- # Look for ClipYou (uses EaglePlatform) embeds
- mobj = re.search(
- r'<iframe[^>]+src="https?://(?P<host>media\.clipyou\.ru)/index/player\?.*\brecord_id=(?P<id>\d+).*"', webpage)
- if mobj is not None:
- return self.url_result('eagleplatform:%(host)s:%(id)s' % mobj.groupdict(), 'EaglePlatform')
-
- # Look for Pladform embeds
- pladform_url = PladformIE._extract_url(webpage)
- if pladform_url:
- return self.url_result(pladform_url)
-
- # Look for Videomore embeds
- videomore_url = VideomoreIE._extract_url(webpage)
- if videomore_url:
- return self.url_result(videomore_url)
-
- # Look for Webcaster embeds
- webcaster_url = WebcasterFeedIE._extract_url(self, webpage)
- if webcaster_url:
- return self.url_result(webcaster_url, ie=WebcasterFeedIE.ie_key())
-
- # Look for Playwire embeds
- mobj = re.search(
- r'<script[^>]+data-config=(["\'])(?P<url>(?:https?:)?//config\.playwire\.com/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for 5min embeds
- mobj = re.search(
- r'<meta[^>]+property="og:video"[^>]+content="https?://embed\.5min\.com/(?P<id>[0-9]+)/?', webpage)
- if mobj is not None:
- return self.url_result('5min:%s' % mobj.group('id'), 'FiveMin')
-
- # Look for Crooks and Liars embeds
- mobj = re.search(
- r'<(?:iframe[^>]+src|param[^>]+value)=(["\'])(?P<url>(?:https?:)?//embed\.crooksandliars\.com/(?:embed|v)/.+?)\1', webpage)
- if mobj is not None:
- return self.url_result(mobj.group('url'))
-
- # Look for NBC Sports VPlayer embeds
- nbc_sports_url = NBCSportsVPlayerIE._extract_url(webpage)
- if nbc_sports_url:
- return self.url_result(nbc_sports_url, 'NBCSportsVPlayer')
-
- # Look for NBC News embeds
- nbc_news_embed_url = re.search(
- r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//www\.nbcnews\.com/widget/video-embed/[^"\']+)\1', webpage)
- if nbc_news_embed_url:
- return self.url_result(nbc_news_embed_url.group('url'), 'NBCNews')
-
- # Look for Google Drive embeds
- google_drive_url = GoogleDriveIE._extract_url(webpage)
- if google_drive_url:
- return self.url_result(google_drive_url, 'GoogleDrive')
-
- # Look for UDN embeds
- mobj = re.search(
- r'<iframe[^>]+src="(?:https?:)?(?P<url>%s)"' % UDNEmbedIE._PROTOCOL_RELATIVE_VALID_URL, webpage)
- if mobj is not None:
- return self.url_result(
- compat_urlparse.urljoin(url, mobj.group('url')), 'UDNEmbed')
-
- # Look for Senate ISVP iframe
- senate_isvp_url = SenateISVPIE._search_iframe_url(webpage)
- if senate_isvp_url:
- return self.url_result(senate_isvp_url, 'SenateISVP')
-
- # Look for OnionStudios embeds
- onionstudios_url = OnionStudiosIE._extract_url(webpage)
- if onionstudios_url:
- return self.url_result(onionstudios_url)
-
- # Look for ViewLift embeds
- viewlift_url = ViewLiftEmbedIE._extract_url(webpage)
- if viewlift_url:
- return self.url_result(viewlift_url)
-
- # Look for JWPlatform embeds
- jwplatform_urls = JWPlatformIE._extract_urls(webpage)
- if jwplatform_urls:
- return self.playlist_from_matches(jwplatform_urls, video_id, video_title, ie=JWPlatformIE.ie_key())
-
- # Look for Digiteka embeds
- digiteka_url = DigitekaIE._extract_url(webpage)
- if digiteka_url:
- return self.url_result(self._proto_relative_url(digiteka_url), DigitekaIE.ie_key())
-
- # Look for Arkena embeds
- arkena_url = ArkenaIE._extract_url(webpage)
- if arkena_url:
- return self.url_result(arkena_url, ArkenaIE.ie_key())
-
- # Look for Piksel embeds
- piksel_url = PikselIE._extract_url(webpage)
- if piksel_url:
- return self.url_result(piksel_url, PikselIE.ie_key())
-
- # Look for Limelight embeds
- limelight_urls = LimelightBaseIE._extract_urls(webpage, url)
- if limelight_urls:
- return self.playlist_result(
- limelight_urls, video_id, video_title, video_description)
-
- # Look for Anvato embeds
- anvato_urls = AnvatoIE._extract_urls(self, webpage, video_id)
- if anvato_urls:
- return self.playlist_result(
- anvato_urls, video_id, video_title, video_description)
-
- # Look for AdobeTVVideo embeds
- mobj = re.search(
- r'<iframe[^>]+src=[\'"]((?:https?:)?//video\.tv\.adobe\.com/v/\d+[^"]+)[\'"]',
- webpage)
- if mobj is not None:
- return self.url_result(
- self._proto_relative_url(unescapeHTML(mobj.group(1))),
- 'AdobeTVVideo')
-
- # Look for Vine embeds
- mobj = re.search(
- r'<iframe[^>]+src=[\'"]((?:https?:)?//(?:www\.)?vine\.co/v/[^/]+/embed/(?:simple|postcard))',
- webpage)
- if mobj is not None:
- return self.url_result(
- self._proto_relative_url(unescapeHTML(mobj.group(1))), 'Vine')
-
- # Look for VODPlatform embeds
- mobj = re.search(
- r'<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//(?:www\.)?vod-platform\.net/[eE]mbed/.+?)\1',
- webpage)
- if mobj is not None:
- return self.url_result(
- self._proto_relative_url(unescapeHTML(mobj.group('url'))), 'VODPlatform')
-
- # Look for Mangomolo embeds
- mobj = re.search(
- r'''(?x)<iframe[^>]+src=(["\'])(?P<url>(?:https?:)?//(?:www\.)?admin\.mangomolo\.com/analytics/index\.php/customers/embed/
- (?:
- video\?.*?\bid=(?P<video_id>\d+)|
- index\?.*?\bchannelid=(?P<channel_id>(?:[A-Za-z0-9+/=]|%2B|%2F|%3D)+)
- ).+?)\1''', webpage)
- if mobj is not None:
- info = {
- '_type': 'url_transparent',
- 'url': self._proto_relative_url(unescapeHTML(mobj.group('url'))),
- 'title': video_title,
- 'description': video_description,
- 'thumbnail': video_thumbnail,
- 'uploader': video_uploader,
- }
- video_id = mobj.group('video_id')
- if video_id:
- info.update({
- 'ie_key': 'MangomoloVideo',
- 'id': video_id,
- })
- else:
- info.update({
- 'ie_key': 'MangomoloLive',
- 'id': mobj.group('channel_id'),
- })
- return info
-
- # Look for Instagram embeds
- instagram_embed_url = InstagramIE._extract_embed_url(webpage)
- if instagram_embed_url is not None:
- return self.url_result(
- self._proto_relative_url(instagram_embed_url), InstagramIE.ie_key())
-
- # Look for LiveLeak embeds
- liveleak_urls = LiveLeakIE._extract_urls(webpage)
- if liveleak_urls:
- return self.playlist_from_matches(liveleak_urls, video_id, video_title)
-
- # Look for 3Q SDN embeds
- threeqsdn_url = ThreeQSDNIE._extract_url(webpage)
- if threeqsdn_url:
- return {
- '_type': 'url_transparent',
- 'ie_key': ThreeQSDNIE.ie_key(),
- 'url': self._proto_relative_url(threeqsdn_url),
- 'title': video_title,
- 'description': video_description,
- 'thumbnail': video_thumbnail,
- 'uploader': video_uploader,
- }
-
- # Look for VBOX7 embeds
- vbox7_url = Vbox7IE._extract_url(webpage)
- if vbox7_url:
- return self.url_result(vbox7_url, Vbox7IE.ie_key())
-
- # Look for DBTV embeds
- dbtv_urls = DBTVIE._extract_urls(webpage)
- if dbtv_urls:
- return self.playlist_from_matches(dbtv_urls, video_id, video_title, ie=DBTVIE.ie_key())
-
- # Look for Videa embeds
- videa_urls = VideaIE._extract_urls(webpage)
- if videa_urls:
- return self.playlist_from_matches(videa_urls, video_id, video_title, ie=VideaIE.ie_key())
-
- # Look for 20 minuten embeds
- twentymin_urls = TwentyMinutenIE._extract_urls(webpage)
- if twentymin_urls:
- return self.playlist_from_matches(
- twentymin_urls, video_id, video_title, ie=TwentyMinutenIE.ie_key())
-
- # Look for Openload embeds
- openload_urls = OpenloadIE._extract_urls(webpage)
- if openload_urls:
- return self.playlist_from_matches(
- openload_urls, video_id, video_title, ie=OpenloadIE.ie_key())
-
- # Look for VideoPress embeds
- videopress_urls = VideoPressIE._extract_urls(webpage)
- if videopress_urls:
- return self.playlist_from_matches(
- videopress_urls, video_id, video_title, ie=VideoPressIE.ie_key())
-
- # Look for Rutube embeds
- rutube_urls = RutubeIE._extract_urls(webpage)
- if rutube_urls:
- return self.playlist_from_matches(
- rutube_urls, video_id, video_title, ie=RutubeIE.ie_key())
-
- # Look for WashingtonPost embeds
- wapo_urls = WashingtonPostIE._extract_urls(webpage)
- if wapo_urls:
- return self.playlist_from_matches(
- wapo_urls, video_id, video_title, ie=WashingtonPostIE.ie_key())
-
- # Look for Mediaset embeds
- mediaset_urls = MediasetIE._extract_urls(webpage)
- if mediaset_urls:
- return self.playlist_from_matches(
- mediaset_urls, video_id, video_title, ie=MediasetIE.ie_key())
-
- # Look for JOJ.sk embeds
- joj_urls = JojIE._extract_urls(webpage)
- if joj_urls:
- return self.playlist_from_matches(
- joj_urls, video_id, video_title, ie=JojIE.ie_key())
-
- # Look for megaphone.fm embeds
- mpfn_urls = MegaphoneIE._extract_urls(webpage)
- if mpfn_urls:
- return self.playlist_from_matches(
- mpfn_urls, video_id, video_title, ie=MegaphoneIE.ie_key())
-
- # Look for vzaar embeds
- vzaar_urls = VzaarIE._extract_urls(webpage)
- if vzaar_urls:
- return self.playlist_from_matches(
- vzaar_urls, video_id, video_title, ie=VzaarIE.ie_key())
-
- channel9_urls = Channel9IE._extract_urls(webpage)
- if channel9_urls:
- return self.playlist_from_matches(
- channel9_urls, video_id, video_title, ie=Channel9IE.ie_key())
-
- vshare_urls = VShareIE._extract_urls(webpage)
- if vshare_urls:
- return self.playlist_from_matches(
- vshare_urls, video_id, video_title, ie=VShareIE.ie_key())
-
- # Look for Mediasite embeds
- mediasite_urls = MediasiteIE._extract_urls(webpage)
- if mediasite_urls:
- entries = [
- self.url_result(smuggle_url(
- compat_urlparse.urljoin(url, mediasite_url),
- {'UrlReferrer': url}), ie=MediasiteIE.ie_key())
- for mediasite_url in mediasite_urls]
- return self.playlist_result(entries, video_id, video_title)
-
- springboardplatform_urls = SpringboardPlatformIE._extract_urls(webpage)
- if springboardplatform_urls:
- return self.playlist_from_matches(
- springboardplatform_urls, video_id, video_title,
- ie=SpringboardPlatformIE.ie_key())
-
- yapfiles_urls = YapFilesIE._extract_urls(webpage)
- if yapfiles_urls:
- return self.playlist_from_matches(
- yapfiles_urls, video_id, video_title, ie=YapFilesIE.ie_key())
-
- vice_urls = ViceIE._extract_urls(webpage)
- if vice_urls:
- return self.playlist_from_matches(
- vice_urls, video_id, video_title, ie=ViceIE.ie_key())
-
- xfileshare_urls = XFileShareIE._extract_urls(webpage)
- if xfileshare_urls:
- return self.playlist_from_matches(
- xfileshare_urls, video_id, video_title, ie=XFileShareIE.ie_key())
-
- cloudflarestream_urls = CloudflareStreamIE._extract_urls(webpage)
- if cloudflarestream_urls:
- return self.playlist_from_matches(
- cloudflarestream_urls, video_id, video_title, ie=CloudflareStreamIE.ie_key())
-
- peertube_urls = PeerTubeIE._extract_urls(webpage, url)
- if peertube_urls:
- return self.playlist_from_matches(
- peertube_urls, video_id, video_title, ie=PeerTubeIE.ie_key())
-
- indavideo_urls = IndavideoEmbedIE._extract_urls(webpage)
- if indavideo_urls:
- return self.playlist_from_matches(
- indavideo_urls, video_id, video_title, ie=IndavideoEmbedIE.ie_key())
-
- apa_urls = APAIE._extract_urls(webpage)
- if apa_urls:
- return self.playlist_from_matches(
- apa_urls, video_id, video_title, ie=APAIE.ie_key())
-
- foxnews_urls = FoxNewsIE._extract_urls(webpage)
- if foxnews_urls:
- return self.playlist_from_matches(
- foxnews_urls, video_id, video_title, ie=FoxNewsIE.ie_key())
-
- sharevideos_urls = [mobj.group('url') for mobj in re.finditer(
- r'<iframe[^>]+?\bsrc\s*=\s*(["\'])(?P<url>(?:https?:)?//embed\.share-videos\.se/auto/embed/\d+\?.*?\buid=\d+.*?)\1',
- webpage)]
- if sharevideos_urls:
- return self.playlist_from_matches(
- sharevideos_urls, video_id, video_title)
-
- # Look for HTML5 media
- entries = self._parse_html5_media_entries(url, webpage, video_id, m3u8_id='hls')
- if entries:
- if len(entries) == 1:
- entries[0].update({
- 'id': video_id,
- 'title': video_title,
- })
- else:
- for num, entry in enumerate(entries, start=1):
- entry.update({
- 'id': '%s-%s' % (video_id, num),
- 'title': '%s (%d)' % (video_title, num),
- })
- for entry in entries:
- self._sort_formats(entry['formats'])
- return self.playlist_result(entries, video_id, video_title)
-
- jwplayer_data = self._find_jwplayer_data(
- webpage, video_id, transform_source=js_to_json)
- if jwplayer_data:
- info = self._parse_jwplayer_data(
- jwplayer_data, video_id, require_title=False, base_url=url)
- return merge_dicts(info, info_dict)
-
- # Video.js embed
- mobj = re.search(
- r'(?s)\bvideojs\s*\(.+?\.src\s*\(\s*((?:\[.+?\]|{.+?}))\s*\)\s*;',
- webpage)
- if mobj is not None:
- sources = self._parse_json(
- mobj.group(1), video_id, transform_source=js_to_json,
- fatal=False) or []
- if not isinstance(sources, list):
- sources = [sources]
- formats = []
- for source in sources:
- src = source.get('src')
- if not src or not isinstance(src, compat_str):
- continue
- src = compat_urlparse.urljoin(url, src)
- src_type = source.get('type')
- if isinstance(src_type, compat_str):
- src_type = src_type.lower()
- ext = determine_ext(src).lower()
- if src_type == 'video/youtube':
- return self.url_result(src, YoutubeIE.ie_key())
- if src_type == 'application/dash+xml' or ext == 'mpd':
- formats.extend(self._extract_mpd_formats(
- src, video_id, mpd_id='dash', fatal=False))
- elif src_type == 'application/x-mpegurl' or ext == 'm3u8':
- formats.extend(self._extract_m3u8_formats(
- src, video_id, 'mp4', entry_protocol='m3u8_native',
- m3u8_id='hls', fatal=False))
- else:
- formats.append({
- 'url': src,
- 'ext': (mimetype2ext(src_type) or
- ext if ext in KNOWN_EXTENSIONS else 'mp4'),
- })
- if formats:
- self._sort_formats(formats)
- info_dict['formats'] = formats
- return info_dict
-
- # Looking for http://schema.org/VideoObject
- json_ld = self._search_json_ld(
- webpage, video_id, default={}, expected_type='VideoObject')
- if json_ld.get('url'):
- return merge_dicts(json_ld, info_dict)
-
- def check_video(vurl):
- if YoutubeIE.suitable(vurl):
- return True
- if RtmpIE.suitable(vurl):
- return True
- vpath = compat_urlparse.urlparse(vurl).path
- vext = determine_ext(vpath)
- return '.' in vpath and vext not in ('swf', 'png', 'jpg', 'srt', 'sbv', 'sub', 'vtt', 'ttml', 'js', 'xml')
-
- def filter_video(urls):
- return list(filter(check_video, urls))
-
- # Start with something easy: JW Player in SWFObject
- found = filter_video(re.findall(r'flashvars: [\'"](?:.*&)?file=(http[^\'"&]*)', webpage))
- if not found:
- # Look for gorilla-vid style embedding
- found = filter_video(re.findall(r'''(?sx)
- (?:
- jw_plugins|
- JWPlayerOptions|
- jwplayer\s*\(\s*["'][^'"]+["']\s*\)\s*\.setup
- )
- .*?
- ['"]?file['"]?\s*:\s*["\'](.*?)["\']''', webpage))
- if not found:
- # Broaden the search a little bit
- found = filter_video(re.findall(r'[^A-Za-z0-9]?(?:file|source)=(http[^\'"&]*)', webpage))
- if not found:
- # Broaden the findall a little bit: JWPlayer JS loader
- found = filter_video(re.findall(
- r'[^A-Za-z0-9]?(?:file|video_url)["\']?:\s*["\'](http(?![^\'"]+\.[0-9]+[\'"])[^\'"]+)["\']', webpage))
- if not found:
- # Flow player
- found = filter_video(re.findall(r'''(?xs)
- flowplayer\("[^"]+",\s*
- \{[^}]+?\}\s*,
- \s*\{[^}]+? ["']?clip["']?\s*:\s*\{\s*
- ["']?url["']?\s*:\s*["']([^"']+)["']
- ''', webpage))
- if not found:
- # Cinerama player
- found = re.findall(
- r"cinerama\.embedPlayer\(\s*\'[^']+\',\s*'([^']+)'", webpage)
- if not found:
- # Try to find twitter cards info
- # twitter:player:stream should be checked before twitter:player since
- # it is expected to contain a raw stream (see
- # https://dev.twitter.com/cards/types/player#On_twitter.com_via_desktop_browser)
- found = filter_video(re.findall(
- r'<meta (?:property|name)="twitter:player:stream" (?:content|value)="(.+?)"', webpage))
- if not found:
- # We look for Open Graph info:
- # We have to match any number spaces between elements, some sites try to align them (eg.: statigr.am)
- m_video_type = re.findall(r'<meta.*?property="og:video:type".*?content="video/(.*?)"', webpage)
- # We only look in og:video if the MIME type is a video, don't try if it's a Flash player:
- if m_video_type is not None:
- found = filter_video(re.findall(r'<meta.*?property="og:video".*?content="(.*?)"', webpage))
- if not found:
- REDIRECT_REGEX = r'[0-9]{,2};\s*(?:URL|url)=\'?([^\'"]+)'
- found = re.search(
- r'(?i)<meta\s+(?=(?:[a-z-]+="[^"]+"\s+)*http-equiv="refresh")'
- r'(?:[a-z-]+="[^"]+"\s+)*?content="%s' % REDIRECT_REGEX,
- webpage)
- if not found:
- # Look also in Refresh HTTP header
- refresh_header = head_response.headers.get('Refresh')
- if refresh_header:
- # In python 2 response HTTP headers are bytestrings
- if sys.version_info < (3, 0) and isinstance(refresh_header, str):
- refresh_header = refresh_header.decode('iso-8859-1')
- found = re.search(REDIRECT_REGEX, refresh_header)
- if found:
- new_url = compat_urlparse.urljoin(url, unescapeHTML(found.group(1)))
- if new_url != url:
- self.report_following_redirect(new_url)
- return {
- '_type': 'url',
- 'url': new_url,
- }
- else:
- found = None
-
- if not found:
- # twitter:player is a https URL to iframe player that may or may not
- # be supported by youtube-dl thus this is checked the very last (see
- # https://dev.twitter.com/cards/types/player#On_twitter.com_via_desktop_browser)
- embed_url = self._html_search_meta('twitter:player', webpage, default=None)
- if embed_url and embed_url != url:
- return self.url_result(embed_url)
-
- if not found:
- raise UnsupportedError(url)
-
- entries = []
- for video_url in orderedSet(found):
- video_url = unescapeHTML(video_url)
- video_url = video_url.replace('\\/', '/')
- video_url = compat_urlparse.urljoin(url, video_url)
- video_id = compat_urllib_parse_unquote(os.path.basename(video_url))
-
- # Sometimes, jwplayer extraction will result in a YouTube URL
- if YoutubeIE.suitable(video_url):
- entries.append(self.url_result(video_url, 'Youtube'))
- continue
-
- # here's a fun little line of code for you:
- video_id = os.path.splitext(video_id)[0]
-
- entry_info_dict = {
- 'id': video_id,
- 'uploader': video_uploader,
- 'title': video_title,
- 'age_limit': age_limit,
- }
-
- if RtmpIE.suitable(video_url):
- entry_info_dict.update({
- '_type': 'url_transparent',
- 'ie_key': RtmpIE.ie_key(),
- 'url': video_url,
- })
- entries.append(entry_info_dict)
- continue
-
- ext = determine_ext(video_url)
- if ext == 'smil':
- entry_info_dict['formats'] = self._extract_smil_formats(video_url, video_id)
- elif ext == 'xspf':
- return self.playlist_result(self._extract_xspf_playlist(video_url, video_id), video_id)
- elif ext == 'm3u8':
- entry_info_dict['formats'] = self._extract_m3u8_formats(video_url, video_id, ext='mp4')
- elif ext == 'mpd':
- entry_info_dict['formats'] = self._extract_mpd_formats(video_url, video_id)
- elif ext == 'f4m':
- entry_info_dict['formats'] = self._extract_f4m_formats(video_url, video_id)
- elif re.search(r'(?i)\.(?:ism|smil)/manifest', video_url) and video_url != url:
- # Just matching .ism/manifest is not enough to be reliably sure
- # whether it's actually an ISM manifest or some other streaming
- # manifest since there are various streaming URL formats
- # possible (see [1]) as well as some other shenanigans like
- # .smil/manifest URLs that actually serve an ISM (see [2]) and
- # so on.
- # Thus the most reasonable way to solve this is to delegate
- # to generic extractor in order to look into the contents of
- # the manifest itself.
- # 1. https://azure.microsoft.com/en-us/documentation/articles/media-services-deliver-content-overview/#streaming-url-formats
- # 2. https://svs.itworkscdn.net/lbcivod/smil:itwfcdn/lbci/170976.smil/Manifest
- entry_info_dict = self.url_result(
- smuggle_url(video_url, {'to_generic': True}),
- GenericIE.ie_key())
- else:
- entry_info_dict['url'] = video_url
-
- if entry_info_dict.get('formats'):
- self._sort_formats(entry_info_dict['formats'])
-
- entries.append(entry_info_dict)
-
- if len(entries) == 1:
- return entries[0]
- else:
- for num, e in enumerate(entries, start=1):
- # 'url' results don't have a title
- if e.get('title') is not None:
- e['title'] = '%s (%d)' % (e['title'], num)
- return {
- '_type': 'playlist',
- 'entries': entries,
- }
diff --git a/youtube_dl/extractor/openload.py b/youtube_dl/extractor/openload.py
deleted file mode 100644
index d264fe2..0000000
--- a/youtube_dl/extractor/openload.py
+++ /dev/null
@@ -1,379 +0,0 @@
-# coding: utf-8
-from __future__ import unicode_literals
-
-import json
-import os
-import re
-import subprocess
-import tempfile
-
-from .common import InfoExtractor
-from ..compat import (
- compat_urlparse,
- compat_kwargs,
-)
-from ..utils import (
- check_executable,
- determine_ext,
- encodeArgument,
- ExtractorError,
- get_element_by_id,
- get_exe_version,
- is_outdated_version,
- std_headers,
-)
-
-
-def cookie_to_dict(cookie):
- cookie_dict = {
- 'name': cookie.name,
- 'value': cookie.value,
- }
- if cookie.port_specified:
- cookie_dict['port'] = cookie.port
- if cookie.domain_specified:
- cookie_dict['domain'] = cookie.domain
- if cookie.path_specified:
- cookie_dict['path'] = cookie.path
- if cookie.expires is not None:
- cookie_dict['expires'] = cookie.expires
- if cookie.secure is not None:
- cookie_dict['secure'] = cookie.secure
- if cookie.discard is not None:
- cookie_dict['discard'] = cookie.discard
- try:
- if (cookie.has_nonstandard_attr('httpOnly') or
- cookie.has_nonstandard_attr('httponly') or
- cookie.has_nonstandard_attr('HttpOnly')):
- cookie_dict['httponly'] = True
- except TypeError:
- pass
- return cookie_dict
-
-
-def cookie_jar_to_list(cookie_jar):
- return [cookie_to_dict(cookie) for cookie in cookie_jar]
-
-
-class PhantomJSwrapper(object):
- """PhantomJS wrapper class
-
- This class is experimental.
- """
-
- _TEMPLATE = r'''
- phantom.onError = function(msg, trace) {{
- var msgStack = ['PHANTOM ERROR: ' + msg];
- if(trace && trace.length) {{
- msgStack.push('TRACE:');
- trace.forEach(function(t) {{
- msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line
- + (t.function ? ' (in function ' + t.function +')' : ''));
- }});
- }}
- console.error(msgStack.join('\n'));
- phantom.exit(1);
- }};
- var page = require('webpage').create();
- var fs = require('fs');
- var read = {{ mode: 'r', charset: 'utf-8' }};
- var write = {{ mode: 'w', charset: 'utf-8' }};
- JSON.parse(fs.read("{cookies}", read)).forEach(function(x) {{
- phantom.addCookie(x);
- }});
- page.settings.resourceTimeout = {timeout};
- page.settings.userAgent = "{ua}";
- page.onLoadStarted = function() {{
- page.evaluate(function() {{
- delete window._phantom;
- delete window.callPhantom;
- }});
- }};
- var saveAndExit = function() {{
- fs.write("{html}", page.content, write);
- fs.write("{cookies}", JSON.stringify(phantom.cookies), write);
- phantom.exit();
- }};
- page.onLoadFinished = function(status) {{
- if(page.url === "") {{
- page.setContent(fs.read("{html}", read), "{url}");
- }}
- else {{
- {jscode}
- }}
- }};
- page.open("");
- '''
-
- _TMP_FILE_NAMES = ['script', 'html', 'cookies']
-
- @staticmethod
- def _version():
- return get_exe_version('phantomjs', version_re=r'([0-9.]+)')
-
- def __init__(self, extractor, required_version=None, timeout=10000):
- self._TMP_FILES = {}
-
- self.exe = check_executable('phantomjs', ['-v'])
- if not self.exe:
- raise ExtractorError('PhantomJS executable not found in PATH, '
- 'download it from http://phantomjs.org',
- expected=True)
-
- self.extractor = extractor
-
- if required_version:
- version = self._version()
- if is_outdated_version(version, required_version):
- self.extractor._downloader.report_warning(
- 'Your copy of PhantomJS is outdated, update it to version '
- '%s or newer if you encounter any errors.' % required_version)
-
- self.options = {
- 'timeout': timeout,
- }
- for name in self._TMP_FILE_NAMES:
- tmp = tempfile.NamedTemporaryFile(delete=False)
- tmp.close()
- self._TMP_FILES[name] = tmp
-
- def __del__(self):
- for name in self._TMP_FILE_NAMES:
- try:
- os.remove(self._TMP_FILES[name].name)
- except (IOError, OSError, KeyError):
- pass
-
- def _save_cookies(self, url):
- cookies = cookie_jar_to_list(self.extractor._downloader.cookiejar)
- for cookie in cookies:
- if 'path' not in cookie:
- cookie['path'] = '/'
- if 'domain' not in cookie:
- cookie['domain'] = compat_urlparse.urlparse(url).netloc
- with open(self._TMP_FILES['cookies'].name, 'wb') as f:
- f.write(json.dumps(cookies).encode('utf-8'))
-
- def _load_cookies(self):
- with open(self._TMP_FILES['cookies'].name, 'rb') as f:
- cookies = json.loads(f.read().decode('utf-8'))
- for cookie in cookies:
- if cookie['httponly'] is True:
- cookie['rest'] = {'httpOnly': None}
- if 'expiry' in cookie:
- cookie['expire_time'] = cookie['expiry']
- self.extractor._set_cookie(**compat_kwargs(cookie))
-
- def get(self, url, html=None, video_id=None, note=None, note2='Executing JS on webpage', headers={}, jscode='saveAndExit();'):
- """
- Downloads webpage (if needed) and executes JS
-
- Params:
- url: website url
- html: optional, html code of website
- video_id: video id
- note: optional, displayed when downloading webpage
- note2: optional, displayed when executing JS
- headers: custom http headers
- jscode: code to be executed when page is loaded
-
- Returns tuple with:
- * downloaded website (after JS execution)
- * anything you print with `console.log` (but not inside `page.execute`!)
-
- In most cases you don't need to add any `jscode`.
- It is executed in `page.onLoadFinished`.
- `saveAndExit();` is mandatory, use it instead of `phantom.exit()`
- It is possible to wait for some element on the webpage, for example:
- var check = function() {
- var elementFound = page.evaluate(function() {
- return document.querySelector('#b.done') !== null;
- });
- if(elementFound)
- saveAndExit();
- else
- window.setTimeout(check, 500);
- }
-
- page.evaluate(function(){
- document.querySelector('#a').click();
- });
- check();
- """
- if 'saveAndExit();' not in jscode:
- raise ExtractorError('`saveAndExit();` not found in `jscode`')
- if not html:
- html = self.extractor._download_webpage(url, video_id, note=note, headers=headers)
- with open(self._TMP_FILES['html'].name, 'wb') as f:
- f.write(html.encode('utf-8'))
-
- self._save_cookies(url)
-
- replaces = self.options
- replaces['url'] = url
- user_agent = headers.get('User-Agent') or std_headers['User-Agent']
- replaces['ua'] = user_agent.replace('"', '\\"')
- replaces['jscode'] = jscode
-
- for x in self._TMP_FILE_NAMES:
- replaces[x] = self._TMP_FILES[x].name.replace('\\', '\\\\').replace('"', '\\"')
-
- with open(self._TMP_FILES['script'].name, 'wb') as f:
- f.write(self._TEMPLATE.format(**replaces).encode('utf-8'))
-
- if video_id is None:
- self.extractor.to_screen('%s' % (note2,))
- else:
- self.extractor.to_screen('%s: %s' % (video_id, note2))
-
- p = subprocess.Popen([
- self.exe, '--ssl-protocol=any',
- self._TMP_FILES['script'].name
- ], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- out, err = p.communicate()
- if p.returncode != 0:
- raise ExtractorError(
- 'Executing JS failed\n:' + encodeArgument(err))
- with open(self._TMP_FILES['html'].name, 'rb') as f:
- html = f.read().decode('utf-8')
-
- self._load_cookies()
-
- return (html, encodeArgument(out))
-
-
-class OpenloadIE(InfoExtractor):
- _VALID_URL = r'https?://(?:www\.)?(?:openload\.(?:co|io|link)|oload\.(?:tv|stream|site|xyz|win|download))/(?:f|embed)/(?P<id>[a-zA-Z0-9-_]+)'
-
- _TESTS = [{
- 'url': 'https://openload.co/f/kUEfGclsU9o',
- 'md5': 'bf1c059b004ebc7a256f89408e65c36e',
- 'info_dict': {
- 'id': 'kUEfGclsU9o',
- 'ext': 'mp4',
- 'title': 'skyrim_no-audio_1080.mp4',
- 'thumbnail': r're:^https?://.*\.jpg$',
- },
- }, {
- 'url': 'https://openload.co/embed/rjC09fkPLYs',
- 'info_dict': {
- 'id': 'rjC09fkPLYs',
- 'ext': 'mp4',
- 'title': 'movie.mp4',
- 'thumbnail': r're:^https?://.*\.jpg$',
- 'subtitles': {
- 'en': [{
- 'ext': 'vtt',
- }],
- },
- },
- 'params': {
- 'skip_download': True, # test subtitles only
- },
- }, {
- 'url': 'https://openload.co/embed/kUEfGclsU9o/skyrim_no-audio_1080.mp4',
- 'only_matching': True,
- }, {
- 'url': 'https://openload.io/f/ZAn6oz-VZGE/',
- 'only_matching': True,
- }, {
- 'url': 'https://openload.co/f/_-ztPaZtMhM/',
- 'only_matching': True,
- }, {
- # unavailable via https://openload.co/f/Sxz5sADo82g/, different layout
- # for title and ext
- 'url': 'https://openload.co/embed/Sxz5sADo82g/',
- 'only_matching': True,
- }, {
- # unavailable via https://openload.co/embed/e-Ixz9ZR5L0/ but available
- # via https://openload.co/f/e-Ixz9ZR5L0/
- 'url': 'https://openload.co/f/e-Ixz9ZR5L0/',
- 'only_matching': True,
- }, {
- 'url': 'https://oload.tv/embed/KnG-kKZdcfY/',
- 'only_matching': True,
- }, {
- 'url': 'http://www.openload.link/f/KnG-kKZdcfY',
- 'only_matching': True,
- }, {
- 'url': 'https://oload.stream/f/KnG-kKZdcfY',
- 'only_matching': True,
- }, {
- 'url': 'https://oload.xyz/f/WwRBpzW8Wtk',
- 'only_matching': True,
- }, {
- 'url': 'https://oload.win/f/kUEfGclsU9o',
- 'only_matching': True,
- }, {
- 'url': 'https://oload.download/f/kUEfGclsU9o',
- 'only_matching': True,
- }, {
- # Its title has not got its extension but url has it
- 'url': 'https://oload.download/f/N4Otkw39VCw/Tomb.Raider.2018.HDRip.XviD.AC3-EVO.avi.mp4',
- 'only_matching': True,
- }]
-
- _USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'
-
- @staticmethod
- def _extract_urls(webpage):
- return re.findall(
- r'<iframe[^>]+src=["\']((?:https?://)?(?:openload\.(?:co|io)|oload\.tv)/embed/[a-zA-Z0-9-_]+)',
- webpage)
-
- def _real_extract(self, url):
- video_id = self._match_id(url)
- url_pattern = 'https://openload.co/%%s/%s/' % video_id
- headers = {
- 'User-Agent': self._USER_AGENT,
- }
-
- for path in ('embed', 'f'):
- page_url = url_pattern % path
- last = path == 'f'
- webpage = self._download_webpage(
- page_url, video_id, 'Downloading %s webpage' % path,
- headers=headers, fatal=last)
- if not webpage:
- continue
- if 'File not found' in webpage or 'deleted by the owner' in webpage:
- if not last:
- continue
- raise ExtractorError('File not found', expected=True, video_id=video_id)
- break
-
- phantom = PhantomJSwrapper(self, required_version='2.0')
- webpage, _ = phantom.get(page_url, html=webpage, video_id=video_id, headers=headers)
-
- decoded_id = (get_element_by_id('streamurl', webpage) or
- get_element_by_id('streamuri', webpage) or
- get_element_by_id('streamurj', webpage) or
- self._search_regex(
- (r'>\s*([\w-]+~\d{10,}~\d+\.\d+\.0\.0~[\w-]+)\s*<',
- r'>\s*([\w~-]+~\d+\.\d+\.\d+\.\d+~[\w~-]+)',
- r'>\s*([\w-]+~\d{10,}~(?:[a-f\d]+:){2}:~[\w-]+)\s*<',
- r'>\s*([\w~-]+~[a-f0-9:]+~[\w~-]+)\s*<',
- r'>\s*([\w~-]+~[a-f0-9:]+~[\w~-]+)'), webpage,
- 'stream URL'))
-
- video_url = 'https://openload.co/stream/%s?mime=true' % decoded_id
-
- title = self._og_search_title(webpage, default=None) or self._search_regex(
- r'<span[^>]+class=["\']title["\'][^>]*>([^<]+)', webpage,
- 'title', default=None) or self._html_search_meta(
- 'description', webpage, 'title', fatal=True)
-
- entries = self._parse_html5_media_entries(page_url, webpage, video_id)
- entry = entries[0] if entries else {}
- subtitles = entry.get('subtitles')
-
- info_dict = {
- 'id': video_id,
- 'title': title,
- 'thumbnail': entry.get('thumbnail') or self._og_search_thumbnail(webpage, default=None),
- 'url': video_url,
- 'ext': determine_ext(title, None) or determine_ext(url, 'mp4'),
- 'subtitles': subtitles,
- 'http_headers': headers,
- }
- return info_dict
diff --git a/youtube_dl/extractor/youtube.py b/youtube_dl/extractor/youtube.py
deleted file mode 100644
index 85be28d..0000000
--- a/youtube_dl/extractor/youtube.py
+++ /dev/null
@@ -1,3264 +0,0 @@
-# coding: utf-8
-
-from __future__ import unicode_literals
-
-
-import itertools
-import json
-import os.path
-import random
-import re
-import time
-import traceback
-import html
-
-from .common import InfoExtractor, SearchInfoExtractor
-from ..jsinterp import JSInterpreter
-from ..swfinterp import SWFInterpreter
-from ..compat import (
- compat_chr,
- compat_HTTPError,
- compat_kwargs,
- compat_parse_qs,
- compat_urllib_parse_unquote,
- compat_urllib_parse_unquote_plus,
- compat_urllib_parse_urlencode,
- compat_urllib_parse_urlparse,
- compat_urlparse,
- compat_str,
-)
-from ..utils import (
- clean_html,
- dict_get,
- error_to_compat_str,
- ExtractorError,
- float_or_none,
- get_element_by_attribute,
- get_element_by_id,
- int_or_none,
- mimetype2ext,
- orderedSet,
- parse_codecs,
- parse_duration,
- qualities,
- remove_quotes,
- remove_start,
- smuggle_url,
- str_or_none,
- str_to_int,
- try_get,
- unescapeHTML,
- unified_strdate,
- unsmuggle_url,
- uppercase_escape,
- url_or_none,
- urlencode_postdata,
-)
-class YoutubeError(Exception):
- pass
-
-class YoutubeBaseInfoExtractor(InfoExtractor):
- """Provide base functions for Youtube extractors"""
- _LOGIN_URL = 'https://accounts.google.com/ServiceLogin'
- _TWOFACTOR_URL = 'https://accounts.google.com/signin/challenge'
-
- _LOOKUP_URL = 'https://accounts.google.com/_/signin/sl/lookup'
- _CHALLENGE_URL = 'https://accounts.google.com/_/signin/sl/challenge'
- _TFA_URL = 'https://accounts.google.com/_/signin/challenge?hl=en&TL={0}'
-
- _NETRC_MACHINE = 'youtube'
- # If True it will raise an error if no login info is provided
- _LOGIN_REQUIRED = False
-
- _PLAYLIST_ID_RE = r'(?:PL|LL|EC|UU|FL|RD|UL|TL|OLAK5uy_)[0-9A-Za-z-_]{10,}'
-
- def _set_language(self):
- self._set_cookie(
- '.youtube.com', 'PREF', 'f1=50000000&hl=en',
- # YouTube sets the expire time to about two months
- expire_time=time.time() + 2 * 30 * 24 * 3600)
-
- def _ids_to_results(self, ids):
- return [
- self.url_result(vid_id, 'Youtube', video_id=vid_id)
- for vid_id in ids]
-
- def _login(self):
- """
- Attempt to log in to YouTube.
- True is returned if successful or skipped.
- False is returned if login failed.
-
- If _LOGIN_REQUIRED is set and no authentication was provided, an error is raised.
- """
- username, password = self._get_login_info()
- # No authentication to be performed
- if username is None:
- if self._LOGIN_REQUIRED and self._downloader.params.get('cookiefile') is None:
- raise ExtractorError('No login info available, needed for using %s.' % self.IE_NAME, expected=True)
- return True
-
- login_page = self._download_webpage(
- self._LOGIN_URL, None,
- note='Downloading login page',
- errnote='unable to fetch login page', fatal=False)
- if login_page is False:
- return
-
- login_form = self._hidden_inputs(login_page)
-
- def req(url, f_req, note, errnote):
- data = login_form.copy()
- data.update({
- 'pstMsg': 1,
- 'checkConnection': 'youtube',
- 'checkedDomains': 'youtube',
- 'hl': 'en',
- 'deviceinfo': '[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]',
- 'f.req': json.dumps(f_req),
- 'flowName': 'GlifWebSignIn',
- 'flowEntry': 'ServiceLogin',
- })
- return self._download_json(
- url, None, note=note, errnote=errnote,
- transform_source=lambda s: re.sub(r'^[^[]*', '', s),
- fatal=False,
- data=urlencode_postdata(data), headers={
- 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
- 'Google-Accounts-XSRF': 1,
- })
-
- def warn(message):
- self._downloader.report_warning(message)
-
- lookup_req = [
- username,
- None, [], None, 'US', None, None, 2, False, True,
- [
- None, None,
- [2, 1, None, 1,
- 'https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn',
- None, [], 4],
- 1, [None, None, []], None, None, None, True
- ],
- username,
- ]
-
- lookup_results = req(
- self._LOOKUP_URL, lookup_req,
- 'Looking up account info', 'Unable to look up account info')
-
- if lookup_results is False:
- return False
-
- user_hash = try_get(lookup_results, lambda x: x[0][2], compat_str)
- if not user_hash:
- warn('Unable to extract user hash')
- return False
-
- challenge_req = [
- user_hash,
- None, 1, None, [1, None, None, None, [password, None, True]],
- [
- None, None, [2, 1, None, 1, 'https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn', None, [], 4],
- 1, [None, None, []], None, None, None, True
- ]]
-
- challenge_results = req(
- self._CHALLENGE_URL, challenge_req,
- 'Logging in', 'Unable to log in')
-
- if challenge_results is False:
- return
-
- login_res = try_get(challenge_results, lambda x: x[0][5], list)
- if login_res:
- login_msg = try_get(login_res, lambda x: x[5], compat_str)
- warn(
- 'Unable to login: %s' % 'Invalid password'
- if login_msg == 'INCORRECT_ANSWER_ENTERED' else login_msg)
- return False
-
- res = try_get(challenge_results, lambda x: x[0][-1], list)
- if not res:
- warn('Unable to extract result entry')
- return False
-
- login_challenge = try_get(res, lambda x: x[0][0], list)
- if login_challenge:
- challenge_str = try_get(login_challenge, lambda x: x[2], compat_str)
- if challenge_str == 'TWO_STEP_VERIFICATION':
- # SEND_SUCCESS - TFA code has been successfully sent to phone
- # QUOTA_EXCEEDED - reached the limit of TFA codes
- status = try_get(login_challenge, lambda x: x[5], compat_str)
- if status == 'QUOTA_EXCEEDED':
- warn('Exceeded the limit of TFA codes, try later')
- return False
-
- tl = try_get(challenge_results, lambda x: x[1][2], compat_str)
- if not tl:
- warn('Unable to extract TL')
- return False
-
- tfa_code = self._get_tfa_info('2-step verification code')
-
- if not tfa_code:
- warn(
- 'Two-factor authentication required. Provide it either interactively or with --twofactor <code>'
- '(Note that only TOTP (Google Authenticator App) codes work at this time.)')
- return False
-
- tfa_code = remove_start(tfa_code, 'G-')
-
- tfa_req = [
- user_hash, None, 2, None,
- [
- 9, None, None, None, None, None, None, None,
- [None, tfa_code, True, 2]
- ]]
-
- tfa_results = req(
- self._TFA_URL.format(tl), tfa_req,
- 'Submitting TFA code', 'Unable to submit TFA code')
-
- if tfa_results is False:
- return False
-
- tfa_res = try_get(tfa_results, lambda x: x[0][5], list)
- if tfa_res:
- tfa_msg = try_get(tfa_res, lambda x: x[5], compat_str)
- warn(
- 'Unable to finish TFA: %s' % 'Invalid TFA code'
- if tfa_msg == 'INCORRECT_ANSWER_ENTERED' else tfa_msg)
- return False
-
- check_cookie_url = try_get(
- tfa_results, lambda x: x[0][-1][2], compat_str)
- else:
- CHALLENGES = {
- 'LOGIN_CHALLENGE': "This device isn't recognized. For your security, Google wants to make sure it's really you.",
- 'USERNAME_RECOVERY': 'Please provide additional information to aid in the recovery process.',
- 'REAUTH': "There is something unusual about your activity. For your security, Google wants to make sure it's really you.",
- }
- challenge = CHALLENGES.get(
- challenge_str,
- '%s returned error %s.' % (self.IE_NAME, challenge_str))
- warn('%s\nGo to https://accounts.google.com/, login and solve a challenge.' % challenge)
- return False
- else:
- check_cookie_url = try_get(res, lambda x: x[2], compat_str)
-
- if not check_cookie_url:
- warn('Unable to extract CheckCookie URL')
- return False
-
- check_cookie_results = self._download_webpage(
- check_cookie_url, None, 'Checking cookie', fatal=False)
-
- if check_cookie_results is False:
- return False
-
- if 'https://myaccount.google.com/' not in check_cookie_results:
- warn('Unable to log in')
- return False
-
- return True
-
- def _download_webpage_handle(self, *args, **kwargs):
- query = kwargs.get('query', {}).copy()
- query['disable_polymer'] = 'true'
- kwargs['query'] = query
- return super(YoutubeBaseInfoExtractor, self)._download_webpage_handle(
- *args, **compat_kwargs(kwargs))
-
- def _real_initialize(self):
- if self._downloader is None:
- return
- self._set_language()
- if not self._login():
- return
-
-
-class YoutubeEntryListBaseInfoExtractor(YoutubeBaseInfoExtractor):
- # Extract entries from page with "Load more" button
- def _entries(self, page, playlist_id):
- more_widget_html = content_html = page
- for page_num in itertools.count(1):
- for entry in self._process_page(content_html):
- yield entry
-
- mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
- if not mobj:
- break
-
- count = 0
- retries = 3
- while count <= retries:
- try:
- # Downloading page may result in intermittent 5xx HTTP error
- # that is usually worked around with a retry
- more = self._download_json(
- 'https://youtube.com/%s' % mobj.group('more'), playlist_id,
- 'Downloading page #%s%s'
- % (page_num, ' (retry #%d)' % count if count else ''),
- transform_source=uppercase_escape)
- break
- except ExtractorError as e:
- if isinstance(e.cause, compat_HTTPError) and e.cause.code in (500, 503):
- count += 1
- if count <= retries:
- continue
- raise
-
- content_html = more['content_html']
- if not content_html.strip():
- # Some webpages show a "Load more" button but they don't
- # have more videos
- break
- more_widget_html = more['load_more_widget_html']
-
-
-class YoutubePlaylistBaseInfoExtractor(YoutubeEntryListBaseInfoExtractor):
- def _process_page(self, content):
- for video_id, video_title in self.extract_videos_from_page(content):
- yield self.url_result(video_id, 'Youtube', video_id, video_title)
-
- def extract_videos_from_page(self, page):
- ids_in_page = []
- titles_in_page = []
- for mobj in re.finditer(self._VIDEO_RE, page):
- # The link with index 0 is not the first video of the playlist (not sure if still actual)
- if 'index' in mobj.groupdict() and mobj.group('id') == '0':
- continue
- video_id = mobj.group('id')
- video_title = unescapeHTML(mobj.group('title'))
- if video_title:
- video_title = video_title.strip()
- try:
- idx = ids_in_page.index(video_id)
- if video_title and not titles_in_page[idx]:
- titles_in_page[idx] = video_title
- except ValueError:
- ids_in_page.append(video_id)
- titles_in_page.append(video_title)
- return zip(ids_in_page, titles_in_page)
-
-
-class YoutubePlaylistsBaseInfoExtractor(YoutubeEntryListBaseInfoExtractor):
- def _process_page(self, content):
- for playlist_id in orderedSet(re.findall(
- r'<h3[^>]+class="[^"]*yt-lockup-title[^"]*"[^>]*><a[^>]+href="/?playlist\?list=([0-9A-Za-z-_]{10,})"',
- content)):
- yield self.url_result(
- 'https://www.youtube.com/playlist?list=%s' % playlist_id, 'YoutubePlaylist')
-
- def _real_extract(self, url):
- playlist_id = self._match_id(url)
- webpage = self._download_webpage(url, playlist_id)
- title = self._og_search_title(webpage, fatal=False)
- return self.playlist_result(self._entries(webpage, playlist_id), playlist_id, title)
-
-
-class YoutubeIE(YoutubeBaseInfoExtractor):
- IE_DESC = 'YouTube.com'
- _VALID_URL = r"""(?x)^
- (
- (?:https?://|//) # http(s):// or protocol-independent URL
- (?:(?:(?:(?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie)?\.com/|
- (?:www\.)?deturl\.com/www\.youtube\.com/|
- (?:www\.)?pwnyoutube\.com/|
- (?:www\.)?hooktube\.com/|
- (?:www\.)?yourepeat\.com/|
- tube\.majestyc\.net/|
- (?:(?:www|dev)\.)?invidio\.us/|
- (?:www\.)?invidiou\.sh/|
- (?:www\.)?invidious\.snopyta\.org/|
- (?:www\.)?invidious\.kabi\.tk/|
- (?:www\.)?vid\.wxzm\.sx/|
- youtube\.googleapis\.com/) # the various hostnames, with wildcard subdomains
- (?:.*?\#/)? # handle anchor (#/) redirect urls
- (?: # the various things that can precede the ID:
- (?:(?:v|embed|e)/(?!videoseries)) # v/ or embed/ or e/
- |(?: # or the v= param in all its forms
- (?:(?:watch|movie)(?:_popup)?(?:\.php)?/?)? # preceding watch(_popup|.php) or nothing (like /?v=xxxx)
- (?:\?|\#!?) # the params delimiter ? or # or #!
- (?:.*?[&;])?? # any other preceding param (like /?s=tuff&v=xxxx or ?s=tuff&amp;v=V36LpHqtcDY)
- v=
- )
- ))
- |(?:
- youtu\.be| # just youtu.be/xxxx
- vid\.plus| # or vid.plus/xxxx
- zwearz\.com/watch| # or zwearz.com/watch/xxxx
- )/
- |(?:www\.)?cleanvideosearch\.com/media/action/yt/watch\?videoId=
- )
- )? # all until now is optional -> you can pass the naked ID
- ([0-9A-Za-z_-]{11}) # here is it! the YouTube video ID
- (?!.*?\blist=
- (?:
- %(playlist_id)s| # combined list/video URLs are handled by the playlist IE
- WL # WL are handled by the watch later IE
- )
- )
- (?(1).+)? # if we found the ID, everything can follow
- $""" % {'playlist_id': YoutubeBaseInfoExtractor._PLAYLIST_ID_RE}
- _NEXT_URL_RE = r'[\?&]next_url=([^&]+)'
- _formats = {
- '5': {'ext': 'flv', 'width': 400, 'height': 240, 'acodec': 'mp3', 'abr': 64, 'vcodec': 'h263'},
- '6': {'ext': 'flv', 'width': 450, 'height': 270, 'acodec': 'mp3', 'abr': 64, 'vcodec': 'h263'},
- '13': {'ext': '3gp', 'acodec': 'aac', 'vcodec': 'mp4v'},
- '17': {'ext': '3gp', 'width': 176, 'height': 144, 'acodec': 'aac', 'abr': 24, 'vcodec': 'mp4v'},
- '18': {'ext': 'mp4', 'width': 640, 'height': 360, 'acodec': 'aac', 'abr': 96, 'vcodec': 'h264'},
- '22': {'ext': 'mp4', 'width': 1280, 'height': 720, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
- '34': {'ext': 'flv', 'width': 640, 'height': 360, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
- '35': {'ext': 'flv', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
- # itag 36 videos are either 320x180 (BaW_jenozKc) or 320x240 (__2ABJjxzNo), abr varies as well
- '36': {'ext': '3gp', 'width': 320, 'acodec': 'aac', 'vcodec': 'mp4v'},
- '37': {'ext': 'mp4', 'width': 1920, 'height': 1080, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
- '38': {'ext': 'mp4', 'width': 4096, 'height': 3072, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
- '43': {'ext': 'webm', 'width': 640, 'height': 360, 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8'},
- '44': {'ext': 'webm', 'width': 854, 'height': 480, 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8'},
- '45': {'ext': 'webm', 'width': 1280, 'height': 720, 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8'},
- '46': {'ext': 'webm', 'width': 1920, 'height': 1080, 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8'},
- '59': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
- '78': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
-
-
- # 3D videos
- '82': {'ext': 'mp4', 'height': 360, 'format_note': '3D', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -20},
- '83': {'ext': 'mp4', 'height': 480, 'format_note': '3D', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -20},
- '84': {'ext': 'mp4', 'height': 720, 'format_note': '3D', 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264', 'preference': -20},
- '85': {'ext': 'mp4', 'height': 1080, 'format_note': '3D', 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264', 'preference': -20},
- '100': {'ext': 'webm', 'height': 360, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8', 'preference': -20},
- '101': {'ext': 'webm', 'height': 480, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8', 'preference': -20},
- '102': {'ext': 'webm', 'height': 720, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8', 'preference': -20},
-
- # Apple HTTP Live Streaming
- '91': {'ext': 'mp4', 'height': 144, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
- '92': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
- '93': {'ext': 'mp4', 'height': 360, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -10},
- '94': {'ext': 'mp4', 'height': 480, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -10},
- '95': {'ext': 'mp4', 'height': 720, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 256, 'vcodec': 'h264', 'preference': -10},
- '96': {'ext': 'mp4', 'height': 1080, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 256, 'vcodec': 'h264', 'preference': -10},
- '132': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
- '151': {'ext': 'mp4', 'height': 72, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 24, 'vcodec': 'h264', 'preference': -10},
-
- # DASH mp4 video
- '133': {'ext': 'mp4', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '134': {'ext': 'mp4', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '135': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '136': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '137': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '138': {'ext': 'mp4', 'format_note': 'DASH video', 'vcodec': 'h264'}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
- '160': {'ext': 'mp4', 'height': 144, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '212': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '264': {'ext': 'mp4', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '298': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
- '299': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
- '266': {'ext': 'mp4', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'h264'},
-
- # Dash mp4 audio
- '139': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 48, 'container': 'm4a_dash'},
- '140': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 128, 'container': 'm4a_dash'},
- '141': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 256, 'container': 'm4a_dash'},
- '256': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
- '258': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
- '325': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'dtse', 'container': 'm4a_dash'},
- '328': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'ec-3', 'container': 'm4a_dash'},
-
- # Dash webm
- '167': {'ext': 'webm', 'height': 360, 'width': 640, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '168': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '169': {'ext': 'webm', 'height': 720, 'width': 1280, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '170': {'ext': 'webm', 'height': 1080, 'width': 1920, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '218': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '219': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '278': {'ext': 'webm', 'height': 144, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp9'},
- '242': {'ext': 'webm', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '243': {'ext': 'webm', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '244': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '245': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '246': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '247': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '248': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '271': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug)
- '272': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '302': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
- '303': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
- '308': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
- '313': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '315': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
-
- # Dash webm audio
- '171': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'abr': 128},
- '172': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'abr': 256},
-
- # Dash webm audio with opus inside
- '249': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 50},
- '250': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 70},
- '251': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 160},
-
- # RTMP (unnamed)
- '_rtmp': {'protocol': 'rtmp'},
-
- # av01 video only formats sometimes served with "unknown" codecs
- '394': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- '395': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- '396': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- '397': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- }
- _SUBTITLE_FORMATS = ('srv1', 'srv2', 'srv3', 'ttml', 'vtt')
-
- _GEO_BYPASS = False
-
- IE_NAME = 'youtube'
- _TESTS = [
- {
- 'url': 'https://www.youtube.com/watch?v=BaW_jenozKc&t=1s&end=9',
- 'info_dict': {
- 'id': 'BaW_jenozKc',
- 'ext': 'mp4',
- 'title': 'youtube-dl test video "\'/\\ä↭𝕐',
- 'uploader': 'Philipp Hagemeister',
- 'uploader_id': 'phihag',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/phihag',
- 'channel_id': 'UCLqxVugv74EIW3VWh2NOa3Q',
- 'channel_url': r're:https?://(?:www\.)?youtube\.com/channel/UCLqxVugv74EIW3VWh2NOa3Q',
- 'upload_date': '20121002',
- 'description': 'test chars: "\'/\\ä↭𝕐\ntest URL: https://github.com/rg3/youtube-dl/issues/1892\n\nThis is a test video for youtube-dl.\n\nFor more information, contact phihag@phihag.de .',
- 'categories': ['Science & Technology'],
- 'tags': ['youtube-dl'],
- 'duration': 10,
- 'view_count': int,
- 'like_count': int,
- 'dislike_count': int,
- 'start_time': 1,
- 'end_time': 9,
- }
- },
- {
- 'url': 'https://www.youtube.com/watch?v=UxxajLWwzqY',
- 'note': 'Test generic use_cipher_signature video (#897)',
- 'info_dict': {
- 'id': 'UxxajLWwzqY',
- 'ext': 'mp4',
- 'upload_date': '20120506',
- 'title': 'Icona Pop - I Love It (feat. Charli XCX) [OFFICIAL VIDEO]',
- 'alt_title': 'I Love It (feat. Charli XCX)',
- 'description': 'md5:f3ceb5ef83a08d95b9d146f973157cc8',
- 'tags': ['Icona Pop i love it', 'sweden', 'pop music', 'big beat records', 'big beat', 'charli',
- 'xcx', 'charli xcx', 'girls', 'hbo', 'i love it', "i don't care", 'icona', 'pop',
- 'iconic ep', 'iconic', 'love', 'it'],
- 'duration': 180,
- 'uploader': 'Icona Pop',
- 'uploader_id': 'IconaPop',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/IconaPop',
- 'creator': 'Icona Pop',
- 'track': 'I Love It (feat. Charli XCX)',
- 'artist': 'Icona Pop',
- }
- },
- {
- 'url': 'https://www.youtube.com/watch?v=07FYdnEawAQ',
- 'note': 'Test VEVO video with age protection (#956)',
- 'info_dict': {
- 'id': '07FYdnEawAQ',
- 'ext': 'mp4',
- 'upload_date': '20130703',
- 'title': 'Justin Timberlake - Tunnel Vision (Official Music Video) (Explicit)',
- 'alt_title': 'Tunnel Vision',
- 'description': 'md5:07dab3356cde4199048e4c7cd93471e1',
- 'duration': 419,
- 'uploader': 'justintimberlakeVEVO',
- 'uploader_id': 'justintimberlakeVEVO',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/justintimberlakeVEVO',
- 'creator': 'Justin Timberlake',
- 'track': 'Tunnel Vision',
- 'artist': 'Justin Timberlake',
- 'age_limit': 18,
- }
- },
- {
- 'url': '//www.YouTube.com/watch?v=yZIXLfi8CZQ',
- 'note': 'Embed-only video (#1746)',
- 'info_dict': {
- 'id': 'yZIXLfi8CZQ',
- 'ext': 'mp4',
- 'upload_date': '20120608',
- 'title': 'Principal Sexually Assaults A Teacher - Episode 117 - 8th June 2012',
- 'description': 'md5:09b78bd971f1e3e289601dfba15ca4f7',
- 'uploader': 'SET India',
- 'uploader_id': 'setindia',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/setindia',
- 'age_limit': 18,
- }
- },
- {
- 'url': 'https://www.youtube.com/watch?v=BaW_jenozKc&v=UxxajLWwzqY',
- 'note': 'Use the first video ID in the URL',
- 'info_dict': {
- 'id': 'BaW_jenozKc',
- 'ext': 'mp4',
- 'title': 'youtube-dl test video "\'/\\ä↭𝕐',
- 'uploader': 'Philipp Hagemeister',
- 'uploader_id': 'phihag',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/phihag',
- 'upload_date': '20121002',
- 'description': 'test chars: "\'/\\ä↭𝕐\ntest URL: https://github.com/rg3/youtube-dl/issues/1892\n\nThis is a test video for youtube-dl.\n\nFor more information, contact phihag@phihag.de .',
- 'categories': ['Science & Technology'],
- 'tags': ['youtube-dl'],
- 'duration': 10,
- 'view_count': int,
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- 'url': 'https://www.youtube.com/watch?v=a9LDPn-MO4I',
- 'note': '256k DASH audio (format 141) via DASH manifest',
- 'info_dict': {
- 'id': 'a9LDPn-MO4I',
- 'ext': 'm4a',
- 'upload_date': '20121002',
- 'uploader_id': '8KVIDEO',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/8KVIDEO',
- 'description': '',
- 'uploader': '8KVIDEO',
- 'title': 'UHDTV TEST 8K VIDEO.mp4'
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '141',
- },
- 'skip': 'format 141 not served anymore',
- },
- # DASH manifest with encrypted signature
- {
- 'url': 'https://www.youtube.com/watch?v=IB3lcPjvWLA',
- 'info_dict': {
- 'id': 'IB3lcPjvWLA',
- 'ext': 'm4a',
- 'title': 'Afrojack, Spree Wilson - The Spark (Official Music Video) ft. Spree Wilson',
- 'description': 'md5:8f5e2b82460520b619ccac1f509d43bf',
- 'duration': 244,
- 'uploader': 'AfrojackVEVO',
- 'uploader_id': 'AfrojackVEVO',
- 'upload_date': '20131011',
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '141/bestaudio[ext=m4a]',
- },
- },
- # JS player signature function name containing $
- {
- 'url': 'https://www.youtube.com/watch?v=nfWlot6h_JM',
- 'info_dict': {
- 'id': 'nfWlot6h_JM',
- 'ext': 'm4a',
- 'title': 'Taylor Swift - Shake It Off',
- 'description': 'md5:bec2185232c05479482cb5a9b82719bf',
- 'duration': 242,
- 'uploader': 'TaylorSwiftVEVO',
- 'uploader_id': 'TaylorSwiftVEVO',
- 'upload_date': '20140818',
- 'creator': 'Taylor Swift',
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '141/bestaudio[ext=m4a]',
- },
- },
- # Controversy video
- {
- 'url': 'https://www.youtube.com/watch?v=T4XJQO3qol8',
- 'info_dict': {
- 'id': 'T4XJQO3qol8',
- 'ext': 'mp4',
- 'duration': 219,
- 'upload_date': '20100909',
- 'uploader': 'Amazing Atheist',
- 'uploader_id': 'TheAmazingAtheist',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/TheAmazingAtheist',
- 'title': 'Burning Everyone\'s Koran',
- 'description': 'SUBSCRIBE: http://www.youtube.com/saturninefilms\n\nEven Obama has taken a stand against freedom on this issue: http://www.huffingtonpost.com/2010/09/09/obama-gma-interview-quran_n_710282.html',
- }
- },
- # Normal age-gate video (No vevo, embed allowed)
- {
- 'url': 'https://youtube.com/watch?v=HtVdAasjOgU',
- 'info_dict': {
- 'id': 'HtVdAasjOgU',
- 'ext': 'mp4',
- 'title': 'The Witcher 3: Wild Hunt - The Sword Of Destiny Trailer',
- 'description': r're:(?s).{100,}About the Game\n.*?The Witcher 3: Wild Hunt.{100,}',
- 'duration': 142,
- 'uploader': 'The Witcher',
- 'uploader_id': 'WitcherGame',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/WitcherGame',
- 'upload_date': '20140605',
- 'age_limit': 18,
- },
- },
- # Age-gate video with encrypted signature
- {
- 'url': 'https://www.youtube.com/watch?v=6kLq3WMV1nU',
- 'info_dict': {
- 'id': '6kLq3WMV1nU',
- 'ext': 'mp4',
- 'title': 'Dedication To My Ex (Miss That) (Lyric Video)',
- 'description': 'md5:33765bb339e1b47e7e72b5490139bb41',
- 'duration': 246,
- 'uploader': 'LloydVEVO',
- 'uploader_id': 'LloydVEVO',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/LloydVEVO',
- 'upload_date': '20110629',
- 'age_limit': 18,
- },
- },
- # video_info is None (https://github.com/ytdl-org/youtube-dl/issues/4421)
- # YouTube Red ad is not captured for creator
- {
- 'url': '__2ABJjxzNo',
- 'info_dict': {
- 'id': '__2ABJjxzNo',
- 'ext': 'mp4',
- 'duration': 266,
- 'upload_date': '20100430',
- 'uploader_id': 'deadmau5',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/deadmau5',
- 'creator': 'deadmau5',
- 'description': 'md5:12c56784b8032162bb936a5f76d55360',
- 'uploader': 'deadmau5',
- 'title': 'Deadmau5 - Some Chords (HD)',
- 'alt_title': 'Some Chords',
- },
- 'expected_warnings': [
- 'DASH manifest missing',
- ]
- },
- # Olympics (https://github.com/ytdl-org/youtube-dl/issues/4431)
- {
- 'url': 'lqQg6PlCWgI',
- 'info_dict': {
- 'id': 'lqQg6PlCWgI',
- 'ext': 'mp4',
- 'duration': 6085,
- 'upload_date': '20150827',
- 'uploader_id': 'olympic',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/olympic',
- 'description': 'HO09 - Women - GER-AUS - Hockey - 31 July 2012 - London 2012 Olympic Games',
- 'uploader': 'Olympic',
- 'title': 'Hockey - Women - GER-AUS - London 2012 Olympic Games',
- },
- 'params': {
- 'skip_download': 'requires avconv',
- }
- },
- # Non-square pixels
- {
- 'url': 'https://www.youtube.com/watch?v=_b-2C3KPAM0',
- 'info_dict': {
- 'id': '_b-2C3KPAM0',
- 'ext': 'mp4',
- 'stretched_ratio': 16 / 9.,
- 'duration': 85,
- 'upload_date': '20110310',
- 'uploader_id': 'AllenMeow',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/AllenMeow',
- 'description': 'made by Wacom from Korea | 字幕&加油添醋 by TY\'s Allen | 感謝heylisa00cavey1001同學熱情提供梗及翻譯',
- 'uploader': '孫ᄋᄅ',
- 'title': '[A-made] 變態妍字幕版 太妍 我就是這樣的人',
- },
- },
- # url_encoded_fmt_stream_map is empty string
- {
- 'url': 'qEJwOuvDf7I',
- 'info_dict': {
- 'id': 'qEJwOuvDf7I',
- 'ext': 'webm',
- 'title': 'Обсуждение судебной практики по выборам 14 сентября 2014 года в Санкт-Петербурге',
- 'description': '',
- 'upload_date': '20150404',
- 'uploader_id': 'spbelect',
- 'uploader': 'Наблюдатели Петербурга',
- },
- 'params': {
- 'skip_download': 'requires avconv',
- },
- 'skip': 'This live event has ended.',
- },
- # Extraction from multiple DASH manifests (https://github.com/ytdl-org/youtube-dl/pull/6097)
- {
- 'url': 'https://www.youtube.com/watch?v=FIl7x6_3R5Y',
- 'info_dict': {
- 'id': 'FIl7x6_3R5Y',
- 'ext': 'webm',
- 'title': 'md5:7b81415841e02ecd4313668cde88737a',
- 'description': 'md5:116377fd2963b81ec4ce64b542173306',
- 'duration': 220,
- 'upload_date': '20150625',
- 'uploader_id': 'dorappi2000',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/dorappi2000',
- 'uploader': 'dorappi2000',
- 'formats': 'mincount:31',
- },
- 'skip': 'not actual anymore',
- },
- # DASH manifest with segment_list
- {
- 'url': 'https://www.youtube.com/embed/CsmdDsKjzN8',
- 'md5': '8ce563a1d667b599d21064e982ab9e31',
- 'info_dict': {
- 'id': 'CsmdDsKjzN8',
- 'ext': 'mp4',
- 'upload_date': '20150501', # According to '<meta itemprop="datePublished"', but in other places it's 20150510
- 'uploader': 'Airtek',
- 'description': 'Retransmisión en directo de la XVIII media maratón de Zaragoza.',
- 'uploader_id': 'UCzTzUmjXxxacNnL8I3m4LnQ',
- 'title': 'Retransmisión XVIII Media maratón Zaragoza 2015',
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '135', # bestvideo
- },
- 'skip': 'This live event has ended.',
- },
- {
- # Multifeed videos (multiple cameras), URL is for Main Camera
- 'url': 'https://www.youtube.com/watch?v=jqWvoWXjCVs',
- 'info_dict': {
- 'id': 'jqWvoWXjCVs',
- 'title': 'teamPGP: Rocket League Noob Stream',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- },
- 'playlist': [{
- 'info_dict': {
- 'id': 'jqWvoWXjCVs',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (Main Camera)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7335,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }, {
- 'info_dict': {
- 'id': '6h8e8xoXJzg',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (kreestuh)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7337,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }, {
- 'info_dict': {
- 'id': 'PUOgX5z9xZw',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (grizzle)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7337,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }, {
- 'info_dict': {
- 'id': 'teuwxikvS5k',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (zim)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7334,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }],
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video is not available.',
- },
- {
- # Multifeed video with comma in title (see https://github.com/ytdl-org/youtube-dl/issues/8536)
- 'url': 'https://www.youtube.com/watch?v=gVfLd0zydlo',
- 'info_dict': {
- 'id': 'gVfLd0zydlo',
- 'title': 'DevConf.cz 2016 Day 2 Workshops 1 14:00 - 15:30',
- },
- 'playlist_count': 2,
- 'skip': 'Not multifeed anymore',
- },
- {
- 'url': 'https://vid.plus/FlRa-iH7PGw',
- 'only_matching': True,
- },
- {
- 'url': 'https://zwearz.com/watch/9lWxNJF-ufM/electra-woman-dyna-girl-official-trailer-grace-helbig.html',
- 'only_matching': True,
- },
- {
- # Title with JS-like syntax "};" (see https://github.com/ytdl-org/youtube-dl/issues/7468)
- # Also tests cut-off URL expansion in video description (see
- # https://github.com/ytdl-org/youtube-dl/issues/1892,
- # https://github.com/ytdl-org/youtube-dl/issues/8164)
- 'url': 'https://www.youtube.com/watch?v=lsguqyKfVQg',
- 'info_dict': {
- 'id': 'lsguqyKfVQg',
- 'ext': 'mp4',
- 'title': '{dark walk}; Loki/AC/Dishonored; collab w/Elflover21',
- 'alt_title': 'Dark Walk - Position Music',
- 'description': 'md5:8085699c11dc3f597ce0410b0dcbb34a',
- 'duration': 133,
- 'upload_date': '20151119',
- 'uploader_id': 'IronSoulElf',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/IronSoulElf',
- 'uploader': 'IronSoulElf',
- 'creator': 'Todd Haberman, Daniel Law Heath and Aaron Kaplan',
- 'track': 'Dark Walk - Position Music',
- 'artist': 'Todd Haberman, Daniel Law Heath and Aaron Kaplan',
- 'album': 'Position Music - Production Music Vol. 143 - Dark Walk',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Tags with '};' (see https://github.com/ytdl-org/youtube-dl/issues/7468)
- 'url': 'https://www.youtube.com/watch?v=Ms7iBXnlUO8',
- 'only_matching': True,
- },
- {
- # Video with yt:stretch=17:0
- 'url': 'https://www.youtube.com/watch?v=Q39EVAstoRM',
- 'info_dict': {
- 'id': 'Q39EVAstoRM',
- 'ext': 'mp4',
- 'title': 'Clash Of Clans#14 Dicas De Ataque Para CV 4',
- 'description': 'md5:ee18a25c350637c8faff806845bddee9',
- 'upload_date': '20151107',
- 'uploader_id': 'UCCr7TALkRbo3EtFzETQF1LA',
- 'uploader': 'CH GAMER DROID',
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video does not exist.',
- },
- {
- # Video licensed under Creative Commons
- 'url': 'https://www.youtube.com/watch?v=M4gD1WSo5mA',
- 'info_dict': {
- 'id': 'M4gD1WSo5mA',
- 'ext': 'mp4',
- 'title': 'md5:e41008789470fc2533a3252216f1c1d1',
- 'description': 'md5:a677553cf0840649b731a3024aeff4cc',
- 'duration': 721,
- 'upload_date': '20150127',
- 'uploader_id': 'BerkmanCenter',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/BerkmanCenter',
- 'uploader': 'The Berkman Klein Center for Internet & Society',
- 'license': 'Creative Commons Attribution license (reuse allowed)',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Channel-like uploader_url
- 'url': 'https://www.youtube.com/watch?v=eQcmzGIKrzg',
- 'info_dict': {
- 'id': 'eQcmzGIKrzg',
- 'ext': 'mp4',
- 'title': 'Democratic Socialism and Foreign Policy | Bernie Sanders',
- 'description': 'md5:dda0d780d5a6e120758d1711d062a867',
- 'duration': 4060,
- 'upload_date': '20151119',
- 'uploader': 'Bernie Sanders',
- 'uploader_id': 'UCH1dpzjCEiGAt8CXkryhkZg',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCH1dpzjCEiGAt8CXkryhkZg',
- 'license': 'Creative Commons Attribution license (reuse allowed)',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- 'url': 'https://www.youtube.com/watch?feature=player_embedded&amp;amp;v=V36LpHqtcDY',
- 'only_matching': True,
- },
- {
- # YouTube Red paid video (https://github.com/ytdl-org/youtube-dl/issues/10059)
- 'url': 'https://www.youtube.com/watch?v=i1Ko8UG-Tdo',
- 'only_matching': True,
- },
- {
- # Rental video preview
- 'url': 'https://www.youtube.com/watch?v=yYr8q0y5Jfg',
- 'info_dict': {
- 'id': 'uGpuVWrhIzE',
- 'ext': 'mp4',
- 'title': 'Piku - Trailer',
- 'description': 'md5:c36bd60c3fd6f1954086c083c72092eb',
- 'upload_date': '20150811',
- 'uploader': 'FlixMatrix',
- 'uploader_id': 'FlixMatrixKaravan',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/FlixMatrixKaravan',
- 'license': 'Standard YouTube License',
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video is not available.',
- },
- {
- # YouTube Red video with episode data
- 'url': 'https://www.youtube.com/watch?v=iqKdEhx-dD4',
- 'info_dict': {
- 'id': 'iqKdEhx-dD4',
- 'ext': 'mp4',
- 'title': 'Isolation - Mind Field (Ep 1)',
- 'description': 'md5:46a29be4ceffa65b92d277b93f463c0f',
- 'duration': 2085,
- 'upload_date': '20170118',
- 'uploader': 'Vsauce',
- 'uploader_id': 'Vsauce',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/Vsauce',
- 'series': 'Mind Field',
- 'season_number': 1,
- 'episode_number': 1,
- },
- 'params': {
- 'skip_download': True,
- },
- 'expected_warnings': [
- 'Skipping DASH manifest',
- ],
- },
- {
- # The following content has been identified by the YouTube community
- # as inappropriate or offensive to some audiences.
- 'url': 'https://www.youtube.com/watch?v=6SJNVb0GnPI',
- 'info_dict': {
- 'id': '6SJNVb0GnPI',
- 'ext': 'mp4',
- 'title': 'Race Differences in Intelligence',
- 'description': 'md5:5d161533167390427a1f8ee89a1fc6f1',
- 'duration': 965,
- 'upload_date': '20140124',
- 'uploader': 'New Century Foundation',
- 'uploader_id': 'UCEJYpZGqgUob0zVVEaLhvVg',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCEJYpZGqgUob0zVVEaLhvVg',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # itag 212
- 'url': '1t24XAntNCY',
- 'only_matching': True,
- },
- {
- # geo restricted to JP
- 'url': 'sJL6WA-aGkQ',
- 'only_matching': True,
- },
- {
- 'url': 'https://www.youtube.com/watch?v=MuAGGZNfUkU&list=RDMM',
- 'only_matching': True,
- },
- {
- 'url': 'https://invidio.us/watch?v=BaW_jenozKc',
- 'only_matching': True,
- },
- {
- # DRM protected
- 'url': 'https://www.youtube.com/watch?v=s7_qI6_mIXc',
- 'only_matching': True,
- },
- {
- # Video with unsupported adaptive stream type formats
- 'url': 'https://www.youtube.com/watch?v=Z4Vy8R84T1U',
- 'info_dict': {
- 'id': 'Z4Vy8R84T1U',
- 'ext': 'mp4',
- 'title': 'saman SMAN 53 Jakarta(Sancety) opening COFFEE4th at SMAN 53 Jakarta',
- 'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
- 'duration': 433,
- 'upload_date': '20130923',
- 'uploader': 'Amelia Putri Harwita',
- 'uploader_id': 'UCpOxM49HJxmC1qCalXyB3_Q',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCpOxM49HJxmC1qCalXyB3_Q',
- 'formats': 'maxcount:10',
- },
- 'params': {
- 'skip_download': True,
- 'youtube_include_dash_manifest': False,
- },
- },
- {
- # Youtube Music Auto-generated description
- 'url': 'https://music.youtube.com/watch?v=MgNrAu2pzNs',
- 'info_dict': {
- 'id': 'MgNrAu2pzNs',
- 'ext': 'mp4',
- 'title': 'Voyeur Girl',
- 'description': 'md5:7ae382a65843d6df2685993e90a8628f',
- 'upload_date': '20190312',
- 'uploader': 'Various Artists - Topic',
- 'uploader_id': 'UCVWKBi1ELZn0QX2CBLSkiyw',
- 'artist': 'Stephen',
- 'track': 'Voyeur Girl',
- 'album': 'it\'s too much love to know my dear',
- 'release_date': '20190313',
- 'release_year': 2019,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Youtube Music Auto-generated description
- # Retrieve 'artist' field from 'Artist:' in video description
- # when it is present on youtube music video
- 'url': 'https://www.youtube.com/watch?v=k0jLE7tTwjY',
- 'info_dict': {
- 'id': 'k0jLE7tTwjY',
- 'ext': 'mp4',
- 'title': 'Latch Feat. Sam Smith',
- 'description': 'md5:3cb1e8101a7c85fcba9b4fb41b951335',
- 'upload_date': '20150110',
- 'uploader': 'Various Artists - Topic',
- 'uploader_id': 'UCNkEcmYdjrH4RqtNgh7BZ9w',
- 'artist': 'Disclosure',
- 'track': 'Latch Feat. Sam Smith',
- 'album': 'Latch Featuring Sam Smith',
- 'release_date': '20121008',
- 'release_year': 2012,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Youtube Music Auto-generated description
- # handle multiple artists on youtube music video
- 'url': 'https://www.youtube.com/watch?v=74qn0eJSjpA',
- 'info_dict': {
- 'id': '74qn0eJSjpA',
- 'ext': 'mp4',
- 'title': 'Eastside',
- 'description': 'md5:290516bb73dcbfab0dcc4efe6c3de5f2',
- 'upload_date': '20180710',
- 'uploader': 'Benny Blanco - Topic',
- 'uploader_id': 'UCzqz_ksRu_WkIzmivMdIS7A',
- 'artist': 'benny blanco, Halsey, Khalid',
- 'track': 'Eastside',
- 'album': 'Eastside',
- 'release_date': '20180713',
- 'release_year': 2018,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Youtube Music Auto-generated description
- # handle youtube music video with release_year and no release_date
- 'url': 'https://www.youtube.com/watch?v=-hcAI0g-f5M',
- 'info_dict': {
- 'id': '-hcAI0g-f5M',
- 'ext': 'mp4',
- 'title': 'Put It On Me',
- 'description': 'md5:93c55acc682ae7b0c668f2e34e1c069e',
- 'upload_date': '20180426',
- 'uploader': 'Matt Maeson - Topic',
- 'uploader_id': 'UCnEkIGqtGcQMLk73Kp-Q5LQ',
- 'artist': 'Matt Maeson',
- 'track': 'Put It On Me',
- 'album': 'The Hearse',
- 'release_date': None,
- 'release_year': 2018,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- ]
-
- def __init__(self, *args, **kwargs):
- super(YoutubeIE, self).__init__(*args, **kwargs)
- self._player_cache = {}
-
- def report_video_info_webpage_download(self, video_id):
- """Report attempt to download video info webpage."""
- self.to_screen('%s: Downloading video info webpage' % video_id)
-
- def report_information_extraction(self, video_id):
- """Report attempt to extract video information."""
- self.to_screen('%s: Extracting video information' % video_id)
-
- def report_unavailable_format(self, video_id, format):
- """Report extracted video URL."""
- self.to_screen('%s: Format %s not available' % (video_id, format))
-
- def report_rtmp_download(self):
- """Indicate the download will use the RTMP protocol."""
- self.to_screen('RTMP download detected')
-
- def _signature_cache_id(self, example_sig):
- """ Return a string representation of a signature """
- return '.'.join(compat_str(len(part)) for part in example_sig.split('.'))
-
- def _extract_signature_function(self, video_id, player_url, example_sig):
- id_m = re.match(
- r'.*?-(?P<id>[a-zA-Z0-9_-]+)(?:/watch_as3|/html5player(?:-new)?|(?:/[a-z]{2,3}_[A-Z]{2})?/base)?\.(?P<ext>[a-z]+)$',
- player_url)
- if not id_m:
- raise ExtractorError('Cannot identify player %r' % player_url)
- player_type = id_m.group('ext')
- player_id = id_m.group('id')
-
- # Read from filesystem cache
- func_id = '%s_%s_%s' % (
- player_type, player_id, self._signature_cache_id(example_sig))
- assert os.path.basename(func_id) == func_id
-
- cache_spec = self._downloader.cache.load('youtube-sigfuncs', func_id)
- if cache_spec is not None:
- return lambda s: ''.join(s[i] for i in cache_spec)
-
- download_note = (
- 'Downloading player %s' % player_url
- if self._downloader.params.get('verbose') else
- 'Downloading %s player %s' % (player_type, player_id)
- )
- if player_type == 'js':
- code = self._download_webpage(
- player_url, video_id,
- note=download_note,
- errnote='Download of %s failed' % player_url)
- res = self._parse_sig_js(code)
- elif player_type == 'swf':
- urlh = self._request_webpage(
- player_url, video_id,
- note=download_note,
- errnote='Download of %s failed' % player_url)
- code = urlh.read()
- res = self._parse_sig_swf(code)
- else:
- assert False, 'Invalid player type %r' % player_type
-
- test_string = ''.join(map(compat_chr, range(len(example_sig))))
- cache_res = res(test_string)
- cache_spec = [ord(c) for c in cache_res]
-
- self._downloader.cache.store('youtube-sigfuncs', func_id, cache_spec)
- return res
-
- def _print_sig_code(self, func, example_sig):
- def gen_sig_code(idxs):
- def _genslice(start, end, step):
- starts = '' if start == 0 else str(start)
- ends = (':%d' % (end + step)) if end + step >= 0 else ':'
- steps = '' if step == 1 else (':%d' % step)
- return 's[%s%s%s]' % (starts, ends, steps)
-
- step = None
- # Quelch pyflakes warnings - start will be set when step is set
- start = '(Never used)'
- for i, prev in zip(idxs[1:], idxs[:-1]):
- if step is not None:
- if i - prev == step:
- continue
- yield _genslice(start, prev, step)
- step = None
- continue
- if i - prev in [-1, 1]:
- step = i - prev
- start = prev
- continue
- else:
- yield 's[%d]' % prev
- if step is None:
- yield 's[%d]' % i
- else:
- yield _genslice(start, i, step)
-
- test_string = ''.join(map(compat_chr, range(len(example_sig))))
- cache_res = func(test_string)
- cache_spec = [ord(c) for c in cache_res]
- expr_code = ' + '.join(gen_sig_code(cache_spec))
- signature_id_tuple = '(%s)' % (
- ', '.join(compat_str(len(p)) for p in example_sig.split('.')))
- code = ('if tuple(len(p) for p in s.split(\'.\')) == %s:\n'
- ' return %s\n') % (signature_id_tuple, expr_code)
- self.to_screen('Extracted signature function:\n' + code)
-
- def _parse_sig_js(self, jscode):
- funcname = self._search_regex(
- (r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'(?P<sig>[a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)',
- # Obsolete patterns
- r'(["\'])signature\1\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\.sig\|\|(?P<sig>[a-zA-Z0-9$]+)\(',
- r'yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\('),
- jscode, 'Initial JS player signature function name', group='sig')
-
- jsi = JSInterpreter(jscode)
- initial_function = jsi.extract_function(funcname)
- return lambda s: initial_function([s])
-
- def _parse_sig_swf(self, file_contents):
- swfi = SWFInterpreter(file_contents)
- TARGET_CLASSNAME = 'SignatureDecipher'
- searched_class = swfi.extract_class(TARGET_CLASSNAME)
- initial_function = swfi.extract_function(searched_class, 'decipher')
- return lambda s: initial_function([s])
-
- def _decrypt_signature(self, s, video_id, player_url, age_gate=False):
- """Turn the encrypted s field into a working signature"""
-
- if player_url is None:
- raise ExtractorError('Cannot decrypt signature without player_url')
-
- if player_url.startswith('//'):
- player_url = 'https:' + player_url
- elif not re.match(r'https?://', player_url):
- player_url = compat_urlparse.urljoin(
- 'https://www.youtube.com', player_url)
- try:
- player_id = (player_url, self._signature_cache_id(s))
- if player_id not in self._player_cache:
- func = self._extract_signature_function(
- video_id, player_url, s
- )
- self._player_cache[player_id] = func
- func = self._player_cache[player_id]
- if self._downloader.params.get('youtube_print_sig_code'):
- self._print_sig_code(func, s)
- return func(s)
- except Exception as e:
- tb = traceback.format_exc()
- raise ExtractorError(
- 'Signature extraction failed: ' + tb, cause=e)
-
- def _get_subtitles(self, video_id, webpage):
- try:
- subs_doc = self._download_xml(
- 'https://video.google.com/timedtext?hl=en&type=list&v=%s' % video_id,
- video_id, note=False)
- except ExtractorError as err:
- self._downloader.report_warning('unable to download video subtitles: %s' % error_to_compat_str(err))
- return {}
-
- sub_lang_list = {}
- for track in subs_doc.findall('track'):
- lang = track.attrib['lang_code']
- if lang in sub_lang_list:
- continue
- sub_formats = []
- for ext in self._SUBTITLE_FORMATS:
- params = compat_urllib_parse_urlencode({
- 'lang': lang,
- 'v': video_id,
- 'fmt': ext,
- 'name': track.attrib['name'].encode('utf-8'),
- })
- sub_formats.append({
- 'url': 'https://www.youtube.com/api/timedtext?' + params,
- 'ext': ext,
- })
- sub_lang_list[lang] = sub_formats
- if not sub_lang_list:
- self._downloader.report_warning('video doesn\'t have subtitles')
- return {}
- return sub_lang_list
-
- def _get_ytplayer_config(self, video_id, webpage):
- patterns = (
- # User data may contain arbitrary character sequences that may affect
- # JSON extraction with regex, e.g. when '};' is contained the second
- # regex won't capture the whole JSON. Yet working around by trying more
- # concrete regex first keeping in mind proper quoted string handling
- # to be implemented in future that will replace this workaround (see
- # https://github.com/ytdl-org/youtube-dl/issues/7468,
- # https://github.com/ytdl-org/youtube-dl/pull/7599)
- r';ytplayer\.config\s*=\s*({.+?});ytplayer',
- r';ytplayer\.config\s*=\s*({.+?});',
- )
- config = self._search_regex(
- patterns, webpage, 'ytplayer.config', default=None)
- if config:
- return self._parse_json(
- uppercase_escape(config), video_id, fatal=False)
-
- def _get_automatic_captions(self, video_id, webpage):
- """We need the webpage for getting the captions url, pass it as an
- argument to speed up the process."""
- self.to_screen('%s: Looking for automatic captions' % video_id)
- player_config = self._get_ytplayer_config(video_id, webpage)
- err_msg = 'Couldn\'t find automatic captions for %s' % video_id
- if not player_config:
- self._downloader.report_warning(err_msg)
- return {}
- try:
- args = player_config['args']
- caption_url = args.get('ttsurl')
- if caption_url:
- timestamp = args['timestamp']
- # We get the available subtitles
- list_params = compat_urllib_parse_urlencode({
- 'type': 'list',
- 'tlangs': 1,
- 'asrs': 1,
- })
- list_url = caption_url + '&' + list_params
- caption_list = self._download_xml(list_url, video_id)
- original_lang_node = caption_list.find('track')
- if original_lang_node is None:
- self._downloader.report_warning('Video doesn\'t have automatic captions')
- return {}
- original_lang = original_lang_node.attrib['lang_code']
- caption_kind = original_lang_node.attrib.get('kind', '')
-
- sub_lang_list = {}
- for lang_node in caption_list.findall('target'):
- sub_lang = lang_node.attrib['lang_code']
- sub_formats = []
- for ext in self._SUBTITLE_FORMATS:
- params = compat_urllib_parse_urlencode({
- 'lang': original_lang,
- 'tlang': sub_lang,
- 'fmt': ext,
- 'ts': timestamp,
- 'kind': caption_kind,
- })
- sub_formats.append({
- 'url': caption_url + '&' + params,
- 'ext': ext,
- })
- sub_lang_list[sub_lang] = sub_formats
- return sub_lang_list
-
- def make_captions(sub_url, sub_langs):
- parsed_sub_url = compat_urllib_parse_urlparse(sub_url)
- caption_qs = compat_parse_qs(parsed_sub_url.query)
- captions = {}
- for sub_lang in sub_langs:
- sub_formats = []
- for ext in self._SUBTITLE_FORMATS:
- caption_qs.update({
- 'tlang': [sub_lang],
- 'fmt': [ext],
- })
- sub_url = compat_urlparse.urlunparse(parsed_sub_url._replace(
- query=compat_urllib_parse_urlencode(caption_qs, True)))
- sub_formats.append({
- 'url': sub_url,
- 'ext': ext,
- })
- captions[sub_lang] = sub_formats
- return captions
-
- # New captions format as of 22.06.2017
- player_response = args.get('player_response')
- if player_response and isinstance(player_response, compat_str):
- player_response = self._parse_json(
- player_response, video_id, fatal=False)
- if player_response:
- renderer = player_response['captions']['playerCaptionsTracklistRenderer']
- base_url = renderer['captionTracks'][0]['baseUrl']
- sub_lang_list = []
- for lang in renderer['translationLanguages']:
- lang_code = lang.get('languageCode')
- if lang_code:
- sub_lang_list.append(lang_code)
- return make_captions(base_url, sub_lang_list)
-
- # Some videos don't provide ttsurl but rather caption_tracks and
- # caption_translation_languages (e.g. 20LmZk1hakA)
- # Does not used anymore as of 22.06.2017
- caption_tracks = args['caption_tracks']
- caption_translation_languages = args['caption_translation_languages']
- caption_url = compat_parse_qs(caption_tracks.split(',')[0])['u'][0]
- sub_lang_list = []
- for lang in caption_translation_languages.split(','):
- lang_qs = compat_parse_qs(compat_urllib_parse_unquote_plus(lang))
- sub_lang = lang_qs.get('lc', [None])[0]
- if sub_lang:
- sub_lang_list.append(sub_lang)
- return make_captions(caption_url, sub_lang_list)
- # An extractor error can be raise by the download process if there are
- # no automatic captions but there are subtitles
- except (KeyError, IndexError, ExtractorError):
- self._downloader.report_warning(err_msg)
- return {}
-
- def _mark_watched(self, video_id, video_info, player_response):
- playback_url = url_or_none(try_get(
- player_response,
- lambda x: x['playbackTracking']['videostatsPlaybackUrl']['baseUrl']) or try_get(
- video_info, lambda x: x['videostats_playback_base_url'][0]))
- if not playback_url:
- return
- parsed_playback_url = compat_urlparse.urlparse(playback_url)
- qs = compat_urlparse.parse_qs(parsed_playback_url.query)
-
- # cpn generation algorithm is reverse engineered from base.js.
- # In fact it works even with dummy cpn.
- CPN_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'
- cpn = ''.join((CPN_ALPHABET[random.randint(0, 256) & 63] for _ in range(0, 16)))
-
- qs.update({
- 'ver': ['2'],
- 'cpn': [cpn],
- })
- playback_url = compat_urlparse.urlunparse(
- parsed_playback_url._replace(query=compat_urllib_parse_urlencode(qs, True)))
-
- self._download_webpage(
- playback_url, video_id, 'Marking watched',
- 'Unable to mark watched', fatal=False)
-
- @staticmethod
- def _extract_urls(webpage):
- # Embedded YouTube player
- entries = [
- unescapeHTML(mobj.group('url'))
- for mobj in re.finditer(r'''(?x)
- (?:
- <iframe[^>]+?src=|
- data-video-url=|
- <embed[^>]+?src=|
- embedSWF\(?:\s*|
- <object[^>]+data=|
- new\s+SWFObject\(
- )
- (["\'])
- (?P<url>(?:https?:)?//(?:www\.)?youtube(?:-nocookie)?\.com/
- (?:embed|v|p)/[0-9A-Za-z_-]{11}.*?)
- \1''', webpage)]
-
- # lazyYT YouTube embed
- entries.extend(list(map(
- unescapeHTML,
- re.findall(r'class="lazyYT" data-youtube-id="([^"]+)"', webpage))))
-
- # Wordpress "YouTube Video Importer" plugin
- matches = re.findall(r'''(?x)<div[^>]+
- class=(?P<q1>[\'"])[^\'"]*\byvii_single_video_player\b[^\'"]*(?P=q1)[^>]+
- data-video_id=(?P<q2>[\'"])([^\'"]+)(?P=q2)''', webpage)
- entries.extend(m[-1] for m in matches)
-
- return entries
-
- @staticmethod
- def _extract_url(webpage):
- urls = YoutubeIE._extract_urls(webpage)
- return urls[0] if urls else None
-
- @classmethod
- def extract_id(cls, url):
- mobj = re.match(cls._VALID_URL, url, re.VERBOSE)
- if mobj is None:
- raise ExtractorError('Invalid URL: %s' % url)
- video_id = mobj.group(2)
- return video_id
-
- def _extract_annotations(self, video_id):
- return self._download_webpage(
- 'https://www.youtube.com/annotations_invideo', video_id,
- note='Downloading annotations',
- errnote='Unable to download video annotations', fatal=False,
- query={
- 'features': 1,
- 'legacy': 1,
- 'video_id': video_id,
- })
-
- @staticmethod
- def _extract_chapters(description, duration):
- if not description:
- return None
- chapter_lines = re.findall(
- r'(?:^|<br\s*/>)([^<]*<a[^>]+onclick=["\']yt\.www\.watch\.player\.seekTo[^>]+>(\d{1,2}:\d{1,2}(?::\d{1,2})?)</a>[^>]*)(?=$|<br\s*/>)',
- description)
- if not chapter_lines:
- return None
- chapters = []
- for next_num, (chapter_line, time_point) in enumerate(
- chapter_lines, start=1):
- start_time = parse_duration(time_point)
- if start_time is None:
- continue
- if start_time > duration:
- break
- end_time = (duration if next_num == len(chapter_lines)
- else parse_duration(chapter_lines[next_num][1]))
- if end_time is None:
- continue
- if end_time > duration:
- end_time = duration
- if start_time > end_time:
- break
- chapter_title = re.sub(
- r'<a[^>]+>[^<]+</a>', '', chapter_line).strip(' \t-')
- chapter_title = re.sub(r'\s+', ' ', chapter_title)
- chapters.append({
- 'start_time': start_time,
- 'end_time': end_time,
- 'title': chapter_title,
- })
- return chapters
-
- ul_tag_pattern = re.compile(r'(</?ul)')
- music_info_pattern = re.compile(r'<h4 class="title">\s*(Song|Music|Artist|Album)\s*</h4>\s*<ul class="content watch-info-tag-list">\s*<li>(?:<a[^>]*>)?([^<]*)(?:</a>)?</li>')
- def _real_extract(self, url):
- url, smuggled_data = unsmuggle_url(url, {})
-
- proto = (
- 'http' if self._downloader.params.get('prefer_insecure', False)
- else 'https')
-
- start_time = None
- end_time = None
- parsed_url = compat_urllib_parse_urlparse(url)
- for component in [parsed_url.fragment, parsed_url.query]:
- query = compat_parse_qs(component)
- if start_time is None and 't' in query:
- start_time = parse_duration(query['t'][0])
- if start_time is None and 'start' in query:
- start_time = parse_duration(query['start'][0])
- if end_time is None and 'end' in query:
- end_time = parse_duration(query['end'][0])
-
- # Extract original video URL from URL with redirection, like age verification, using next_url parameter
- mobj = re.search(self._NEXT_URL_RE, url)
- if mobj:
- url = proto + '://www.youtube.com/' + compat_urllib_parse_unquote(mobj.group(1)).lstrip('/')
- video_id = self.extract_id(url)
-
- # Get video webpage
- url = proto + '://www.youtube.com/watch?v=%s&gl=US&hl=en&has_verified=1&bpctr=9999999999' % video_id
- video_webpage = self._download_webpage(url, video_id)
-
- # Attempt to extract SWF player URL
- mobj = re.search(r'swfConfig.*?"(https?:\\/\\/.*?watch.*?-.*?\.swf)"', video_webpage)
- if mobj is not None:
- player_url = re.sub(r'\\(.)', r'\1', mobj.group(1))
- else:
- player_url = None
-
- dash_mpds = []
-
- def add_dash_mpd(video_info):
- dash_mpd = video_info.get('dashmpd')
- if dash_mpd and dash_mpd[0] not in dash_mpds:
- dash_mpds.append(dash_mpd[0])
-
- def add_dash_mpd_pr(pl_response):
- dash_mpd = url_or_none(try_get(
- pl_response, lambda x: x['streamingData']['dashManifestUrl'],
- compat_str))
- if dash_mpd and dash_mpd not in dash_mpds:
- dash_mpds.append(dash_mpd)
-
- is_live = None
- view_count = None
-
- def extract_view_count(v_info):
- return int_or_none(try_get(v_info, lambda x: x['view_count'][0]))
-
- def extract_token(v_info):
- return dict_get(v_info, ('account_playback_token', 'accountPlaybackToken', 'token'))
-
- player_response = {}
-
-
-
- # Is it unlisted?
- unlisted = ('<span id="watch-privacy-icon"' in video_webpage)
-
-
- # Related videos
- related_vids = []
- try:
- rvs_match = re.search(r'"rvs":"(.*?)[^\\]"', video_webpage)
- if rvs_match is not None:
- rvs = json.loads('"' + rvs_match.group(1) + '"') # unescape json string (\u0026 for example)
- related_vid_parts = (compat_parse_qs(related_item) for related_item in rvs.split(","))
- related_vids = [{key : value[0] for key,value in vid.items()} for vid in related_vid_parts]
- else:
- print('Failed to extract related videos: no rvs')
-
- except Exception:
- print('Error while extracting related videos:')
- traceback.print_exc()
-
-
- # Music list
- # Test case: https://www.youtube.com/watch?v=jbkZdRglnKY
- music_list = []
- metadata_start = video_webpage.find('<ul class="watch-extras-section">')
- if metadata_start != -1:
- metadata_start += 33
- tag_index = metadata_start
- open_tags = 1
- while open_tags > 0:
- match = self.ul_tag_pattern.search(video_webpage, tag_index)
- if match is None:
- print("Couldn't match ul tag")
- break
- tag_index = match.end()
- tag = match.group(1)
- if tag == "<ul":
- open_tags += 1
- else:
- open_tags -= 1
- else:
- last_index = 0
- metadata = video_webpage[metadata_start:tag_index]
- current_song = None
- while True:
- match = self.music_info_pattern.search(metadata, last_index)
- if match is None:
- if current_song is not None:
- music_list.append(current_song)
- break
- title, value = match.group(1), html.unescape(match.group(2))
- if title in ("Song", "Music"):
- if current_song is not None:
- music_list.append(current_song)
- current_song = {"title": value}
- else:
- current_song[title.lower()] = value
- last_index = match.end()
-
-
-
- # Get video info
- embed_webpage = None
- if re.search(r'player-age-gate-content">', video_webpage) is not None:
- age_gate = True
- # We simulate the access to the video from www.youtube.com/v/{video_id}
- # this can be viewed without login into Youtube
- url = proto + '://www.youtube.com/embed/%s' % video_id
- embed_webpage = self._download_webpage(url, video_id, 'Downloading embed webpage')
- data = compat_urllib_parse_urlencode({
- 'video_id': video_id,
- 'eurl': 'https://youtube.googleapis.com/v/' + video_id,
- 'sts': self._search_regex(
- r'"sts"\s*:\s*(\d+)', embed_webpage, 'sts', default=''),
- })
- video_info_url = proto + '://www.youtube.com/get_video_info?' + data
- video_info_webpage = self._download_webpage(
- video_info_url, video_id,
- note='Refetching age-gated info webpage',
- errnote='unable to download video info webpage')
- video_info = compat_parse_qs(video_info_webpage)
- add_dash_mpd(video_info)
- else:
- age_gate = False
- video_info = None
- sts = None
- # Try looking directly into the video webpage
- ytplayer_config = self._get_ytplayer_config(video_id, video_webpage)
- if ytplayer_config:
- args = ytplayer_config['args']
- if args.get('url_encoded_fmt_stream_map') or args.get('hlsvp'):
- # Convert to the same format returned by compat_parse_qs
- video_info = dict((k, [v]) for k, v in args.items())
- add_dash_mpd(video_info)
- # Rental video is not rented but preview is available (e.g.
- # https://www.youtube.com/watch?v=yYr8q0y5Jfg,
- # https://github.com/ytdl-org/youtube-dl/issues/10532)
- if not video_info and args.get('ypc_vid'):
- return self.url_result(
- args['ypc_vid'], YoutubeIE.ie_key(), video_id=args['ypc_vid'])
- if args.get('livestream') == '1' or args.get('live_playback') == 1:
- is_live = True
- sts = ytplayer_config.get('sts')
- if not player_response:
- pl_response = str_or_none(args.get('player_response'))
- if pl_response:
- pl_response = self._parse_json(pl_response, video_id, fatal=False)
- if isinstance(pl_response, dict):
- player_response = pl_response
- if not video_info or self._downloader.params.get('youtube_include_dash_manifest', True):
- add_dash_mpd_pr(player_response)
- # We also try looking in get_video_info since it may contain different dashmpd
- # URL that points to a DASH manifest with possibly different itag set (some itags
- # are missing from DASH manifest pointed by webpage's dashmpd, some - from DASH
- # manifest pointed by get_video_info's dashmpd).
- # The general idea is to take a union of itags of both DASH manifests (for example
- # video with such 'manifest behavior' see https://github.com/ytdl-org/youtube-dl/issues/6093)
- self.report_video_info_webpage_download(video_id)
- for el in ('embedded', 'detailpage', 'vevo', ''):
- query = {
- 'video_id': video_id,
- 'ps': 'default',
- 'eurl': '',
- 'gl': 'US',
- 'hl': 'en',
- }
- if el:
- query['el'] = el
- if sts:
- query['sts'] = sts
- video_info_webpage = self._download_webpage(
- '%s://www.youtube.com/get_video_info' % proto,
- video_id, note=False,
- errnote='unable to download video info webpage',
- fatal=False, query=query)
- if not video_info_webpage:
- continue
- get_video_info = compat_parse_qs(video_info_webpage)
- if not player_response:
- pl_response = get_video_info.get('player_response', [None])[0]
- if isinstance(pl_response, dict):
- player_response = pl_response
- add_dash_mpd_pr(player_response)
- add_dash_mpd(get_video_info)
- if view_count is None:
- view_count = extract_view_count(get_video_info)
- if not video_info:
- video_info = get_video_info
- get_token = extract_token(get_video_info)
- if get_token:
- # Different get_video_info requests may report different results, e.g.
- # some may report video unavailability, but some may serve it without
- # any complaint (see https://github.com/ytdl-org/youtube-dl/issues/7362,
- # the original webpage as well as el=info and el=embedded get_video_info
- # requests report video unavailability due to geo restriction while
- # el=detailpage succeeds and returns valid data). This is probably
- # due to YouTube measures against IP ranges of hosting providers.
- # Working around by preferring the first succeeded video_info containing
- # the token if no such video_info yet was found.
- token = extract_token(video_info)
- if not token:
- video_info = get_video_info
- break
-
- def extract_unavailable_message():
- return self._html_search_regex(
- r'(?s)<h1[^>]+id="unavailable-message"[^>]*>(.+?)</h1>',
- video_webpage, 'unavailable message', default=None)
-
- if not video_info:
- unavailable_message = extract_unavailable_message()
- if not unavailable_message:
- unavailable_message = 'Unable to extract video data'
- raise ExtractorError(
- 'YouTube said: %s' % unavailable_message, expected=True, video_id=video_id)
-
- video_details = try_get(
- player_response, lambda x: x['videoDetails'], dict) or {}
-
- video_title = video_info.get('title', [None])[0] or video_details.get('title')
- if not video_title:
- self._downloader.report_warning('Unable to extract video title')
- video_title = '_'
-
- # description
- description_original = video_description = get_element_by_id("eow-description", video_webpage)
- if video_description:
-
- def replace_url(m):
- redir_url = compat_urlparse.urljoin(url, m.group(1))
- parsed_redir_url = compat_urllib_parse_urlparse(redir_url)
- if re.search(r'^(?:www\.)?(?:youtube(?:-nocookie)?\.com|youtu\.be)$', parsed_redir_url.netloc) and parsed_redir_url.path == '/redirect':
- qs = compat_parse_qs(parsed_redir_url.query)
- q = qs.get('q')
- if q and q[0]:
- return q[0]
- return redir_url
-
- description_original = video_description = re.sub(r'''(?x)
- <a\s+
- (?:[a-zA-Z-]+="[^"]*"\s+)*?
- (?:title|href)="([^"]+)"\s+
- (?:[a-zA-Z-]+="[^"]*"\s+)*?
- class="[^"]*"[^>]*>
- [^<]+\.{3}\s*
- </a>
- ''', replace_url, video_description)
- video_description = clean_html(video_description)
- else:
- video_description = self._html_search_meta('description', video_webpage) or video_details.get('shortDescription')
-
- if not smuggled_data.get('force_singlefeed', False):
- if not self._downloader.params.get('noplaylist'):
- multifeed_metadata_list = try_get(
- player_response,
- lambda x: x['multicamera']['playerLegacyMulticameraRenderer']['metadataList'],
- compat_str) or try_get(
- video_info, lambda x: x['multifeed_metadata_list'][0], compat_str)
- if multifeed_metadata_list:
- entries = []
- feed_ids = []
- for feed in multifeed_metadata_list.split(','):
- # Unquote should take place before split on comma (,) since textual
- # fields may contain comma as well (see
- # https://github.com/ytdl-org/youtube-dl/issues/8536)
- feed_data = compat_parse_qs(compat_urllib_parse_unquote_plus(feed))
- entries.append({
- '_type': 'url_transparent',
- 'ie_key': 'Youtube',
- 'url': smuggle_url(
- '%s://www.youtube.com/watch?v=%s' % (proto, feed_data['id'][0]),
- {'force_singlefeed': True}),
- 'title': '%s (%s)' % (video_title, feed_data['title'][0]),
- })
- feed_ids.append(feed_data['id'][0])
- self.to_screen(
- 'Downloading multifeed video (%s) - add --no-playlist to just download video %s'
- % (', '.join(feed_ids), video_id))
- return self.playlist_result(entries, video_id, video_title, video_description)
- else:
- self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
-
- if view_count is None:
- view_count = extract_view_count(video_info)
- if view_count is None and video_details:
- view_count = int_or_none(video_details.get('viewCount'))
-
- # Check for "rental" videos
- if 'ypc_video_rental_bar_text' in video_info and 'author' not in video_info:
- raise ExtractorError('"rental" videos not supported. See https://github.com/ytdl-org/youtube-dl/issues/359 for more information.', expected=True)
-
- def _extract_filesize(media_url):
- return int_or_none(self._search_regex(
- r'\bclen[=/](\d+)', media_url, 'filesize', default=None))
-
- if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'):
- self.report_rtmp_download()
- formats = [{
- 'format_id': '_rtmp',
- 'protocol': 'rtmp',
- 'url': video_info['conn'][0],
- 'player_url': player_url,
- }]
- elif not is_live and (len(video_info.get('url_encoded_fmt_stream_map', [''])[0]) >= 1 or len(video_info.get('adaptive_fmts', [''])[0]) >= 1):
- encoded_url_map = video_info.get('url_encoded_fmt_stream_map', [''])[0] + ',' + video_info.get('adaptive_fmts', [''])[0]
- if 'rtmpe%3Dyes' in encoded_url_map:
- raise ExtractorError('rtmpe downloads are not supported, see https://github.com/ytdl-org/youtube-dl/issues/343 for more information.', expected=True)
- formats_spec = {}
- fmt_list = video_info.get('fmt_list', [''])[0]
- if fmt_list:
- for fmt in fmt_list.split(','):
- spec = fmt.split('/')
- if len(spec) > 1:
- width_height = spec[1].split('x')
- if len(width_height) == 2:
- formats_spec[spec[0]] = {
- 'resolution': spec[1],
- 'width': int_or_none(width_height[0]),
- 'height': int_or_none(width_height[1]),
- }
- q = qualities(['small', 'medium', 'hd720'])
- streaming_formats = try_get(player_response, lambda x: x['streamingData']['formats'], list)
- if streaming_formats:
- for fmt in streaming_formats:
- itag = str_or_none(fmt.get('itag'))
- if not itag:
- continue
- quality = fmt.get('quality')
- quality_label = fmt.get('qualityLabel') or quality
- formats_spec[itag] = {
- 'asr': int_or_none(fmt.get('audioSampleRate')),
- 'filesize': int_or_none(fmt.get('contentLength')),
- 'format_note': quality_label,
- 'fps': int_or_none(fmt.get('fps')),
- 'height': int_or_none(fmt.get('height')),
- 'quality': q(quality),
- # bitrate for itag 43 is always 2147483647
- 'tbr': float_or_none(fmt.get('averageBitrate') or fmt.get('bitrate'), 1000) if itag != '43' else None,
- 'width': int_or_none(fmt.get('width')),
- }
- formats = []
- for url_data_str in encoded_url_map.split(','):
- url_data = compat_parse_qs(url_data_str)
- if 'itag' not in url_data or 'url' not in url_data or url_data.get('drm_families'):
- continue
- stream_type = int_or_none(try_get(url_data, lambda x: x['stream_type'][0]))
- # Unsupported FORMAT_STREAM_TYPE_OTF
- if stream_type == 3:
- continue
- format_id = url_data['itag'][0]
- url = url_data['url'][0]
-
- if 's' in url_data or self._downloader.params.get('youtube_include_dash_manifest', True):
- ASSETS_RE = r'"assets":.+?"js":\s*("[^"]+")'
- jsplayer_url_json = self._search_regex(
- ASSETS_RE,
- embed_webpage if age_gate else video_webpage,
- 'JS player URL (1)', default=None)
- if not jsplayer_url_json and not age_gate:
- # We need the embed website after all
- if embed_webpage is None:
- embed_url = proto + '://www.youtube.com/embed/%s' % video_id
- embed_webpage = self._download_webpage(
- embed_url, video_id, 'Downloading embed webpage')
- jsplayer_url_json = self._search_regex(
- ASSETS_RE, embed_webpage, 'JS player URL')
-
- player_url = json.loads(jsplayer_url_json)
- if player_url is None:
- player_url_json = self._search_regex(
- r'ytplayer\.config.*?"url"\s*:\s*("[^"]+")',
- video_webpage, 'age gate player URL')
- player_url = json.loads(player_url_json)
-
- if 'sig' in url_data:
- url += '&signature=' + url_data['sig'][0]
- elif 's' in url_data:
- encrypted_sig = url_data['s'][0]
-
- if self._downloader.params.get('verbose'):
- if player_url is None:
- player_version = 'unknown'
- player_desc = 'unknown'
- else:
- if player_url.endswith('swf'):
- player_version = self._search_regex(
- r'-(.+?)(?:/watch_as3)?\.swf$', player_url,
- 'flash player', fatal=False)
- player_desc = 'flash player %s' % player_version
- else:
- player_version = self._search_regex(
- [r'html5player-([^/]+?)(?:/html5player(?:-new)?)?\.js',
- r'(?:www|player(?:_ias)?)-([^/]+)(?:/[a-z]{2,3}_[A-Z]{2})?/base\.js'],
- player_url,
- 'html5 player', fatal=False)
- player_desc = 'html5 player %s' % player_version
-
- parts_sizes = self._signature_cache_id(encrypted_sig)
- self.to_screen('{%s} signature length %s, %s' %
- (format_id, parts_sizes, player_desc))
-
- signature = self._decrypt_signature(
- encrypted_sig, video_id, player_url, age_gate)
- sp = try_get(url_data, lambda x: x['sp'][0], compat_str) or 'signature'
- url += '&%s=%s' % (sp, signature)
- if 'ratebypass' not in url:
- url += '&ratebypass=yes'
-
- dct = {
- 'format_id': format_id,
- 'url': url,
- 'player_url': player_url,
- }
- if format_id in self._formats:
- dct.update(self._formats[format_id])
- if format_id in formats_spec:
- dct.update(formats_spec[format_id])
-
- # Some itags are not included in DASH manifest thus corresponding formats will
- # lack metadata (see https://github.com/ytdl-org/youtube-dl/pull/5993).
- # Trying to extract metadata from url_encoded_fmt_stream_map entry.
- mobj = re.search(r'^(?P<width>\d+)[xX](?P<height>\d+)$', url_data.get('size', [''])[0])
- width, height = (int(mobj.group('width')), int(mobj.group('height'))) if mobj else (None, None)
-
- filesize = int_or_none(url_data.get(
- 'clen', [None])[0]) or _extract_filesize(url)
-
- quality = url_data.get('quality', [None])[0]
-
- more_fields = {
- 'filesize': filesize,
- 'tbr': float_or_none(url_data.get('bitrate', [None])[0], 1000),
- 'width': width,
- 'height': height,
- 'fps': int_or_none(url_data.get('fps', [None])[0]),
- 'format_note': url_data.get('quality_label', [None])[0] or quality,
- 'quality': q(quality),
- }
- for key, value in more_fields.items():
- if value:
- dct[key] = value
- type_ = url_data.get('type', [None])[0]
- if type_:
- type_split = type_.split(';')
- kind_ext = type_split[0].split('/')
- if len(kind_ext) == 2:
- kind, _ = kind_ext
- dct['ext'] = mimetype2ext(type_split[0])
- if kind in ('audio', 'video'):
- codecs = None
- for mobj in re.finditer(
- r'(?P<key>[a-zA-Z_-]+)=(?P<quote>["\']?)(?P<val>.+?)(?P=quote)(?:;|$)', type_):
- if mobj.group('key') == 'codecs':
- codecs = mobj.group('val')
- break
- if codecs:
- dct.update(parse_codecs(codecs))
- if dct.get('acodec') == 'none' or dct.get('vcodec') == 'none':
- dct['downloader_options'] = {
- # Youtube throttles chunks >~10M
- 'http_chunk_size': 10485760,
- }
- formats.append(dct)
- else:
- manifest_url = (
- url_or_none(try_get(
- player_response,
- lambda x: x['streamingData']['hlsManifestUrl'],
- compat_str))
- or url_or_none(try_get(
- video_info, lambda x: x['hlsvp'][0], compat_str)))
- if manifest_url:
- formats = []
- m3u8_formats = self._extract_m3u8_formats(
- manifest_url, video_id, 'mp4', fatal=False)
- for a_format in m3u8_formats:
- itag = self._search_regex(
- r'/itag/(\d+)/', a_format['url'], 'itag', default=None)
- if itag:
- a_format['format_id'] = itag
- if itag in self._formats:
- dct = self._formats[itag].copy()
- dct.update(a_format)
- a_format = dct
- a_format['player_url'] = player_url
- # Accept-Encoding header causes failures in live streams on Youtube and Youtube Gaming
- a_format.setdefault('http_headers', {})['Youtubedl-no-compression'] = 'True'
- formats.append(a_format)
- else:
- error_message = clean_html(video_info.get('reason', [None])[0])
- alt_error_message = clean_html(video_info.get('reason', [None])[0])
- print(alt_error_message)
- if not error_message:
- error_message = alt_error_message
- if error_message:
- raise YoutubeError(error_message)
- raise ExtractorError('no conn, hlsvp, hlsManifestUrl or url_encoded_fmt_stream_map information found in video info')
-
- # uploader
- video_uploader = try_get(
- video_info, lambda x: x['author'][0],
- compat_str) or str_or_none(video_details.get('author'))
- if video_uploader:
- video_uploader = compat_urllib_parse_unquote_plus(video_uploader)
- else:
- self._downloader.report_warning('unable to extract uploader name')
-
- # uploader_id
- video_uploader_id = None
- video_uploader_url = None
- mobj = re.search(
- r'<link itemprop="url" href="(?P<uploader_url>https?://www\.youtube\.com/(?:user|channel)/(?P<uploader_id>[^"]+))">',
- video_webpage)
- if mobj is not None:
- video_uploader_id = mobj.group('uploader_id')
- video_uploader_url = mobj.group('uploader_url')
- else:
- self._downloader.report_warning('unable to extract uploader nickname')
-
- channel_id = (
- str_or_none(video_details.get('channelId'))
- or self._html_search_meta(
- 'channelId', video_webpage, 'channel id', default=None)
- or self._search_regex(
- r'data-channel-external-id=(["\'])(?P<id>(?:(?!\1).)+)\1',
- video_webpage, 'channel id', default=None, group='id'))
- channel_url = 'http://www.youtube.com/channel/%s' % channel_id if channel_id else None
-
- # thumbnail image
- # We try first to get a high quality image:
- m_thumb = re.search(r'<span itemprop="thumbnail".*?href="(.*?)">',
- video_webpage, re.DOTALL)
- if m_thumb is not None:
- video_thumbnail = m_thumb.group(1)
- elif 'thumbnail_url' not in video_info:
- self._downloader.report_warning('unable to extract video thumbnail')
- video_thumbnail = None
- else: # don't panic if we can't find it
- video_thumbnail = compat_urllib_parse_unquote_plus(video_info['thumbnail_url'][0])
-
- # upload date
- upload_date = self._html_search_meta(
- 'datePublished', video_webpage, 'upload date', default=None)
- if not upload_date:
- upload_date = self._search_regex(
- [r'(?s)id="eow-date.*?>(.*?)</span>',
- r'(?:id="watch-uploader-info".*?>.*?|["\']simpleText["\']\s*:\s*["\'])(?:Published|Uploaded|Streamed live|Started) on (.+?)[<"\']'],
- video_webpage, 'upload date', default=None)
- upload_date = unified_strdate(upload_date)
-
- video_license = self._html_search_regex(
- r'<h4[^>]+class="title"[^>]*>\s*License\s*</h4>\s*<ul[^>]*>\s*<li>(.+?)</li',
- video_webpage, 'license', default=None)
-
- m_music = re.search(
- r'''(?x)
- <h4[^>]+class="title"[^>]*>\s*Music\s*</h4>\s*
- <ul[^>]*>\s*
- <li>(?P<title>.+?)
- by (?P<creator>.+?)
- (?:
- \(.+?\)|
- <a[^>]*
- (?:
- \bhref=["\']/red[^>]*>| # drop possible
- >\s*Listen ad-free with YouTube Red # YouTube Red ad
- )
- .*?
- )?</li
- ''',
- video_webpage)
- if m_music:
- video_alt_title = remove_quotes(unescapeHTML(m_music.group('title')))
- video_creator = clean_html(m_music.group('creator'))
- else:
- video_alt_title = video_creator = None
-
- def extract_meta(field):
- return self._html_search_regex(
- r'<h4[^>]+class="title"[^>]*>\s*%s\s*</h4>\s*<ul[^>]*>\s*<li>(.+?)</li>\s*' % field,
- video_webpage, field, default=None)
-
- track = extract_meta('Song')
- artist = extract_meta('Artist')
- album = extract_meta('Album')
-
- # Youtube Music Auto-generated description
- release_date = release_year = None
- if video_description:
- mobj = re.search(r'(?s)Provided to YouTube by [^\n]+\n+(?P<track>[^·]+)·(?P<artist>[^\n]+)\n+(?P<album>[^\n]+)(?:.+?℗\s*(?P<release_year>\d{4})(?!\d))?(?:.+?Released on\s*:\s*(?P<release_date>\d{4}-\d{2}-\d{2}))?(.+?\nArtist\s*:\s*(?P<clean_artist>[^\n]+))?', video_description)
- if mobj:
- if not track:
- track = mobj.group('track').strip()
- if not artist:
- artist = mobj.group('clean_artist') or ', '.join(a.strip() for a in mobj.group('artist').split('·'))
- if not album:
- album = mobj.group('album'.strip())
- release_year = mobj.group('release_year')
- release_date = mobj.group('release_date')
- if release_date:
- release_date = release_date.replace('-', '')
- if not release_year:
- release_year = int(release_date[:4])
- if release_year:
- release_year = int(release_year)
-
- m_episode = re.search(
- r'<div[^>]+id="watch7-headline"[^>]*>\s*<span[^>]*>.*?>(?P<series>[^<]+)</a></b>\s*S(?P<season>\d+)\s*•\s*E(?P<episode>\d+)</span>',
- video_webpage)
- if m_episode:
- series = unescapeHTML(m_episode.group('series'))
- season_number = int(m_episode.group('season'))
- episode_number = int(m_episode.group('episode'))
- else:
- series = season_number = episode_number = None
-
- m_cat_container = self._search_regex(
- r'(?s)<h4[^>]*>\s*Category\s*</h4>\s*<ul[^>]*>(.*?)</ul>',
- video_webpage, 'categories', default=None)
- if m_cat_container:
- category = self._html_search_regex(
- r'(?s)<a[^<]+>(.*?)</a>', m_cat_container, 'category',
- default=None)
- video_categories = None if category is None else [category]
- else:
- video_categories = None
-
- video_tags = [
- unescapeHTML(m.group('content'))
- for m in re.finditer(self._meta_regex('og:video:tag'), video_webpage)]
-
- def _extract_count(count_name):
- return str_to_int(self._search_regex(
- r'-%s-button[^>]+><span[^>]+class="yt-uix-button-content"[^>]*>([\d,]+)</span>'
- % re.escape(count_name),
- video_webpage, count_name, default=None))
-
- like_count = _extract_count('like')
- dislike_count = _extract_count('dislike')
-
- if view_count is None:
- view_count = str_to_int(self._search_regex(
- r'<[^>]+class=["\']watch-view-count[^>]+>\s*([\d,\s]+)', video_webpage,
- 'view count', default=None))
-
- average_rating = (
- float_or_none(video_details.get('averageRating'))
- or try_get(video_info, lambda x: float_or_none(x['avg_rating'][0])))
-
- # subtitles
- video_subtitles = self._get_subtitles(video_id, video_webpage)
- automatic_captions = self._get_automatic_captions(video_id, video_webpage)
-
- video_duration = try_get(
- video_info, lambda x: int_or_none(x['length_seconds'][0]))
- if not video_duration:
- video_duration = int_or_none(video_details.get('lengthSeconds'))
- if not video_duration:
- video_duration = parse_duration(self._html_search_meta(
- 'duration', video_webpage, 'video duration'))
-
- # annotations
- video_annotations = None
- if self._downloader.params.get('writeannotations', False):
- video_annotations = self._extract_annotations(video_id)
-
- chapters = self._extract_chapters(description_original, video_duration)
-
- # Look for the DASH manifest
- if self._downloader.params.get('youtube_include_dash_manifest', True):
- dash_mpd_fatal = True
- for mpd_url in dash_mpds:
- dash_formats = {}
- try:
- def decrypt_sig(mobj):
- s = mobj.group(1)
- dec_s = self._decrypt_signature(s, video_id, player_url, age_gate)
- return '/signature/%s' % dec_s
-
- mpd_url = re.sub(r'/s/([a-fA-F0-9\.]+)', decrypt_sig, mpd_url)
-
- for df in self._extract_mpd_formats(
- mpd_url, video_id, fatal=dash_mpd_fatal,
- formats_dict=self._formats):
- if not df.get('filesize'):
- df['filesize'] = _extract_filesize(df['url'])
- # Do not overwrite DASH format found in some previous DASH manifest
- if df['format_id'] not in dash_formats:
- dash_formats[df['format_id']] = df
- # Additional DASH manifests may end up in HTTP Error 403 therefore
- # allow them to fail without bug report message if we already have
- # some DASH manifest succeeded. This is temporary workaround to reduce
- # burst of bug reports until we figure out the reason and whether it
- # can be fixed at all.
- dash_mpd_fatal = False
- except (ExtractorError, KeyError) as e:
- self.report_warning(
- 'Skipping DASH manifest: %r' % e, video_id)
- if dash_formats:
- # Remove the formats we found through non-DASH, they
- # contain less info and it can be wrong, because we use
- # fixed values (for example the resolution). See
- # https://github.com/ytdl-org/youtube-dl/issues/5774 for an
- # example.
- formats = [f for f in formats if f['format_id'] not in dash_formats.keys()]
- formats.extend(dash_formats.values())
-
- # Check for malformed aspect ratio
- stretched_m = re.search(
- r'<meta\s+property="og:video:tag".*?content="yt:stretch=(?P<w>[0-9]+):(?P<h>[0-9]+)">',
- video_webpage)
- if stretched_m:
- w = float(stretched_m.group('w'))
- h = float(stretched_m.group('h'))
- # yt:stretch may hold invalid ratio data (e.g. for Q39EVAstoRM ratio is 17:0).
- # We will only process correct ratios.
- if w > 0 and h > 0:
- ratio = w / h
- for f in formats:
- if f.get('vcodec') != 'none':
- f['stretched_ratio'] = ratio
-
- if not formats:
- token = extract_token(video_info)
- if not token:
- if 'reason' in video_info:
- if 'The uploader has not made this video available in your country.' in video_info['reason']:
- regions_allowed = self._html_search_meta(
- 'regionsAllowed', video_webpage, default=None)
- countries = regions_allowed.split(',') if regions_allowed else None
- self.raise_geo_restricted(
- msg=video_info['reason'][0], countries=countries)
- reason = video_info['reason'][0]
- if 'Invalid parameters' in reason:
- unavailable_message = extract_unavailable_message()
- if unavailable_message:
- reason = unavailable_message
- raise YoutubeError(
- 'YouTube said: %s' % reason,
- expected=True, video_id=video_id)
- else:
- raise ExtractorError(
- '"token" parameter not in video info for unknown reason',
- video_id=video_id)
-
- if not formats and (video_info.get('license_info') or try_get(player_response, lambda x: x['streamingData']['licenseInfos'])):
- raise ExtractorError('This video is DRM protected.', expected=True)
-
- self._sort_formats(formats)
-
- self.mark_watched(video_id, video_info, player_response)
-
- return {
- 'id': video_id,
- 'uploader': video_uploader,
- 'uploader_id': video_uploader_id,
- 'uploader_url': video_uploader_url,
- 'channel_id': channel_id,
- 'channel_url': channel_url,
- 'upload_date': upload_date,
- 'license': video_license,
- 'creator': video_creator or artist,
- 'title': video_title,
- 'alt_title': video_alt_title or track,
- 'thumbnail': video_thumbnail,
- 'description': video_description,
- 'categories': video_categories,
- 'tags': video_tags,
- 'subtitles': video_subtitles,
- 'automatic_captions': automatic_captions,
- 'duration': video_duration,
- 'age_limit': 18 if age_gate else 0,
- 'annotations': video_annotations,
- 'chapters': chapters,
- 'webpage_url': proto + '://www.youtube.com/watch?v=%s' % video_id,
- 'view_count': view_count,
- 'like_count': like_count,
- 'dislike_count': dislike_count,
- 'average_rating': average_rating,
- 'formats': formats,
- 'is_live': is_live,
- 'start_time': start_time,
- 'end_time': end_time,
- 'series': series,
- 'season_number': season_number,
- 'episode_number': episode_number,
- 'track': track,
- 'artist': artist,
- 'album': album,
- 'release_date': release_date,
- 'release_year': release_year,
- 'related_vids': related_vids,
- 'music_list': music_list,
- 'unlisted': unlisted,
- }
-
-
-class YoutubePlaylistIE(YoutubePlaylistBaseInfoExtractor):
- IE_DESC = 'YouTube.com playlists'
- _VALID_URL = r"""(?x)(?:
- (?:https?://)?
- (?:\w+\.)?
- (?:
- (?:
- youtube\.com|
- invidio\.us
- )
- /
- (?:
- (?:course|view_play_list|my_playlists|artist|playlist|watch|embed/(?:videoseries|[0-9A-Za-z_-]{11}))
- \? (?:.*?[&;])*? (?:p|a|list)=
- | p/
- )|
- youtu\.be/[0-9A-Za-z_-]{11}\?.*?\blist=
- )
- (
- (?:PL|LL|EC|UU|FL|RD|UL|TL|OLAK5uy_)?[0-9A-Za-z-_]{10,}
- # Top tracks, they can also include dots
- |(?:MC)[\w\.]*
- )
- .*
- |
- (%(playlist_id)s)
- )""" % {'playlist_id': YoutubeBaseInfoExtractor._PLAYLIST_ID_RE}
- _TEMPLATE_URL = 'https://www.youtube.com/playlist?list=%s'
- _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})&amp;[^"]*?index=(?P<index>\d+)(?:[^>]+>(?P<title>[^<]+))?'
- IE_NAME = 'youtube:playlist'
- _TESTS = [{
- 'url': 'https://www.youtube.com/playlist?list=PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
- 'info_dict': {
- 'title': 'ytdl test PL',
- 'id': 'PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
- },
- 'playlist_count': 3,
- }, {
- 'url': 'https://www.youtube.com/playlist?list=PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
- 'info_dict': {
- 'id': 'PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
- 'title': 'YDL_Empty_List',
- },
- 'playlist_count': 0,
- 'skip': 'This playlist is private',
- }, {
- 'note': 'Playlist with deleted videos (#651). As a bonus, the video #51 is also twice in this list.',
- 'url': 'https://www.youtube.com/playlist?list=PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
- 'info_dict': {
- 'title': '29C3: Not my department',
- 'id': 'PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
- },
- 'playlist_count': 95,
- }, {
- 'note': 'issue #673',
- 'url': 'PLBB231211A4F62143',
- 'info_dict': {
- 'title': '[OLD]Team Fortress 2 (Class-based LP)',
- 'id': 'PLBB231211A4F62143',
- },
- 'playlist_mincount': 26,
- }, {
- 'note': 'Large playlist',
- 'url': 'https://www.youtube.com/playlist?list=UUBABnxM4Ar9ten8Mdjj1j0Q',
- 'info_dict': {
- 'title': 'Uploads from Cauchemar',
- 'id': 'UUBABnxM4Ar9ten8Mdjj1j0Q',
- },
- 'playlist_mincount': 799,
- }, {
- 'url': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
- 'info_dict': {
- 'title': 'YDL_safe_search',
- 'id': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
- },
- 'playlist_count': 2,
- 'skip': 'This playlist is private',
- }, {
- 'note': 'embedded',
- 'url': 'https://www.youtube.com/embed/videoseries?list=PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
- 'playlist_count': 4,
- 'info_dict': {
- 'title': 'JODA15',
- 'id': 'PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
- }
- }, {
- 'url': 'http://www.youtube.com/embed/_xDOZElKyNU?list=PLsyOSbh5bs16vubvKePAQ1x3PhKavfBIl',
- 'playlist_mincount': 485,
- 'info_dict': {
- 'title': '2017 華語最新單曲 (2/24更新)',
- 'id': 'PLsyOSbh5bs16vubvKePAQ1x3PhKavfBIl',
- }
- }, {
- 'note': 'Embedded SWF player',
- 'url': 'https://www.youtube.com/p/YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ?hl=en_US&fs=1&rel=0',
- 'playlist_count': 4,
- 'info_dict': {
- 'title': 'JODA7',
- 'id': 'YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ',
- }
- }, {
- 'note': 'Buggy playlist: the webpage has a "Load more" button but it doesn\'t have more videos',
- 'url': 'https://www.youtube.com/playlist?list=UUXw-G3eDE9trcvY2sBMM_aA',
- 'info_dict': {
- 'title': 'Uploads from Interstellar Movie',
- 'id': 'UUXw-G3eDE9trcvY2sBMM_aA',
- },
- 'playlist_mincount': 21,
- }, {
- # Playlist URL that does not actually serve a playlist
- 'url': 'https://www.youtube.com/watch?v=FqZTN594JQw&list=PLMYEtVRpaqY00V9W81Cwmzp6N6vZqfUKD4',
- 'info_dict': {
- 'id': 'FqZTN594JQw',
- 'ext': 'webm',
- 'title': "Smiley's People 01 detective, Adventure Series, Action",
- 'uploader': 'STREEM',
- 'uploader_id': 'UCyPhqAZgwYWZfxElWVbVJng',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCyPhqAZgwYWZfxElWVbVJng',
- 'upload_date': '20150526',
- 'license': 'Standard YouTube License',
- 'description': 'md5:507cdcb5a49ac0da37a920ece610be80',
- 'categories': ['People & Blogs'],
- 'tags': list,
- 'view_count': int,
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [YoutubeIE.ie_key()],
- }, {
- 'url': 'https://youtu.be/yeWKywCrFtk?list=PL2qgrgXsNUG5ig9cat4ohreBjYLAPC0J5',
- 'info_dict': {
- 'id': 'yeWKywCrFtk',
- 'ext': 'mp4',
- 'title': 'Small Scale Baler and Braiding Rugs',
- 'uploader': 'Backus-Page House Museum',
- 'uploader_id': 'backuspagemuseum',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/backuspagemuseum',
- 'upload_date': '20161008',
- 'license': 'Standard YouTube License',
- 'description': 'md5:800c0c78d5eb128500bffd4f0b4f2e8a',
- 'categories': ['Nonprofits & Activism'],
- 'tags': list,
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'noplaylist': True,
- 'skip_download': True,
- },
- }, {
- 'url': 'https://youtu.be/uWyaPkt-VOI?list=PL9D9FC436B881BA21',
- 'only_matching': True,
- }, {
- 'url': 'TLGGrESM50VT6acwMjAyMjAxNw',
- 'only_matching': True,
- }, {
- # music album playlist
- 'url': 'OLAK5uy_m4xAFdmMC5rX3Ji3g93pQe3hqLZw_9LhM',
- 'only_matching': True,
- }, {
- 'url': 'https://invidio.us/playlist?list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU',
- 'only_matching': True,
- }]
-
- def _real_initialize(self):
- self._login()
-
- def _extract_mix(self, playlist_id):
- # The mixes are generated from a single video
- # the id of the playlist is just 'RD' + video_id
- ids = []
- last_id = playlist_id[-11:]
- for n in itertools.count(1):
- url = 'https://youtube.com/watch?v=%s&list=%s' % (last_id, playlist_id)
- webpage = self._download_webpage(
- url, playlist_id, 'Downloading page {0} of Youtube mix'.format(n))
- new_ids = orderedSet(re.findall(
- r'''(?xs)data-video-username=".*?".*?
- href="/watch\?v=([0-9A-Za-z_-]{11})&amp;[^"]*?list=%s''' % re.escape(playlist_id),
- webpage))
- # Fetch new pages until all the videos are repeated, it seems that
- # there are always 51 unique videos.
- new_ids = [_id for _id in new_ids if _id not in ids]
- if not new_ids:
- break
- ids.extend(new_ids)
- last_id = ids[-1]
-
- url_results = self._ids_to_results(ids)
-
- search_title = lambda class_name: get_element_by_attribute('class', class_name, webpage)
- title_span = (
- search_title('playlist-title')
- or search_title('title long-title')
- or search_title('title'))
- title = clean_html(title_span)
-
- return self.playlist_result(url_results, playlist_id, title)
-
- def _extract_playlist(self, playlist_id):
- url = self._TEMPLATE_URL % playlist_id
- page = self._download_webpage(url, playlist_id)
-
- # the yt-alert-message now has tabindex attribute (see https://github.com/ytdl-org/youtube-dl/issues/11604)
- for match in re.findall(r'<div class="yt-alert-message"[^>]*>([^<]+)</div>', page):
- match = match.strip()
- # Check if the playlist exists or is private
- mobj = re.match(r'[^<]*(?:The|This) playlist (?P<reason>does not exist|is private)[^<]*', match)
- if mobj:
- reason = mobj.group('reason')
- message = 'This playlist %s' % reason
- if 'private' in reason:
- message += ', use --username or --netrc to access it'
- message += '.'
- raise ExtractorError(message, expected=True)
- elif re.match(r'[^<]*Invalid parameters[^<]*', match):
- raise ExtractorError(
- 'Invalid parameters. Maybe URL is incorrect.',
- expected=True)
- elif re.match(r'[^<]*Choose your language[^<]*', match):
- continue
- else:
- self.report_warning('Youtube gives an alert message: ' + match)
-
- playlist_title = self._html_search_regex(
- r'(?s)<h1 class="pl-header-title[^"]*"[^>]*>\s*(.*?)\s*</h1>',
- page, 'title', default=None)
-
- _UPLOADER_BASE = r'class=["\']pl-header-details[^>]+>\s*<li>\s*<a[^>]+\bhref='
- uploader = self._search_regex(
- r'%s["\']/(?:user|channel)/[^>]+>([^<]+)' % _UPLOADER_BASE,
- page, 'uploader', default=None)
- mobj = re.search(
- r'%s(["\'])(?P<path>/(?:user|channel)/(?P<uploader_id>.+?))\1' % _UPLOADER_BASE,
- page)
- if mobj:
- uploader_id = mobj.group('uploader_id')
- uploader_url = compat_urlparse.urljoin(url, mobj.group('path'))
- else:
- uploader_id = uploader_url = None
-
- has_videos = True
-
- if not playlist_title:
- try:
- # Some playlist URLs don't actually serve a playlist (e.g.
- # https://www.youtube.com/watch?v=FqZTN594JQw&list=PLMYEtVRpaqY00V9W81Cwmzp6N6vZqfUKD4)
- next(self._entries(page, playlist_id))
- except StopIteration:
- has_videos = False
-
- playlist = self.playlist_result(
- self._entries(page, playlist_id), playlist_id, playlist_title)
- playlist.update({
- 'uploader': uploader,
- 'uploader_id': uploader_id,
- 'uploader_url': uploader_url,
- })
-
- return has_videos, playlist
-
- def _check_download_just_video(self, url, playlist_id):
- # Check if it's a video-specific URL
- query_dict = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
- video_id = query_dict.get('v', [None])[0] or self._search_regex(
- r'(?:(?:^|//)youtu\.be/|youtube\.com/embed/(?!videoseries))([0-9A-Za-z_-]{11})', url,
- 'video id', default=None)
- if video_id:
- if self._downloader.params.get('noplaylist'):
- self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
- return video_id, self.url_result(video_id, 'Youtube', video_id=video_id)
- else:
- self.to_screen('Downloading playlist %s - add --no-playlist to just download video %s' % (playlist_id, video_id))
- return video_id, None
- return None, None
-
- def _real_extract(self, url):
- # Extract playlist id
- mobj = re.match(self._VALID_URL, url)
- if mobj is None:
- raise ExtractorError('Invalid URL: %s' % url)
- playlist_id = mobj.group(1) or mobj.group(2)
-
- video_id, video = self._check_download_just_video(url, playlist_id)
- if video:
- return video
-
- if playlist_id.startswith(('RD', 'UL', 'PU')):
- # Mixes require a custom extraction process
- return self._extract_mix(playlist_id)
-
- has_videos, playlist = self._extract_playlist(playlist_id)
- if has_videos or not video_id:
- return playlist
-
- # Some playlist URLs don't actually serve a playlist (see
- # https://github.com/ytdl-org/youtube-dl/issues/10537).
- # Fallback to plain video extraction if there is a video id
- # along with playlist id.
- return self.url_result(video_id, 'Youtube', video_id=video_id)
-
-
-class YoutubeChannelIE(YoutubePlaylistBaseInfoExtractor):
- IE_DESC = 'YouTube.com channels'
- _VALID_URL = r'https?://(?:youtu\.be|(?:\w+\.)?youtube(?:-nocookie)?\.com|(?:www\.)?invidio\.us)/channel/(?P<id>[0-9A-Za-z_-]+)'
- _TEMPLATE_URL = 'https://www.youtube.com/channel/%s/videos'
- _VIDEO_RE = r'(?:title="(?P<title>[^"]+)"[^>]+)?href="/watch\?v=(?P<id>[0-9A-Za-z_-]+)&?'
- IE_NAME = 'youtube:channel'
- _TESTS = [{
- 'note': 'paginated channel',
- 'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
- 'playlist_mincount': 91,
- 'info_dict': {
- 'id': 'UUKfVa3S1e4PHvxWcwyMMg8w',
- 'title': 'Uploads from lex will',
- }
- }, {
- 'note': 'Age restricted channel',
- # from https://www.youtube.com/user/DeusExOfficial
- 'url': 'https://www.youtube.com/channel/UCs0ifCMCm1icqRbqhUINa0w',
- 'playlist_mincount': 64,
- 'info_dict': {
- 'id': 'UUs0ifCMCm1icqRbqhUINa0w',
- 'title': 'Uploads from Deus Ex',
- },
- }, {
- 'url': 'https://invidio.us/channel/UC23qupoDRn9YOAVzeoxjOQA',
- 'only_matching': True,
- }]
-
- @classmethod
- def suitable(cls, url):
- return (False if YoutubePlaylistsIE.suitable(url) or YoutubeLiveIE.suitable(url)
- else super(YoutubeChannelIE, cls).suitable(url))
-
- def _build_template_url(self, url, channel_id):
- return self._TEMPLATE_URL % channel_id
-
- def _real_extract(self, url):
- channel_id = self._match_id(url)
-
- url = self._build_template_url(url, channel_id)
-
- # Channel by page listing is restricted to 35 pages of 30 items, i.e. 1050 videos total (see #5778)
- # Workaround by extracting as a playlist if managed to obtain channel playlist URL
- # otherwise fallback on channel by page extraction
- channel_page = self._download_webpage(
- url + '?view=57', channel_id,
- 'Downloading channel page', fatal=False)
- if channel_page is False:
- channel_playlist_id = False
- else:
- channel_playlist_id = self._html_search_meta(
- 'channelId', channel_page, 'channel id', default=None)
- if not channel_playlist_id:
- channel_url = self._html_search_meta(
- ('al:ios:url', 'twitter:app:url:iphone', 'twitter:app:url:ipad'),
- channel_page, 'channel url', default=None)
- if channel_url:
- channel_playlist_id = self._search_regex(
- r'vnd\.youtube://user/([0-9A-Za-z_-]+)',
- channel_url, 'channel id', default=None)
- if channel_playlist_id and channel_playlist_id.startswith('UC'):
- playlist_id = 'UU' + channel_playlist_id[2:]
- return self.url_result(
- compat_urlparse.urljoin(url, '/playlist?list=%s' % playlist_id), 'YoutubePlaylist')
-
- channel_page = self._download_webpage(url, channel_id, 'Downloading page #1')
- autogenerated = re.search(r'''(?x)
- class="[^"]*?(?:
- channel-header-autogenerated-label|
- yt-channel-title-autogenerated
- )[^"]*"''', channel_page) is not None
-
- if autogenerated:
- # The videos are contained in a single page
- # the ajax pages can't be used, they are empty
- entries = [
- self.url_result(
- video_id, 'Youtube', video_id=video_id,
- video_title=video_title)
- for video_id, video_title in self.extract_videos_from_page(channel_page)]
- return self.playlist_result(entries, channel_id)
-
- try:
- next(self._entries(channel_page, channel_id))
- except StopIteration:
- alert_message = self._html_search_regex(
- r'(?s)<div[^>]+class=(["\']).*?\byt-alert-message\b.*?\1[^>]*>(?P<alert>[^<]+)</div>',
- channel_page, 'alert', default=None, group='alert')
- if alert_message:
- raise ExtractorError('Youtube said: %s' % alert_message, expected=True)
-
- return self.playlist_result(self._entries(channel_page, channel_id), channel_id)
-
-
-class YoutubeUserIE(YoutubeChannelIE):
- IE_DESC = 'YouTube.com user videos (URL or "ytuser" keyword)'
- _VALID_URL = r'(?:(?:https?://(?:\w+\.)?youtube\.com/(?:(?P<user>user|c)/)?(?!(?:attribution_link|watch|results|shared)(?:$|[^a-z_A-Z0-9-])))|ytuser:)(?!feed/)(?P<id>[A-Za-z0-9_-]+)'
- _TEMPLATE_URL = 'https://www.youtube.com/%s/%s/videos'
- IE_NAME = 'youtube:user'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/user/TheLinuxFoundation',
- 'playlist_mincount': 320,
- 'info_dict': {
- 'id': 'UUfX55Sx5hEFjoC3cNs6mCUQ',
- 'title': 'Uploads from The Linux Foundation',
- }
- }, {
- # Only available via https://www.youtube.com/c/12minuteathlete/videos
- # but not https://www.youtube.com/user/12minuteathlete/videos
- 'url': 'https://www.youtube.com/c/12minuteathlete/videos',
- 'playlist_mincount': 249,
- 'info_dict': {
- 'id': 'UUVjM-zV6_opMDx7WYxnjZiQ',
- 'title': 'Uploads from 12 Minute Athlete',
- }
- }, {
- 'url': 'ytuser:phihag',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/c/gametrailers',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/gametrailers',
- 'only_matching': True,
- }, {
- # This channel is not available, geo restricted to JP
- 'url': 'https://www.youtube.com/user/kananishinoSMEJ/videos',
- 'only_matching': True,
- }]
-
- @classmethod
- def suitable(cls, url):
- # Don't return True if the url can be extracted with other youtube
- # extractor, the regex would is too permissive and it would match.
- other_yt_ies = iter(klass for (name, klass) in globals().items() if name.startswith('Youtube') and name.endswith('IE') and klass is not cls)
- if any(ie.suitable(url) for ie in other_yt_ies):
- return False
- else:
- return super(YoutubeUserIE, cls).suitable(url)
-
- def _build_template_url(self, url, channel_id):
- mobj = re.match(self._VALID_URL, url)
- return self._TEMPLATE_URL % (mobj.group('user') or 'user', mobj.group('id'))
-
-
-class YoutubeLiveIE(YoutubeBaseInfoExtractor):
- IE_DESC = 'YouTube.com live streams'
- _VALID_URL = r'(?P<base_url>https?://(?:\w+\.)?youtube\.com/(?:(?:user|channel|c)/)?(?P<id>[^/]+))/live'
- IE_NAME = 'youtube:live'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/user/TheYoungTurks/live',
- 'info_dict': {
- 'id': 'a48o2S1cPoo',
- 'ext': 'mp4',
- 'title': 'The Young Turks - Live Main Show',
- 'uploader': 'The Young Turks',
- 'uploader_id': 'TheYoungTurks',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/TheYoungTurks',
- 'upload_date': '20150715',
- 'license': 'Standard YouTube License',
- 'description': 'md5:438179573adcdff3c97ebb1ee632b891',
- 'categories': ['News & Politics'],
- 'tags': ['Cenk Uygur (TV Program Creator)', 'The Young Turks (Award-Winning Work)', 'Talk Show (TV Genre)'],
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- }, {
- 'url': 'https://www.youtube.com/channel/UC1yBKRuGpC1tSM73A0ZjYjQ/live',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/c/CommanderVideoHq/live',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/TheYoungTurks/live',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- mobj = re.match(self._VALID_URL, url)
- channel_id = mobj.group('id')
- base_url = mobj.group('base_url')
- webpage = self._download_webpage(url, channel_id, fatal=False)
- if webpage:
- page_type = self._og_search_property(
- 'type', webpage, 'page type', default='')
- video_id = self._html_search_meta(
- 'videoId', webpage, 'video id', default=None)
- if page_type.startswith('video') and video_id and re.match(
- r'^[0-9A-Za-z_-]{11}$', video_id):
- return self.url_result(video_id, YoutubeIE.ie_key())
- return self.url_result(base_url)
-
-
-class YoutubePlaylistsIE(YoutubePlaylistsBaseInfoExtractor):
- IE_DESC = 'YouTube.com user/channel playlists'
- _VALID_URL = r'https?://(?:\w+\.)?youtube\.com/(?:user|channel)/(?P<id>[^/]+)/playlists'
- IE_NAME = 'youtube:playlists'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/user/ThirstForScience/playlists',
- 'playlist_mincount': 4,
- 'info_dict': {
- 'id': 'ThirstForScience',
- 'title': 'Thirst for Science',
- },
- }, {
- # with "Load more" button
- 'url': 'https://www.youtube.com/user/igorkle1/playlists?view=1&sort=dd',
- 'playlist_mincount': 70,
- 'info_dict': {
- 'id': 'igorkle1',
- 'title': 'Игорь Клейнер',
- },
- }, {
- 'url': 'https://www.youtube.com/channel/UCiU1dHvZObB2iP6xkJ__Icw/playlists',
- 'playlist_mincount': 17,
- 'info_dict': {
- 'id': 'UCiU1dHvZObB2iP6xkJ__Icw',
- 'title': 'Chem Player',
- },
- }]
-
-
-class YoutubeSearchBaseInfoExtractor(YoutubePlaylistBaseInfoExtractor):
- _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})(?:[^"]*"[^>]+\btitle="(?P<title>[^"]+))?'
-
-
-class YoutubeSearchIE(SearchInfoExtractor, YoutubeSearchBaseInfoExtractor):
- IE_DESC = 'YouTube.com searches'
- # there doesn't appear to be a real limit, for example if you search for
- # 'python' you get more than 8.000.000 results
- _MAX_RESULTS = float('inf')
- IE_NAME = 'youtube:search'
- _SEARCH_KEY = 'ytsearch'
- _EXTRA_QUERY_ARGS = {}
- _TESTS = []
-
- def _get_n_results(self, query, n):
- """Get a specified number of results for a query"""
-
- videos = []
- limit = n
-
- url_query = {
- 'search_query': query.encode('utf-8'),
- }
- url_query.update(self._EXTRA_QUERY_ARGS)
- result_url = 'https://www.youtube.com/results?' + compat_urllib_parse_urlencode(url_query)
-
- for pagenum in itertools.count(1):
- data = self._download_json(
- result_url, video_id='query "%s"' % query,
- note='Downloading page %s' % pagenum,
- errnote='Unable to download API page',
- query={'spf': 'navigate'})
- html_content = data[1]['body']['content']
-
- if 'class="search-message' in html_content:
- raise ExtractorError(
- '[youtube] No video results', expected=True)
-
- new_videos = list(self._process_page(html_content))
- videos += new_videos
- if not new_videos or len(videos) > limit:
- break
- next_link = self._html_search_regex(
- r'href="(/results\?[^"]*\bsp=[^"]+)"[^>]*>\s*<span[^>]+class="[^"]*\byt-uix-button-content\b[^"]*"[^>]*>Next',
- html_content, 'next link', default=None)
- if next_link is None:
- break
- result_url = compat_urlparse.urljoin('https://www.youtube.com/', next_link)
-
- if len(videos) > n:
- videos = videos[:n]
- return self.playlist_result(videos, query)
-
-
-class YoutubeSearchDateIE(YoutubeSearchIE):
- IE_NAME = YoutubeSearchIE.IE_NAME + ':date'
- _SEARCH_KEY = 'ytsearchdate'
- IE_DESC = 'YouTube.com searches, newest videos first'
- _EXTRA_QUERY_ARGS = {'search_sort': 'video_date_uploaded'}
-
-
-class YoutubeSearchURLIE(YoutubeSearchBaseInfoExtractor):
- IE_DESC = 'YouTube.com search URLs'
- IE_NAME = 'youtube:search_url'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/results\?(.*?&)?(?:search_query|q)=(?P<query>[^&]+)(?:[&]|$)'
- _TESTS = [{
- 'url': 'https://www.youtube.com/results?baz=bar&search_query=youtube-dl+test+video&filters=video&lclk=video',
- 'playlist_mincount': 5,
- 'info_dict': {
- 'title': 'youtube-dl test video',
- }
- }, {
- 'url': 'https://www.youtube.com/results?q=test&sp=EgQIBBgB',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- mobj = re.match(self._VALID_URL, url)
- query = compat_urllib_parse_unquote_plus(mobj.group('query'))
- webpage = self._download_webpage(url, query)
- return self.playlist_result(self._process_page(webpage), playlist_title=query)
-
-
-class YoutubeShowIE(YoutubePlaylistsBaseInfoExtractor):
- IE_DESC = 'YouTube.com (multi-season) shows'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/show/(?P<id>[^?#]*)'
- IE_NAME = 'youtube:show'
- _TESTS = [{
- 'url': 'https://www.youtube.com/show/airdisasters',
- 'playlist_mincount': 5,
- 'info_dict': {
- 'id': 'airdisasters',
- 'title': 'Air Disasters',
- }
- }]
-
- def _real_extract(self, url):
- playlist_id = self._match_id(url)
- return super(YoutubeShowIE, self)._real_extract(
- 'https://www.youtube.com/show/%s/playlists' % playlist_id)
-
-
-class YoutubeFeedsInfoExtractor(YoutubeBaseInfoExtractor):
- """
- Base class for feed extractors
- Subclasses must define the _FEED_NAME and _PLAYLIST_TITLE properties.
- """
- _LOGIN_REQUIRED = True
-
- @property
- def IE_NAME(self):
- return 'youtube:%s' % self._FEED_NAME
-
- def _real_initialize(self):
- self._login()
-
- def _entries(self, page):
- # The extraction process is the same as for playlists, but the regex
- # for the video ids doesn't contain an index
- ids = []
- more_widget_html = content_html = page
- for page_num in itertools.count(1):
- matches = re.findall(r'href="\s*/watch\?v=([0-9A-Za-z_-]{11})', content_html)
-
- # 'recommended' feed has infinite 'load more' and each new portion spins
- # the same videos in (sometimes) slightly different order, so we'll check
- # for unicity and break when portion has no new videos
- new_ids = list(filter(lambda video_id: video_id not in ids, orderedSet(matches)))
- if not new_ids:
- break
-
- ids.extend(new_ids)
-
- for entry in self._ids_to_results(new_ids):
- yield entry
-
- mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
- if not mobj:
- break
-
- more = self._download_json(
- 'https://youtube.com/%s' % mobj.group('more'), self._PLAYLIST_TITLE,
- 'Downloading page #%s' % page_num,
- transform_source=uppercase_escape)
- content_html = more['content_html']
- more_widget_html = more['load_more_widget_html']
-
- def _real_extract(self, url):
- page = self._download_webpage(
- 'https://www.youtube.com/feed/%s' % self._FEED_NAME,
- self._PLAYLIST_TITLE)
- return self.playlist_result(
- self._entries(page), playlist_title=self._PLAYLIST_TITLE)
-
-
-class YoutubeWatchLaterIE(YoutubePlaylistIE):
- IE_NAME = 'youtube:watchlater'
- IE_DESC = 'Youtube watch later list, ":ytwatchlater" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/(?:feed/watch_later|(?:playlist|watch)\?(?:.+&)?list=WL)|:ytwatchlater'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/playlist?list=WL',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?v=bCNU9TrbiRk&index=1&list=WL',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- _, video = self._check_download_just_video(url, 'WL')
- if video:
- return video
- _, playlist = self._extract_playlist('WL')
- return playlist
-
-
-class YoutubeFavouritesIE(YoutubeBaseInfoExtractor):
- IE_NAME = 'youtube:favorites'
- IE_DESC = 'YouTube.com favourite videos, ":ytfav" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/my_favorites|:ytfav(?:ou?rites)?'
- _LOGIN_REQUIRED = True
-
- def _real_extract(self, url):
- webpage = self._download_webpage('https://www.youtube.com/my_favorites', 'Youtube Favourites videos')
- playlist_id = self._search_regex(r'list=(.+?)["&]', webpage, 'favourites playlist id')
- return self.url_result(playlist_id, 'YoutubePlaylist')
-
-
-class YoutubeRecommendedIE(YoutubeFeedsInfoExtractor):
- IE_DESC = 'YouTube.com recommended videos, ":ytrec" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/feed/recommended|:ytrec(?:ommended)?'
- _FEED_NAME = 'recommended'
- _PLAYLIST_TITLE = 'Youtube Recommended videos'
-
-
-class YoutubeSubscriptionsIE(YoutubeFeedsInfoExtractor):
- IE_DESC = 'YouTube.com subscriptions feed, "ytsubs" keyword (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/feed/subscriptions|:ytsubs(?:criptions)?'
- _FEED_NAME = 'subscriptions'
- _PLAYLIST_TITLE = 'Youtube Subscriptions'
-
-
-class YoutubeHistoryIE(YoutubeFeedsInfoExtractor):
- IE_DESC = 'Youtube watch history, ":ythistory" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/feed/history|:ythistory'
- _FEED_NAME = 'history'
- _PLAYLIST_TITLE = 'Youtube History'
-
-
-class YoutubeTruncatedURLIE(InfoExtractor):
- IE_NAME = 'youtube:truncated_url'
- IE_DESC = False # Do not list
- _VALID_URL = r'''(?x)
- (?:https?://)?
- (?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie)?\.com/
- (?:watch\?(?:
- feature=[a-z_]+|
- annotation_id=annotation_[^&]+|
- x-yt-cl=[0-9]+|
- hl=[^&]*|
- t=[0-9]+
- )?
- |
- attribution_link\?a=[^&]+
- )
- $
- '''
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/watch?annotation_id=annotation_3951667041',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?x-yt-cl=84503534',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?feature=foo',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?hl=en-GB',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?t=2372',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- raise ExtractorError(
- 'Did you forget to quote the URL? Remember that & is a meta '
- 'character in most shells, so you want to put the URL in quotes, '
- 'like youtube-dl '
- '"https://www.youtube.com/watch?feature=foo&v=BaW_jenozKc" '
- ' or simply youtube-dl BaW_jenozKc .',
- expected=True)
-
-
-class YoutubeTruncatedIDIE(InfoExtractor):
- IE_NAME = 'youtube:truncated_id'
- IE_DESC = False # Do not list
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/watch\?v=(?P<id>[0-9A-Za-z_-]{1,10})$'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/watch?v=N_708QY7Ob',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- video_id = self._match_id(url)
- raise ExtractorError(
- 'Incomplete YouTube ID %s. URL %s looks truncated.' % (video_id, url),
- expected=True)
diff --git a/youtube_dl/extractor/youtube_unmodified_reference.py b/youtube_dl/extractor/youtube_unmodified_reference.py
deleted file mode 100644
index f76a6e7..0000000
--- a/youtube_dl/extractor/youtube_unmodified_reference.py
+++ /dev/null
@@ -1,3192 +0,0 @@
-# coding: utf-8
-
-from __future__ import unicode_literals
-
-
-import itertools
-import json
-import os.path
-import random
-import re
-import time
-import traceback
-
-from .common import InfoExtractor, SearchInfoExtractor
-from ..jsinterp import JSInterpreter
-from ..swfinterp import SWFInterpreter
-from ..compat import (
- compat_chr,
- compat_HTTPError,
- compat_kwargs,
- compat_parse_qs,
- compat_urllib_parse_unquote,
- compat_urllib_parse_unquote_plus,
- compat_urllib_parse_urlencode,
- compat_urllib_parse_urlparse,
- compat_urlparse,
- compat_str,
-)
-from ..utils import (
- clean_html,
- dict_get,
- error_to_compat_str,
- ExtractorError,
- float_or_none,
- get_element_by_attribute,
- get_element_by_id,
- int_or_none,
- mimetype2ext,
- orderedSet,
- parse_codecs,
- parse_duration,
- qualities,
- remove_quotes,
- remove_start,
- smuggle_url,
- str_or_none,
- str_to_int,
- try_get,
- unescapeHTML,
- unified_strdate,
- unsmuggle_url,
- uppercase_escape,
- url_or_none,
- urlencode_postdata,
-)
-
-
-class YoutubeBaseInfoExtractor(InfoExtractor):
- """Provide base functions for Youtube extractors"""
- _LOGIN_URL = 'https://accounts.google.com/ServiceLogin'
- _TWOFACTOR_URL = 'https://accounts.google.com/signin/challenge'
-
- _LOOKUP_URL = 'https://accounts.google.com/_/signin/sl/lookup'
- _CHALLENGE_URL = 'https://accounts.google.com/_/signin/sl/challenge'
- _TFA_URL = 'https://accounts.google.com/_/signin/challenge?hl=en&TL={0}'
-
- _NETRC_MACHINE = 'youtube'
- # If True it will raise an error if no login info is provided
- _LOGIN_REQUIRED = False
-
- _PLAYLIST_ID_RE = r'(?:PL|LL|EC|UU|FL|RD|UL|TL|OLAK5uy_)[0-9A-Za-z-_]{10,}'
-
- def _set_language(self):
- self._set_cookie(
- '.youtube.com', 'PREF', 'f1=50000000&hl=en',
- # YouTube sets the expire time to about two months
- expire_time=time.time() + 2 * 30 * 24 * 3600)
-
- def _ids_to_results(self, ids):
- return [
- self.url_result(vid_id, 'Youtube', video_id=vid_id)
- for vid_id in ids]
-
- def _login(self):
- """
- Attempt to log in to YouTube.
- True is returned if successful or skipped.
- False is returned if login failed.
-
- If _LOGIN_REQUIRED is set and no authentication was provided, an error is raised.
- """
- username, password = self._get_login_info()
- # No authentication to be performed
- if username is None:
- if self._LOGIN_REQUIRED and self._downloader.params.get('cookiefile') is None:
- raise ExtractorError('No login info available, needed for using %s.' % self.IE_NAME, expected=True)
- return True
-
- login_page = self._download_webpage(
- self._LOGIN_URL, None,
- note='Downloading login page',
- errnote='unable to fetch login page', fatal=False)
- if login_page is False:
- return
-
- login_form = self._hidden_inputs(login_page)
-
- def req(url, f_req, note, errnote):
- data = login_form.copy()
- data.update({
- 'pstMsg': 1,
- 'checkConnection': 'youtube',
- 'checkedDomains': 'youtube',
- 'hl': 'en',
- 'deviceinfo': '[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]',
- 'f.req': json.dumps(f_req),
- 'flowName': 'GlifWebSignIn',
- 'flowEntry': 'ServiceLogin',
- })
- return self._download_json(
- url, None, note=note, errnote=errnote,
- transform_source=lambda s: re.sub(r'^[^[]*', '', s),
- fatal=False,
- data=urlencode_postdata(data), headers={
- 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
- 'Google-Accounts-XSRF': 1,
- })
-
- def warn(message):
- self._downloader.report_warning(message)
-
- lookup_req = [
- username,
- None, [], None, 'US', None, None, 2, False, True,
- [
- None, None,
- [2, 1, None, 1,
- 'https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn',
- None, [], 4],
- 1, [None, None, []], None, None, None, True
- ],
- username,
- ]
-
- lookup_results = req(
- self._LOOKUP_URL, lookup_req,
- 'Looking up account info', 'Unable to look up account info')
-
- if lookup_results is False:
- return False
-
- user_hash = try_get(lookup_results, lambda x: x[0][2], compat_str)
- if not user_hash:
- warn('Unable to extract user hash')
- return False
-
- challenge_req = [
- user_hash,
- None, 1, None, [1, None, None, None, [password, None, True]],
- [
- None, None, [2, 1, None, 1, 'https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn', None, [], 4],
- 1, [None, None, []], None, None, None, True
- ]]
-
- challenge_results = req(
- self._CHALLENGE_URL, challenge_req,
- 'Logging in', 'Unable to log in')
-
- if challenge_results is False:
- return
-
- login_res = try_get(challenge_results, lambda x: x[0][5], list)
- if login_res:
- login_msg = try_get(login_res, lambda x: x[5], compat_str)
- warn(
- 'Unable to login: %s' % 'Invalid password'
- if login_msg == 'INCORRECT_ANSWER_ENTERED' else login_msg)
- return False
-
- res = try_get(challenge_results, lambda x: x[0][-1], list)
- if not res:
- warn('Unable to extract result entry')
- return False
-
- login_challenge = try_get(res, lambda x: x[0][0], list)
- if login_challenge:
- challenge_str = try_get(login_challenge, lambda x: x[2], compat_str)
- if challenge_str == 'TWO_STEP_VERIFICATION':
- # SEND_SUCCESS - TFA code has been successfully sent to phone
- # QUOTA_EXCEEDED - reached the limit of TFA codes
- status = try_get(login_challenge, lambda x: x[5], compat_str)
- if status == 'QUOTA_EXCEEDED':
- warn('Exceeded the limit of TFA codes, try later')
- return False
-
- tl = try_get(challenge_results, lambda x: x[1][2], compat_str)
- if not tl:
- warn('Unable to extract TL')
- return False
-
- tfa_code = self._get_tfa_info('2-step verification code')
-
- if not tfa_code:
- warn(
- 'Two-factor authentication required. Provide it either interactively or with --twofactor <code>'
- '(Note that only TOTP (Google Authenticator App) codes work at this time.)')
- return False
-
- tfa_code = remove_start(tfa_code, 'G-')
-
- tfa_req = [
- user_hash, None, 2, None,
- [
- 9, None, None, None, None, None, None, None,
- [None, tfa_code, True, 2]
- ]]
-
- tfa_results = req(
- self._TFA_URL.format(tl), tfa_req,
- 'Submitting TFA code', 'Unable to submit TFA code')
-
- if tfa_results is False:
- return False
-
- tfa_res = try_get(tfa_results, lambda x: x[0][5], list)
- if tfa_res:
- tfa_msg = try_get(tfa_res, lambda x: x[5], compat_str)
- warn(
- 'Unable to finish TFA: %s' % 'Invalid TFA code'
- if tfa_msg == 'INCORRECT_ANSWER_ENTERED' else tfa_msg)
- return False
-
- check_cookie_url = try_get(
- tfa_results, lambda x: x[0][-1][2], compat_str)
- else:
- CHALLENGES = {
- 'LOGIN_CHALLENGE': "This device isn't recognized. For your security, Google wants to make sure it's really you.",
- 'USERNAME_RECOVERY': 'Please provide additional information to aid in the recovery process.',
- 'REAUTH': "There is something unusual about your activity. For your security, Google wants to make sure it's really you.",
- }
- challenge = CHALLENGES.get(
- challenge_str,
- '%s returned error %s.' % (self.IE_NAME, challenge_str))
- warn('%s\nGo to https://accounts.google.com/, login and solve a challenge.' % challenge)
- return False
- else:
- check_cookie_url = try_get(res, lambda x: x[2], compat_str)
-
- if not check_cookie_url:
- warn('Unable to extract CheckCookie URL')
- return False
-
- check_cookie_results = self._download_webpage(
- check_cookie_url, None, 'Checking cookie', fatal=False)
-
- if check_cookie_results is False:
- return False
-
- if 'https://myaccount.google.com/' not in check_cookie_results:
- warn('Unable to log in')
- return False
-
- return True
-
- def _download_webpage_handle(self, *args, **kwargs):
- query = kwargs.get('query', {}).copy()
- query['disable_polymer'] = 'true'
- kwargs['query'] = query
- return super(YoutubeBaseInfoExtractor, self)._download_webpage_handle(
- *args, **compat_kwargs(kwargs))
-
- def _real_initialize(self):
- if self._downloader is None:
- return
- self._set_language()
- if not self._login():
- return
-
-
-class YoutubeEntryListBaseInfoExtractor(YoutubeBaseInfoExtractor):
- # Extract entries from page with "Load more" button
- def _entries(self, page, playlist_id):
- more_widget_html = content_html = page
- for page_num in itertools.count(1):
- for entry in self._process_page(content_html):
- yield entry
-
- mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
- if not mobj:
- break
-
- count = 0
- retries = 3
- while count <= retries:
- try:
- # Downloading page may result in intermittent 5xx HTTP error
- # that is usually worked around with a retry
- more = self._download_json(
- 'https://youtube.com/%s' % mobj.group('more'), playlist_id,
- 'Downloading page #%s%s'
- % (page_num, ' (retry #%d)' % count if count else ''),
- transform_source=uppercase_escape)
- break
- except ExtractorError as e:
- if isinstance(e.cause, compat_HTTPError) and e.cause.code in (500, 503):
- count += 1
- if count <= retries:
- continue
- raise
-
- content_html = more['content_html']
- if not content_html.strip():
- # Some webpages show a "Load more" button but they don't
- # have more videos
- break
- more_widget_html = more['load_more_widget_html']
-
-
-class YoutubePlaylistBaseInfoExtractor(YoutubeEntryListBaseInfoExtractor):
- def _process_page(self, content):
- for video_id, video_title in self.extract_videos_from_page(content):
- yield self.url_result(video_id, 'Youtube', video_id, video_title)
-
- def extract_videos_from_page(self, page):
- ids_in_page = []
- titles_in_page = []
- for mobj in re.finditer(self._VIDEO_RE, page):
- # The link with index 0 is not the first video of the playlist (not sure if still actual)
- if 'index' in mobj.groupdict() and mobj.group('id') == '0':
- continue
- video_id = mobj.group('id')
- video_title = unescapeHTML(mobj.group('title'))
- if video_title:
- video_title = video_title.strip()
- try:
- idx = ids_in_page.index(video_id)
- if video_title and not titles_in_page[idx]:
- titles_in_page[idx] = video_title
- except ValueError:
- ids_in_page.append(video_id)
- titles_in_page.append(video_title)
- return zip(ids_in_page, titles_in_page)
-
-
-class YoutubePlaylistsBaseInfoExtractor(YoutubeEntryListBaseInfoExtractor):
- def _process_page(self, content):
- for playlist_id in orderedSet(re.findall(
- r'<h3[^>]+class="[^"]*yt-lockup-title[^"]*"[^>]*><a[^>]+href="/?playlist\?list=([0-9A-Za-z-_]{10,})"',
- content)):
- yield self.url_result(
- 'https://www.youtube.com/playlist?list=%s' % playlist_id, 'YoutubePlaylist')
-
- def _real_extract(self, url):
- playlist_id = self._match_id(url)
- webpage = self._download_webpage(url, playlist_id)
- title = self._og_search_title(webpage, fatal=False)
- return self.playlist_result(self._entries(webpage, playlist_id), playlist_id, title)
-
-
-class YoutubeIE(YoutubeBaseInfoExtractor):
- IE_DESC = 'YouTube.com'
- _VALID_URL = r"""(?x)^
- (
- (?:https?://|//) # http(s):// or protocol-independent URL
- (?:(?:(?:(?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie)?\.com/|
- (?:www\.)?deturl\.com/www\.youtube\.com/|
- (?:www\.)?pwnyoutube\.com/|
- (?:www\.)?hooktube\.com/|
- (?:www\.)?yourepeat\.com/|
- tube\.majestyc\.net/|
- (?:(?:www|dev)\.)?invidio\.us/|
- (?:www\.)?invidiou\.sh/|
- (?:www\.)?invidious\.snopyta\.org/|
- (?:www\.)?invidious\.kabi\.tk/|
- (?:www\.)?vid\.wxzm\.sx/|
- youtube\.googleapis\.com/) # the various hostnames, with wildcard subdomains
- (?:.*?\#/)? # handle anchor (#/) redirect urls
- (?: # the various things that can precede the ID:
- (?:(?:v|embed|e)/(?!videoseries)) # v/ or embed/ or e/
- |(?: # or the v= param in all its forms
- (?:(?:watch|movie)(?:_popup)?(?:\.php)?/?)? # preceding watch(_popup|.php) or nothing (like /?v=xxxx)
- (?:\?|\#!?) # the params delimiter ? or # or #!
- (?:.*?[&;])?? # any other preceding param (like /?s=tuff&v=xxxx or ?s=tuff&amp;v=V36LpHqtcDY)
- v=
- )
- ))
- |(?:
- youtu\.be| # just youtu.be/xxxx
- vid\.plus| # or vid.plus/xxxx
- zwearz\.com/watch| # or zwearz.com/watch/xxxx
- )/
- |(?:www\.)?cleanvideosearch\.com/media/action/yt/watch\?videoId=
- )
- )? # all until now is optional -> you can pass the naked ID
- ([0-9A-Za-z_-]{11}) # here is it! the YouTube video ID
- (?!.*?\blist=
- (?:
- %(playlist_id)s| # combined list/video URLs are handled by the playlist IE
- WL # WL are handled by the watch later IE
- )
- )
- (?(1).+)? # if we found the ID, everything can follow
- $""" % {'playlist_id': YoutubeBaseInfoExtractor._PLAYLIST_ID_RE}
- _NEXT_URL_RE = r'[\?&]next_url=([^&]+)'
- _formats = {
- '5': {'ext': 'flv', 'width': 400, 'height': 240, 'acodec': 'mp3', 'abr': 64, 'vcodec': 'h263'},
- '6': {'ext': 'flv', 'width': 450, 'height': 270, 'acodec': 'mp3', 'abr': 64, 'vcodec': 'h263'},
- '13': {'ext': '3gp', 'acodec': 'aac', 'vcodec': 'mp4v'},
- '17': {'ext': '3gp', 'width': 176, 'height': 144, 'acodec': 'aac', 'abr': 24, 'vcodec': 'mp4v'},
- '18': {'ext': 'mp4', 'width': 640, 'height': 360, 'acodec': 'aac', 'abr': 96, 'vcodec': 'h264'},
- '22': {'ext': 'mp4', 'width': 1280, 'height': 720, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
- '34': {'ext': 'flv', 'width': 640, 'height': 360, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
- '35': {'ext': 'flv', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
- # itag 36 videos are either 320x180 (BaW_jenozKc) or 320x240 (__2ABJjxzNo), abr varies as well
- '36': {'ext': '3gp', 'width': 320, 'acodec': 'aac', 'vcodec': 'mp4v'},
- '37': {'ext': 'mp4', 'width': 1920, 'height': 1080, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
- '38': {'ext': 'mp4', 'width': 4096, 'height': 3072, 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264'},
- '43': {'ext': 'webm', 'width': 640, 'height': 360, 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8'},
- '44': {'ext': 'webm', 'width': 854, 'height': 480, 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8'},
- '45': {'ext': 'webm', 'width': 1280, 'height': 720, 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8'},
- '46': {'ext': 'webm', 'width': 1920, 'height': 1080, 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8'},
- '59': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
- '78': {'ext': 'mp4', 'width': 854, 'height': 480, 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264'},
-
-
- # 3D videos
- '82': {'ext': 'mp4', 'height': 360, 'format_note': '3D', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -20},
- '83': {'ext': 'mp4', 'height': 480, 'format_note': '3D', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -20},
- '84': {'ext': 'mp4', 'height': 720, 'format_note': '3D', 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264', 'preference': -20},
- '85': {'ext': 'mp4', 'height': 1080, 'format_note': '3D', 'acodec': 'aac', 'abr': 192, 'vcodec': 'h264', 'preference': -20},
- '100': {'ext': 'webm', 'height': 360, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 128, 'vcodec': 'vp8', 'preference': -20},
- '101': {'ext': 'webm', 'height': 480, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8', 'preference': -20},
- '102': {'ext': 'webm', 'height': 720, 'format_note': '3D', 'acodec': 'vorbis', 'abr': 192, 'vcodec': 'vp8', 'preference': -20},
-
- # Apple HTTP Live Streaming
- '91': {'ext': 'mp4', 'height': 144, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
- '92': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
- '93': {'ext': 'mp4', 'height': 360, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -10},
- '94': {'ext': 'mp4', 'height': 480, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 128, 'vcodec': 'h264', 'preference': -10},
- '95': {'ext': 'mp4', 'height': 720, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 256, 'vcodec': 'h264', 'preference': -10},
- '96': {'ext': 'mp4', 'height': 1080, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 256, 'vcodec': 'h264', 'preference': -10},
- '132': {'ext': 'mp4', 'height': 240, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 48, 'vcodec': 'h264', 'preference': -10},
- '151': {'ext': 'mp4', 'height': 72, 'format_note': 'HLS', 'acodec': 'aac', 'abr': 24, 'vcodec': 'h264', 'preference': -10},
-
- # DASH mp4 video
- '133': {'ext': 'mp4', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '134': {'ext': 'mp4', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '135': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '136': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '137': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '138': {'ext': 'mp4', 'format_note': 'DASH video', 'vcodec': 'h264'}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559)
- '160': {'ext': 'mp4', 'height': 144, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '212': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '264': {'ext': 'mp4', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'h264'},
- '298': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
- '299': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'h264', 'fps': 60},
- '266': {'ext': 'mp4', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'h264'},
-
- # Dash mp4 audio
- '139': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 48, 'container': 'm4a_dash'},
- '140': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 128, 'container': 'm4a_dash'},
- '141': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'abr': 256, 'container': 'm4a_dash'},
- '256': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
- '258': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'aac', 'container': 'm4a_dash'},
- '325': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'dtse', 'container': 'm4a_dash'},
- '328': {'ext': 'm4a', 'format_note': 'DASH audio', 'acodec': 'ec-3', 'container': 'm4a_dash'},
-
- # Dash webm
- '167': {'ext': 'webm', 'height': 360, 'width': 640, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '168': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '169': {'ext': 'webm', 'height': 720, 'width': 1280, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '170': {'ext': 'webm', 'height': 1080, 'width': 1920, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '218': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '219': {'ext': 'webm', 'height': 480, 'width': 854, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp8'},
- '278': {'ext': 'webm', 'height': 144, 'format_note': 'DASH video', 'container': 'webm', 'vcodec': 'vp9'},
- '242': {'ext': 'webm', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '243': {'ext': 'webm', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '244': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '245': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '246': {'ext': 'webm', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '247': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '248': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '271': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug)
- '272': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '302': {'ext': 'webm', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
- '303': {'ext': 'webm', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
- '308': {'ext': 'webm', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
- '313': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9'},
- '315': {'ext': 'webm', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'vp9', 'fps': 60},
-
- # Dash webm audio
- '171': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'abr': 128},
- '172': {'ext': 'webm', 'acodec': 'vorbis', 'format_note': 'DASH audio', 'abr': 256},
-
- # Dash webm audio with opus inside
- '249': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 50},
- '250': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 70},
- '251': {'ext': 'webm', 'format_note': 'DASH audio', 'acodec': 'opus', 'abr': 160},
-
- # RTMP (unnamed)
- '_rtmp': {'protocol': 'rtmp'},
-
- # av01 video only formats sometimes served with "unknown" codecs
- '394': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- '395': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- '396': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- '397': {'acodec': 'none', 'vcodec': 'av01.0.05M.08'},
- }
- _SUBTITLE_FORMATS = ('srv1', 'srv2', 'srv3', 'ttml', 'vtt')
-
- _GEO_BYPASS = False
-
- IE_NAME = 'youtube'
- _TESTS = [
- {
- 'url': 'https://www.youtube.com/watch?v=BaW_jenozKc&t=1s&end=9',
- 'info_dict': {
- 'id': 'BaW_jenozKc',
- 'ext': 'mp4',
- 'title': 'youtube-dl test video "\'/\\ä↭𝕐',
- 'uploader': 'Philipp Hagemeister',
- 'uploader_id': 'phihag',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/phihag',
- 'channel_id': 'UCLqxVugv74EIW3VWh2NOa3Q',
- 'channel_url': r're:https?://(?:www\.)?youtube\.com/channel/UCLqxVugv74EIW3VWh2NOa3Q',
- 'upload_date': '20121002',
- 'description': 'test chars: "\'/\\ä↭𝕐\ntest URL: https://github.com/rg3/youtube-dl/issues/1892\n\nThis is a test video for youtube-dl.\n\nFor more information, contact phihag@phihag.de .',
- 'categories': ['Science & Technology'],
- 'tags': ['youtube-dl'],
- 'duration': 10,
- 'view_count': int,
- 'like_count': int,
- 'dislike_count': int,
- 'start_time': 1,
- 'end_time': 9,
- }
- },
- {
- 'url': 'https://www.youtube.com/watch?v=UxxajLWwzqY',
- 'note': 'Test generic use_cipher_signature video (#897)',
- 'info_dict': {
- 'id': 'UxxajLWwzqY',
- 'ext': 'mp4',
- 'upload_date': '20120506',
- 'title': 'Icona Pop - I Love It (feat. Charli XCX) [OFFICIAL VIDEO]',
- 'alt_title': 'I Love It (feat. Charli XCX)',
- 'description': 'md5:f3ceb5ef83a08d95b9d146f973157cc8',
- 'tags': ['Icona Pop i love it', 'sweden', 'pop music', 'big beat records', 'big beat', 'charli',
- 'xcx', 'charli xcx', 'girls', 'hbo', 'i love it', "i don't care", 'icona', 'pop',
- 'iconic ep', 'iconic', 'love', 'it'],
- 'duration': 180,
- 'uploader': 'Icona Pop',
- 'uploader_id': 'IconaPop',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/IconaPop',
- 'creator': 'Icona Pop',
- 'track': 'I Love It (feat. Charli XCX)',
- 'artist': 'Icona Pop',
- }
- },
- {
- 'url': 'https://www.youtube.com/watch?v=07FYdnEawAQ',
- 'note': 'Test VEVO video with age protection (#956)',
- 'info_dict': {
- 'id': '07FYdnEawAQ',
- 'ext': 'mp4',
- 'upload_date': '20130703',
- 'title': 'Justin Timberlake - Tunnel Vision (Official Music Video) (Explicit)',
- 'alt_title': 'Tunnel Vision',
- 'description': 'md5:07dab3356cde4199048e4c7cd93471e1',
- 'duration': 419,
- 'uploader': 'justintimberlakeVEVO',
- 'uploader_id': 'justintimberlakeVEVO',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/justintimberlakeVEVO',
- 'creator': 'Justin Timberlake',
- 'track': 'Tunnel Vision',
- 'artist': 'Justin Timberlake',
- 'age_limit': 18,
- }
- },
- {
- 'url': '//www.YouTube.com/watch?v=yZIXLfi8CZQ',
- 'note': 'Embed-only video (#1746)',
- 'info_dict': {
- 'id': 'yZIXLfi8CZQ',
- 'ext': 'mp4',
- 'upload_date': '20120608',
- 'title': 'Principal Sexually Assaults A Teacher - Episode 117 - 8th June 2012',
- 'description': 'md5:09b78bd971f1e3e289601dfba15ca4f7',
- 'uploader': 'SET India',
- 'uploader_id': 'setindia',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/setindia',
- 'age_limit': 18,
- }
- },
- {
- 'url': 'https://www.youtube.com/watch?v=BaW_jenozKc&v=UxxajLWwzqY',
- 'note': 'Use the first video ID in the URL',
- 'info_dict': {
- 'id': 'BaW_jenozKc',
- 'ext': 'mp4',
- 'title': 'youtube-dl test video "\'/\\ä↭𝕐',
- 'uploader': 'Philipp Hagemeister',
- 'uploader_id': 'phihag',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/phihag',
- 'upload_date': '20121002',
- 'description': 'test chars: "\'/\\ä↭𝕐\ntest URL: https://github.com/rg3/youtube-dl/issues/1892\n\nThis is a test video for youtube-dl.\n\nFor more information, contact phihag@phihag.de .',
- 'categories': ['Science & Technology'],
- 'tags': ['youtube-dl'],
- 'duration': 10,
- 'view_count': int,
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- 'url': 'https://www.youtube.com/watch?v=a9LDPn-MO4I',
- 'note': '256k DASH audio (format 141) via DASH manifest',
- 'info_dict': {
- 'id': 'a9LDPn-MO4I',
- 'ext': 'm4a',
- 'upload_date': '20121002',
- 'uploader_id': '8KVIDEO',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/8KVIDEO',
- 'description': '',
- 'uploader': '8KVIDEO',
- 'title': 'UHDTV TEST 8K VIDEO.mp4'
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '141',
- },
- 'skip': 'format 141 not served anymore',
- },
- # DASH manifest with encrypted signature
- {
- 'url': 'https://www.youtube.com/watch?v=IB3lcPjvWLA',
- 'info_dict': {
- 'id': 'IB3lcPjvWLA',
- 'ext': 'm4a',
- 'title': 'Afrojack, Spree Wilson - The Spark (Official Music Video) ft. Spree Wilson',
- 'description': 'md5:8f5e2b82460520b619ccac1f509d43bf',
- 'duration': 244,
- 'uploader': 'AfrojackVEVO',
- 'uploader_id': 'AfrojackVEVO',
- 'upload_date': '20131011',
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '141/bestaudio[ext=m4a]',
- },
- },
- # JS player signature function name containing $
- {
- 'url': 'https://www.youtube.com/watch?v=nfWlot6h_JM',
- 'info_dict': {
- 'id': 'nfWlot6h_JM',
- 'ext': 'm4a',
- 'title': 'Taylor Swift - Shake It Off',
- 'description': 'md5:bec2185232c05479482cb5a9b82719bf',
- 'duration': 242,
- 'uploader': 'TaylorSwiftVEVO',
- 'uploader_id': 'TaylorSwiftVEVO',
- 'upload_date': '20140818',
- 'creator': 'Taylor Swift',
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '141/bestaudio[ext=m4a]',
- },
- },
- # Controversy video
- {
- 'url': 'https://www.youtube.com/watch?v=T4XJQO3qol8',
- 'info_dict': {
- 'id': 'T4XJQO3qol8',
- 'ext': 'mp4',
- 'duration': 219,
- 'upload_date': '20100909',
- 'uploader': 'Amazing Atheist',
- 'uploader_id': 'TheAmazingAtheist',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/TheAmazingAtheist',
- 'title': 'Burning Everyone\'s Koran',
- 'description': 'SUBSCRIBE: http://www.youtube.com/saturninefilms\n\nEven Obama has taken a stand against freedom on this issue: http://www.huffingtonpost.com/2010/09/09/obama-gma-interview-quran_n_710282.html',
- }
- },
- # Normal age-gate video (No vevo, embed allowed)
- {
- 'url': 'https://youtube.com/watch?v=HtVdAasjOgU',
- 'info_dict': {
- 'id': 'HtVdAasjOgU',
- 'ext': 'mp4',
- 'title': 'The Witcher 3: Wild Hunt - The Sword Of Destiny Trailer',
- 'description': r're:(?s).{100,}About the Game\n.*?The Witcher 3: Wild Hunt.{100,}',
- 'duration': 142,
- 'uploader': 'The Witcher',
- 'uploader_id': 'WitcherGame',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/WitcherGame',
- 'upload_date': '20140605',
- 'age_limit': 18,
- },
- },
- # Age-gate video with encrypted signature
- {
- 'url': 'https://www.youtube.com/watch?v=6kLq3WMV1nU',
- 'info_dict': {
- 'id': '6kLq3WMV1nU',
- 'ext': 'mp4',
- 'title': 'Dedication To My Ex (Miss That) (Lyric Video)',
- 'description': 'md5:33765bb339e1b47e7e72b5490139bb41',
- 'duration': 246,
- 'uploader': 'LloydVEVO',
- 'uploader_id': 'LloydVEVO',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/LloydVEVO',
- 'upload_date': '20110629',
- 'age_limit': 18,
- },
- },
- # video_info is None (https://github.com/ytdl-org/youtube-dl/issues/4421)
- # YouTube Red ad is not captured for creator
- {
- 'url': '__2ABJjxzNo',
- 'info_dict': {
- 'id': '__2ABJjxzNo',
- 'ext': 'mp4',
- 'duration': 266,
- 'upload_date': '20100430',
- 'uploader_id': 'deadmau5',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/deadmau5',
- 'creator': 'deadmau5',
- 'description': 'md5:12c56784b8032162bb936a5f76d55360',
- 'uploader': 'deadmau5',
- 'title': 'Deadmau5 - Some Chords (HD)',
- 'alt_title': 'Some Chords',
- },
- 'expected_warnings': [
- 'DASH manifest missing',
- ]
- },
- # Olympics (https://github.com/ytdl-org/youtube-dl/issues/4431)
- {
- 'url': 'lqQg6PlCWgI',
- 'info_dict': {
- 'id': 'lqQg6PlCWgI',
- 'ext': 'mp4',
- 'duration': 6085,
- 'upload_date': '20150827',
- 'uploader_id': 'olympic',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/olympic',
- 'description': 'HO09 - Women - GER-AUS - Hockey - 31 July 2012 - London 2012 Olympic Games',
- 'uploader': 'Olympic',
- 'title': 'Hockey - Women - GER-AUS - London 2012 Olympic Games',
- },
- 'params': {
- 'skip_download': 'requires avconv',
- }
- },
- # Non-square pixels
- {
- 'url': 'https://www.youtube.com/watch?v=_b-2C3KPAM0',
- 'info_dict': {
- 'id': '_b-2C3KPAM0',
- 'ext': 'mp4',
- 'stretched_ratio': 16 / 9.,
- 'duration': 85,
- 'upload_date': '20110310',
- 'uploader_id': 'AllenMeow',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/AllenMeow',
- 'description': 'made by Wacom from Korea | 字幕&加油添醋 by TY\'s Allen | 感謝heylisa00cavey1001同學熱情提供梗及翻譯',
- 'uploader': '孫ᄋᄅ',
- 'title': '[A-made] 變態妍字幕版 太妍 我就是這樣的人',
- },
- },
- # url_encoded_fmt_stream_map is empty string
- {
- 'url': 'qEJwOuvDf7I',
- 'info_dict': {
- 'id': 'qEJwOuvDf7I',
- 'ext': 'webm',
- 'title': 'Обсуждение судебной практики по выборам 14 сентября 2014 года в Санкт-Петербурге',
- 'description': '',
- 'upload_date': '20150404',
- 'uploader_id': 'spbelect',
- 'uploader': 'Наблюдатели Петербурга',
- },
- 'params': {
- 'skip_download': 'requires avconv',
- },
- 'skip': 'This live event has ended.',
- },
- # Extraction from multiple DASH manifests (https://github.com/ytdl-org/youtube-dl/pull/6097)
- {
- 'url': 'https://www.youtube.com/watch?v=FIl7x6_3R5Y',
- 'info_dict': {
- 'id': 'FIl7x6_3R5Y',
- 'ext': 'webm',
- 'title': 'md5:7b81415841e02ecd4313668cde88737a',
- 'description': 'md5:116377fd2963b81ec4ce64b542173306',
- 'duration': 220,
- 'upload_date': '20150625',
- 'uploader_id': 'dorappi2000',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/dorappi2000',
- 'uploader': 'dorappi2000',
- 'formats': 'mincount:31',
- },
- 'skip': 'not actual anymore',
- },
- # DASH manifest with segment_list
- {
- 'url': 'https://www.youtube.com/embed/CsmdDsKjzN8',
- 'md5': '8ce563a1d667b599d21064e982ab9e31',
- 'info_dict': {
- 'id': 'CsmdDsKjzN8',
- 'ext': 'mp4',
- 'upload_date': '20150501', # According to '<meta itemprop="datePublished"', but in other places it's 20150510
- 'uploader': 'Airtek',
- 'description': 'Retransmisión en directo de la XVIII media maratón de Zaragoza.',
- 'uploader_id': 'UCzTzUmjXxxacNnL8I3m4LnQ',
- 'title': 'Retransmisión XVIII Media maratón Zaragoza 2015',
- },
- 'params': {
- 'youtube_include_dash_manifest': True,
- 'format': '135', # bestvideo
- },
- 'skip': 'This live event has ended.',
- },
- {
- # Multifeed videos (multiple cameras), URL is for Main Camera
- 'url': 'https://www.youtube.com/watch?v=jqWvoWXjCVs',
- 'info_dict': {
- 'id': 'jqWvoWXjCVs',
- 'title': 'teamPGP: Rocket League Noob Stream',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- },
- 'playlist': [{
- 'info_dict': {
- 'id': 'jqWvoWXjCVs',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (Main Camera)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7335,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }, {
- 'info_dict': {
- 'id': '6h8e8xoXJzg',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (kreestuh)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7337,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }, {
- 'info_dict': {
- 'id': 'PUOgX5z9xZw',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (grizzle)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7337,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }, {
- 'info_dict': {
- 'id': 'teuwxikvS5k',
- 'ext': 'mp4',
- 'title': 'teamPGP: Rocket League Noob Stream (zim)',
- 'description': 'md5:dc7872fb300e143831327f1bae3af010',
- 'duration': 7334,
- 'upload_date': '20150721',
- 'uploader': 'Beer Games Beer',
- 'uploader_id': 'beergamesbeer',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/beergamesbeer',
- 'license': 'Standard YouTube License',
- },
- }],
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video is not available.',
- },
- {
- # Multifeed video with comma in title (see https://github.com/ytdl-org/youtube-dl/issues/8536)
- 'url': 'https://www.youtube.com/watch?v=gVfLd0zydlo',
- 'info_dict': {
- 'id': 'gVfLd0zydlo',
- 'title': 'DevConf.cz 2016 Day 2 Workshops 1 14:00 - 15:30',
- },
- 'playlist_count': 2,
- 'skip': 'Not multifeed anymore',
- },
- {
- 'url': 'https://vid.plus/FlRa-iH7PGw',
- 'only_matching': True,
- },
- {
- 'url': 'https://zwearz.com/watch/9lWxNJF-ufM/electra-woman-dyna-girl-official-trailer-grace-helbig.html',
- 'only_matching': True,
- },
- {
- # Title with JS-like syntax "};" (see https://github.com/ytdl-org/youtube-dl/issues/7468)
- # Also tests cut-off URL expansion in video description (see
- # https://github.com/ytdl-org/youtube-dl/issues/1892,
- # https://github.com/ytdl-org/youtube-dl/issues/8164)
- 'url': 'https://www.youtube.com/watch?v=lsguqyKfVQg',
- 'info_dict': {
- 'id': 'lsguqyKfVQg',
- 'ext': 'mp4',
- 'title': '{dark walk}; Loki/AC/Dishonored; collab w/Elflover21',
- 'alt_title': 'Dark Walk - Position Music',
- 'description': 'md5:8085699c11dc3f597ce0410b0dcbb34a',
- 'duration': 133,
- 'upload_date': '20151119',
- 'uploader_id': 'IronSoulElf',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/IronSoulElf',
- 'uploader': 'IronSoulElf',
- 'creator': 'Todd Haberman, Daniel Law Heath and Aaron Kaplan',
- 'track': 'Dark Walk - Position Music',
- 'artist': 'Todd Haberman, Daniel Law Heath and Aaron Kaplan',
- 'album': 'Position Music - Production Music Vol. 143 - Dark Walk',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Tags with '};' (see https://github.com/ytdl-org/youtube-dl/issues/7468)
- 'url': 'https://www.youtube.com/watch?v=Ms7iBXnlUO8',
- 'only_matching': True,
- },
- {
- # Video with yt:stretch=17:0
- 'url': 'https://www.youtube.com/watch?v=Q39EVAstoRM',
- 'info_dict': {
- 'id': 'Q39EVAstoRM',
- 'ext': 'mp4',
- 'title': 'Clash Of Clans#14 Dicas De Ataque Para CV 4',
- 'description': 'md5:ee18a25c350637c8faff806845bddee9',
- 'upload_date': '20151107',
- 'uploader_id': 'UCCr7TALkRbo3EtFzETQF1LA',
- 'uploader': 'CH GAMER DROID',
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video does not exist.',
- },
- {
- # Video licensed under Creative Commons
- 'url': 'https://www.youtube.com/watch?v=M4gD1WSo5mA',
- 'info_dict': {
- 'id': 'M4gD1WSo5mA',
- 'ext': 'mp4',
- 'title': 'md5:e41008789470fc2533a3252216f1c1d1',
- 'description': 'md5:a677553cf0840649b731a3024aeff4cc',
- 'duration': 721,
- 'upload_date': '20150127',
- 'uploader_id': 'BerkmanCenter',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/BerkmanCenter',
- 'uploader': 'The Berkman Klein Center for Internet & Society',
- 'license': 'Creative Commons Attribution license (reuse allowed)',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Channel-like uploader_url
- 'url': 'https://www.youtube.com/watch?v=eQcmzGIKrzg',
- 'info_dict': {
- 'id': 'eQcmzGIKrzg',
- 'ext': 'mp4',
- 'title': 'Democratic Socialism and Foreign Policy | Bernie Sanders',
- 'description': 'md5:dda0d780d5a6e120758d1711d062a867',
- 'duration': 4060,
- 'upload_date': '20151119',
- 'uploader': 'Bernie Sanders',
- 'uploader_id': 'UCH1dpzjCEiGAt8CXkryhkZg',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCH1dpzjCEiGAt8CXkryhkZg',
- 'license': 'Creative Commons Attribution license (reuse allowed)',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- 'url': 'https://www.youtube.com/watch?feature=player_embedded&amp;amp;v=V36LpHqtcDY',
- 'only_matching': True,
- },
- {
- # YouTube Red paid video (https://github.com/ytdl-org/youtube-dl/issues/10059)
- 'url': 'https://www.youtube.com/watch?v=i1Ko8UG-Tdo',
- 'only_matching': True,
- },
- {
- # Rental video preview
- 'url': 'https://www.youtube.com/watch?v=yYr8q0y5Jfg',
- 'info_dict': {
- 'id': 'uGpuVWrhIzE',
- 'ext': 'mp4',
- 'title': 'Piku - Trailer',
- 'description': 'md5:c36bd60c3fd6f1954086c083c72092eb',
- 'upload_date': '20150811',
- 'uploader': 'FlixMatrix',
- 'uploader_id': 'FlixMatrixKaravan',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/FlixMatrixKaravan',
- 'license': 'Standard YouTube License',
- },
- 'params': {
- 'skip_download': True,
- },
- 'skip': 'This video is not available.',
- },
- {
- # YouTube Red video with episode data
- 'url': 'https://www.youtube.com/watch?v=iqKdEhx-dD4',
- 'info_dict': {
- 'id': 'iqKdEhx-dD4',
- 'ext': 'mp4',
- 'title': 'Isolation - Mind Field (Ep 1)',
- 'description': 'md5:46a29be4ceffa65b92d277b93f463c0f',
- 'duration': 2085,
- 'upload_date': '20170118',
- 'uploader': 'Vsauce',
- 'uploader_id': 'Vsauce',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/Vsauce',
- 'series': 'Mind Field',
- 'season_number': 1,
- 'episode_number': 1,
- },
- 'params': {
- 'skip_download': True,
- },
- 'expected_warnings': [
- 'Skipping DASH manifest',
- ],
- },
- {
- # The following content has been identified by the YouTube community
- # as inappropriate or offensive to some audiences.
- 'url': 'https://www.youtube.com/watch?v=6SJNVb0GnPI',
- 'info_dict': {
- 'id': '6SJNVb0GnPI',
- 'ext': 'mp4',
- 'title': 'Race Differences in Intelligence',
- 'description': 'md5:5d161533167390427a1f8ee89a1fc6f1',
- 'duration': 965,
- 'upload_date': '20140124',
- 'uploader': 'New Century Foundation',
- 'uploader_id': 'UCEJYpZGqgUob0zVVEaLhvVg',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCEJYpZGqgUob0zVVEaLhvVg',
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # itag 212
- 'url': '1t24XAntNCY',
- 'only_matching': True,
- },
- {
- # geo restricted to JP
- 'url': 'sJL6WA-aGkQ',
- 'only_matching': True,
- },
- {
- 'url': 'https://www.youtube.com/watch?v=MuAGGZNfUkU&list=RDMM',
- 'only_matching': True,
- },
- {
- 'url': 'https://invidio.us/watch?v=BaW_jenozKc',
- 'only_matching': True,
- },
- {
- # DRM protected
- 'url': 'https://www.youtube.com/watch?v=s7_qI6_mIXc',
- 'only_matching': True,
- },
- {
- # Video with unsupported adaptive stream type formats
- 'url': 'https://www.youtube.com/watch?v=Z4Vy8R84T1U',
- 'info_dict': {
- 'id': 'Z4Vy8R84T1U',
- 'ext': 'mp4',
- 'title': 'saman SMAN 53 Jakarta(Sancety) opening COFFEE4th at SMAN 53 Jakarta',
- 'description': 'md5:d41d8cd98f00b204e9800998ecf8427e',
- 'duration': 433,
- 'upload_date': '20130923',
- 'uploader': 'Amelia Putri Harwita',
- 'uploader_id': 'UCpOxM49HJxmC1qCalXyB3_Q',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCpOxM49HJxmC1qCalXyB3_Q',
- 'formats': 'maxcount:10',
- },
- 'params': {
- 'skip_download': True,
- 'youtube_include_dash_manifest': False,
- },
- },
- {
- # Youtube Music Auto-generated description
- 'url': 'https://music.youtube.com/watch?v=MgNrAu2pzNs',
- 'info_dict': {
- 'id': 'MgNrAu2pzNs',
- 'ext': 'mp4',
- 'title': 'Voyeur Girl',
- 'description': 'md5:7ae382a65843d6df2685993e90a8628f',
- 'upload_date': '20190312',
- 'uploader': 'Various Artists - Topic',
- 'uploader_id': 'UCVWKBi1ELZn0QX2CBLSkiyw',
- 'artist': 'Stephen',
- 'track': 'Voyeur Girl',
- 'album': 'it\'s too much love to know my dear',
- 'release_date': '20190313',
- 'release_year': 2019,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Youtube Music Auto-generated description
- # Retrieve 'artist' field from 'Artist:' in video description
- # when it is present on youtube music video
- 'url': 'https://www.youtube.com/watch?v=k0jLE7tTwjY',
- 'info_dict': {
- 'id': 'k0jLE7tTwjY',
- 'ext': 'mp4',
- 'title': 'Latch Feat. Sam Smith',
- 'description': 'md5:3cb1e8101a7c85fcba9b4fb41b951335',
- 'upload_date': '20150110',
- 'uploader': 'Various Artists - Topic',
- 'uploader_id': 'UCNkEcmYdjrH4RqtNgh7BZ9w',
- 'artist': 'Disclosure',
- 'track': 'Latch Feat. Sam Smith',
- 'album': 'Latch Featuring Sam Smith',
- 'release_date': '20121008',
- 'release_year': 2012,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Youtube Music Auto-generated description
- # handle multiple artists on youtube music video
- 'url': 'https://www.youtube.com/watch?v=74qn0eJSjpA',
- 'info_dict': {
- 'id': '74qn0eJSjpA',
- 'ext': 'mp4',
- 'title': 'Eastside',
- 'description': 'md5:290516bb73dcbfab0dcc4efe6c3de5f2',
- 'upload_date': '20180710',
- 'uploader': 'Benny Blanco - Topic',
- 'uploader_id': 'UCzqz_ksRu_WkIzmivMdIS7A',
- 'artist': 'benny blanco, Halsey, Khalid',
- 'track': 'Eastside',
- 'album': 'Eastside',
- 'release_date': '20180713',
- 'release_year': 2018,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- {
- # Youtube Music Auto-generated description
- # handle youtube music video with release_year and no release_date
- 'url': 'https://www.youtube.com/watch?v=-hcAI0g-f5M',
- 'info_dict': {
- 'id': '-hcAI0g-f5M',
- 'ext': 'mp4',
- 'title': 'Put It On Me',
- 'description': 'md5:93c55acc682ae7b0c668f2e34e1c069e',
- 'upload_date': '20180426',
- 'uploader': 'Matt Maeson - Topic',
- 'uploader_id': 'UCnEkIGqtGcQMLk73Kp-Q5LQ',
- 'artist': 'Matt Maeson',
- 'track': 'Put It On Me',
- 'album': 'The Hearse',
- 'release_date': None,
- 'release_year': 2018,
- },
- 'params': {
- 'skip_download': True,
- },
- },
- ]
-
- def __init__(self, *args, **kwargs):
- super(YoutubeIE, self).__init__(*args, **kwargs)
- self._player_cache = {}
-
- def report_video_info_webpage_download(self, video_id):
- """Report attempt to download video info webpage."""
- self.to_screen('%s: Downloading video info webpage' % video_id)
-
- def report_information_extraction(self, video_id):
- """Report attempt to extract video information."""
- self.to_screen('%s: Extracting video information' % video_id)
-
- def report_unavailable_format(self, video_id, format):
- """Report extracted video URL."""
- self.to_screen('%s: Format %s not available' % (video_id, format))
-
- def report_rtmp_download(self):
- """Indicate the download will use the RTMP protocol."""
- self.to_screen('RTMP download detected')
-
- def _signature_cache_id(self, example_sig):
- """ Return a string representation of a signature """
- return '.'.join(compat_str(len(part)) for part in example_sig.split('.'))
-
- def _extract_signature_function(self, video_id, player_url, example_sig):
- id_m = re.match(
- r'.*?-(?P<id>[a-zA-Z0-9_-]+)(?:/watch_as3|/html5player(?:-new)?|(?:/[a-z]{2,3}_[A-Z]{2})?/base)?\.(?P<ext>[a-z]+)$',
- player_url)
- if not id_m:
- raise ExtractorError('Cannot identify player %r' % player_url)
- player_type = id_m.group('ext')
- player_id = id_m.group('id')
-
- # Read from filesystem cache
- func_id = '%s_%s_%s' % (
- player_type, player_id, self._signature_cache_id(example_sig))
- assert os.path.basename(func_id) == func_id
-
- cache_spec = self._downloader.cache.load('youtube-sigfuncs', func_id)
- if cache_spec is not None:
- return lambda s: ''.join(s[i] for i in cache_spec)
-
- download_note = (
- 'Downloading player %s' % player_url
- if self._downloader.params.get('verbose') else
- 'Downloading %s player %s' % (player_type, player_id)
- )
- if player_type == 'js':
- code = self._download_webpage(
- player_url, video_id,
- note=download_note,
- errnote='Download of %s failed' % player_url)
- res = self._parse_sig_js(code)
- elif player_type == 'swf':
- urlh = self._request_webpage(
- player_url, video_id,
- note=download_note,
- errnote='Download of %s failed' % player_url)
- code = urlh.read()
- res = self._parse_sig_swf(code)
- else:
- assert False, 'Invalid player type %r' % player_type
-
- test_string = ''.join(map(compat_chr, range(len(example_sig))))
- cache_res = res(test_string)
- cache_spec = [ord(c) for c in cache_res]
-
- self._downloader.cache.store('youtube-sigfuncs', func_id, cache_spec)
- return res
-
- def _print_sig_code(self, func, example_sig):
- def gen_sig_code(idxs):
- def _genslice(start, end, step):
- starts = '' if start == 0 else str(start)
- ends = (':%d' % (end + step)) if end + step >= 0 else ':'
- steps = '' if step == 1 else (':%d' % step)
- return 's[%s%s%s]' % (starts, ends, steps)
-
- step = None
- # Quelch pyflakes warnings - start will be set when step is set
- start = '(Never used)'
- for i, prev in zip(idxs[1:], idxs[:-1]):
- if step is not None:
- if i - prev == step:
- continue
- yield _genslice(start, prev, step)
- step = None
- continue
- if i - prev in [-1, 1]:
- step = i - prev
- start = prev
- continue
- else:
- yield 's[%d]' % prev
- if step is None:
- yield 's[%d]' % i
- else:
- yield _genslice(start, i, step)
-
- test_string = ''.join(map(compat_chr, range(len(example_sig))))
- cache_res = func(test_string)
- cache_spec = [ord(c) for c in cache_res]
- expr_code = ' + '.join(gen_sig_code(cache_spec))
- signature_id_tuple = '(%s)' % (
- ', '.join(compat_str(len(p)) for p in example_sig.split('.')))
- code = ('if tuple(len(p) for p in s.split(\'.\')) == %s:\n'
- ' return %s\n') % (signature_id_tuple, expr_code)
- self.to_screen('Extracted signature function:\n' + code)
-
- def _parse_sig_js(self, jscode):
- funcname = self._search_regex(
- (r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'(?P<sig>[a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)',
- # Obsolete patterns
- r'(["\'])signature\1\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\.sig\|\|(?P<sig>[a-zA-Z0-9$]+)\(',
- r'yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(',
- r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\('),
- jscode, 'Initial JS player signature function name', group='sig')
-
- jsi = JSInterpreter(jscode)
- initial_function = jsi.extract_function(funcname)
- return lambda s: initial_function([s])
-
- def _parse_sig_swf(self, file_contents):
- swfi = SWFInterpreter(file_contents)
- TARGET_CLASSNAME = 'SignatureDecipher'
- searched_class = swfi.extract_class(TARGET_CLASSNAME)
- initial_function = swfi.extract_function(searched_class, 'decipher')
- return lambda s: initial_function([s])
-
- def _decrypt_signature(self, s, video_id, player_url, age_gate=False):
- """Turn the encrypted s field into a working signature"""
-
- if player_url is None:
- raise ExtractorError('Cannot decrypt signature without player_url')
-
- if player_url.startswith('//'):
- player_url = 'https:' + player_url
- elif not re.match(r'https?://', player_url):
- player_url = compat_urlparse.urljoin(
- 'https://www.youtube.com', player_url)
- try:
- player_id = (player_url, self._signature_cache_id(s))
- if player_id not in self._player_cache:
- func = self._extract_signature_function(
- video_id, player_url, s
- )
- self._player_cache[player_id] = func
- func = self._player_cache[player_id]
- if self._downloader.params.get('youtube_print_sig_code'):
- self._print_sig_code(func, s)
- return func(s)
- except Exception as e:
- tb = traceback.format_exc()
- raise ExtractorError(
- 'Signature extraction failed: ' + tb, cause=e)
-
- def _get_subtitles(self, video_id, webpage):
- try:
- subs_doc = self._download_xml(
- 'https://video.google.com/timedtext?hl=en&type=list&v=%s' % video_id,
- video_id, note=False)
- except ExtractorError as err:
- self._downloader.report_warning('unable to download video subtitles: %s' % error_to_compat_str(err))
- return {}
-
- sub_lang_list = {}
- for track in subs_doc.findall('track'):
- lang = track.attrib['lang_code']
- if lang in sub_lang_list:
- continue
- sub_formats = []
- for ext in self._SUBTITLE_FORMATS:
- params = compat_urllib_parse_urlencode({
- 'lang': lang,
- 'v': video_id,
- 'fmt': ext,
- 'name': track.attrib['name'].encode('utf-8'),
- })
- sub_formats.append({
- 'url': 'https://www.youtube.com/api/timedtext?' + params,
- 'ext': ext,
- })
- sub_lang_list[lang] = sub_formats
- if not sub_lang_list:
- self._downloader.report_warning('video doesn\'t have subtitles')
- return {}
- return sub_lang_list
-
- def _get_ytplayer_config(self, video_id, webpage):
- patterns = (
- # User data may contain arbitrary character sequences that may affect
- # JSON extraction with regex, e.g. when '};' is contained the second
- # regex won't capture the whole JSON. Yet working around by trying more
- # concrete regex first keeping in mind proper quoted string handling
- # to be implemented in future that will replace this workaround (see
- # https://github.com/ytdl-org/youtube-dl/issues/7468,
- # https://github.com/ytdl-org/youtube-dl/pull/7599)
- r';ytplayer\.config\s*=\s*({.+?});ytplayer',
- r';ytplayer\.config\s*=\s*({.+?});',
- )
- config = self._search_regex(
- patterns, webpage, 'ytplayer.config', default=None)
- if config:
- return self._parse_json(
- uppercase_escape(config), video_id, fatal=False)
-
- def _get_automatic_captions(self, video_id, webpage):
- """We need the webpage for getting the captions url, pass it as an
- argument to speed up the process."""
- self.to_screen('%s: Looking for automatic captions' % video_id)
- player_config = self._get_ytplayer_config(video_id, webpage)
- err_msg = 'Couldn\'t find automatic captions for %s' % video_id
- if not player_config:
- self._downloader.report_warning(err_msg)
- return {}
- try:
- args = player_config['args']
- caption_url = args.get('ttsurl')
- if caption_url:
- timestamp = args['timestamp']
- # We get the available subtitles
- list_params = compat_urllib_parse_urlencode({
- 'type': 'list',
- 'tlangs': 1,
- 'asrs': 1,
- })
- list_url = caption_url + '&' + list_params
- caption_list = self._download_xml(list_url, video_id)
- original_lang_node = caption_list.find('track')
- if original_lang_node is None:
- self._downloader.report_warning('Video doesn\'t have automatic captions')
- return {}
- original_lang = original_lang_node.attrib['lang_code']
- caption_kind = original_lang_node.attrib.get('kind', '')
-
- sub_lang_list = {}
- for lang_node in caption_list.findall('target'):
- sub_lang = lang_node.attrib['lang_code']
- sub_formats = []
- for ext in self._SUBTITLE_FORMATS:
- params = compat_urllib_parse_urlencode({
- 'lang': original_lang,
- 'tlang': sub_lang,
- 'fmt': ext,
- 'ts': timestamp,
- 'kind': caption_kind,
- })
- sub_formats.append({
- 'url': caption_url + '&' + params,
- 'ext': ext,
- })
- sub_lang_list[sub_lang] = sub_formats
- return sub_lang_list
-
- def make_captions(sub_url, sub_langs):
- parsed_sub_url = compat_urllib_parse_urlparse(sub_url)
- caption_qs = compat_parse_qs(parsed_sub_url.query)
- captions = {}
- for sub_lang in sub_langs:
- sub_formats = []
- for ext in self._SUBTITLE_FORMATS:
- caption_qs.update({
- 'tlang': [sub_lang],
- 'fmt': [ext],
- })
- sub_url = compat_urlparse.urlunparse(parsed_sub_url._replace(
- query=compat_urllib_parse_urlencode(caption_qs, True)))
- sub_formats.append({
- 'url': sub_url,
- 'ext': ext,
- })
- captions[sub_lang] = sub_formats
- return captions
-
- # New captions format as of 22.06.2017
- player_response = args.get('player_response')
- if player_response and isinstance(player_response, compat_str):
- player_response = self._parse_json(
- player_response, video_id, fatal=False)
- if player_response:
- renderer = player_response['captions']['playerCaptionsTracklistRenderer']
- base_url = renderer['captionTracks'][0]['baseUrl']
- sub_lang_list = []
- for lang in renderer['translationLanguages']:
- lang_code = lang.get('languageCode')
- if lang_code:
- sub_lang_list.append(lang_code)
- return make_captions(base_url, sub_lang_list)
-
- # Some videos don't provide ttsurl but rather caption_tracks and
- # caption_translation_languages (e.g. 20LmZk1hakA)
- # Does not used anymore as of 22.06.2017
- caption_tracks = args['caption_tracks']
- caption_translation_languages = args['caption_translation_languages']
- caption_url = compat_parse_qs(caption_tracks.split(',')[0])['u'][0]
- sub_lang_list = []
- for lang in caption_translation_languages.split(','):
- lang_qs = compat_parse_qs(compat_urllib_parse_unquote_plus(lang))
- sub_lang = lang_qs.get('lc', [None])[0]
- if sub_lang:
- sub_lang_list.append(sub_lang)
- return make_captions(caption_url, sub_lang_list)
- # An extractor error can be raise by the download process if there are
- # no automatic captions but there are subtitles
- except (KeyError, IndexError, ExtractorError):
- self._downloader.report_warning(err_msg)
- return {}
-
- def _mark_watched(self, video_id, video_info, player_response):
- playback_url = url_or_none(try_get(
- player_response,
- lambda x: x['playbackTracking']['videostatsPlaybackUrl']['baseUrl']) or try_get(
- video_info, lambda x: x['videostats_playback_base_url'][0]))
- if not playback_url:
- return
- parsed_playback_url = compat_urlparse.urlparse(playback_url)
- qs = compat_urlparse.parse_qs(parsed_playback_url.query)
-
- # cpn generation algorithm is reverse engineered from base.js.
- # In fact it works even with dummy cpn.
- CPN_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'
- cpn = ''.join((CPN_ALPHABET[random.randint(0, 256) & 63] for _ in range(0, 16)))
-
- qs.update({
- 'ver': ['2'],
- 'cpn': [cpn],
- })
- playback_url = compat_urlparse.urlunparse(
- parsed_playback_url._replace(query=compat_urllib_parse_urlencode(qs, True)))
-
- self._download_webpage(
- playback_url, video_id, 'Marking watched',
- 'Unable to mark watched', fatal=False)
-
- @staticmethod
- def _extract_urls(webpage):
- # Embedded YouTube player
- entries = [
- unescapeHTML(mobj.group('url'))
- for mobj in re.finditer(r'''(?x)
- (?:
- <iframe[^>]+?src=|
- data-video-url=|
- <embed[^>]+?src=|
- embedSWF\(?:\s*|
- <object[^>]+data=|
- new\s+SWFObject\(
- )
- (["\'])
- (?P<url>(?:https?:)?//(?:www\.)?youtube(?:-nocookie)?\.com/
- (?:embed|v|p)/[0-9A-Za-z_-]{11}.*?)
- \1''', webpage)]
-
- # lazyYT YouTube embed
- entries.extend(list(map(
- unescapeHTML,
- re.findall(r'class="lazyYT" data-youtube-id="([^"]+)"', webpage))))
-
- # Wordpress "YouTube Video Importer" plugin
- matches = re.findall(r'''(?x)<div[^>]+
- class=(?P<q1>[\'"])[^\'"]*\byvii_single_video_player\b[^\'"]*(?P=q1)[^>]+
- data-video_id=(?P<q2>[\'"])([^\'"]+)(?P=q2)''', webpage)
- entries.extend(m[-1] for m in matches)
-
- return entries
-
- @staticmethod
- def _extract_url(webpage):
- urls = YoutubeIE._extract_urls(webpage)
- return urls[0] if urls else None
-
- @classmethod
- def extract_id(cls, url):
- mobj = re.match(cls._VALID_URL, url, re.VERBOSE)
- if mobj is None:
- raise ExtractorError('Invalid URL: %s' % url)
- video_id = mobj.group(2)
- return video_id
-
- def _extract_annotations(self, video_id):
- return self._download_webpage(
- 'https://www.youtube.com/annotations_invideo', video_id,
- note='Downloading annotations',
- errnote='Unable to download video annotations', fatal=False,
- query={
- 'features': 1,
- 'legacy': 1,
- 'video_id': video_id,
- })
-
- @staticmethod
- def _extract_chapters(description, duration):
- if not description:
- return None
- chapter_lines = re.findall(
- r'(?:^|<br\s*/>)([^<]*<a[^>]+onclick=["\']yt\.www\.watch\.player\.seekTo[^>]+>(\d{1,2}:\d{1,2}(?::\d{1,2})?)</a>[^>]*)(?=$|<br\s*/>)',
- description)
- if not chapter_lines:
- return None
- chapters = []
- for next_num, (chapter_line, time_point) in enumerate(
- chapter_lines, start=1):
- start_time = parse_duration(time_point)
- if start_time is None:
- continue
- if start_time > duration:
- break
- end_time = (duration if next_num == len(chapter_lines)
- else parse_duration(chapter_lines[next_num][1]))
- if end_time is None:
- continue
- if end_time > duration:
- end_time = duration
- if start_time > end_time:
- break
- chapter_title = re.sub(
- r'<a[^>]+>[^<]+</a>', '', chapter_line).strip(' \t-')
- chapter_title = re.sub(r'\s+', ' ', chapter_title)
- chapters.append({
- 'start_time': start_time,
- 'end_time': end_time,
- 'title': chapter_title,
- })
- return chapters
-
- def _real_extract(self, url):
- url, smuggled_data = unsmuggle_url(url, {})
-
- proto = (
- 'http' if self._downloader.params.get('prefer_insecure', False)
- else 'https')
-
- start_time = None
- end_time = None
- parsed_url = compat_urllib_parse_urlparse(url)
- for component in [parsed_url.fragment, parsed_url.query]:
- query = compat_parse_qs(component)
- if start_time is None and 't' in query:
- start_time = parse_duration(query['t'][0])
- if start_time is None and 'start' in query:
- start_time = parse_duration(query['start'][0])
- if end_time is None and 'end' in query:
- end_time = parse_duration(query['end'][0])
-
- # Extract original video URL from URL with redirection, like age verification, using next_url parameter
- mobj = re.search(self._NEXT_URL_RE, url)
- if mobj:
- url = proto + '://www.youtube.com/' + compat_urllib_parse_unquote(mobj.group(1)).lstrip('/')
- video_id = self.extract_id(url)
-
- # Get video webpage
- url = proto + '://www.youtube.com/watch?v=%s&gl=US&hl=en&has_verified=1&bpctr=9999999999' % video_id
- video_webpage = self._download_webpage(url, video_id)
-
- # Attempt to extract SWF player URL
- mobj = re.search(r'swfConfig.*?"(https?:\\/\\/.*?watch.*?-.*?\.swf)"', video_webpage)
- if mobj is not None:
- player_url = re.sub(r'\\(.)', r'\1', mobj.group(1))
- else:
- player_url = None
-
- dash_mpds = []
-
- def add_dash_mpd(video_info):
- dash_mpd = video_info.get('dashmpd')
- if dash_mpd and dash_mpd[0] not in dash_mpds:
- dash_mpds.append(dash_mpd[0])
-
- def add_dash_mpd_pr(pl_response):
- dash_mpd = url_or_none(try_get(
- pl_response, lambda x: x['streamingData']['dashManifestUrl'],
- compat_str))
- if dash_mpd and dash_mpd not in dash_mpds:
- dash_mpds.append(dash_mpd)
-
- is_live = None
- view_count = None
-
- def extract_view_count(v_info):
- return int_or_none(try_get(v_info, lambda x: x['view_count'][0]))
-
- def extract_token(v_info):
- return dict_get(v_info, ('account_playback_token', 'accountPlaybackToken', 'token'))
-
- player_response = {}
-
- # Get video info
- embed_webpage = None
- if re.search(r'player-age-gate-content">', video_webpage) is not None:
- age_gate = True
- # We simulate the access to the video from www.youtube.com/v/{video_id}
- # this can be viewed without login into Youtube
- url = proto + '://www.youtube.com/embed/%s' % video_id
- embed_webpage = self._download_webpage(url, video_id, 'Downloading embed webpage')
- data = compat_urllib_parse_urlencode({
- 'video_id': video_id,
- 'eurl': 'https://youtube.googleapis.com/v/' + video_id,
- 'sts': self._search_regex(
- r'"sts"\s*:\s*(\d+)', embed_webpage, 'sts', default=''),
- })
- video_info_url = proto + '://www.youtube.com/get_video_info?' + data
- video_info_webpage = self._download_webpage(
- video_info_url, video_id,
- note='Refetching age-gated info webpage',
- errnote='unable to download video info webpage')
- video_info = compat_parse_qs(video_info_webpage)
- add_dash_mpd(video_info)
- else:
- age_gate = False
- video_info = None
- sts = None
- # Try looking directly into the video webpage
- ytplayer_config = self._get_ytplayer_config(video_id, video_webpage)
- if ytplayer_config:
- args = ytplayer_config['args']
- if args.get('url_encoded_fmt_stream_map') or args.get('hlsvp'):
- # Convert to the same format returned by compat_parse_qs
- video_info = dict((k, [v]) for k, v in args.items())
- add_dash_mpd(video_info)
- # Rental video is not rented but preview is available (e.g.
- # https://www.youtube.com/watch?v=yYr8q0y5Jfg,
- # https://github.com/ytdl-org/youtube-dl/issues/10532)
- if not video_info and args.get('ypc_vid'):
- return self.url_result(
- args['ypc_vid'], YoutubeIE.ie_key(), video_id=args['ypc_vid'])
- if args.get('livestream') == '1' or args.get('live_playback') == 1:
- is_live = True
- sts = ytplayer_config.get('sts')
- if not player_response:
- pl_response = str_or_none(args.get('player_response'))
- if pl_response:
- pl_response = self._parse_json(pl_response, video_id, fatal=False)
- if isinstance(pl_response, dict):
- player_response = pl_response
- if not video_info or self._downloader.params.get('youtube_include_dash_manifest', True):
- add_dash_mpd_pr(player_response)
- # We also try looking in get_video_info since it may contain different dashmpd
- # URL that points to a DASH manifest with possibly different itag set (some itags
- # are missing from DASH manifest pointed by webpage's dashmpd, some - from DASH
- # manifest pointed by get_video_info's dashmpd).
- # The general idea is to take a union of itags of both DASH manifests (for example
- # video with such 'manifest behavior' see https://github.com/ytdl-org/youtube-dl/issues/6093)
- self.report_video_info_webpage_download(video_id)
- for el in ('embedded', 'detailpage', 'vevo', ''):
- query = {
- 'video_id': video_id,
- 'ps': 'default',
- 'eurl': '',
- 'gl': 'US',
- 'hl': 'en',
- }
- if el:
- query['el'] = el
- if sts:
- query['sts'] = sts
- video_info_webpage = self._download_webpage(
- '%s://www.youtube.com/get_video_info' % proto,
- video_id, note=False,
- errnote='unable to download video info webpage',
- fatal=False, query=query)
- if not video_info_webpage:
- continue
- get_video_info = compat_parse_qs(video_info_webpage)
- if not player_response:
- pl_response = get_video_info.get('player_response', [None])[0]
- if isinstance(pl_response, dict):
- player_response = pl_response
- add_dash_mpd_pr(player_response)
- add_dash_mpd(get_video_info)
- if view_count is None:
- view_count = extract_view_count(get_video_info)
- if not video_info:
- video_info = get_video_info
- get_token = extract_token(get_video_info)
- if get_token:
- # Different get_video_info requests may report different results, e.g.
- # some may report video unavailability, but some may serve it without
- # any complaint (see https://github.com/ytdl-org/youtube-dl/issues/7362,
- # the original webpage as well as el=info and el=embedded get_video_info
- # requests report video unavailability due to geo restriction while
- # el=detailpage succeeds and returns valid data). This is probably
- # due to YouTube measures against IP ranges of hosting providers.
- # Working around by preferring the first succeeded video_info containing
- # the token if no such video_info yet was found.
- token = extract_token(video_info)
- if not token:
- video_info = get_video_info
- break
-
- def extract_unavailable_message():
- return self._html_search_regex(
- r'(?s)<h1[^>]+id="unavailable-message"[^>]*>(.+?)</h1>',
- video_webpage, 'unavailable message', default=None)
-
- if not video_info:
- unavailable_message = extract_unavailable_message()
- if not unavailable_message:
- unavailable_message = 'Unable to extract video data'
- raise ExtractorError(
- 'YouTube said: %s' % unavailable_message, expected=True, video_id=video_id)
-
- video_details = try_get(
- player_response, lambda x: x['videoDetails'], dict) or {}
-
- video_title = video_info.get('title', [None])[0] or video_details.get('title')
- if not video_title:
- self._downloader.report_warning('Unable to extract video title')
- video_title = '_'
-
- description_original = video_description = get_element_by_id("eow-description", video_webpage)
- if video_description:
-
- def replace_url(m):
- redir_url = compat_urlparse.urljoin(url, m.group(1))
- parsed_redir_url = compat_urllib_parse_urlparse(redir_url)
- if re.search(r'^(?:www\.)?(?:youtube(?:-nocookie)?\.com|youtu\.be)$', parsed_redir_url.netloc) and parsed_redir_url.path == '/redirect':
- qs = compat_parse_qs(parsed_redir_url.query)
- q = qs.get('q')
- if q and q[0]:
- return q[0]
- return redir_url
-
- description_original = video_description = re.sub(r'''(?x)
- <a\s+
- (?:[a-zA-Z-]+="[^"]*"\s+)*?
- (?:title|href)="([^"]+)"\s+
- (?:[a-zA-Z-]+="[^"]*"\s+)*?
- class="[^"]*"[^>]*>
- [^<]+\.{3}\s*
- </a>
- ''', replace_url, video_description)
- video_description = clean_html(video_description)
- else:
- video_description = self._html_search_meta('description', video_webpage) or video_details.get('shortDescription')
-
- if not smuggled_data.get('force_singlefeed', False):
- if not self._downloader.params.get('noplaylist'):
- multifeed_metadata_list = try_get(
- player_response,
- lambda x: x['multicamera']['playerLegacyMulticameraRenderer']['metadataList'],
- compat_str) or try_get(
- video_info, lambda x: x['multifeed_metadata_list'][0], compat_str)
- if multifeed_metadata_list:
- entries = []
- feed_ids = []
- for feed in multifeed_metadata_list.split(','):
- # Unquote should take place before split on comma (,) since textual
- # fields may contain comma as well (see
- # https://github.com/ytdl-org/youtube-dl/issues/8536)
- feed_data = compat_parse_qs(compat_urllib_parse_unquote_plus(feed))
- entries.append({
- '_type': 'url_transparent',
- 'ie_key': 'Youtube',
- 'url': smuggle_url(
- '%s://www.youtube.com/watch?v=%s' % (proto, feed_data['id'][0]),
- {'force_singlefeed': True}),
- 'title': '%s (%s)' % (video_title, feed_data['title'][0]),
- })
- feed_ids.append(feed_data['id'][0])
- self.to_screen(
- 'Downloading multifeed video (%s) - add --no-playlist to just download video %s'
- % (', '.join(feed_ids), video_id))
- return self.playlist_result(entries, video_id, video_title, video_description)
- else:
- self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
-
- if view_count is None:
- view_count = extract_view_count(video_info)
- if view_count is None and video_details:
- view_count = int_or_none(video_details.get('viewCount'))
-
- # Check for "rental" videos
- if 'ypc_video_rental_bar_text' in video_info and 'author' not in video_info:
- raise ExtractorError('"rental" videos not supported. See https://github.com/ytdl-org/youtube-dl/issues/359 for more information.', expected=True)
-
- def _extract_filesize(media_url):
- return int_or_none(self._search_regex(
- r'\bclen[=/](\d+)', media_url, 'filesize', default=None))
-
- if 'conn' in video_info and video_info['conn'][0].startswith('rtmp'):
- self.report_rtmp_download()
- formats = [{
- 'format_id': '_rtmp',
- 'protocol': 'rtmp',
- 'url': video_info['conn'][0],
- 'player_url': player_url,
- }]
- elif not is_live and (len(video_info.get('url_encoded_fmt_stream_map', [''])[0]) >= 1 or len(video_info.get('adaptive_fmts', [''])[0]) >= 1):
- encoded_url_map = video_info.get('url_encoded_fmt_stream_map', [''])[0] + ',' + video_info.get('adaptive_fmts', [''])[0]
- if 'rtmpe%3Dyes' in encoded_url_map:
- raise ExtractorError('rtmpe downloads are not supported, see https://github.com/ytdl-org/youtube-dl/issues/343 for more information.', expected=True)
- formats_spec = {}
- fmt_list = video_info.get('fmt_list', [''])[0]
- if fmt_list:
- for fmt in fmt_list.split(','):
- spec = fmt.split('/')
- if len(spec) > 1:
- width_height = spec[1].split('x')
- if len(width_height) == 2:
- formats_spec[spec[0]] = {
- 'resolution': spec[1],
- 'width': int_or_none(width_height[0]),
- 'height': int_or_none(width_height[1]),
- }
- q = qualities(['small', 'medium', 'hd720'])
- streaming_formats = try_get(player_response, lambda x: x['streamingData']['formats'], list)
- if streaming_formats:
- for fmt in streaming_formats:
- itag = str_or_none(fmt.get('itag'))
- if not itag:
- continue
- quality = fmt.get('quality')
- quality_label = fmt.get('qualityLabel') or quality
- formats_spec[itag] = {
- 'asr': int_or_none(fmt.get('audioSampleRate')),
- 'filesize': int_or_none(fmt.get('contentLength')),
- 'format_note': quality_label,
- 'fps': int_or_none(fmt.get('fps')),
- 'height': int_or_none(fmt.get('height')),
- 'quality': q(quality),
- # bitrate for itag 43 is always 2147483647
- 'tbr': float_or_none(fmt.get('averageBitrate') or fmt.get('bitrate'), 1000) if itag != '43' else None,
- 'width': int_or_none(fmt.get('width')),
- }
- formats = []
- for url_data_str in encoded_url_map.split(','):
- url_data = compat_parse_qs(url_data_str)
- if 'itag' not in url_data or 'url' not in url_data or url_data.get('drm_families'):
- continue
- stream_type = int_or_none(try_get(url_data, lambda x: x['stream_type'][0]))
- # Unsupported FORMAT_STREAM_TYPE_OTF
- if stream_type == 3:
- continue
- format_id = url_data['itag'][0]
- url = url_data['url'][0]
-
- if 's' in url_data or self._downloader.params.get('youtube_include_dash_manifest', True):
- ASSETS_RE = r'"assets":.+?"js":\s*("[^"]+")'
- jsplayer_url_json = self._search_regex(
- ASSETS_RE,
- embed_webpage if age_gate else video_webpage,
- 'JS player URL (1)', default=None)
- if not jsplayer_url_json and not age_gate:
- # We need the embed website after all
- if embed_webpage is None:
- embed_url = proto + '://www.youtube.com/embed/%s' % video_id
- embed_webpage = self._download_webpage(
- embed_url, video_id, 'Downloading embed webpage')
- jsplayer_url_json = self._search_regex(
- ASSETS_RE, embed_webpage, 'JS player URL')
-
- player_url = json.loads(jsplayer_url_json)
- if player_url is None:
- player_url_json = self._search_regex(
- r'ytplayer\.config.*?"url"\s*:\s*("[^"]+")',
- video_webpage, 'age gate player URL')
- player_url = json.loads(player_url_json)
-
- if 'sig' in url_data:
- url += '&signature=' + url_data['sig'][0]
- elif 's' in url_data:
- encrypted_sig = url_data['s'][0]
-
- if self._downloader.params.get('verbose'):
- if player_url is None:
- player_version = 'unknown'
- player_desc = 'unknown'
- else:
- if player_url.endswith('swf'):
- player_version = self._search_regex(
- r'-(.+?)(?:/watch_as3)?\.swf$', player_url,
- 'flash player', fatal=False)
- player_desc = 'flash player %s' % player_version
- else:
- player_version = self._search_regex(
- [r'html5player-([^/]+?)(?:/html5player(?:-new)?)?\.js',
- r'(?:www|player(?:_ias)?)-([^/]+)(?:/[a-z]{2,3}_[A-Z]{2})?/base\.js'],
- player_url,
- 'html5 player', fatal=False)
- player_desc = 'html5 player %s' % player_version
-
- parts_sizes = self._signature_cache_id(encrypted_sig)
- self.to_screen('{%s} signature length %s, %s' %
- (format_id, parts_sizes, player_desc))
-
- signature = self._decrypt_signature(
- encrypted_sig, video_id, player_url, age_gate)
- sp = try_get(url_data, lambda x: x['sp'][0], compat_str) or 'signature'
- url += '&%s=%s' % (sp, signature)
- if 'ratebypass' not in url:
- url += '&ratebypass=yes'
-
- dct = {
- 'format_id': format_id,
- 'url': url,
- 'player_url': player_url,
- }
- if format_id in self._formats:
- dct.update(self._formats[format_id])
- if format_id in formats_spec:
- dct.update(formats_spec[format_id])
-
- # Some itags are not included in DASH manifest thus corresponding formats will
- # lack metadata (see https://github.com/ytdl-org/youtube-dl/pull/5993).
- # Trying to extract metadata from url_encoded_fmt_stream_map entry.
- mobj = re.search(r'^(?P<width>\d+)[xX](?P<height>\d+)$', url_data.get('size', [''])[0])
- width, height = (int(mobj.group('width')), int(mobj.group('height'))) if mobj else (None, None)
-
- filesize = int_or_none(url_data.get(
- 'clen', [None])[0]) or _extract_filesize(url)
-
- quality = url_data.get('quality', [None])[0]
-
- more_fields = {
- 'filesize': filesize,
- 'tbr': float_or_none(url_data.get('bitrate', [None])[0], 1000),
- 'width': width,
- 'height': height,
- 'fps': int_or_none(url_data.get('fps', [None])[0]),
- 'format_note': url_data.get('quality_label', [None])[0] or quality,
- 'quality': q(quality),
- }
- for key, value in more_fields.items():
- if value:
- dct[key] = value
- type_ = url_data.get('type', [None])[0]
- if type_:
- type_split = type_.split(';')
- kind_ext = type_split[0].split('/')
- if len(kind_ext) == 2:
- kind, _ = kind_ext
- dct['ext'] = mimetype2ext(type_split[0])
- if kind in ('audio', 'video'):
- codecs = None
- for mobj in re.finditer(
- r'(?P<key>[a-zA-Z_-]+)=(?P<quote>["\']?)(?P<val>.+?)(?P=quote)(?:;|$)', type_):
- if mobj.group('key') == 'codecs':
- codecs = mobj.group('val')
- break
- if codecs:
- dct.update(parse_codecs(codecs))
- if dct.get('acodec') == 'none' or dct.get('vcodec') == 'none':
- dct['downloader_options'] = {
- # Youtube throttles chunks >~10M
- 'http_chunk_size': 10485760,
- }
- formats.append(dct)
- else:
- manifest_url = (
- url_or_none(try_get(
- player_response,
- lambda x: x['streamingData']['hlsManifestUrl'],
- compat_str))
- or url_or_none(try_get(
- video_info, lambda x: x['hlsvp'][0], compat_str)))
- if manifest_url:
- formats = []
- m3u8_formats = self._extract_m3u8_formats(
- manifest_url, video_id, 'mp4', fatal=False)
- for a_format in m3u8_formats:
- itag = self._search_regex(
- r'/itag/(\d+)/', a_format['url'], 'itag', default=None)
- if itag:
- a_format['format_id'] = itag
- if itag in self._formats:
- dct = self._formats[itag].copy()
- dct.update(a_format)
- a_format = dct
- a_format['player_url'] = player_url
- # Accept-Encoding header causes failures in live streams on Youtube and Youtube Gaming
- a_format.setdefault('http_headers', {})['Youtubedl-no-compression'] = 'True'
- formats.append(a_format)
- else:
- error_message = clean_html(video_info.get('reason', [None])[0])
- if not error_message:
- error_message = extract_unavailable_message()
- if error_message:
- raise ExtractorError(error_message, expected=True)
- raise ExtractorError('no conn, hlsvp, hlsManifestUrl or url_encoded_fmt_stream_map information found in video info')
-
- # uploader
- video_uploader = try_get(
- video_info, lambda x: x['author'][0],
- compat_str) or str_or_none(video_details.get('author'))
- if video_uploader:
- video_uploader = compat_urllib_parse_unquote_plus(video_uploader)
- else:
- self._downloader.report_warning('unable to extract uploader name')
-
- # uploader_id
- video_uploader_id = None
- video_uploader_url = None
- mobj = re.search(
- r'<link itemprop="url" href="(?P<uploader_url>https?://www\.youtube\.com/(?:user|channel)/(?P<uploader_id>[^"]+))">',
- video_webpage)
- if mobj is not None:
- video_uploader_id = mobj.group('uploader_id')
- video_uploader_url = mobj.group('uploader_url')
- else:
- self._downloader.report_warning('unable to extract uploader nickname')
-
- channel_id = (
- str_or_none(video_details.get('channelId'))
- or self._html_search_meta(
- 'channelId', video_webpage, 'channel id', default=None)
- or self._search_regex(
- r'data-channel-external-id=(["\'])(?P<id>(?:(?!\1).)+)\1',
- video_webpage, 'channel id', default=None, group='id'))
- channel_url = 'http://www.youtube.com/channel/%s' % channel_id if channel_id else None
-
- # thumbnail image
- # We try first to get a high quality image:
- m_thumb = re.search(r'<span itemprop="thumbnail".*?href="(.*?)">',
- video_webpage, re.DOTALL)
- if m_thumb is not None:
- video_thumbnail = m_thumb.group(1)
- elif 'thumbnail_url' not in video_info:
- self._downloader.report_warning('unable to extract video thumbnail')
- video_thumbnail = None
- else: # don't panic if we can't find it
- video_thumbnail = compat_urllib_parse_unquote_plus(video_info['thumbnail_url'][0])
-
- # upload date
- upload_date = self._html_search_meta(
- 'datePublished', video_webpage, 'upload date', default=None)
- if not upload_date:
- upload_date = self._search_regex(
- [r'(?s)id="eow-date.*?>(.*?)</span>',
- r'(?:id="watch-uploader-info".*?>.*?|["\']simpleText["\']\s*:\s*["\'])(?:Published|Uploaded|Streamed live|Started) on (.+?)[<"\']'],
- video_webpage, 'upload date', default=None)
- upload_date = unified_strdate(upload_date)
-
- video_license = self._html_search_regex(
- r'<h4[^>]+class="title"[^>]*>\s*License\s*</h4>\s*<ul[^>]*>\s*<li>(.+?)</li',
- video_webpage, 'license', default=None)
-
- m_music = re.search(
- r'''(?x)
- <h4[^>]+class="title"[^>]*>\s*Music\s*</h4>\s*
- <ul[^>]*>\s*
- <li>(?P<title>.+?)
- by (?P<creator>.+?)
- (?:
- \(.+?\)|
- <a[^>]*
- (?:
- \bhref=["\']/red[^>]*>| # drop possible
- >\s*Listen ad-free with YouTube Red # YouTube Red ad
- )
- .*?
- )?</li
- ''',
- video_webpage)
- if m_music:
- video_alt_title = remove_quotes(unescapeHTML(m_music.group('title')))
- video_creator = clean_html(m_music.group('creator'))
- else:
- video_alt_title = video_creator = None
-
- def extract_meta(field):
- return self._html_search_regex(
- r'<h4[^>]+class="title"[^>]*>\s*%s\s*</h4>\s*<ul[^>]*>\s*<li>(.+?)</li>\s*' % field,
- video_webpage, field, default=None)
-
- track = extract_meta('Song')
- artist = extract_meta('Artist')
- album = extract_meta('Album')
-
- # Youtube Music Auto-generated description
- release_date = release_year = None
- if video_description:
- mobj = re.search(r'(?s)Provided to YouTube by [^\n]+\n+(?P<track>[^·]+)·(?P<artist>[^\n]+)\n+(?P<album>[^\n]+)(?:.+?℗\s*(?P<release_year>\d{4})(?!\d))?(?:.+?Released on\s*:\s*(?P<release_date>\d{4}-\d{2}-\d{2}))?(.+?\nArtist\s*:\s*(?P<clean_artist>[^\n]+))?', video_description)
- if mobj:
- if not track:
- track = mobj.group('track').strip()
- if not artist:
- artist = mobj.group('clean_artist') or ', '.join(a.strip() for a in mobj.group('artist').split('·'))
- if not album:
- album = mobj.group('album'.strip())
- release_year = mobj.group('release_year')
- release_date = mobj.group('release_date')
- if release_date:
- release_date = release_date.replace('-', '')
- if not release_year:
- release_year = int(release_date[:4])
- if release_year:
- release_year = int(release_year)
-
- m_episode = re.search(
- r'<div[^>]+id="watch7-headline"[^>]*>\s*<span[^>]*>.*?>(?P<series>[^<]+)</a></b>\s*S(?P<season>\d+)\s*•\s*E(?P<episode>\d+)</span>',
- video_webpage)
- if m_episode:
- series = unescapeHTML(m_episode.group('series'))
- season_number = int(m_episode.group('season'))
- episode_number = int(m_episode.group('episode'))
- else:
- series = season_number = episode_number = None
-
- m_cat_container = self._search_regex(
- r'(?s)<h4[^>]*>\s*Category\s*</h4>\s*<ul[^>]*>(.*?)</ul>',
- video_webpage, 'categories', default=None)
- if m_cat_container:
- category = self._html_search_regex(
- r'(?s)<a[^<]+>(.*?)</a>', m_cat_container, 'category',
- default=None)
- video_categories = None if category is None else [category]
- else:
- video_categories = None
-
- video_tags = [
- unescapeHTML(m.group('content'))
- for m in re.finditer(self._meta_regex('og:video:tag'), video_webpage)]
-
- def _extract_count(count_name):
- return str_to_int(self._search_regex(
- r'-%s-button[^>]+><span[^>]+class="yt-uix-button-content"[^>]*>([\d,]+)</span>'
- % re.escape(count_name),
- video_webpage, count_name, default=None))
-
- like_count = _extract_count('like')
- dislike_count = _extract_count('dislike')
-
- if view_count is None:
- view_count = str_to_int(self._search_regex(
- r'<[^>]+class=["\']watch-view-count[^>]+>\s*([\d,\s]+)', video_webpage,
- 'view count', default=None))
-
- average_rating = (
- float_or_none(video_details.get('averageRating'))
- or try_get(video_info, lambda x: float_or_none(x['avg_rating'][0])))
-
- # subtitles
- video_subtitles = self.extract_subtitles(video_id, video_webpage)
- automatic_captions = self.extract_automatic_captions(video_id, video_webpage)
-
- video_duration = try_get(
- video_info, lambda x: int_or_none(x['length_seconds'][0]))
- if not video_duration:
- video_duration = int_or_none(video_details.get('lengthSeconds'))
- if not video_duration:
- video_duration = parse_duration(self._html_search_meta(
- 'duration', video_webpage, 'video duration'))
-
- # annotations
- video_annotations = None
- if self._downloader.params.get('writeannotations', False):
- video_annotations = self._extract_annotations(video_id)
-
- chapters = self._extract_chapters(description_original, video_duration)
-
- # Look for the DASH manifest
- if self._downloader.params.get('youtube_include_dash_manifest', True):
- dash_mpd_fatal = True
- for mpd_url in dash_mpds:
- dash_formats = {}
- try:
- def decrypt_sig(mobj):
- s = mobj.group(1)
- dec_s = self._decrypt_signature(s, video_id, player_url, age_gate)
- return '/signature/%s' % dec_s
-
- mpd_url = re.sub(r'/s/([a-fA-F0-9\.]+)', decrypt_sig, mpd_url)
-
- for df in self._extract_mpd_formats(
- mpd_url, video_id, fatal=dash_mpd_fatal,
- formats_dict=self._formats):
- if not df.get('filesize'):
- df['filesize'] = _extract_filesize(df['url'])
- # Do not overwrite DASH format found in some previous DASH manifest
- if df['format_id'] not in dash_formats:
- dash_formats[df['format_id']] = df
- # Additional DASH manifests may end up in HTTP Error 403 therefore
- # allow them to fail without bug report message if we already have
- # some DASH manifest succeeded. This is temporary workaround to reduce
- # burst of bug reports until we figure out the reason and whether it
- # can be fixed at all.
- dash_mpd_fatal = False
- except (ExtractorError, KeyError) as e:
- self.report_warning(
- 'Skipping DASH manifest: %r' % e, video_id)
- if dash_formats:
- # Remove the formats we found through non-DASH, they
- # contain less info and it can be wrong, because we use
- # fixed values (for example the resolution). See
- # https://github.com/ytdl-org/youtube-dl/issues/5774 for an
- # example.
- formats = [f for f in formats if f['format_id'] not in dash_formats.keys()]
- formats.extend(dash_formats.values())
-
- # Check for malformed aspect ratio
- stretched_m = re.search(
- r'<meta\s+property="og:video:tag".*?content="yt:stretch=(?P<w>[0-9]+):(?P<h>[0-9]+)">',
- video_webpage)
- if stretched_m:
- w = float(stretched_m.group('w'))
- h = float(stretched_m.group('h'))
- # yt:stretch may hold invalid ratio data (e.g. for Q39EVAstoRM ratio is 17:0).
- # We will only process correct ratios.
- if w > 0 and h > 0:
- ratio = w / h
- for f in formats:
- if f.get('vcodec') != 'none':
- f['stretched_ratio'] = ratio
-
- if not formats:
- token = extract_token(video_info)
- if not token:
- if 'reason' in video_info:
- if 'The uploader has not made this video available in your country.' in video_info['reason']:
- regions_allowed = self._html_search_meta(
- 'regionsAllowed', video_webpage, default=None)
- countries = regions_allowed.split(',') if regions_allowed else None
- self.raise_geo_restricted(
- msg=video_info['reason'][0], countries=countries)
- reason = video_info['reason'][0]
- if 'Invalid parameters' in reason:
- unavailable_message = extract_unavailable_message()
- if unavailable_message:
- reason = unavailable_message
- raise ExtractorError(
- 'YouTube said: %s' % reason,
- expected=True, video_id=video_id)
- else:
- raise ExtractorError(
- '"token" parameter not in video info for unknown reason',
- video_id=video_id)
-
- if not formats and (video_info.get('license_info') or try_get(player_response, lambda x: x['streamingData']['licenseInfos'])):
- raise ExtractorError('This video is DRM protected.', expected=True)
-
- self._sort_formats(formats)
-
- self.mark_watched(video_id, video_info, player_response)
-
- return {
- 'id': video_id,
- 'uploader': video_uploader,
- 'uploader_id': video_uploader_id,
- 'uploader_url': video_uploader_url,
- 'channel_id': channel_id,
- 'channel_url': channel_url,
- 'upload_date': upload_date,
- 'license': video_license,
- 'creator': video_creator or artist,
- 'title': video_title,
- 'alt_title': video_alt_title or track,
- 'thumbnail': video_thumbnail,
- 'description': video_description,
- 'categories': video_categories,
- 'tags': video_tags,
- 'subtitles': video_subtitles,
- 'automatic_captions': automatic_captions,
- 'duration': video_duration,
- 'age_limit': 18 if age_gate else 0,
- 'annotations': video_annotations,
- 'chapters': chapters,
- 'webpage_url': proto + '://www.youtube.com/watch?v=%s' % video_id,
- 'view_count': view_count,
- 'like_count': like_count,
- 'dislike_count': dislike_count,
- 'average_rating': average_rating,
- 'formats': formats,
- 'is_live': is_live,
- 'start_time': start_time,
- 'end_time': end_time,
- 'series': series,
- 'season_number': season_number,
- 'episode_number': episode_number,
- 'track': track,
- 'artist': artist,
- 'album': album,
- 'release_date': release_date,
- 'release_year': release_year,
- }
-
-
-class YoutubePlaylistIE(YoutubePlaylistBaseInfoExtractor):
- IE_DESC = 'YouTube.com playlists'
- _VALID_URL = r"""(?x)(?:
- (?:https?://)?
- (?:\w+\.)?
- (?:
- (?:
- youtube\.com|
- invidio\.us
- )
- /
- (?:
- (?:course|view_play_list|my_playlists|artist|playlist|watch|embed/(?:videoseries|[0-9A-Za-z_-]{11}))
- \? (?:.*?[&;])*? (?:p|a|list)=
- | p/
- )|
- youtu\.be/[0-9A-Za-z_-]{11}\?.*?\blist=
- )
- (
- (?:PL|LL|EC|UU|FL|RD|UL|TL|OLAK5uy_)?[0-9A-Za-z-_]{10,}
- # Top tracks, they can also include dots
- |(?:MC)[\w\.]*
- )
- .*
- |
- (%(playlist_id)s)
- )""" % {'playlist_id': YoutubeBaseInfoExtractor._PLAYLIST_ID_RE}
- _TEMPLATE_URL = 'https://www.youtube.com/playlist?list=%s'
- _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})&amp;[^"]*?index=(?P<index>\d+)(?:[^>]+>(?P<title>[^<]+))?'
- IE_NAME = 'youtube:playlist'
- _TESTS = [{
- 'url': 'https://www.youtube.com/playlist?list=PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
- 'info_dict': {
- 'title': 'ytdl test PL',
- 'id': 'PLwiyx1dc3P2JR9N8gQaQN_BCvlSlap7re',
- },
- 'playlist_count': 3,
- }, {
- 'url': 'https://www.youtube.com/playlist?list=PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
- 'info_dict': {
- 'id': 'PLtPgu7CB4gbZDA7i_euNxn75ISqxwZPYx',
- 'title': 'YDL_Empty_List',
- },
- 'playlist_count': 0,
- 'skip': 'This playlist is private',
- }, {
- 'note': 'Playlist with deleted videos (#651). As a bonus, the video #51 is also twice in this list.',
- 'url': 'https://www.youtube.com/playlist?list=PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
- 'info_dict': {
- 'title': '29C3: Not my department',
- 'id': 'PLwP_SiAcdui0KVebT0mU9Apz359a4ubsC',
- },
- 'playlist_count': 95,
- }, {
- 'note': 'issue #673',
- 'url': 'PLBB231211A4F62143',
- 'info_dict': {
- 'title': '[OLD]Team Fortress 2 (Class-based LP)',
- 'id': 'PLBB231211A4F62143',
- },
- 'playlist_mincount': 26,
- }, {
- 'note': 'Large playlist',
- 'url': 'https://www.youtube.com/playlist?list=UUBABnxM4Ar9ten8Mdjj1j0Q',
- 'info_dict': {
- 'title': 'Uploads from Cauchemar',
- 'id': 'UUBABnxM4Ar9ten8Mdjj1j0Q',
- },
- 'playlist_mincount': 799,
- }, {
- 'url': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
- 'info_dict': {
- 'title': 'YDL_safe_search',
- 'id': 'PLtPgu7CB4gbY9oDN3drwC3cMbJggS7dKl',
- },
- 'playlist_count': 2,
- 'skip': 'This playlist is private',
- }, {
- 'note': 'embedded',
- 'url': 'https://www.youtube.com/embed/videoseries?list=PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
- 'playlist_count': 4,
- 'info_dict': {
- 'title': 'JODA15',
- 'id': 'PL6IaIsEjSbf96XFRuNccS_RuEXwNdsoEu',
- }
- }, {
- 'url': 'http://www.youtube.com/embed/_xDOZElKyNU?list=PLsyOSbh5bs16vubvKePAQ1x3PhKavfBIl',
- 'playlist_mincount': 485,
- 'info_dict': {
- 'title': '2017 華語最新單曲 (2/24更新)',
- 'id': 'PLsyOSbh5bs16vubvKePAQ1x3PhKavfBIl',
- }
- }, {
- 'note': 'Embedded SWF player',
- 'url': 'https://www.youtube.com/p/YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ?hl=en_US&fs=1&rel=0',
- 'playlist_count': 4,
- 'info_dict': {
- 'title': 'JODA7',
- 'id': 'YN5VISEtHet5D4NEvfTd0zcgFk84NqFZ',
- }
- }, {
- 'note': 'Buggy playlist: the webpage has a "Load more" button but it doesn\'t have more videos',
- 'url': 'https://www.youtube.com/playlist?list=UUXw-G3eDE9trcvY2sBMM_aA',
- 'info_dict': {
- 'title': 'Uploads from Interstellar Movie',
- 'id': 'UUXw-G3eDE9trcvY2sBMM_aA',
- },
- 'playlist_mincount': 21,
- }, {
- # Playlist URL that does not actually serve a playlist
- 'url': 'https://www.youtube.com/watch?v=FqZTN594JQw&list=PLMYEtVRpaqY00V9W81Cwmzp6N6vZqfUKD4',
- 'info_dict': {
- 'id': 'FqZTN594JQw',
- 'ext': 'webm',
- 'title': "Smiley's People 01 detective, Adventure Series, Action",
- 'uploader': 'STREEM',
- 'uploader_id': 'UCyPhqAZgwYWZfxElWVbVJng',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/channel/UCyPhqAZgwYWZfxElWVbVJng',
- 'upload_date': '20150526',
- 'license': 'Standard YouTube License',
- 'description': 'md5:507cdcb5a49ac0da37a920ece610be80',
- 'categories': ['People & Blogs'],
- 'tags': list,
- 'view_count': int,
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- 'add_ie': [YoutubeIE.ie_key()],
- }, {
- 'url': 'https://youtu.be/yeWKywCrFtk?list=PL2qgrgXsNUG5ig9cat4ohreBjYLAPC0J5',
- 'info_dict': {
- 'id': 'yeWKywCrFtk',
- 'ext': 'mp4',
- 'title': 'Small Scale Baler and Braiding Rugs',
- 'uploader': 'Backus-Page House Museum',
- 'uploader_id': 'backuspagemuseum',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/backuspagemuseum',
- 'upload_date': '20161008',
- 'license': 'Standard YouTube License',
- 'description': 'md5:800c0c78d5eb128500bffd4f0b4f2e8a',
- 'categories': ['Nonprofits & Activism'],
- 'tags': list,
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'noplaylist': True,
- 'skip_download': True,
- },
- }, {
- 'url': 'https://youtu.be/uWyaPkt-VOI?list=PL9D9FC436B881BA21',
- 'only_matching': True,
- }, {
- 'url': 'TLGGrESM50VT6acwMjAyMjAxNw',
- 'only_matching': True,
- }, {
- # music album playlist
- 'url': 'OLAK5uy_m4xAFdmMC5rX3Ji3g93pQe3hqLZw_9LhM',
- 'only_matching': True,
- }, {
- 'url': 'https://invidio.us/playlist?list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU',
- 'only_matching': True,
- }]
-
- def _real_initialize(self):
- self._login()
-
- def _extract_mix(self, playlist_id):
- # The mixes are generated from a single video
- # the id of the playlist is just 'RD' + video_id
- ids = []
- last_id = playlist_id[-11:]
- for n in itertools.count(1):
- url = 'https://youtube.com/watch?v=%s&list=%s' % (last_id, playlist_id)
- webpage = self._download_webpage(
- url, playlist_id, 'Downloading page {0} of Youtube mix'.format(n))
- new_ids = orderedSet(re.findall(
- r'''(?xs)data-video-username=".*?".*?
- href="/watch\?v=([0-9A-Za-z_-]{11})&amp;[^"]*?list=%s''' % re.escape(playlist_id),
- webpage))
- # Fetch new pages until all the videos are repeated, it seems that
- # there are always 51 unique videos.
- new_ids = [_id for _id in new_ids if _id not in ids]
- if not new_ids:
- break
- ids.extend(new_ids)
- last_id = ids[-1]
-
- url_results = self._ids_to_results(ids)
-
- search_title = lambda class_name: get_element_by_attribute('class', class_name, webpage)
- title_span = (
- search_title('playlist-title')
- or search_title('title long-title')
- or search_title('title'))
- title = clean_html(title_span)
-
- return self.playlist_result(url_results, playlist_id, title)
-
- def _extract_playlist(self, playlist_id):
- url = self._TEMPLATE_URL % playlist_id
- page = self._download_webpage(url, playlist_id)
-
- # the yt-alert-message now has tabindex attribute (see https://github.com/ytdl-org/youtube-dl/issues/11604)
- for match in re.findall(r'<div class="yt-alert-message"[^>]*>([^<]+)</div>', page):
- match = match.strip()
- # Check if the playlist exists or is private
- mobj = re.match(r'[^<]*(?:The|This) playlist (?P<reason>does not exist|is private)[^<]*', match)
- if mobj:
- reason = mobj.group('reason')
- message = 'This playlist %s' % reason
- if 'private' in reason:
- message += ', use --username or --netrc to access it'
- message += '.'
- raise ExtractorError(message, expected=True)
- elif re.match(r'[^<]*Invalid parameters[^<]*', match):
- raise ExtractorError(
- 'Invalid parameters. Maybe URL is incorrect.',
- expected=True)
- elif re.match(r'[^<]*Choose your language[^<]*', match):
- continue
- else:
- self.report_warning('Youtube gives an alert message: ' + match)
-
- playlist_title = self._html_search_regex(
- r'(?s)<h1 class="pl-header-title[^"]*"[^>]*>\s*(.*?)\s*</h1>',
- page, 'title', default=None)
-
- _UPLOADER_BASE = r'class=["\']pl-header-details[^>]+>\s*<li>\s*<a[^>]+\bhref='
- uploader = self._search_regex(
- r'%s["\']/(?:user|channel)/[^>]+>([^<]+)' % _UPLOADER_BASE,
- page, 'uploader', default=None)
- mobj = re.search(
- r'%s(["\'])(?P<path>/(?:user|channel)/(?P<uploader_id>.+?))\1' % _UPLOADER_BASE,
- page)
- if mobj:
- uploader_id = mobj.group('uploader_id')
- uploader_url = compat_urlparse.urljoin(url, mobj.group('path'))
- else:
- uploader_id = uploader_url = None
-
- has_videos = True
-
- if not playlist_title:
- try:
- # Some playlist URLs don't actually serve a playlist (e.g.
- # https://www.youtube.com/watch?v=FqZTN594JQw&list=PLMYEtVRpaqY00V9W81Cwmzp6N6vZqfUKD4)
- next(self._entries(page, playlist_id))
- except StopIteration:
- has_videos = False
-
- playlist = self.playlist_result(
- self._entries(page, playlist_id), playlist_id, playlist_title)
- playlist.update({
- 'uploader': uploader,
- 'uploader_id': uploader_id,
- 'uploader_url': uploader_url,
- })
-
- return has_videos, playlist
-
- def _check_download_just_video(self, url, playlist_id):
- # Check if it's a video-specific URL
- query_dict = compat_urlparse.parse_qs(compat_urlparse.urlparse(url).query)
- video_id = query_dict.get('v', [None])[0] or self._search_regex(
- r'(?:(?:^|//)youtu\.be/|youtube\.com/embed/(?!videoseries))([0-9A-Za-z_-]{11})', url,
- 'video id', default=None)
- if video_id:
- if self._downloader.params.get('noplaylist'):
- self.to_screen('Downloading just video %s because of --no-playlist' % video_id)
- return video_id, self.url_result(video_id, 'Youtube', video_id=video_id)
- else:
- self.to_screen('Downloading playlist %s - add --no-playlist to just download video %s' % (playlist_id, video_id))
- return video_id, None
- return None, None
-
- def _real_extract(self, url):
- # Extract playlist id
- mobj = re.match(self._VALID_URL, url)
- if mobj is None:
- raise ExtractorError('Invalid URL: %s' % url)
- playlist_id = mobj.group(1) or mobj.group(2)
-
- video_id, video = self._check_download_just_video(url, playlist_id)
- if video:
- return video
-
- if playlist_id.startswith(('RD', 'UL', 'PU')):
- # Mixes require a custom extraction process
- return self._extract_mix(playlist_id)
-
- has_videos, playlist = self._extract_playlist(playlist_id)
- if has_videos or not video_id:
- return playlist
-
- # Some playlist URLs don't actually serve a playlist (see
- # https://github.com/ytdl-org/youtube-dl/issues/10537).
- # Fallback to plain video extraction if there is a video id
- # along with playlist id.
- return self.url_result(video_id, 'Youtube', video_id=video_id)
-
-
-class YoutubeChannelIE(YoutubePlaylistBaseInfoExtractor):
- IE_DESC = 'YouTube.com channels'
- _VALID_URL = r'https?://(?:youtu\.be|(?:\w+\.)?youtube(?:-nocookie)?\.com|(?:www\.)?invidio\.us)/channel/(?P<id>[0-9A-Za-z_-]+)'
- _TEMPLATE_URL = 'https://www.youtube.com/channel/%s/videos'
- _VIDEO_RE = r'(?:title="(?P<title>[^"]+)"[^>]+)?href="/watch\?v=(?P<id>[0-9A-Za-z_-]+)&?'
- IE_NAME = 'youtube:channel'
- _TESTS = [{
- 'note': 'paginated channel',
- 'url': 'https://www.youtube.com/channel/UCKfVa3S1e4PHvxWcwyMMg8w',
- 'playlist_mincount': 91,
- 'info_dict': {
- 'id': 'UUKfVa3S1e4PHvxWcwyMMg8w',
- 'title': 'Uploads from lex will',
- }
- }, {
- 'note': 'Age restricted channel',
- # from https://www.youtube.com/user/DeusExOfficial
- 'url': 'https://www.youtube.com/channel/UCs0ifCMCm1icqRbqhUINa0w',
- 'playlist_mincount': 64,
- 'info_dict': {
- 'id': 'UUs0ifCMCm1icqRbqhUINa0w',
- 'title': 'Uploads from Deus Ex',
- },
- }, {
- 'url': 'https://invidio.us/channel/UC23qupoDRn9YOAVzeoxjOQA',
- 'only_matching': True,
- }]
-
- @classmethod
- def suitable(cls, url):
- return (False if YoutubePlaylistsIE.suitable(url) or YoutubeLiveIE.suitable(url)
- else super(YoutubeChannelIE, cls).suitable(url))
-
- def _build_template_url(self, url, channel_id):
- return self._TEMPLATE_URL % channel_id
-
- def _real_extract(self, url):
- channel_id = self._match_id(url)
-
- url = self._build_template_url(url, channel_id)
-
- # Channel by page listing is restricted to 35 pages of 30 items, i.e. 1050 videos total (see #5778)
- # Workaround by extracting as a playlist if managed to obtain channel playlist URL
- # otherwise fallback on channel by page extraction
- channel_page = self._download_webpage(
- url + '?view=57', channel_id,
- 'Downloading channel page', fatal=False)
- if channel_page is False:
- channel_playlist_id = False
- else:
- channel_playlist_id = self._html_search_meta(
- 'channelId', channel_page, 'channel id', default=None)
- if not channel_playlist_id:
- channel_url = self._html_search_meta(
- ('al:ios:url', 'twitter:app:url:iphone', 'twitter:app:url:ipad'),
- channel_page, 'channel url', default=None)
- if channel_url:
- channel_playlist_id = self._search_regex(
- r'vnd\.youtube://user/([0-9A-Za-z_-]+)',
- channel_url, 'channel id', default=None)
- if channel_playlist_id and channel_playlist_id.startswith('UC'):
- playlist_id = 'UU' + channel_playlist_id[2:]
- return self.url_result(
- compat_urlparse.urljoin(url, '/playlist?list=%s' % playlist_id), 'YoutubePlaylist')
-
- channel_page = self._download_webpage(url, channel_id, 'Downloading page #1')
- autogenerated = re.search(r'''(?x)
- class="[^"]*?(?:
- channel-header-autogenerated-label|
- yt-channel-title-autogenerated
- )[^"]*"''', channel_page) is not None
-
- if autogenerated:
- # The videos are contained in a single page
- # the ajax pages can't be used, they are empty
- entries = [
- self.url_result(
- video_id, 'Youtube', video_id=video_id,
- video_title=video_title)
- for video_id, video_title in self.extract_videos_from_page(channel_page)]
- return self.playlist_result(entries, channel_id)
-
- try:
- next(self._entries(channel_page, channel_id))
- except StopIteration:
- alert_message = self._html_search_regex(
- r'(?s)<div[^>]+class=(["\']).*?\byt-alert-message\b.*?\1[^>]*>(?P<alert>[^<]+)</div>',
- channel_page, 'alert', default=None, group='alert')
- if alert_message:
- raise ExtractorError('Youtube said: %s' % alert_message, expected=True)
-
- return self.playlist_result(self._entries(channel_page, channel_id), channel_id)
-
-
-class YoutubeUserIE(YoutubeChannelIE):
- IE_DESC = 'YouTube.com user videos (URL or "ytuser" keyword)'
- _VALID_URL = r'(?:(?:https?://(?:\w+\.)?youtube\.com/(?:(?P<user>user|c)/)?(?!(?:attribution_link|watch|results|shared)(?:$|[^a-z_A-Z0-9-])))|ytuser:)(?!feed/)(?P<id>[A-Za-z0-9_-]+)'
- _TEMPLATE_URL = 'https://www.youtube.com/%s/%s/videos'
- IE_NAME = 'youtube:user'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/user/TheLinuxFoundation',
- 'playlist_mincount': 320,
- 'info_dict': {
- 'id': 'UUfX55Sx5hEFjoC3cNs6mCUQ',
- 'title': 'Uploads from The Linux Foundation',
- }
- }, {
- # Only available via https://www.youtube.com/c/12minuteathlete/videos
- # but not https://www.youtube.com/user/12minuteathlete/videos
- 'url': 'https://www.youtube.com/c/12minuteathlete/videos',
- 'playlist_mincount': 249,
- 'info_dict': {
- 'id': 'UUVjM-zV6_opMDx7WYxnjZiQ',
- 'title': 'Uploads from 12 Minute Athlete',
- }
- }, {
- 'url': 'ytuser:phihag',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/c/gametrailers',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/gametrailers',
- 'only_matching': True,
- }, {
- # This channel is not available, geo restricted to JP
- 'url': 'https://www.youtube.com/user/kananishinoSMEJ/videos',
- 'only_matching': True,
- }]
-
- @classmethod
- def suitable(cls, url):
- # Don't return True if the url can be extracted with other youtube
- # extractor, the regex would is too permissive and it would match.
- other_yt_ies = iter(klass for (name, klass) in globals().items() if name.startswith('Youtube') and name.endswith('IE') and klass is not cls)
- if any(ie.suitable(url) for ie in other_yt_ies):
- return False
- else:
- return super(YoutubeUserIE, cls).suitable(url)
-
- def _build_template_url(self, url, channel_id):
- mobj = re.match(self._VALID_URL, url)
- return self._TEMPLATE_URL % (mobj.group('user') or 'user', mobj.group('id'))
-
-
-class YoutubeLiveIE(YoutubeBaseInfoExtractor):
- IE_DESC = 'YouTube.com live streams'
- _VALID_URL = r'(?P<base_url>https?://(?:\w+\.)?youtube\.com/(?:(?:user|channel|c)/)?(?P<id>[^/]+))/live'
- IE_NAME = 'youtube:live'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/user/TheYoungTurks/live',
- 'info_dict': {
- 'id': 'a48o2S1cPoo',
- 'ext': 'mp4',
- 'title': 'The Young Turks - Live Main Show',
- 'uploader': 'The Young Turks',
- 'uploader_id': 'TheYoungTurks',
- 'uploader_url': r're:https?://(?:www\.)?youtube\.com/user/TheYoungTurks',
- 'upload_date': '20150715',
- 'license': 'Standard YouTube License',
- 'description': 'md5:438179573adcdff3c97ebb1ee632b891',
- 'categories': ['News & Politics'],
- 'tags': ['Cenk Uygur (TV Program Creator)', 'The Young Turks (Award-Winning Work)', 'Talk Show (TV Genre)'],
- 'like_count': int,
- 'dislike_count': int,
- },
- 'params': {
- 'skip_download': True,
- },
- }, {
- 'url': 'https://www.youtube.com/channel/UC1yBKRuGpC1tSM73A0ZjYjQ/live',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/c/CommanderVideoHq/live',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/TheYoungTurks/live',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- mobj = re.match(self._VALID_URL, url)
- channel_id = mobj.group('id')
- base_url = mobj.group('base_url')
- webpage = self._download_webpage(url, channel_id, fatal=False)
- if webpage:
- page_type = self._og_search_property(
- 'type', webpage, 'page type', default='')
- video_id = self._html_search_meta(
- 'videoId', webpage, 'video id', default=None)
- if page_type.startswith('video') and video_id and re.match(
- r'^[0-9A-Za-z_-]{11}$', video_id):
- return self.url_result(video_id, YoutubeIE.ie_key())
- return self.url_result(base_url)
-
-
-class YoutubePlaylistsIE(YoutubePlaylistsBaseInfoExtractor):
- IE_DESC = 'YouTube.com user/channel playlists'
- _VALID_URL = r'https?://(?:\w+\.)?youtube\.com/(?:user|channel)/(?P<id>[^/]+)/playlists'
- IE_NAME = 'youtube:playlists'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/user/ThirstForScience/playlists',
- 'playlist_mincount': 4,
- 'info_dict': {
- 'id': 'ThirstForScience',
- 'title': 'Thirst for Science',
- },
- }, {
- # with "Load more" button
- 'url': 'https://www.youtube.com/user/igorkle1/playlists?view=1&sort=dd',
- 'playlist_mincount': 70,
- 'info_dict': {
- 'id': 'igorkle1',
- 'title': 'Игорь Клейнер',
- },
- }, {
- 'url': 'https://www.youtube.com/channel/UCiU1dHvZObB2iP6xkJ__Icw/playlists',
- 'playlist_mincount': 17,
- 'info_dict': {
- 'id': 'UCiU1dHvZObB2iP6xkJ__Icw',
- 'title': 'Chem Player',
- },
- }]
-
-
-class YoutubeSearchBaseInfoExtractor(YoutubePlaylistBaseInfoExtractor):
- _VIDEO_RE = r'href="\s*/watch\?v=(?P<id>[0-9A-Za-z_-]{11})(?:[^"]*"[^>]+\btitle="(?P<title>[^"]+))?'
-
-
-class YoutubeSearchIE(SearchInfoExtractor, YoutubeSearchBaseInfoExtractor):
- IE_DESC = 'YouTube.com searches'
- # there doesn't appear to be a real limit, for example if you search for
- # 'python' you get more than 8.000.000 results
- _MAX_RESULTS = float('inf')
- IE_NAME = 'youtube:search'
- _SEARCH_KEY = 'ytsearch'
- _EXTRA_QUERY_ARGS = {}
- _TESTS = []
-
- def _get_n_results(self, query, n):
- """Get a specified number of results for a query"""
-
- videos = []
- limit = n
-
- url_query = {
- 'search_query': query.encode('utf-8'),
- }
- url_query.update(self._EXTRA_QUERY_ARGS)
- result_url = 'https://www.youtube.com/results?' + compat_urllib_parse_urlencode(url_query)
-
- for pagenum in itertools.count(1):
- data = self._download_json(
- result_url, video_id='query "%s"' % query,
- note='Downloading page %s' % pagenum,
- errnote='Unable to download API page',
- query={'spf': 'navigate'})
- html_content = data[1]['body']['content']
-
- if 'class="search-message' in html_content:
- raise ExtractorError(
- '[youtube] No video results', expected=True)
-
- new_videos = list(self._process_page(html_content))
- videos += new_videos
- if not new_videos or len(videos) > limit:
- break
- next_link = self._html_search_regex(
- r'href="(/results\?[^"]*\bsp=[^"]+)"[^>]*>\s*<span[^>]+class="[^"]*\byt-uix-button-content\b[^"]*"[^>]*>Next',
- html_content, 'next link', default=None)
- if next_link is None:
- break
- result_url = compat_urlparse.urljoin('https://www.youtube.com/', next_link)
-
- if len(videos) > n:
- videos = videos[:n]
- return self.playlist_result(videos, query)
-
-
-class YoutubeSearchDateIE(YoutubeSearchIE):
- IE_NAME = YoutubeSearchIE.IE_NAME + ':date'
- _SEARCH_KEY = 'ytsearchdate'
- IE_DESC = 'YouTube.com searches, newest videos first'
- _EXTRA_QUERY_ARGS = {'search_sort': 'video_date_uploaded'}
-
-
-class YoutubeSearchURLIE(YoutubeSearchBaseInfoExtractor):
- IE_DESC = 'YouTube.com search URLs'
- IE_NAME = 'youtube:search_url'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/results\?(.*?&)?(?:search_query|q)=(?P<query>[^&]+)(?:[&]|$)'
- _TESTS = [{
- 'url': 'https://www.youtube.com/results?baz=bar&search_query=youtube-dl+test+video&filters=video&lclk=video',
- 'playlist_mincount': 5,
- 'info_dict': {
- 'title': 'youtube-dl test video',
- }
- }, {
- 'url': 'https://www.youtube.com/results?q=test&sp=EgQIBBgB',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- mobj = re.match(self._VALID_URL, url)
- query = compat_urllib_parse_unquote_plus(mobj.group('query'))
- webpage = self._download_webpage(url, query)
- return self.playlist_result(self._process_page(webpage), playlist_title=query)
-
-
-class YoutubeShowIE(YoutubePlaylistsBaseInfoExtractor):
- IE_DESC = 'YouTube.com (multi-season) shows'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/show/(?P<id>[^?#]*)'
- IE_NAME = 'youtube:show'
- _TESTS = [{
- 'url': 'https://www.youtube.com/show/airdisasters',
- 'playlist_mincount': 5,
- 'info_dict': {
- 'id': 'airdisasters',
- 'title': 'Air Disasters',
- }
- }]
-
- def _real_extract(self, url):
- playlist_id = self._match_id(url)
- return super(YoutubeShowIE, self)._real_extract(
- 'https://www.youtube.com/show/%s/playlists' % playlist_id)
-
-
-class YoutubeFeedsInfoExtractor(YoutubeBaseInfoExtractor):
- """
- Base class for feed extractors
- Subclasses must define the _FEED_NAME and _PLAYLIST_TITLE properties.
- """
- _LOGIN_REQUIRED = True
-
- @property
- def IE_NAME(self):
- return 'youtube:%s' % self._FEED_NAME
-
- def _real_initialize(self):
- self._login()
-
- def _entries(self, page):
- # The extraction process is the same as for playlists, but the regex
- # for the video ids doesn't contain an index
- ids = []
- more_widget_html = content_html = page
- for page_num in itertools.count(1):
- matches = re.findall(r'href="\s*/watch\?v=([0-9A-Za-z_-]{11})', content_html)
-
- # 'recommended' feed has infinite 'load more' and each new portion spins
- # the same videos in (sometimes) slightly different order, so we'll check
- # for unicity and break when portion has no new videos
- new_ids = list(filter(lambda video_id: video_id not in ids, orderedSet(matches)))
- if not new_ids:
- break
-
- ids.extend(new_ids)
-
- for entry in self._ids_to_results(new_ids):
- yield entry
-
- mobj = re.search(r'data-uix-load-more-href="/?(?P<more>[^"]+)"', more_widget_html)
- if not mobj:
- break
-
- more = self._download_json(
- 'https://youtube.com/%s' % mobj.group('more'), self._PLAYLIST_TITLE,
- 'Downloading page #%s' % page_num,
- transform_source=uppercase_escape)
- content_html = more['content_html']
- more_widget_html = more['load_more_widget_html']
-
- def _real_extract(self, url):
- page = self._download_webpage(
- 'https://www.youtube.com/feed/%s' % self._FEED_NAME,
- self._PLAYLIST_TITLE)
- return self.playlist_result(
- self._entries(page), playlist_title=self._PLAYLIST_TITLE)
-
-
-class YoutubeWatchLaterIE(YoutubePlaylistIE):
- IE_NAME = 'youtube:watchlater'
- IE_DESC = 'Youtube watch later list, ":ytwatchlater" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/(?:feed/watch_later|(?:playlist|watch)\?(?:.+&)?list=WL)|:ytwatchlater'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/playlist?list=WL',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?v=bCNU9TrbiRk&index=1&list=WL',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- _, video = self._check_download_just_video(url, 'WL')
- if video:
- return video
- _, playlist = self._extract_playlist('WL')
- return playlist
-
-
-class YoutubeFavouritesIE(YoutubeBaseInfoExtractor):
- IE_NAME = 'youtube:favorites'
- IE_DESC = 'YouTube.com favourite videos, ":ytfav" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/my_favorites|:ytfav(?:ou?rites)?'
- _LOGIN_REQUIRED = True
-
- def _real_extract(self, url):
- webpage = self._download_webpage('https://www.youtube.com/my_favorites', 'Youtube Favourites videos')
- playlist_id = self._search_regex(r'list=(.+?)["&]', webpage, 'favourites playlist id')
- return self.url_result(playlist_id, 'YoutubePlaylist')
-
-
-class YoutubeRecommendedIE(YoutubeFeedsInfoExtractor):
- IE_DESC = 'YouTube.com recommended videos, ":ytrec" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/feed/recommended|:ytrec(?:ommended)?'
- _FEED_NAME = 'recommended'
- _PLAYLIST_TITLE = 'Youtube Recommended videos'
-
-
-class YoutubeSubscriptionsIE(YoutubeFeedsInfoExtractor):
- IE_DESC = 'YouTube.com subscriptions feed, "ytsubs" keyword (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/feed/subscriptions|:ytsubs(?:criptions)?'
- _FEED_NAME = 'subscriptions'
- _PLAYLIST_TITLE = 'Youtube Subscriptions'
-
-
-class YoutubeHistoryIE(YoutubeFeedsInfoExtractor):
- IE_DESC = 'Youtube watch history, ":ythistory" for short (requires authentication)'
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/feed/history|:ythistory'
- _FEED_NAME = 'history'
- _PLAYLIST_TITLE = 'Youtube History'
-
-
-class YoutubeTruncatedURLIE(InfoExtractor):
- IE_NAME = 'youtube:truncated_url'
- IE_DESC = False # Do not list
- _VALID_URL = r'''(?x)
- (?:https?://)?
- (?:\w+\.)?[yY][oO][uU][tT][uU][bB][eE](?:-nocookie)?\.com/
- (?:watch\?(?:
- feature=[a-z_]+|
- annotation_id=annotation_[^&]+|
- x-yt-cl=[0-9]+|
- hl=[^&]*|
- t=[0-9]+
- )?
- |
- attribution_link\?a=[^&]+
- )
- $
- '''
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/watch?annotation_id=annotation_3951667041',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?x-yt-cl=84503534',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?feature=foo',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?hl=en-GB',
- 'only_matching': True,
- }, {
- 'url': 'https://www.youtube.com/watch?t=2372',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- raise ExtractorError(
- 'Did you forget to quote the URL? Remember that & is a meta '
- 'character in most shells, so you want to put the URL in quotes, '
- 'like youtube-dl '
- '"https://www.youtube.com/watch?feature=foo&v=BaW_jenozKc" '
- ' or simply youtube-dl BaW_jenozKc .',
- expected=True)
-
-
-class YoutubeTruncatedIDIE(InfoExtractor):
- IE_NAME = 'youtube:truncated_id'
- IE_DESC = False # Do not list
- _VALID_URL = r'https?://(?:www\.)?youtube\.com/watch\?v=(?P<id>[0-9A-Za-z_-]{1,10})$'
-
- _TESTS = [{
- 'url': 'https://www.youtube.com/watch?v=N_708QY7Ob',
- 'only_matching': True,
- }]
-
- def _real_extract(self, url):
- video_id = self._match_id(url)
- raise ExtractorError(
- 'Incomplete YouTube ID %s. URL %s looks truncated.' % (video_id, url),
- expected=True)
diff --git a/youtube_dl/jsinterp.py b/youtube_dl/jsinterp.py
deleted file mode 100644
index 7bda596..0000000
--- a/youtube_dl/jsinterp.py
+++ /dev/null
@@ -1,262 +0,0 @@
-from __future__ import unicode_literals
-
-import json
-import operator
-import re
-
-from .utils import (
- ExtractorError,
- remove_quotes,
-)
-
-_OPERATORS = [
- ('|', operator.or_),
- ('^', operator.xor),
- ('&', operator.and_),
- ('>>', operator.rshift),
- ('<<', operator.lshift),
- ('-', operator.sub),
- ('+', operator.add),
- ('%', operator.mod),
- ('/', operator.truediv),
- ('*', operator.mul),
-]
-_ASSIGN_OPERATORS = [(op + '=', opfunc) for op, opfunc in _OPERATORS]
-_ASSIGN_OPERATORS.append(('=', lambda cur, right: right))
-
-_NAME_RE = r'[a-zA-Z_$][a-zA-Z_$0-9]*'
-
-
-class JSInterpreter(object):
- def __init__(self, code, objects=None):
- if objects is None:
- objects = {}
- self.code = code
- self._functions = {}
- self._objects = objects
-
- def interpret_statement(self, stmt, local_vars, allow_recursion=100):
- if allow_recursion < 0:
- raise ExtractorError('Recursion limit reached')
-
- should_abort = False
- stmt = stmt.lstrip()
- stmt_m = re.match(r'var\s', stmt)
- if stmt_m:
- expr = stmt[len(stmt_m.group(0)):]
- else:
- return_m = re.match(r'return(?:\s+|$)', stmt)
- if return_m:
- expr = stmt[len(return_m.group(0)):]
- should_abort = True
- else:
- # Try interpreting it as an expression
- expr = stmt
-
- v = self.interpret_expression(expr, local_vars, allow_recursion)
- return v, should_abort
-
- def interpret_expression(self, expr, local_vars, allow_recursion):
- expr = expr.strip()
- if expr == '': # Empty expression
- return None
-
- if expr.startswith('('):
- parens_count = 0
- for m in re.finditer(r'[()]', expr):
- if m.group(0) == '(':
- parens_count += 1
- else:
- parens_count -= 1
- if parens_count == 0:
- sub_expr = expr[1:m.start()]
- sub_result = self.interpret_expression(
- sub_expr, local_vars, allow_recursion)
- remaining_expr = expr[m.end():].strip()
- if not remaining_expr:
- return sub_result
- else:
- expr = json.dumps(sub_result) + remaining_expr
- break
- else:
- raise ExtractorError('Premature end of parens in %r' % expr)
-
- for op, opfunc in _ASSIGN_OPERATORS:
- m = re.match(r'''(?x)
- (?P<out>%s)(?:\[(?P<index>[^\]]+?)\])?
- \s*%s
- (?P<expr>.*)$''' % (_NAME_RE, re.escape(op)), expr)
- if not m:
- continue
- right_val = self.interpret_expression(
- m.group('expr'), local_vars, allow_recursion - 1)
-
- if m.groupdict().get('index'):
- lvar = local_vars[m.group('out')]
- idx = self.interpret_expression(
- m.group('index'), local_vars, allow_recursion)
- assert isinstance(idx, int)
- cur = lvar[idx]
- val = opfunc(cur, right_val)
- lvar[idx] = val
- return val
- else:
- cur = local_vars.get(m.group('out'))
- val = opfunc(cur, right_val)
- local_vars[m.group('out')] = val
- return val
-
- if expr.isdigit():
- return int(expr)
-
- var_m = re.match(
- r'(?!if|return|true|false)(?P<name>%s)$' % _NAME_RE,
- expr)
- if var_m:
- return local_vars[var_m.group('name')]
-
- try:
- return json.loads(expr)
- except ValueError:
- pass
-
- m = re.match(
- r'(?P<in>%s)\[(?P<idx>.+)\]$' % _NAME_RE, expr)
- if m:
- val = local_vars[m.group('in')]
- idx = self.interpret_expression(
- m.group('idx'), local_vars, allow_recursion - 1)
- return val[idx]
-
- m = re.match(
- r'(?P<var>%s)(?:\.(?P<member>[^(]+)|\[(?P<member2>[^]]+)\])\s*(?:\(+(?P<args>[^()]*)\))?$' % _NAME_RE,
- expr)
- if m:
- variable = m.group('var')
- member = remove_quotes(m.group('member') or m.group('member2'))
- arg_str = m.group('args')
-
- if variable in local_vars:
- obj = local_vars[variable]
- else:
- if variable not in self._objects:
- self._objects[variable] = self.extract_object(variable)
- obj = self._objects[variable]
-
- if arg_str is None:
- # Member access
- if member == 'length':
- return len(obj)
- return obj[member]
-
- assert expr.endswith(')')
- # Function call
- if arg_str == '':
- argvals = tuple()
- else:
- argvals = tuple([
- self.interpret_expression(v, local_vars, allow_recursion)
- for v in arg_str.split(',')])
-
- if member == 'split':
- assert argvals == ('',)
- return list(obj)
- if member == 'join':
- assert len(argvals) == 1
- return argvals[0].join(obj)
- if member == 'reverse':
- assert len(argvals) == 0
- obj.reverse()
- return obj
- if member == 'slice':
- assert len(argvals) == 1
- return obj[argvals[0]:]
- if member == 'splice':
- assert isinstance(obj, list)
- index, howMany = argvals
- res = []
- for i in range(index, min(index + howMany, len(obj))):
- res.append(obj.pop(index))
- return res
-
- return obj[member](argvals)
-
- for op, opfunc in _OPERATORS:
- m = re.match(r'(?P<x>.+?)%s(?P<y>.+)' % re.escape(op), expr)
- if not m:
- continue
- x, abort = self.interpret_statement(
- m.group('x'), local_vars, allow_recursion - 1)
- if abort:
- raise ExtractorError(
- 'Premature left-side return of %s in %r' % (op, expr))
- y, abort = self.interpret_statement(
- m.group('y'), local_vars, allow_recursion - 1)
- if abort:
- raise ExtractorError(
- 'Premature right-side return of %s in %r' % (op, expr))
- return opfunc(x, y)
-
- m = re.match(
- r'^(?P<func>%s)\((?P<args>[a-zA-Z0-9_$,]*)\)$' % _NAME_RE, expr)
- if m:
- fname = m.group('func')
- argvals = tuple([
- int(v) if v.isdigit() else local_vars[v]
- for v in m.group('args').split(',')]) if len(m.group('args')) > 0 else tuple()
- if fname not in self._functions:
- self._functions[fname] = self.extract_function(fname)
- return self._functions[fname](argvals)
-
- raise ExtractorError('Unsupported JS expression %r' % expr)
-
- def extract_object(self, objname):
- _FUNC_NAME_RE = r'''(?:[a-zA-Z$0-9]+|"[a-zA-Z$0-9]+"|'[a-zA-Z$0-9]+')'''
- obj = {}
- obj_m = re.search(
- r'''(?x)
- (?<!this\.)%s\s*=\s*{\s*
- (?P<fields>(%s\s*:\s*function\s*\(.*?\)\s*{.*?}(?:,\s*)?)*)
- }\s*;
- ''' % (re.escape(objname), _FUNC_NAME_RE),
- self.code)
- fields = obj_m.group('fields')
- # Currently, it only supports function definitions
- fields_m = re.finditer(
- r'''(?x)
- (?P<key>%s)\s*:\s*function\s*\((?P<args>[a-z,]+)\){(?P<code>[^}]+)}
- ''' % _FUNC_NAME_RE,
- fields)
- for f in fields_m:
- argnames = f.group('args').split(',')
- obj[remove_quotes(f.group('key'))] = self.build_function(argnames, f.group('code'))
-
- return obj
-
- def extract_function(self, funcname):
- func_m = re.search(
- r'''(?x)
- (?:function\s+%s|[{;,]\s*%s\s*=\s*function|var\s+%s\s*=\s*function)\s*
- \((?P<args>[^)]*)\)\s*
- \{(?P<code>[^}]+)\}''' % (
- re.escape(funcname), re.escape(funcname), re.escape(funcname)),
- self.code)
- if func_m is None:
- raise ExtractorError('Could not find JS function %r' % funcname)
- argnames = func_m.group('args').split(',')
-
- return self.build_function(argnames, func_m.group('code'))
-
- def call_function(self, funcname, *args):
- f = self.extract_function(funcname)
- return f(args)
-
- def build_function(self, argnames, code):
- def resf(args):
- local_vars = dict(zip(argnames, args))
- for stmt in code.split(';'):
- res, abort = self.interpret_statement(stmt, local_vars)
- if abort:
- break
- return res
- return resf
diff --git a/youtube_dl/options.py b/youtube_dl/options.py
deleted file mode 100644
index e7d8e89..0000000
--- a/youtube_dl/options.py
+++ /dev/null
@@ -1,916 +0,0 @@
-from __future__ import unicode_literals
-
-import os.path
-import optparse
-import re
-import sys
-
-from .downloader.external import list_external_downloaders
-from .compat import (
- compat_expanduser,
- compat_get_terminal_size,
- compat_getenv,
- compat_kwargs,
- compat_shlex_split,
-)
-from .utils import (
- preferredencoding,
- write_string,
-)
-from .version import __version__
-
-
-def _hide_login_info(opts):
- PRIVATE_OPTS = set(['-p', '--password', '-u', '--username', '--video-password', '--ap-password', '--ap-username'])
- eqre = re.compile('^(?P<key>' + ('|'.join(re.escape(po) for po in PRIVATE_OPTS)) + ')=.+$')
-
- def _scrub_eq(o):
- m = eqre.match(o)
- if m:
- return m.group('key') + '=PRIVATE'
- else:
- return o
-
- opts = list(map(_scrub_eq, opts))
- for idx, opt in enumerate(opts):
- if opt in PRIVATE_OPTS and idx + 1 < len(opts):
- opts[idx + 1] = 'PRIVATE'
- return opts
-
-
-def parseOpts(overrideArguments=None):
- def _readOptions(filename_bytes, default=[]):
- try:
- optionf = open(filename_bytes)
- except IOError:
- return default # silently skip if file is not present
- try:
- # FIXME: https://github.com/rg3/youtube-dl/commit/dfe5fa49aed02cf36ba9f743b11b0903554b5e56
- contents = optionf.read()
- if sys.version_info < (3,):
- contents = contents.decode(preferredencoding())
- res = compat_shlex_split(contents, comments=True)
- finally:
- optionf.close()
- return res
-
- def _readUserConf():
- xdg_config_home = compat_getenv('XDG_CONFIG_HOME')
- if xdg_config_home:
- userConfFile = os.path.join(xdg_config_home, 'youtube-dl', 'config')
- if not os.path.isfile(userConfFile):
- userConfFile = os.path.join(xdg_config_home, 'youtube-dl.conf')
- else:
- userConfFile = os.path.join(compat_expanduser('~'), '.config', 'youtube-dl', 'config')
- if not os.path.isfile(userConfFile):
- userConfFile = os.path.join(compat_expanduser('~'), '.config', 'youtube-dl.conf')
- userConf = _readOptions(userConfFile, None)
-
- if userConf is None:
- appdata_dir = compat_getenv('appdata')
- if appdata_dir:
- userConf = _readOptions(
- os.path.join(appdata_dir, 'youtube-dl', 'config'),
- default=None)
- if userConf is None:
- userConf = _readOptions(
- os.path.join(appdata_dir, 'youtube-dl', 'config.txt'),
- default=None)
-
- if userConf is None:
- userConf = _readOptions(
- os.path.join(compat_expanduser('~'), 'youtube-dl.conf'),
- default=None)
- if userConf is None:
- userConf = _readOptions(
- os.path.join(compat_expanduser('~'), 'youtube-dl.conf.txt'),
- default=None)
-
- if userConf is None:
- userConf = []
-
- return userConf
-
- def _format_option_string(option):
- ''' ('-o', '--option') -> -o, --format METAVAR'''
-
- opts = []
-
- if option._short_opts:
- opts.append(option._short_opts[0])
- if option._long_opts:
- opts.append(option._long_opts[0])
- if len(opts) > 1:
- opts.insert(1, ', ')
-
- if option.takes_value():
- opts.append(' %s' % option.metavar)
-
- return ''.join(opts)
-
- def _comma_separated_values_options_callback(option, opt_str, value, parser):
- setattr(parser.values, option.dest, value.split(','))
-
- # No need to wrap help messages if we're on a wide console
- columns = compat_get_terminal_size().columns
- max_width = columns if columns else 80
- max_help_position = 80
-
- fmt = optparse.IndentedHelpFormatter(width=max_width, max_help_position=max_help_position)
- fmt.format_option_strings = _format_option_string
-
- kw = {
- 'version': __version__,
- 'formatter': fmt,
- 'usage': '%prog [OPTIONS] URL [URL...]',
- 'conflict_handler': 'resolve',
- }
-
- parser = optparse.OptionParser(**compat_kwargs(kw))
-
- general = optparse.OptionGroup(parser, 'General Options')
- general.add_option(
- '-h', '--help',
- action='help',
- help='Print this help text and exit')
- general.add_option(
- '-v', '--version',
- action='version',
- help='Print program version and exit')
- general.add_option(
- '-U', '--update',
- action='store_true', dest='update_self',
- help='Update this program to latest version. Make sure that you have sufficient permissions (run with sudo if needed)')
- general.add_option(
- '-i', '--ignore-errors',
- action='store_true', dest='ignoreerrors', default=False,
- help='Continue on download errors, for example to skip unavailable videos in a playlist')
- general.add_option(
- '--abort-on-error',
- action='store_false', dest='ignoreerrors',
- help='Abort downloading of further videos (in the playlist or the command line) if an error occurs')
- general.add_option(
- '--dump-user-agent',
- action='store_true', dest='dump_user_agent', default=False,
- help='Display the current browser identification')
- general.add_option(
- '--list-extractors',
- action='store_true', dest='list_extractors', default=False,
- help='List all supported extractors')
- general.add_option(
- '--extractor-descriptions',
- action='store_true', dest='list_extractor_descriptions', default=False,
- help='Output descriptions of all supported extractors')
- general.add_option(
- '--force-generic-extractor',
- action='store_true', dest='force_generic_extractor', default=False,
- help='Force extraction to use the generic extractor')
- general.add_option(
- '--default-search',
- dest='default_search', metavar='PREFIX',
- help='Use this prefix for unqualified URLs. For example "gvsearch2:" downloads two videos from google videos for youtube-dl "large apple". Use the value "auto" to let youtube-dl guess ("auto_warning" to emit a warning when guessing). "error" just throws an error. The default value "fixup_error" repairs broken URLs, but emits an error if this is not possible instead of searching.')
- general.add_option(
- '--ignore-config',
- action='store_true',
- help='Do not read configuration files. '
- 'When given in the global configuration file /etc/youtube-dl.conf: '
- 'Do not read the user configuration in ~/.config/youtube-dl/config '
- '(%APPDATA%/youtube-dl/config.txt on Windows)')
- general.add_option(
- '--config-location',
- dest='config_location', metavar='PATH',
- help='Location of the configuration file; either the path to the config or its containing directory.')
- general.add_option(
- '--flat-playlist',
- action='store_const', dest='extract_flat', const='in_playlist',
- default=False,
- help='Do not extract the videos of a playlist, only list them.')
- general.add_option(
- '--mark-watched',
- action='store_true', dest='mark_watched', default=False,
- help='Mark videos watched (YouTube only)')
- general.add_option(
- '--no-mark-watched',
- action='store_false', dest='mark_watched', default=False,
- help='Do not mark videos watched (YouTube only)')
- general.add_option(
- '--no-color', '--no-colors',
- action='store_true', dest='no_color',
- default=False,
- help='Do not emit color codes in output')
-
- network = optparse.OptionGroup(parser, 'Network Options')
- network.add_option(
- '--proxy', dest='proxy',
- default=None, metavar='URL',
- help='Use the specified HTTP/HTTPS/SOCKS proxy. To enable '
- 'SOCKS proxy, specify a proper scheme. For example '
- 'socks5://127.0.0.1:1080/. Pass in an empty string (--proxy "") '
- 'for direct connection')
- network.add_option(
- '--socket-timeout',
- dest='socket_timeout', type=float, default=None, metavar='SECONDS',
- help='Time to wait before giving up, in seconds')
- network.add_option(
- '--source-address',
- metavar='IP', dest='source_address', default=None,
- help='Client-side IP address to bind to',
- )
- network.add_option(
- '-4', '--force-ipv4',
- action='store_const', const='0.0.0.0', dest='source_address',
- help='Make all connections via IPv4',
- )
- network.add_option(
- '-6', '--force-ipv6',
- action='store_const', const='::', dest='source_address',
- help='Make all connections via IPv6',
- )
-
- geo = optparse.OptionGroup(parser, 'Geo Restriction')
- geo.add_option(
- '--geo-verification-proxy',
- dest='geo_verification_proxy', default=None, metavar='URL',
- help='Use this proxy to verify the IP address for some geo-restricted sites. '
- 'The default proxy specified by --proxy (or none, if the option is not present) is used for the actual downloading.')
- geo.add_option(
- '--cn-verification-proxy',
- dest='cn_verification_proxy', default=None, metavar='URL',
- help=optparse.SUPPRESS_HELP)
- geo.add_option(
- '--geo-bypass',
- action='store_true', dest='geo_bypass', default=True,
- help='Bypass geographic restriction via faking X-Forwarded-For HTTP header')
- geo.add_option(
- '--no-geo-bypass',
- action='store_false', dest='geo_bypass', default=True,
- help='Do not bypass geographic restriction via faking X-Forwarded-For HTTP header')
- geo.add_option(
- '--geo-bypass-country', metavar='CODE',
- dest='geo_bypass_country', default=None,
- help='Force bypass geographic restriction with explicitly provided two-letter ISO 3166-2 country code')
- geo.add_option(
- '--geo-bypass-ip-block', metavar='IP_BLOCK',
- dest='geo_bypass_ip_block', default=None,
- help='Force bypass geographic restriction with explicitly provided IP block in CIDR notation')
-
- selection = optparse.OptionGroup(parser, 'Video Selection')
- selection.add_option(
- '--playlist-start',
- dest='playliststart', metavar='NUMBER', default=1, type=int,
- help='Playlist video to start at (default is %default)')
- selection.add_option(
- '--playlist-end',
- dest='playlistend', metavar='NUMBER', default=None, type=int,
- help='Playlist video to end at (default is last)')
- selection.add_option(
- '--playlist-items',
- dest='playlist_items', metavar='ITEM_SPEC', default=None,
- help='Playlist video items to download. Specify indices of the videos in the playlist separated by commas like: "--playlist-items 1,2,5,8" if you want to download videos indexed 1, 2, 5, 8 in the playlist. You can specify range: "--playlist-items 1-3,7,10-13", it will download the videos at index 1, 2, 3, 7, 10, 11, 12 and 13.')
- selection.add_option(
- '--match-title',
- dest='matchtitle', metavar='REGEX',
- help='Download only matching titles (regex or caseless sub-string)')
- selection.add_option(
- '--reject-title',
- dest='rejecttitle', metavar='REGEX',
- help='Skip download for matching titles (regex or caseless sub-string)')
- selection.add_option(
- '--max-downloads',
- dest='max_downloads', metavar='NUMBER', type=int, default=None,
- help='Abort after downloading NUMBER files')
- selection.add_option(
- '--min-filesize',
- metavar='SIZE', dest='min_filesize', default=None,
- help='Do not download any videos smaller than SIZE (e.g. 50k or 44.6m)')
- selection.add_option(
- '--max-filesize',
- metavar='SIZE', dest='max_filesize', default=None,
- help='Do not download any videos larger than SIZE (e.g. 50k or 44.6m)')
- selection.add_option(
- '--date',
- metavar='DATE', dest='date', default=None,
- help='Download only videos uploaded in this date')
- selection.add_option(
- '--datebefore',
- metavar='DATE', dest='datebefore', default=None,
- help='Download only videos uploaded on or before this date (i.e. inclusive)')
- selection.add_option(
- '--dateafter',
- metavar='DATE', dest='dateafter', default=None,
- help='Download only videos uploaded on or after this date (i.e. inclusive)')
- selection.add_option(
- '--min-views',
- metavar='COUNT', dest='min_views', default=None, type=int,
- help='Do not download any videos with less than COUNT views')
- selection.add_option(
- '--max-views',
- metavar='COUNT', dest='max_views', default=None, type=int,
- help='Do not download any videos with more than COUNT views')
- selection.add_option(
- '--match-filter',
- metavar='FILTER', dest='match_filter', default=None,
- help=(
- 'Generic video filter. '
- 'Specify any key (see the "OUTPUT TEMPLATE" for a list of available keys) to '
- 'match if the key is present, '
- '!key to check if the key is not present, '
- 'key > NUMBER (like "comment_count > 12", also works with '
- '>=, <, <=, !=, =) to compare against a number, '
- 'key = \'LITERAL\' (like "uploader = \'Mike Smith\'", also works with !=) '
- 'to match against a string literal '
- 'and & to require multiple matches. '
- 'Values which are not known are excluded unless you '
- 'put a question mark (?) after the operator. '
- 'For example, to only match videos that have been liked more than '
- '100 times and disliked less than 50 times (or the dislike '
- 'functionality is not available at the given service), but who '
- 'also have a description, use --match-filter '
- '"like_count > 100 & dislike_count <? 50 & description" .'
- ))
- selection.add_option(
- '--no-playlist',
- action='store_true', dest='noplaylist', default=False,
- help='Download only the video, if the URL refers to a video and a playlist.')
- selection.add_option(
- '--yes-playlist',
- action='store_false', dest='noplaylist', default=False,
- help='Download the playlist, if the URL refers to a video and a playlist.')
- selection.add_option(
- '--age-limit',
- metavar='YEARS', dest='age_limit', default=None, type=int,
- help='Download only videos suitable for the given age')
- selection.add_option(
- '--download-archive', metavar='FILE',
- dest='download_archive',
- help='Download only videos not listed in the archive file. Record the IDs of all downloaded videos in it.')
- selection.add_option(
- '--include-ads',
- dest='include_ads', action='store_true',
- help='Download advertisements as well (experimental)')
-
- authentication = optparse.OptionGroup(parser, 'Authentication Options')
- authentication.add_option(
- '-u', '--username',
- dest='username', metavar='USERNAME',
- help='Login with this account ID')
- authentication.add_option(
- '-p', '--password',
- dest='password', metavar='PASSWORD',
- help='Account password. If this option is left out, youtube-dl will ask interactively.')
- authentication.add_option(
- '-2', '--twofactor',
- dest='twofactor', metavar='TWOFACTOR',
- help='Two-factor authentication code')
- authentication.add_option(
- '-n', '--netrc',
- action='store_true', dest='usenetrc', default=False,
- help='Use .netrc authentication data')
- authentication.add_option(
- '--video-password',
- dest='videopassword', metavar='PASSWORD',
- help='Video password (vimeo, smotri, youku)')
-
- adobe_pass = optparse.OptionGroup(parser, 'Adobe Pass Options')
- adobe_pass.add_option(
- '--ap-mso',
- dest='ap_mso', metavar='MSO',
- help='Adobe Pass multiple-system operator (TV provider) identifier, use --ap-list-mso for a list of available MSOs')
- adobe_pass.add_option(
- '--ap-username',
- dest='ap_username', metavar='USERNAME',
- help='Multiple-system operator account login')
- adobe_pass.add_option(
- '--ap-password',
- dest='ap_password', metavar='PASSWORD',
- help='Multiple-system operator account password. If this option is left out, youtube-dl will ask interactively.')
- adobe_pass.add_option(
- '--ap-list-mso',
- action='store_true', dest='ap_list_mso', default=False,
- help='List all supported multiple-system operators')
-
- video_format = optparse.OptionGroup(parser, 'Video Format Options')
- video_format.add_option(
- '-f', '--format',
- action='store', dest='format', metavar='FORMAT', default=None,
- help='Video format code, see the "FORMAT SELECTION" for all the info')
- video_format.add_option(
- '--all-formats',
- action='store_const', dest='format', const='all',
- help='Download all available video formats')
- video_format.add_option(
- '--prefer-free-formats',
- action='store_true', dest='prefer_free_formats', default=False,
- help='Prefer free video formats unless a specific one is requested')
- video_format.add_option(
- '-F', '--list-formats',
- action='store_true', dest='listformats',
- help='List all available formats of requested videos')
- video_format.add_option(
- '--youtube-include-dash-manifest',
- action='store_true', dest='youtube_include_dash_manifest', default=True,
- help=optparse.SUPPRESS_HELP)
- video_format.add_option(
- '--youtube-skip-dash-manifest',
- action='store_false', dest='youtube_include_dash_manifest',
- help='Do not download the DASH manifests and related data on YouTube videos')
- video_format.add_option(
- '--merge-output-format',
- action='store', dest='merge_output_format', metavar='FORMAT', default=None,
- help=(
- 'If a merge is required (e.g. bestvideo+bestaudio), '
- 'output to given container format. One of mkv, mp4, ogg, webm, flv. '
- 'Ignored if no merge is required'))
-
- subtitles = optparse.OptionGroup(parser, 'Subtitle Options')
- subtitles.add_option(
- '--write-sub', '--write-srt',
- action='store_true', dest='writesubtitles', default=False,
- help='Write subtitle file')
- subtitles.add_option(
- '--write-auto-sub', '--write-automatic-sub',
- action='store_true', dest='writeautomaticsub', default=False,
- help='Write automatically generated subtitle file (YouTube only)')
- subtitles.add_option(
- '--all-subs',
- action='store_true', dest='allsubtitles', default=False,
- help='Download all the available subtitles of the video')
- subtitles.add_option(
- '--list-subs',
- action='store_true', dest='listsubtitles', default=False,
- help='List all available subtitles for the video')
- subtitles.add_option(
- '--sub-format',
- action='store', dest='subtitlesformat', metavar='FORMAT', default='best',
- help='Subtitle format, accepts formats preference, for example: "srt" or "ass/srt/best"')
- subtitles.add_option(
- '--sub-lang', '--sub-langs', '--srt-lang',
- action='callback', dest='subtitleslangs', metavar='LANGS', type='str',
- default=[], callback=_comma_separated_values_options_callback,
- help='Languages of the subtitles to download (optional) separated by commas, use --list-subs for available language tags')
-
- downloader = optparse.OptionGroup(parser, 'Download Options')
- downloader.add_option(
- '-r', '--limit-rate', '--rate-limit',
- dest='ratelimit', metavar='RATE',
- help='Maximum download rate in bytes per second (e.g. 50K or 4.2M)')
- downloader.add_option(
- '-R', '--retries',
- dest='retries', metavar='RETRIES', default=10,
- help='Number of retries (default is %default), or "infinite".')
- downloader.add_option(
- '--fragment-retries',
- dest='fragment_retries', metavar='RETRIES', default=10,
- help='Number of retries for a fragment (default is %default), or "infinite" (DASH, hlsnative and ISM)')
- downloader.add_option(
- '--skip-unavailable-fragments',
- action='store_true', dest='skip_unavailable_fragments', default=True,
- help='Skip unavailable fragments (DASH, hlsnative and ISM)')
- downloader.add_option(
- '--abort-on-unavailable-fragment',
- action='store_false', dest='skip_unavailable_fragments',
- help='Abort downloading when some fragment is not available')
- downloader.add_option(
- '--keep-fragments',
- action='store_true', dest='keep_fragments', default=False,
- help='Keep downloaded fragments on disk after downloading is finished; fragments are erased by default')
- downloader.add_option(
- '--buffer-size',
- dest='buffersize', metavar='SIZE', default='1024',
- help='Size of download buffer (e.g. 1024 or 16K) (default is %default)')
- downloader.add_option(
- '--no-resize-buffer',
- action='store_true', dest='noresizebuffer', default=False,
- help='Do not automatically adjust the buffer size. By default, the buffer size is automatically resized from an initial value of SIZE.')
- downloader.add_option(
- '--http-chunk-size',
- dest='http_chunk_size', metavar='SIZE', default=None,
- help='Size of a chunk for chunk-based HTTP downloading (e.g. 10485760 or 10M) (default is disabled). '
- 'May be useful for bypassing bandwidth throttling imposed by a webserver (experimental)')
- downloader.add_option(
- '--test',
- action='store_true', dest='test', default=False,
- help=optparse.SUPPRESS_HELP)
- downloader.add_option(
- '--playlist-reverse',
- action='store_true',
- help='Download playlist videos in reverse order')
- downloader.add_option(
- '--playlist-random',
- action='store_true',
- help='Download playlist videos in random order')
- downloader.add_option(
- '--xattr-set-filesize',
- dest='xattr_set_filesize', action='store_true',
- help='Set file xattribute ytdl.filesize with expected file size')
- downloader.add_option(
- '--hls-prefer-native',
- dest='hls_prefer_native', action='store_true', default=None,
- help='Use the native HLS downloader instead of ffmpeg')
- downloader.add_option(
- '--hls-prefer-ffmpeg',
- dest='hls_prefer_native', action='store_false', default=None,
- help='Use ffmpeg instead of the native HLS downloader')
- downloader.add_option(
- '--hls-use-mpegts',
- dest='hls_use_mpegts', action='store_true',
- help='Use the mpegts container for HLS videos, allowing to play the '
- 'video while downloading (some players may not be able to play it)')
- downloader.add_option(
- '--external-downloader',
- dest='external_downloader', metavar='COMMAND',
- help='Use the specified external downloader. '
- 'Currently supports %s' % ','.join(list_external_downloaders()))
- downloader.add_option(
- '--external-downloader-args',
- dest='external_downloader_args', metavar='ARGS',
- help='Give these arguments to the external downloader')
-
- workarounds = optparse.OptionGroup(parser, 'Workarounds')
- workarounds.add_option(
- '--encoding',
- dest='encoding', metavar='ENCODING',
- help='Force the specified encoding (experimental)')
- workarounds.add_option(
- '--no-check-certificate',
- action='store_true', dest='no_check_certificate', default=False,
- help='Suppress HTTPS certificate validation')
- workarounds.add_option(
- '--prefer-insecure',
- '--prefer-unsecure', action='store_true', dest='prefer_insecure',
- help='Use an unencrypted connection to retrieve information about the video. (Currently supported only for YouTube)')
- workarounds.add_option(
- '--user-agent',
- metavar='UA', dest='user_agent',
- help='Specify a custom user agent')
- workarounds.add_option(
- '--referer',
- metavar='URL', dest='referer', default=None,
- help='Specify a custom referer, use if the video access is restricted to one domain',
- )
- workarounds.add_option(
- '--add-header',
- metavar='FIELD:VALUE', dest='headers', action='append',
- help='Specify a custom HTTP header and its value, separated by a colon \':\'. You can use this option multiple times',
- )
- workarounds.add_option(
- '--bidi-workaround',
- dest='bidi_workaround', action='store_true',
- help='Work around terminals that lack bidirectional text support. Requires bidiv or fribidi executable in PATH')
- workarounds.add_option(
- '--sleep-interval', '--min-sleep-interval', metavar='SECONDS',
- dest='sleep_interval', type=float,
- help=(
- 'Number of seconds to sleep before each download when used alone '
- 'or a lower bound of a range for randomized sleep before each download '
- '(minimum possible number of seconds to sleep) when used along with '
- '--max-sleep-interval.'))
- workarounds.add_option(
- '--max-sleep-interval', metavar='SECONDS',
- dest='max_sleep_interval', type=float,
- help=(
- 'Upper bound of a range for randomized sleep before each download '
- '(maximum possible number of seconds to sleep). Must only be used '
- 'along with --min-sleep-interval.'))
-
- verbosity = optparse.OptionGroup(parser, 'Verbosity / Simulation Options')
- verbosity.add_option(
- '-q', '--quiet',
- action='store_true', dest='quiet', default=False,
- help='Activate quiet mode')
- verbosity.add_option(
- '--no-warnings',
- dest='no_warnings', action='store_true', default=False,
- help='Ignore warnings')
- verbosity.add_option(
- '-s', '--simulate',
- action='store_true', dest='simulate', default=False,
- help='Do not download the video and do not write anything to disk')
- verbosity.add_option(
- '--skip-download',
- action='store_true', dest='skip_download', default=False,
- help='Do not download the video')
- verbosity.add_option(
- '-g', '--get-url',
- action='store_true', dest='geturl', default=False,
- help='Simulate, quiet but print URL')
- verbosity.add_option(
- '-e', '--get-title',
- action='store_true', dest='gettitle', default=False,
- help='Simulate, quiet but print title')
- verbosity.add_option(
- '--get-id',
- action='store_true', dest='getid', default=False,
- help='Simulate, quiet but print id')
- verbosity.add_option(
- '--get-thumbnail',
- action='store_true', dest='getthumbnail', default=False,
- help='Simulate, quiet but print thumbnail URL')
- verbosity.add_option(
- '--get-description',
- action='store_true', dest='getdescription', default=False,
- help='Simulate, quiet but print video description')
- verbosity.add_option(
- '--get-duration',
- action='store_true', dest='getduration', default=False,
- help='Simulate, quiet but print video length')
- verbosity.add_option(
- '--get-filename',
- action='store_true', dest='getfilename', default=False,
- help='Simulate, quiet but print output filename')
- verbosity.add_option(
- '--get-format',
- action='store_true', dest='getformat', default=False,
- help='Simulate, quiet but print output format')
- verbosity.add_option(
- '-j', '--dump-json',
- action='store_true', dest='dumpjson', default=False,
- help='Simulate, quiet but print JSON information. See the "OUTPUT TEMPLATE" for a description of available keys.')
- verbosity.add_option(
- '-J', '--dump-single-json',
- action='store_true', dest='dump_single_json', default=False,
- help='Simulate, quiet but print JSON information for each command-line argument. If the URL refers to a playlist, dump the whole playlist information in a single line.')
- verbosity.add_option(
- '--print-json',
- action='store_true', dest='print_json', default=False,
- help='Be quiet and print the video information as JSON (video is still being downloaded).',
- )
- verbosity.add_option(
- '--newline',
- action='store_true', dest='progress_with_newline', default=False,
- help='Output progress bar as new lines')
- verbosity.add_option(
- '--no-progress',
- action='store_true', dest='noprogress', default=False,
- help='Do not print progress bar')
- verbosity.add_option(
- '--console-title',
- action='store_true', dest='consoletitle', default=False,
- help='Display progress in console titlebar')
- verbosity.add_option(
- '-v', '--verbose',
- action='store_true', dest='verbose', default=False,
- help='Print various debugging information')
- verbosity.add_option(
- '--dump-pages', '--dump-intermediate-pages',
- action='store_true', dest='dump_intermediate_pages', default=False,
- help='Print downloaded pages encoded using base64 to debug problems (very verbose)')
- verbosity.add_option(
- '--write-pages',
- action='store_true', dest='write_pages', default=False,
- help='Write downloaded intermediary pages to files in the current directory to debug problems')
- verbosity.add_option(
- '--youtube-print-sig-code',
- action='store_true', dest='youtube_print_sig_code', default=False,
- help=optparse.SUPPRESS_HELP)
- verbosity.add_option(
- '--print-traffic', '--dump-headers',
- dest='debug_printtraffic', action='store_true', default=False,
- help='Display sent and read HTTP traffic')
- verbosity.add_option(
- '-C', '--call-home',
- dest='call_home', action='store_true', default=False,
- help='Contact the youtube-dl server for debugging')
- verbosity.add_option(
- '--no-call-home',
- dest='call_home', action='store_false', default=False,
- help='Do NOT contact the youtube-dl server for debugging')
-
- filesystem = optparse.OptionGroup(parser, 'Filesystem Options')
- filesystem.add_option(
- '-a', '--batch-file',
- dest='batchfile', metavar='FILE',
- help="File containing URLs to download ('-' for stdin), one URL per line. "
- "Lines starting with '#', ';' or ']' are considered as comments and ignored.")
- filesystem.add_option(
- '--id', default=False,
- action='store_true', dest='useid', help='Use only video ID in file name')
- filesystem.add_option(
- '-o', '--output',
- dest='outtmpl', metavar='TEMPLATE',
- help=('Output filename template, see the "OUTPUT TEMPLATE" for all the info'))
- filesystem.add_option(
- '--autonumber-size',
- dest='autonumber_size', metavar='NUMBER', type=int,
- help=optparse.SUPPRESS_HELP)
- filesystem.add_option(
- '--autonumber-start',
- dest='autonumber_start', metavar='NUMBER', default=1, type=int,
- help='Specify the start value for %(autonumber)s (default is %default)')
- filesystem.add_option(
- '--restrict-filenames',
- action='store_true', dest='restrictfilenames', default=False,
- help='Restrict filenames to only ASCII characters, and avoid "&" and spaces in filenames')
- filesystem.add_option(
- '-A', '--auto-number',
- action='store_true', dest='autonumber', default=False,
- help=optparse.SUPPRESS_HELP)
- filesystem.add_option(
- '-t', '--title',
- action='store_true', dest='usetitle', default=False,
- help=optparse.SUPPRESS_HELP)
- filesystem.add_option(
- '-l', '--literal', default=False,
- action='store_true', dest='usetitle',
- help=optparse.SUPPRESS_HELP)
- filesystem.add_option(
- '-w', '--no-overwrites',
- action='store_true', dest='nooverwrites', default=False,
- help='Do not overwrite files')
- filesystem.add_option(
- '-c', '--continue',
- action='store_true', dest='continue_dl', default=True,
- help='Force resume of partially downloaded files. By default, youtube-dl will resume downloads if possible.')
- filesystem.add_option(
- '--no-continue',
- action='store_false', dest='continue_dl',
- help='Do not resume partially downloaded files (restart from beginning)')
- filesystem.add_option(
- '--no-part',
- action='store_true', dest='nopart', default=False,
- help='Do not use .part files - write directly into output file')
- filesystem.add_option(
- '--no-mtime',
- action='store_false', dest='updatetime', default=True,
- help='Do not use the Last-modified header to set the file modification time')
- filesystem.add_option(
- '--write-description',
- action='store_true', dest='writedescription', default=False,
- help='Write video description to a .description file')
- filesystem.add_option(
- '--write-info-json',
- action='store_true', dest='writeinfojson', default=False,
- help='Write video metadata to a .info.json file')
- filesystem.add_option(
- '--write-annotations',
- action='store_true', dest='writeannotations', default=False,
- help='Write video annotations to a .annotations.xml file')
- filesystem.add_option(
- '--load-info-json', '--load-info',
- dest='load_info_filename', metavar='FILE',
- help='JSON file containing the video information (created with the "--write-info-json" option)')
- filesystem.add_option(
- '--cookies',
- dest='cookiefile', metavar='FILE',
- help='File to read cookies from and dump cookie jar in')
- filesystem.add_option(
- '--cache-dir', dest='cachedir', default=None, metavar='DIR',
- help='Location in the filesystem where youtube-dl can store some downloaded information permanently. By default $XDG_CACHE_HOME/youtube-dl or ~/.cache/youtube-dl . At the moment, only YouTube player files (for videos with obfuscated signatures) are cached, but that may change.')
- filesystem.add_option(
- '--no-cache-dir', action='store_const', const=False, dest='cachedir',
- help='Disable filesystem caching')
- filesystem.add_option(
- '--rm-cache-dir',
- action='store_true', dest='rm_cachedir',
- help='Delete all filesystem cache files')
-
- thumbnail = optparse.OptionGroup(parser, 'Thumbnail images')
- thumbnail.add_option(
- '--write-thumbnail',
- action='store_true', dest='writethumbnail', default=False,
- help='Write thumbnail image to disk')
- thumbnail.add_option(
- '--write-all-thumbnails',
- action='store_true', dest='write_all_thumbnails', default=False,
- help='Write all thumbnail image formats to disk')
- thumbnail.add_option(
- '--list-thumbnails',
- action='store_true', dest='list_thumbnails', default=False,
- help='Simulate and list all available thumbnail formats')
-
- postproc = optparse.OptionGroup(parser, 'Post-processing Options')
- postproc.add_option(
- '-x', '--extract-audio',
- action='store_true', dest='extractaudio', default=False,
- help='Convert video files to audio-only files (requires ffmpeg or avconv and ffprobe or avprobe)')
- postproc.add_option(
- '--audio-format', metavar='FORMAT', dest='audioformat', default='best',
- help='Specify audio format: "best", "aac", "flac", "mp3", "m4a", "opus", "vorbis", or "wav"; "%default" by default; No effect without -x')
- postproc.add_option(
- '--audio-quality', metavar='QUALITY',
- dest='audioquality', default='5',
- help='Specify ffmpeg/avconv audio quality, insert a value between 0 (better) and 9 (worse) for VBR or a specific bitrate like 128K (default %default)')
- postproc.add_option(
- '--recode-video',
- metavar='FORMAT', dest='recodevideo', default=None,
- help='Encode the video to another format if necessary (currently supported: mp4|flv|ogg|webm|mkv|avi)')
- postproc.add_option(
- '--postprocessor-args',
- dest='postprocessor_args', metavar='ARGS',
- help='Give these arguments to the postprocessor')
- postproc.add_option(
- '-k', '--keep-video',
- action='store_true', dest='keepvideo', default=False,
- help='Keep the video file on disk after the post-processing; the video is erased by default')
- postproc.add_option(
- '--no-post-overwrites',
- action='store_true', dest='nopostoverwrites', default=False,
- help='Do not overwrite post-processed files; the post-processed files are overwritten by default')
- postproc.add_option(
- '--embed-subs',
- action='store_true', dest='embedsubtitles', default=False,
- help='Embed subtitles in the video (only for mp4, webm and mkv videos)')
- postproc.add_option(
- '--embed-thumbnail',
- action='store_true', dest='embedthumbnail', default=False,
- help='Embed thumbnail in the audio as cover art')
- postproc.add_option(
- '--add-metadata',
- action='store_true', dest='addmetadata', default=False,
- help='Write metadata to the video file')
- postproc.add_option(
- '--metadata-from-title',
- metavar='FORMAT', dest='metafromtitle',
- help='Parse additional metadata like song title / artist from the video title. '
- 'The format syntax is the same as --output. Regular expression with '
- 'named capture groups may also be used. '
- 'The parsed parameters replace existing values. '
- 'Example: --metadata-from-title "%(artist)s - %(title)s" matches a title like '
- '"Coldplay - Paradise". '
- 'Example (regex): --metadata-from-title "(?P<artist>.+?) - (?P<title>.+)"')
- postproc.add_option(
- '--xattrs',
- action='store_true', dest='xattrs', default=False,
- help='Write metadata to the video file\'s xattrs (using dublin core and xdg standards)')
- postproc.add_option(
- '--fixup',
- metavar='POLICY', dest='fixup', default='detect_or_warn',
- help='Automatically correct known faults of the file. '
- 'One of never (do nothing), warn (only emit a warning), '
- 'detect_or_warn (the default; fix file if we can, warn otherwise)')
- postproc.add_option(
- '--prefer-avconv',
- action='store_false', dest='prefer_ffmpeg',
- help='Prefer avconv over ffmpeg for running the postprocessors')
- postproc.add_option(
- '--prefer-ffmpeg',
- action='store_true', dest='prefer_ffmpeg',
- help='Prefer ffmpeg over avconv for running the postprocessors (default)')
- postproc.add_option(
- '--ffmpeg-location', '--avconv-location', metavar='PATH',
- dest='ffmpeg_location',
- help='Location of the ffmpeg/avconv binary; either the path to the binary or its containing directory.')
- postproc.add_option(
- '--exec',
- metavar='CMD', dest='exec_cmd',
- help='Execute a command on the file after downloading, similar to find\'s -exec syntax. Example: --exec \'adb push {} /sdcard/Music/ && rm {}\'')
- postproc.add_option(
- '--convert-subs', '--convert-subtitles',
- metavar='FORMAT', dest='convertsubtitles', default=None,
- help='Convert the subtitles to other format (currently supported: srt|ass|vtt|lrc)')
-
- parser.add_option_group(general)
- parser.add_option_group(network)
- parser.add_option_group(geo)
- parser.add_option_group(selection)
- parser.add_option_group(downloader)
- parser.add_option_group(filesystem)
- parser.add_option_group(thumbnail)
- parser.add_option_group(verbosity)
- parser.add_option_group(workarounds)
- parser.add_option_group(video_format)
- parser.add_option_group(subtitles)
- parser.add_option_group(authentication)
- parser.add_option_group(adobe_pass)
- parser.add_option_group(postproc)
-
- if overrideArguments is not None:
- opts, args = parser.parse_args(overrideArguments)
- if opts.verbose:
- write_string('[debug] Override config: ' + repr(overrideArguments) + '\n')
- else:
- def compat_conf(conf):
- if sys.version_info < (3,):
- return [a.decode(preferredencoding(), 'replace') for a in conf]
- return conf
-
- command_line_conf = compat_conf(sys.argv[1:])
- opts, args = parser.parse_args(command_line_conf)
-
- system_conf = user_conf = custom_conf = []
-
- if '--config-location' in command_line_conf:
- location = compat_expanduser(opts.config_location)
- if os.path.isdir(location):
- location = os.path.join(location, 'youtube-dl.conf')
- if not os.path.exists(location):
- parser.error('config-location %s does not exist.' % location)
- custom_conf = _readOptions(location)
- elif '--ignore-config' in command_line_conf:
- pass
- else:
- system_conf = _readOptions('/etc/youtube-dl.conf')
- if '--ignore-config' not in system_conf:
- user_conf = _readUserConf()
-
- argv = system_conf + user_conf + custom_conf + command_line_conf
- opts, args = parser.parse_args(argv)
- if opts.verbose:
- for conf_label, conf in (
- ('System config', system_conf),
- ('User config', user_conf),
- ('Custom config', custom_conf),
- ('Command-line args', command_line_conf)):
- write_string('[debug] %s: %s\n' % (conf_label, repr(_hide_login_info(conf))))
-
- return parser, opts, args
diff --git a/youtube_dl/postprocessor/__init__.py b/youtube_dl/postprocessor/__init__.py
deleted file mode 100644
index 3ea5183..0000000
--- a/youtube_dl/postprocessor/__init__.py
+++ /dev/null
@@ -1,40 +0,0 @@
-from __future__ import unicode_literals
-
-from .embedthumbnail import EmbedThumbnailPP
-from .ffmpeg import (
- FFmpegPostProcessor,
- FFmpegEmbedSubtitlePP,
- FFmpegExtractAudioPP,
- FFmpegFixupStretchedPP,
- FFmpegFixupM3u8PP,
- FFmpegFixupM4aPP,
- FFmpegMergerPP,
- FFmpegMetadataPP,
- FFmpegVideoConvertorPP,
- FFmpegSubtitlesConvertorPP,
-)
-from .xattrpp import XAttrMetadataPP
-from .execafterdownload import ExecAfterDownloadPP
-from .metadatafromtitle import MetadataFromTitlePP
-
-
-def get_postprocessor(key):
- return globals()[key + 'PP']
-
-
-__all__ = [
- 'EmbedThumbnailPP',
- 'ExecAfterDownloadPP',
- 'FFmpegEmbedSubtitlePP',
- 'FFmpegExtractAudioPP',
- 'FFmpegFixupM3u8PP',
- 'FFmpegFixupM4aPP',
- 'FFmpegFixupStretchedPP',
- 'FFmpegMergerPP',
- 'FFmpegMetadataPP',
- 'FFmpegPostProcessor',
- 'FFmpegSubtitlesConvertorPP',
- 'FFmpegVideoConvertorPP',
- 'MetadataFromTitlePP',
- 'XAttrMetadataPP',
-]
diff --git a/youtube_dl/postprocessor/common.py b/youtube_dl/postprocessor/common.py
deleted file mode 100644
index 599dd1d..0000000
--- a/youtube_dl/postprocessor/common.py
+++ /dev/null
@@ -1,69 +0,0 @@
-from __future__ import unicode_literals
-
-import os
-
-from ..utils import (
- PostProcessingError,
- cli_configuration_args,
- encodeFilename,
-)
-
-
-class PostProcessor(object):
- """Post Processor class.
-
- PostProcessor objects can be added to downloaders with their
- add_post_processor() method. When the downloader has finished a
- successful download, it will take its internal chain of PostProcessors
- and start calling the run() method on each one of them, first with
- an initial argument and then with the returned value of the previous
- PostProcessor.
-
- The chain will be stopped if one of them ever returns None or the end
- of the chain is reached.
-
- PostProcessor objects follow a "mutual registration" process similar
- to InfoExtractor objects.
-
- Optionally PostProcessor can use a list of additional command-line arguments
- with self._configuration_args.
- """
-
- _downloader = None
-
- def __init__(self, downloader=None):
- self._downloader = downloader
-
- def set_downloader(self, downloader):
- """Sets the downloader for this PP."""
- self._downloader = downloader
-
- def run(self, information):
- """Run the PostProcessor.
-
- The "information" argument is a dictionary like the ones
- composed by InfoExtractors. The only difference is that this
- one has an extra field called "filepath" that points to the
- downloaded file.
-
- This method returns a tuple, the first element is a list of the files
- that can be deleted, and the second of which is the updated
- information.
-
- In addition, this method may raise a PostProcessingError
- exception if post processing fails.
- """
- return [], information # by default, keep file and do nothing
-
- def try_utime(self, path, atime, mtime, errnote='Cannot update utime of file'):
- try:
- os.utime(encodeFilename(path), (atime, mtime))
- except Exception:
- self._downloader.report_warning(errnote)
-
- def _configuration_args(self, default=[]):
- return cli_configuration_args(self._downloader.params, 'postprocessor_args', default)
-
-
-class AudioConversionError(PostProcessingError):
- pass
diff --git a/youtube_dl/postprocessor/embedthumbnail.py b/youtube_dl/postprocessor/embedthumbnail.py
deleted file mode 100644
index 56be914..0000000
--- a/youtube_dl/postprocessor/embedthumbnail.py
+++ /dev/null
@@ -1,93 +0,0 @@
-# coding: utf-8
-from __future__ import unicode_literals
-
-
-import os
-import subprocess
-
-from .ffmpeg import FFmpegPostProcessor
-
-from ..utils import (
- check_executable,
- encodeArgument,
- encodeFilename,
- PostProcessingError,
- prepend_extension,
- shell_quote
-)
-
-
-class EmbedThumbnailPPError(PostProcessingError):
- pass
-
-
-class EmbedThumbnailPP(FFmpegPostProcessor):
- def __init__(self, downloader=None, already_have_thumbnail=False):
- super(EmbedThumbnailPP, self).__init__(downloader)
- self._already_have_thumbnail = already_have_thumbnail
-
- def run(self, info):
- filename = info['filepath']
- temp_filename = prepend_extension(filename, 'temp')
-
- if not info.get('thumbnails'):
- self._downloader.to_screen('[embedthumbnail] There aren\'t any thumbnails to embed')
- return [], info
-
- thumbnail_filename = info['thumbnails'][-1]['filename']
-
- if not os.path.exists(encodeFilename(thumbnail_filename)):
- self._downloader.report_warning(
- 'Skipping embedding the thumbnail because the file is missing.')
- return [], info
-
- if info['ext'] == 'mp3':
- options = [
- '-c', 'copy', '-map', '0', '-map', '1',
- '-metadata:s:v', 'title="Album cover"', '-metadata:s:v', 'comment="Cover (Front)"']
-
- self._downloader.to_screen('[ffmpeg] Adding thumbnail to "%s"' % filename)
-
- self.run_ffmpeg_multiple_files([filename, thumbnail_filename], temp_filename, options)
-
- if not self._already_have_thumbnail:
- os.remove(encodeFilename(thumbnail_filename))
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
-
- elif info['ext'] in ['m4a', 'mp4']:
- if not check_executable('AtomicParsley', ['-v']):
- raise EmbedThumbnailPPError('AtomicParsley was not found. Please install.')
-
- cmd = [encodeFilename('AtomicParsley', True),
- encodeFilename(filename, True),
- encodeArgument('--artwork'),
- encodeFilename(thumbnail_filename, True),
- encodeArgument('-o'),
- encodeFilename(temp_filename, True)]
-
- self._downloader.to_screen('[atomicparsley] Adding thumbnail to "%s"' % filename)
-
- if self._downloader.params.get('verbose', False):
- self._downloader.to_screen('[debug] AtomicParsley command line: %s' % shell_quote(cmd))
-
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
- stdout, stderr = p.communicate()
-
- if p.returncode != 0:
- msg = stderr.decode('utf-8', 'replace').strip()
- raise EmbedThumbnailPPError(msg)
-
- if not self._already_have_thumbnail:
- os.remove(encodeFilename(thumbnail_filename))
- # for formats that don't support thumbnails (like 3gp) AtomicParsley
- # won't create to the temporary file
- if b'No changes' in stdout:
- self._downloader.report_warning('The file format doesn\'t support embedding a thumbnail')
- else:
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
- else:
- raise EmbedThumbnailPPError('Only mp3 and m4a/mp4 are supported for thumbnail embedding for now.')
-
- return [], info
diff --git a/youtube_dl/postprocessor/execafterdownload.py b/youtube_dl/postprocessor/execafterdownload.py
deleted file mode 100644
index 64dabe7..0000000
--- a/youtube_dl/postprocessor/execafterdownload.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from __future__ import unicode_literals
-
-import subprocess
-
-from .common import PostProcessor
-from ..compat import compat_shlex_quote
-from ..utils import (
- encodeArgument,
- PostProcessingError,
-)
-
-
-class ExecAfterDownloadPP(PostProcessor):
- def __init__(self, downloader, exec_cmd):
- super(ExecAfterDownloadPP, self).__init__(downloader)
- self.exec_cmd = exec_cmd
-
- def run(self, information):
- cmd = self.exec_cmd
- if '{}' not in cmd:
- cmd += ' {}'
-
- cmd = cmd.replace('{}', compat_shlex_quote(information['filepath']))
-
- self._downloader.to_screen('[exec] Executing command: %s' % cmd)
- retCode = subprocess.call(encodeArgument(cmd), shell=True)
- if retCode != 0:
- raise PostProcessingError(
- 'Command returned error code %d' % retCode)
-
- return [], information
diff --git a/youtube_dl/postprocessor/ffmpeg.py b/youtube_dl/postprocessor/ffmpeg.py
deleted file mode 100644
index 757b496..0000000
--- a/youtube_dl/postprocessor/ffmpeg.py
+++ /dev/null
@@ -1,613 +0,0 @@
-from __future__ import unicode_literals
-
-import io
-import os
-import subprocess
-import time
-import re
-
-
-from .common import AudioConversionError, PostProcessor
-
-from ..compat import (
- compat_subprocess_get_DEVNULL,
-)
-from ..utils import (
- encodeArgument,
- encodeFilename,
- get_exe_version,
- is_outdated_version,
- PostProcessingError,
- prepend_extension,
- shell_quote,
- subtitles_filename,
- dfxp2srt,
- ISO639Utils,
- replace_extension,
-)
-
-
-EXT_TO_OUT_FORMATS = {
- 'aac': 'adts',
- 'flac': 'flac',
- 'm4a': 'ipod',
- 'mka': 'matroska',
- 'mkv': 'matroska',
- 'mpg': 'mpeg',
- 'ogv': 'ogg',
- 'ts': 'mpegts',
- 'wma': 'asf',
- 'wmv': 'asf',
-}
-ACODECS = {
- 'mp3': 'libmp3lame',
- 'aac': 'aac',
- 'flac': 'flac',
- 'm4a': 'aac',
- 'opus': 'libopus',
- 'vorbis': 'libvorbis',
- 'wav': None,
-}
-
-
-class FFmpegPostProcessorError(PostProcessingError):
- pass
-
-
-class FFmpegPostProcessor(PostProcessor):
- def __init__(self, downloader=None):
- PostProcessor.__init__(self, downloader)
- self._determine_executables()
-
- def check_version(self):
- if not self.available:
- raise FFmpegPostProcessorError('ffmpeg or avconv not found. Please install one.')
-
- required_version = '10-0' if self.basename == 'avconv' else '1.0'
- if is_outdated_version(
- self._versions[self.basename], required_version):
- warning = 'Your copy of %s is outdated, update %s to version %s or newer if you encounter any errors.' % (
- self.basename, self.basename, required_version)
- if self._downloader:
- self._downloader.report_warning(warning)
-
- @staticmethod
- def get_versions(downloader=None):
- return FFmpegPostProcessor(downloader)._versions
-
- def _determine_executables(self):
- programs = ['avprobe', 'avconv', 'ffmpeg', 'ffprobe']
- prefer_ffmpeg = True
-
- self.basename = None
- self.probe_basename = None
-
- self._paths = None
- self._versions = None
- if self._downloader:
- prefer_ffmpeg = self._downloader.params.get('prefer_ffmpeg', True)
- location = self._downloader.params.get('ffmpeg_location')
- if location is not None:
- if not os.path.exists(location):
- self._downloader.report_warning(
- 'ffmpeg-location %s does not exist! '
- 'Continuing without avconv/ffmpeg.' % (location))
- self._versions = {}
- return
- elif not os.path.isdir(location):
- basename = os.path.splitext(os.path.basename(location))[0]
- if basename not in programs:
- self._downloader.report_warning(
- 'Cannot identify executable %s, its basename should be one of %s. '
- 'Continuing without avconv/ffmpeg.' %
- (location, ', '.join(programs)))
- self._versions = {}
- return None
- location = os.path.dirname(os.path.abspath(location))
- if basename in ('ffmpeg', 'ffprobe'):
- prefer_ffmpeg = True
-
- self._paths = dict(
- (p, os.path.join(location, p)) for p in programs)
- self._versions = dict(
- (p, get_exe_version(self._paths[p], args=['-version']))
- for p in programs)
- if self._versions is None:
- self._versions = dict(
- (p, get_exe_version(p, args=['-version'])) for p in programs)
- self._paths = dict((p, p) for p in programs)
-
- if prefer_ffmpeg is False:
- prefs = ('avconv', 'ffmpeg')
- else:
- prefs = ('ffmpeg', 'avconv')
- for p in prefs:
- if self._versions[p]:
- self.basename = p
- break
-
- if prefer_ffmpeg is False:
- prefs = ('avprobe', 'ffprobe')
- else:
- prefs = ('ffprobe', 'avprobe')
- for p in prefs:
- if self._versions[p]:
- self.probe_basename = p
- break
-
- @property
- def available(self):
- return self.basename is not None
-
- @property
- def executable(self):
- return self._paths[self.basename]
-
- @property
- def probe_available(self):
- return self.probe_basename is not None
-
- @property
- def probe_executable(self):
- return self._paths[self.probe_basename]
-
- def get_audio_codec(self, path):
- if not self.probe_available:
- raise PostProcessingError('ffprobe or avprobe not found. Please install one.')
- try:
- cmd = [
- encodeFilename(self.probe_executable, True),
- encodeArgument('-show_streams'),
- encodeFilename(self._ffmpeg_filename_argument(path), True)]
- if self._downloader.params.get('verbose', False):
- self._downloader.to_screen('[debug] %s command line: %s' % (self.basename, shell_quote(cmd)))
- handle = subprocess.Popen(cmd, stderr=compat_subprocess_get_DEVNULL(), stdout=subprocess.PIPE, stdin=subprocess.PIPE)
- output = handle.communicate()[0]
- if handle.wait() != 0:
- return None
- except (IOError, OSError):
- return None
- audio_codec = None
- for line in output.decode('ascii', 'ignore').split('\n'):
- if line.startswith('codec_name='):
- audio_codec = line.split('=')[1].strip()
- elif line.strip() == 'codec_type=audio' and audio_codec is not None:
- return audio_codec
- return None
-
- def run_ffmpeg_multiple_files(self, input_paths, out_path, opts):
- self.check_version()
-
- oldest_mtime = min(
- os.stat(encodeFilename(path)).st_mtime for path in input_paths)
-
- opts += self._configuration_args()
-
- files_cmd = []
- for path in input_paths:
- files_cmd.extend([
- encodeArgument('-i'),
- encodeFilename(self._ffmpeg_filename_argument(path), True)
- ])
- cmd = ([encodeFilename(self.executable, True), encodeArgument('-y')] +
- files_cmd +
- [encodeArgument(o) for o in opts] +
- [encodeFilename(self._ffmpeg_filename_argument(out_path), True)])
-
- if self._downloader.params.get('verbose', False):
- self._downloader.to_screen('[debug] ffmpeg command line: %s' % shell_quote(cmd))
- p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
- stdout, stderr = p.communicate()
- if p.returncode != 0:
- stderr = stderr.decode('utf-8', 'replace')
- msg = stderr.strip().split('\n')[-1]
- raise FFmpegPostProcessorError(msg)
- self.try_utime(out_path, oldest_mtime, oldest_mtime)
-
- def run_ffmpeg(self, path, out_path, opts):
- self.run_ffmpeg_multiple_files([path], out_path, opts)
-
- def _ffmpeg_filename_argument(self, fn):
- # Always use 'file:' because the filename may contain ':' (ffmpeg
- # interprets that as a protocol) or can start with '-' (-- is broken in
- # ffmpeg, see https://ffmpeg.org/trac/ffmpeg/ticket/2127 for details)
- # Also leave '-' intact in order not to break streaming to stdout.
- return 'file:' + fn if fn != '-' else fn
-
-
-class FFmpegExtractAudioPP(FFmpegPostProcessor):
- def __init__(self, downloader=None, preferredcodec=None, preferredquality=None, nopostoverwrites=False):
- FFmpegPostProcessor.__init__(self, downloader)
- if preferredcodec is None:
- preferredcodec = 'best'
- self._preferredcodec = preferredcodec
- self._preferredquality = preferredquality
- self._nopostoverwrites = nopostoverwrites
-
- def run_ffmpeg(self, path, out_path, codec, more_opts):
- if codec is None:
- acodec_opts = []
- else:
- acodec_opts = ['-acodec', codec]
- opts = ['-vn'] + acodec_opts + more_opts
- try:
- FFmpegPostProcessor.run_ffmpeg(self, path, out_path, opts)
- except FFmpegPostProcessorError as err:
- raise AudioConversionError(err.msg)
-
- def run(self, information):
- path = information['filepath']
-
- filecodec = self.get_audio_codec(path)
- if filecodec is None:
- raise PostProcessingError('WARNING: unable to obtain file audio codec with ffprobe')
-
- more_opts = []
- if self._preferredcodec == 'best' or self._preferredcodec == filecodec or (self._preferredcodec == 'm4a' and filecodec == 'aac'):
- if filecodec == 'aac' and self._preferredcodec in ['m4a', 'best']:
- # Lossless, but in another container
- acodec = 'copy'
- extension = 'm4a'
- more_opts = ['-bsf:a', 'aac_adtstoasc']
- elif filecodec in ['aac', 'flac', 'mp3', 'vorbis', 'opus']:
- # Lossless if possible
- acodec = 'copy'
- extension = filecodec
- if filecodec == 'aac':
- more_opts = ['-f', 'adts']
- if filecodec == 'vorbis':
- extension = 'ogg'
- else:
- # MP3 otherwise.
- acodec = 'libmp3lame'
- extension = 'mp3'
- more_opts = []
- if self._preferredquality is not None:
- if int(self._preferredquality) < 10:
- more_opts += ['-q:a', self._preferredquality]
- else:
- more_opts += ['-b:a', self._preferredquality + 'k']
- else:
- # We convert the audio (lossy if codec is lossy)
- acodec = ACODECS[self._preferredcodec]
- extension = self._preferredcodec
- more_opts = []
- if self._preferredquality is not None:
- # The opus codec doesn't support the -aq option
- if int(self._preferredquality) < 10 and extension != 'opus':
- more_opts += ['-q:a', self._preferredquality]
- else:
- more_opts += ['-b:a', self._preferredquality + 'k']
- if self._preferredcodec == 'aac':
- more_opts += ['-f', 'adts']
- if self._preferredcodec == 'm4a':
- more_opts += ['-bsf:a', 'aac_adtstoasc']
- if self._preferredcodec == 'vorbis':
- extension = 'ogg'
- if self._preferredcodec == 'wav':
- extension = 'wav'
- more_opts += ['-f', 'wav']
-
- prefix, sep, ext = path.rpartition('.') # not os.path.splitext, since the latter does not work on unicode in all setups
- new_path = prefix + sep + extension
-
- information['filepath'] = new_path
- information['ext'] = extension
-
- # If we download foo.mp3 and convert it to... foo.mp3, then don't delete foo.mp3, silly.
- if (new_path == path or
- (self._nopostoverwrites and os.path.exists(encodeFilename(new_path)))):
- self._downloader.to_screen('[ffmpeg] Post-process file %s exists, skipping' % new_path)
- return [], information
-
- try:
- self._downloader.to_screen('[ffmpeg] Destination: ' + new_path)
- self.run_ffmpeg(path, new_path, acodec, more_opts)
- except AudioConversionError as e:
- raise PostProcessingError(
- 'audio conversion failed: ' + e.msg)
- except Exception:
- raise PostProcessingError('error running ' + self.basename)
-
- # Try to update the date time for extracted audio file.
- if information.get('filetime') is not None:
- self.try_utime(
- new_path, time.time(), information['filetime'],
- errnote='Cannot update utime of audio file')
-
- return [path], information
-
-
-class FFmpegVideoConvertorPP(FFmpegPostProcessor):
- def __init__(self, downloader=None, preferedformat=None):
- super(FFmpegVideoConvertorPP, self).__init__(downloader)
- self._preferedformat = preferedformat
-
- def run(self, information):
- path = information['filepath']
- if information['ext'] == self._preferedformat:
- self._downloader.to_screen('[ffmpeg] Not converting video file %s - already is in target format %s' % (path, self._preferedformat))
- return [], information
- options = []
- if self._preferedformat == 'avi':
- options.extend(['-c:v', 'libxvid', '-vtag', 'XVID'])
- prefix, sep, ext = path.rpartition('.')
- outpath = prefix + sep + self._preferedformat
- self._downloader.to_screen('[' + 'ffmpeg' + '] Converting video from %s to %s, Destination: ' % (information['ext'], self._preferedformat) + outpath)
- self.run_ffmpeg(path, outpath, options)
- information['filepath'] = outpath
- information['format'] = self._preferedformat
- information['ext'] = self._preferedformat
- return [path], information
-
-
-class FFmpegEmbedSubtitlePP(FFmpegPostProcessor):
- def run(self, information):
- if information['ext'] not in ('mp4', 'webm', 'mkv'):
- self._downloader.to_screen('[ffmpeg] Subtitles can only be embedded in mp4, webm or mkv files')
- return [], information
- subtitles = information.get('requested_subtitles')
- if not subtitles:
- self._downloader.to_screen('[ffmpeg] There aren\'t any subtitles to embed')
- return [], information
-
- filename = information['filepath']
-
- ext = information['ext']
- sub_langs = []
- sub_filenames = []
- webm_vtt_warn = False
-
- for lang, sub_info in subtitles.items():
- sub_ext = sub_info['ext']
- if ext != 'webm' or ext == 'webm' and sub_ext == 'vtt':
- sub_langs.append(lang)
- sub_filenames.append(subtitles_filename(filename, lang, sub_ext))
- else:
- if not webm_vtt_warn and ext == 'webm' and sub_ext != 'vtt':
- webm_vtt_warn = True
- self._downloader.to_screen('[ffmpeg] Only WebVTT subtitles can be embedded in webm files')
-
- if not sub_langs:
- return [], information
-
- input_files = [filename] + sub_filenames
-
- opts = [
- '-map', '0',
- '-c', 'copy',
- # Don't copy the existing subtitles, we may be running the
- # postprocessor a second time
- '-map', '-0:s',
- ]
- if information['ext'] == 'mp4':
- opts += ['-c:s', 'mov_text']
- for (i, lang) in enumerate(sub_langs):
- opts.extend(['-map', '%d:0' % (i + 1)])
- lang_code = ISO639Utils.short2long(lang)
- if lang_code is not None:
- opts.extend(['-metadata:s:s:%d' % i, 'language=%s' % lang_code])
-
- temp_filename = prepend_extension(filename, 'temp')
- self._downloader.to_screen('[ffmpeg] Embedding subtitles in \'%s\'' % filename)
- self.run_ffmpeg_multiple_files(input_files, temp_filename, opts)
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
-
- return sub_filenames, information
-
-
-class FFmpegMetadataPP(FFmpegPostProcessor):
- def run(self, info):
- metadata = {}
-
- def add(meta_list, info_list=None):
- if not info_list:
- info_list = meta_list
- if not isinstance(meta_list, (list, tuple)):
- meta_list = (meta_list,)
- if not isinstance(info_list, (list, tuple)):
- info_list = (info_list,)
- for info_f in info_list:
- if info.get(info_f) is not None:
- for meta_f in meta_list:
- metadata[meta_f] = info[info_f]
- break
-
- add('title', ('track', 'title'))
- add('date', 'upload_date')
- add(('description', 'comment'), 'description')
- add('purl', 'webpage_url')
- add('track', 'track_number')
- add('artist', ('artist', 'creator', 'uploader', 'uploader_id'))
- add('genre')
- add('album')
- add('album_artist')
- add('disc', 'disc_number')
-
- if not metadata:
- self._downloader.to_screen('[ffmpeg] There isn\'t any metadata to add')
- return [], info
-
- filename = info['filepath']
- temp_filename = prepend_extension(filename, 'temp')
- in_filenames = [filename]
- options = []
-
- if info['ext'] == 'm4a':
- options.extend(['-vn', '-acodec', 'copy'])
- else:
- options.extend(['-c', 'copy'])
-
- for (name, value) in metadata.items():
- options.extend(['-metadata', '%s=%s' % (name, value)])
-
- chapters = info.get('chapters', [])
- if chapters:
- metadata_filename = replace_extension(filename, 'meta')
- with io.open(metadata_filename, 'wt', encoding='utf-8') as f:
- def ffmpeg_escape(text):
- return re.sub(r'(=|;|#|\\|\n)', r'\\\1', text)
-
- metadata_file_content = ';FFMETADATA1\n'
- for chapter in chapters:
- metadata_file_content += '[CHAPTER]\nTIMEBASE=1/1000\n'
- metadata_file_content += 'START=%d\n' % (chapter['start_time'] * 1000)
- metadata_file_content += 'END=%d\n' % (chapter['end_time'] * 1000)
- chapter_title = chapter.get('title')
- if chapter_title:
- metadata_file_content += 'title=%s\n' % ffmpeg_escape(chapter_title)
- f.write(metadata_file_content)
- in_filenames.append(metadata_filename)
- options.extend(['-map_metadata', '1'])
-
- self._downloader.to_screen('[ffmpeg] Adding metadata to \'%s\'' % filename)
- self.run_ffmpeg_multiple_files(in_filenames, temp_filename, options)
- if chapters:
- os.remove(metadata_filename)
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
- return [], info
-
-
-class FFmpegMergerPP(FFmpegPostProcessor):
- def run(self, info):
- filename = info['filepath']
- temp_filename = prepend_extension(filename, 'temp')
- args = ['-c', 'copy', '-map', '0:v:0', '-map', '1:a:0']
- self._downloader.to_screen('[ffmpeg] Merging formats into "%s"' % filename)
- self.run_ffmpeg_multiple_files(info['__files_to_merge'], temp_filename, args)
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
- return info['__files_to_merge'], info
-
- def can_merge(self):
- # TODO: figure out merge-capable ffmpeg version
- if self.basename != 'avconv':
- return True
-
- required_version = '10-0'
- if is_outdated_version(
- self._versions[self.basename], required_version):
- warning = ('Your copy of %s is outdated and unable to properly mux separate video and audio files, '
- 'youtube-dl will download single file media. '
- 'Update %s to version %s or newer to fix this.') % (
- self.basename, self.basename, required_version)
- if self._downloader:
- self._downloader.report_warning(warning)
- return False
- return True
-
-
-class FFmpegFixupStretchedPP(FFmpegPostProcessor):
- def run(self, info):
- stretched_ratio = info.get('stretched_ratio')
- if stretched_ratio is None or stretched_ratio == 1:
- return [], info
-
- filename = info['filepath']
- temp_filename = prepend_extension(filename, 'temp')
-
- options = ['-c', 'copy', '-aspect', '%f' % stretched_ratio]
- self._downloader.to_screen('[ffmpeg] Fixing aspect ratio in "%s"' % filename)
- self.run_ffmpeg(filename, temp_filename, options)
-
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
-
- return [], info
-
-
-class FFmpegFixupM4aPP(FFmpegPostProcessor):
- def run(self, info):
- if info.get('container') != 'm4a_dash':
- return [], info
-
- filename = info['filepath']
- temp_filename = prepend_extension(filename, 'temp')
-
- options = ['-c', 'copy', '-f', 'mp4']
- self._downloader.to_screen('[ffmpeg] Correcting container in "%s"' % filename)
- self.run_ffmpeg(filename, temp_filename, options)
-
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
-
- return [], info
-
-
-class FFmpegFixupM3u8PP(FFmpegPostProcessor):
- def run(self, info):
- filename = info['filepath']
- if self.get_audio_codec(filename) == 'aac':
- temp_filename = prepend_extension(filename, 'temp')
-
- options = ['-c', 'copy', '-f', 'mp4', '-bsf:a', 'aac_adtstoasc']
- self._downloader.to_screen('[ffmpeg] Fixing malformed AAC bitstream in "%s"' % filename)
- self.run_ffmpeg(filename, temp_filename, options)
-
- os.remove(encodeFilename(filename))
- os.rename(encodeFilename(temp_filename), encodeFilename(filename))
- return [], info
-
-
-class FFmpegSubtitlesConvertorPP(FFmpegPostProcessor):
- def __init__(self, downloader=None, format=None):
- super(FFmpegSubtitlesConvertorPP, self).__init__(downloader)
- self.format = format
-
- def run(self, info):
- subs = info.get('requested_subtitles')
- filename = info['filepath']
- new_ext = self.format
- new_format = new_ext
- if new_format == 'vtt':
- new_format = 'webvtt'
- if subs is None:
- self._downloader.to_screen('[ffmpeg] There aren\'t any subtitles to convert')
- return [], info
- self._downloader.to_screen('[ffmpeg] Converting subtitles')
- sub_filenames = []
- for lang, sub in subs.items():
- ext = sub['ext']
- if ext == new_ext:
- self._downloader.to_screen(
- '[ffmpeg] Subtitle file for %s is already in the requested format' % new_ext)
- continue
- old_file = subtitles_filename(filename, lang, ext)
- sub_filenames.append(old_file)
- new_file = subtitles_filename(filename, lang, new_ext)
-
- if ext in ('dfxp', 'ttml', 'tt'):
- self._downloader.report_warning(
- 'You have requested to convert dfxp (TTML) subtitles into another format, '
- 'which results in style information loss')
-
- dfxp_file = old_file
- srt_file = subtitles_filename(filename, lang, 'srt')
-
- with open(dfxp_file, 'rb') as f:
- srt_data = dfxp2srt(f.read())
-
- with io.open(srt_file, 'wt', encoding='utf-8') as f:
- f.write(srt_data)
- old_file = srt_file
-
- subs[lang] = {
- 'ext': 'srt',
- 'data': srt_data
- }
-
- if new_ext == 'srt':
- continue
- else:
- sub_filenames.append(srt_file)
-
- self.run_ffmpeg(old_file, new_file, ['-f', new_format])
-
- with io.open(new_file, 'rt', encoding='utf-8') as f:
- subs[lang] = {
- 'ext': new_ext,
- 'data': f.read(),
- }
-
- return sub_filenames, info
diff --git a/youtube_dl/postprocessor/metadatafromtitle.py b/youtube_dl/postprocessor/metadatafromtitle.py
deleted file mode 100644
index f5c14d9..0000000
--- a/youtube_dl/postprocessor/metadatafromtitle.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from __future__ import unicode_literals
-
-import re
-
-from .common import PostProcessor
-
-
-class MetadataFromTitlePP(PostProcessor):
- def __init__(self, downloader, titleformat):
- super(MetadataFromTitlePP, self).__init__(downloader)
- self._titleformat = titleformat
- self._titleregex = (self.format_to_regex(titleformat)
- if re.search(r'%\(\w+\)s', titleformat)
- else titleformat)
-
- def format_to_regex(self, fmt):
- r"""
- Converts a string like
- '%(title)s - %(artist)s'
- to a regex like
- '(?P<title>.+)\ \-\ (?P<artist>.+)'
- """
- lastpos = 0
- regex = ''
- # replace %(..)s with regex group and escape other string parts
- for match in re.finditer(r'%\((\w+)\)s', fmt):
- regex += re.escape(fmt[lastpos:match.start()])
- regex += r'(?P<' + match.group(1) + '>.+)'
- lastpos = match.end()
- if lastpos < len(fmt):
- regex += re.escape(fmt[lastpos:])
- return regex
-
- def run(self, info):
- title = info['title']
- match = re.match(self._titleregex, title)
- if match is None:
- self._downloader.to_screen(
- '[fromtitle] Could not interpret title of video as "%s"'
- % self._titleformat)
- return [], info
- for attribute, value in match.groupdict().items():
- info[attribute] = value
- self._downloader.to_screen(
- '[fromtitle] parsed %s: %s'
- % (attribute, value if value is not None else 'NA'))
-
- return [], info
diff --git a/youtube_dl/postprocessor/xattrpp.py b/youtube_dl/postprocessor/xattrpp.py
deleted file mode 100644
index b0aed9c..0000000
--- a/youtube_dl/postprocessor/xattrpp.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from __future__ import unicode_literals
-
-from .common import PostProcessor
-from ..compat import compat_os_name
-from ..utils import (
- hyphenate_date,
- write_xattr,
- XAttrMetadataError,
- XAttrUnavailableError,
-)
-
-
-class XAttrMetadataPP(PostProcessor):
-
- #
- # More info about extended attributes for media:
- # http://freedesktop.org/wiki/CommonExtendedAttributes/
- # http://www.freedesktop.org/wiki/PhreedomDraft/
- # http://dublincore.org/documents/usageguide/elements.shtml
- #
- # TODO:
- # * capture youtube keywords and put them in 'user.dublincore.subject' (comma-separated)
- # * figure out which xattrs can be used for 'duration', 'thumbnail', 'resolution'
- #
-
- def run(self, info):
- """ Set extended attributes on downloaded file (if xattr support is found). """
-
- # Write the metadata to the file's xattrs
- self._downloader.to_screen('[metadata] Writing metadata to file\'s xattrs')
-
- filename = info['filepath']
-
- try:
- xattr_mapping = {
- 'user.xdg.referrer.url': 'webpage_url',
- # 'user.xdg.comment': 'description',
- 'user.dublincore.title': 'title',
- 'user.dublincore.date': 'upload_date',
- 'user.dublincore.description': 'description',
- 'user.dublincore.contributor': 'uploader',
- 'user.dublincore.format': 'format',
- }
-
- num_written = 0
- for xattrname, infoname in xattr_mapping.items():
-
- value = info.get(infoname)
-
- if value:
- if infoname == 'upload_date':
- value = hyphenate_date(value)
-
- byte_value = value.encode('utf-8')
- write_xattr(filename, xattrname, byte_value)
- num_written += 1
-
- return [], info
-
- except XAttrUnavailableError as e:
- self._downloader.report_error(str(e))
- return [], info
-
- except XAttrMetadataError as e:
- if e.reason == 'NO_SPACE':
- self._downloader.report_warning(
- 'There\'s no disk space left, disk quota exceeded or filesystem xattr limit exceeded. ' +
- (('Some ' if num_written else '') + 'extended attributes are not written.').capitalize())
- elif e.reason == 'VALUE_TOO_LONG':
- self._downloader.report_warning(
- 'Unable to write extended attributes due to too long values.')
- else:
- msg = 'This filesystem doesn\'t support extended attributes. '
- if compat_os_name == 'nt':
- msg += 'You need to use NTFS.'
- else:
- msg += '(You may have to enable them in your /etc/fstab)'
- self._downloader.report_error(msg)
- return [], info
diff --git a/youtube_dl/socks.py b/youtube_dl/socks.py
deleted file mode 100644
index 5d4adbe..0000000
--- a/youtube_dl/socks.py
+++ /dev/null
@@ -1,273 +0,0 @@
-# Public Domain SOCKS proxy protocol implementation
-# Adapted from https://gist.github.com/bluec0re/cafd3764412967417fd3
-
-from __future__ import unicode_literals
-
-# References:
-# SOCKS4 protocol http://www.openssh.com/txt/socks4.protocol
-# SOCKS4A protocol http://www.openssh.com/txt/socks4a.protocol
-# SOCKS5 protocol https://tools.ietf.org/html/rfc1928
-# SOCKS5 username/password authentication https://tools.ietf.org/html/rfc1929
-
-import collections
-import socket
-
-from .compat import (
- compat_ord,
- compat_struct_pack,
- compat_struct_unpack,
-)
-
-__author__ = 'Timo Schmid <coding@timoschmid.de>'
-
-SOCKS4_VERSION = 4
-SOCKS4_REPLY_VERSION = 0x00
-# Excerpt from SOCKS4A protocol:
-# if the client cannot resolve the destination host's domain name to find its
-# IP address, it should set the first three bytes of DSTIP to NULL and the last
-# byte to a non-zero value.
-SOCKS4_DEFAULT_DSTIP = compat_struct_pack('!BBBB', 0, 0, 0, 0xFF)
-
-SOCKS5_VERSION = 5
-SOCKS5_USER_AUTH_VERSION = 0x01
-SOCKS5_USER_AUTH_SUCCESS = 0x00
-
-
-class Socks4Command(object):
- CMD_CONNECT = 0x01
- CMD_BIND = 0x02
-
-
-class Socks5Command(Socks4Command):
- CMD_UDP_ASSOCIATE = 0x03
-
-
-class Socks5Auth(object):
- AUTH_NONE = 0x00
- AUTH_GSSAPI = 0x01
- AUTH_USER_PASS = 0x02
- AUTH_NO_ACCEPTABLE = 0xFF # For server response
-
-
-class Socks5AddressType(object):
- ATYP_IPV4 = 0x01
- ATYP_DOMAINNAME = 0x03
- ATYP_IPV6 = 0x04
-
-
-class ProxyError(socket.error):
- ERR_SUCCESS = 0x00
-
- def __init__(self, code=None, msg=None):
- if code is not None and msg is None:
- msg = self.CODES.get(code) or 'unknown error'
- super(ProxyError, self).__init__(code, msg)
-
-
-class InvalidVersionError(ProxyError):
- def __init__(self, expected_version, got_version):
- msg = ('Invalid response version from server. Expected {0:02x} got '
- '{1:02x}'.format(expected_version, got_version))
- super(InvalidVersionError, self).__init__(0, msg)
-
-
-class Socks4Error(ProxyError):
- ERR_SUCCESS = 90
-
- CODES = {
- 91: 'request rejected or failed',
- 92: 'request rejected because SOCKS server cannot connect to identd on the client',
- 93: 'request rejected because the client program and identd report different user-ids'
- }
-
-
-class Socks5Error(ProxyError):
- ERR_GENERAL_FAILURE = 0x01
-
- CODES = {
- 0x01: 'general SOCKS server failure',
- 0x02: 'connection not allowed by ruleset',
- 0x03: 'Network unreachable',
- 0x04: 'Host unreachable',
- 0x05: 'Connection refused',
- 0x06: 'TTL expired',
- 0x07: 'Command not supported',
- 0x08: 'Address type not supported',
- 0xFE: 'unknown username or invalid password',
- 0xFF: 'all offered authentication methods were rejected'
- }
-
-
-class ProxyType(object):
- SOCKS4 = 0
- SOCKS4A = 1
- SOCKS5 = 2
-
-
-Proxy = collections.namedtuple('Proxy', (
- 'type', 'host', 'port', 'username', 'password', 'remote_dns'))
-
-
-class sockssocket(socket.socket):
- def __init__(self, *args, **kwargs):
- self._proxy = None
- super(sockssocket, self).__init__(*args, **kwargs)
-
- def setproxy(self, proxytype, addr, port, rdns=True, username=None, password=None):
- assert proxytype in (ProxyType.SOCKS4, ProxyType.SOCKS4A, ProxyType.SOCKS5)
-
- self._proxy = Proxy(proxytype, addr, port, username, password, rdns)
-
- def recvall(self, cnt):
- data = b''
- while len(data) < cnt:
- cur = self.recv(cnt - len(data))
- if not cur:
- raise EOFError('{0} bytes missing'.format(cnt - len(data)))
- data += cur
- return data
-
- def _recv_bytes(self, cnt):
- data = self.recvall(cnt)
- return compat_struct_unpack('!{0}B'.format(cnt), data)
-
- @staticmethod
- def _len_and_data(data):
- return compat_struct_pack('!B', len(data)) + data
-
- def _check_response_version(self, expected_version, got_version):
- if got_version != expected_version:
- self.close()
- raise InvalidVersionError(expected_version, got_version)
-
- def _resolve_address(self, destaddr, default, use_remote_dns):
- try:
- return socket.inet_aton(destaddr)
- except socket.error:
- if use_remote_dns and self._proxy.remote_dns:
- return default
- else:
- return socket.inet_aton(socket.gethostbyname(destaddr))
-
- def _setup_socks4(self, address, is_4a=False):
- destaddr, port = address
-
- ipaddr = self._resolve_address(destaddr, SOCKS4_DEFAULT_DSTIP, use_remote_dns=is_4a)
-
- packet = compat_struct_pack('!BBH', SOCKS4_VERSION, Socks4Command.CMD_CONNECT, port) + ipaddr
-
- username = (self._proxy.username or '').encode('utf-8')
- packet += username + b'\x00'
-
- if is_4a and self._proxy.remote_dns:
- packet += destaddr.encode('utf-8') + b'\x00'
-
- self.sendall(packet)
-
- version, resp_code, dstport, dsthost = compat_struct_unpack('!BBHI', self.recvall(8))
-
- self._check_response_version(SOCKS4_REPLY_VERSION, version)
-
- if resp_code != Socks4Error.ERR_SUCCESS:
- self.close()
- raise Socks4Error(resp_code)
-
- return (dsthost, dstport)
-
- def _setup_socks4a(self, address):
- self._setup_socks4(address, is_4a=True)
-
- def _socks5_auth(self):
- packet = compat_struct_pack('!B', SOCKS5_VERSION)
-
- auth_methods = [Socks5Auth.AUTH_NONE]
- if self._proxy.username and self._proxy.password:
- auth_methods.append(Socks5Auth.AUTH_USER_PASS)
-
- packet += compat_struct_pack('!B', len(auth_methods))
- packet += compat_struct_pack('!{0}B'.format(len(auth_methods)), *auth_methods)
-
- self.sendall(packet)
-
- version, method = self._recv_bytes(2)
-
- self._check_response_version(SOCKS5_VERSION, version)
-
- if method == Socks5Auth.AUTH_NO_ACCEPTABLE or (
- method == Socks5Auth.AUTH_USER_PASS and (not self._proxy.username or not self._proxy.password)):
- self.close()
- raise Socks5Error(Socks5Auth.AUTH_NO_ACCEPTABLE)
-
- if method == Socks5Auth.AUTH_USER_PASS:
- username = self._proxy.username.encode('utf-8')
- password = self._proxy.password.encode('utf-8')
- packet = compat_struct_pack('!B', SOCKS5_USER_AUTH_VERSION)
- packet += self._len_and_data(username) + self._len_and_data(password)
- self.sendall(packet)
-
- version, status = self._recv_bytes(2)
-
- self._check_response_version(SOCKS5_USER_AUTH_VERSION, version)
-
- if status != SOCKS5_USER_AUTH_SUCCESS:
- self.close()
- raise Socks5Error(Socks5Error.ERR_GENERAL_FAILURE)
-
- def _setup_socks5(self, address):
- destaddr, port = address
-
- ipaddr = self._resolve_address(destaddr, None, use_remote_dns=True)
-
- self._socks5_auth()
-
- reserved = 0
- packet = compat_struct_pack('!BBB', SOCKS5_VERSION, Socks5Command.CMD_CONNECT, reserved)
- if ipaddr is None:
- destaddr = destaddr.encode('utf-8')
- packet += compat_struct_pack('!B', Socks5AddressType.ATYP_DOMAINNAME)
- packet += self._len_and_data(destaddr)
- else:
- packet += compat_struct_pack('!B', Socks5AddressType.ATYP_IPV4) + ipaddr
- packet += compat_struct_pack('!H', port)
-
- self.sendall(packet)
-
- version, status, reserved, atype = self._recv_bytes(4)
-
- self._check_response_version(SOCKS5_VERSION, version)
-
- if status != Socks5Error.ERR_SUCCESS:
- self.close()
- raise Socks5Error(status)
-
- if atype == Socks5AddressType.ATYP_IPV4:
- destaddr = self.recvall(4)
- elif atype == Socks5AddressType.ATYP_DOMAINNAME:
- alen = compat_ord(self.recv(1))
- destaddr = self.recvall(alen)
- elif atype == Socks5AddressType.ATYP_IPV6:
- destaddr = self.recvall(16)
- destport = compat_struct_unpack('!H', self.recvall(2))[0]
-
- return (destaddr, destport)
-
- def _make_proxy(self, connect_func, address):
- if not self._proxy:
- return connect_func(self, address)
-
- result = connect_func(self, (self._proxy.host, self._proxy.port))
- if result != 0 and result is not None:
- return result
- setup_funcs = {
- ProxyType.SOCKS4: self._setup_socks4,
- ProxyType.SOCKS4A: self._setup_socks4a,
- ProxyType.SOCKS5: self._setup_socks5,
- }
- setup_funcs[self._proxy.type](address)
- return result
-
- def connect(self, address):
- self._make_proxy(socket.socket.connect, address)
-
- def connect_ex(self, address):
- return self._make_proxy(socket.socket.connect_ex, address)
diff --git a/youtube_dl/swfinterp.py b/youtube_dl/swfinterp.py
deleted file mode 100644
index 0c71585..0000000
--- a/youtube_dl/swfinterp.py
+++ /dev/null
@@ -1,834 +0,0 @@
-from __future__ import unicode_literals
-
-import collections
-import io
-import zlib
-
-from .compat import (
- compat_str,
- compat_struct_unpack,
-)
-from .utils import (
- ExtractorError,
-)
-
-
-def _extract_tags(file_contents):
- if file_contents[1:3] != b'WS':
- raise ExtractorError(
- 'Not an SWF file; header is %r' % file_contents[:3])
- if file_contents[:1] == b'C':
- content = zlib.decompress(file_contents[8:])
- else:
- raise NotImplementedError(
- 'Unsupported compression format %r' %
- file_contents[:1])
-
- # Determine number of bits in framesize rectangle
- framesize_nbits = compat_struct_unpack('!B', content[:1])[0] >> 3
- framesize_len = (5 + 4 * framesize_nbits + 7) // 8
-
- pos = framesize_len + 2 + 2
- while pos < len(content):
- header16 = compat_struct_unpack('<H', content[pos:pos + 2])[0]
- pos += 2
- tag_code = header16 >> 6
- tag_len = header16 & 0x3f
- if tag_len == 0x3f:
- tag_len = compat_struct_unpack('<I', content[pos:pos + 4])[0]
- pos += 4
- assert pos + tag_len <= len(content), \
- ('Tag %d ends at %d+%d - that\'s longer than the file (%d)'
- % (tag_code, pos, tag_len, len(content)))
- yield (tag_code, content[pos:pos + tag_len])
- pos += tag_len
-
-
-class _AVMClass_Object(object):
- def __init__(self, avm_class):
- self.avm_class = avm_class
-
- def __repr__(self):
- return '%s#%x' % (self.avm_class.name, id(self))
-
-
-class _ScopeDict(dict):
- def __init__(self, avm_class):
- super(_ScopeDict, self).__init__()
- self.avm_class = avm_class
-
- def __repr__(self):
- return '%s__Scope(%s)' % (
- self.avm_class.name,
- super(_ScopeDict, self).__repr__())
-
-
-class _AVMClass(object):
- def __init__(self, name_idx, name, static_properties=None):
- self.name_idx = name_idx
- self.name = name
- self.method_names = {}
- self.method_idxs = {}
- self.methods = {}
- self.method_pyfunctions = {}
- self.static_properties = static_properties if static_properties else {}
-
- self.variables = _ScopeDict(self)
- self.constants = {}
-
- def make_object(self):
- return _AVMClass_Object(self)
-
- def __repr__(self):
- return '_AVMClass(%s)' % (self.name)
-
- def register_methods(self, methods):
- self.method_names.update(methods.items())
- self.method_idxs.update(dict(
- (idx, name)
- for name, idx in methods.items()))
-
-
-class _Multiname(object):
- def __init__(self, kind):
- self.kind = kind
-
- def __repr__(self):
- return '[MULTINAME kind: 0x%x]' % self.kind
-
-
-def _read_int(reader):
- res = 0
- shift = 0
- for _ in range(5):
- buf = reader.read(1)
- assert len(buf) == 1
- b = compat_struct_unpack('<B', buf)[0]
- res = res | ((b & 0x7f) << shift)
- if b & 0x80 == 0:
- break
- shift += 7
- return res
-
-
-def _u30(reader):
- res = _read_int(reader)
- assert res & 0xf0000000 == 0
- return res
-
-
-_u32 = _read_int
-
-
-def _s32(reader):
- v = _read_int(reader)
- if v & 0x80000000 != 0:
- v = - ((v ^ 0xffffffff) + 1)
- return v
-
-
-def _s24(reader):
- bs = reader.read(3)
- assert len(bs) == 3
- last_byte = b'\xff' if (ord(bs[2:3]) >= 0x80) else b'\x00'
- return compat_struct_unpack('<i', bs + last_byte)[0]
-
-
-def _read_string(reader):
- slen = _u30(reader)
- resb = reader.read(slen)
- assert len(resb) == slen
- return resb.decode('utf-8')
-
-
-def _read_bytes(count, reader):
- assert count >= 0
- resb = reader.read(count)
- assert len(resb) == count
- return resb
-
-
-def _read_byte(reader):
- resb = _read_bytes(1, reader=reader)
- res = compat_struct_unpack('<B', resb)[0]
- return res
-
-
-StringClass = _AVMClass('(no name idx)', 'String')
-ByteArrayClass = _AVMClass('(no name idx)', 'ByteArray')
-TimerClass = _AVMClass('(no name idx)', 'Timer')
-TimerEventClass = _AVMClass('(no name idx)', 'TimerEvent', {'TIMER': 'timer'})
-_builtin_classes = {
- StringClass.name: StringClass,
- ByteArrayClass.name: ByteArrayClass,
- TimerClass.name: TimerClass,
- TimerEventClass.name: TimerEventClass,
-}
-
-
-class _Undefined(object):
- def __bool__(self):
- return False
- __nonzero__ = __bool__
-
- def __hash__(self):
- return 0
-
- def __str__(self):
- return 'undefined'
- __repr__ = __str__
-
-
-undefined = _Undefined()
-
-
-class SWFInterpreter(object):
- def __init__(self, file_contents):
- self._patched_functions = {
- (TimerClass, 'addEventListener'): lambda params: undefined,
- }
- code_tag = next(tag
- for tag_code, tag in _extract_tags(file_contents)
- if tag_code == 82)
- p = code_tag.index(b'\0', 4) + 1
- code_reader = io.BytesIO(code_tag[p:])
-
- # Parse ABC (AVM2 ByteCode)
-
- # Define a couple convenience methods
- u30 = lambda *args: _u30(*args, reader=code_reader)
- s32 = lambda *args: _s32(*args, reader=code_reader)
- u32 = lambda *args: _u32(*args, reader=code_reader)
- read_bytes = lambda *args: _read_bytes(*args, reader=code_reader)
- read_byte = lambda *args: _read_byte(*args, reader=code_reader)
-
- # minor_version + major_version
- read_bytes(2 + 2)
-
- # Constant pool
- int_count = u30()
- self.constant_ints = [0]
- for _c in range(1, int_count):
- self.constant_ints.append(s32())
- self.constant_uints = [0]
- uint_count = u30()
- for _c in range(1, uint_count):
- self.constant_uints.append(u32())
- double_count = u30()
- read_bytes(max(0, (double_count - 1)) * 8)
- string_count = u30()
- self.constant_strings = ['']
- for _c in range(1, string_count):
- s = _read_string(code_reader)
- self.constant_strings.append(s)
- namespace_count = u30()
- for _c in range(1, namespace_count):
- read_bytes(1) # kind
- u30() # name
- ns_set_count = u30()
- for _c in range(1, ns_set_count):
- count = u30()
- for _c2 in range(count):
- u30()
- multiname_count = u30()
- MULTINAME_SIZES = {
- 0x07: 2, # QName
- 0x0d: 2, # QNameA
- 0x0f: 1, # RTQName
- 0x10: 1, # RTQNameA
- 0x11: 0, # RTQNameL
- 0x12: 0, # RTQNameLA
- 0x09: 2, # Multiname
- 0x0e: 2, # MultinameA
- 0x1b: 1, # MultinameL
- 0x1c: 1, # MultinameLA
- }
- self.multinames = ['']
- for _c in range(1, multiname_count):
- kind = u30()
- assert kind in MULTINAME_SIZES, 'Invalid multiname kind %r' % kind
- if kind == 0x07:
- u30() # namespace_idx
- name_idx = u30()
- self.multinames.append(self.constant_strings[name_idx])
- elif kind == 0x09:
- name_idx = u30()
- u30()
- self.multinames.append(self.constant_strings[name_idx])
- else:
- self.multinames.append(_Multiname(kind))
- for _c2 in range(MULTINAME_SIZES[kind]):
- u30()
-
- # Methods
- method_count = u30()
- MethodInfo = collections.namedtuple(
- 'MethodInfo',
- ['NEED_ARGUMENTS', 'NEED_REST'])
- method_infos = []
- for method_id in range(method_count):
- param_count = u30()
- u30() # return type
- for _ in range(param_count):
- u30() # param type
- u30() # name index (always 0 for youtube)
- flags = read_byte()
- if flags & 0x08 != 0:
- # Options present
- option_count = u30()
- for c in range(option_count):
- u30() # val
- read_bytes(1) # kind
- if flags & 0x80 != 0:
- # Param names present
- for _ in range(param_count):
- u30() # param name
- mi = MethodInfo(flags & 0x01 != 0, flags & 0x04 != 0)
- method_infos.append(mi)
-
- # Metadata
- metadata_count = u30()
- for _c in range(metadata_count):
- u30() # name
- item_count = u30()
- for _c2 in range(item_count):
- u30() # key
- u30() # value
-
- def parse_traits_info():
- trait_name_idx = u30()
- kind_full = read_byte()
- kind = kind_full & 0x0f
- attrs = kind_full >> 4
- methods = {}
- constants = None
- if kind == 0x00: # Slot
- u30() # Slot id
- u30() # type_name_idx
- vindex = u30()
- if vindex != 0:
- read_byte() # vkind
- elif kind == 0x06: # Const
- u30() # Slot id
- u30() # type_name_idx
- vindex = u30()
- vkind = 'any'
- if vindex != 0:
- vkind = read_byte()
- if vkind == 0x03: # Constant_Int
- value = self.constant_ints[vindex]
- elif vkind == 0x04: # Constant_UInt
- value = self.constant_uints[vindex]
- else:
- return {}, None # Ignore silently for now
- constants = {self.multinames[trait_name_idx]: value}
- elif kind in (0x01, 0x02, 0x03): # Method / Getter / Setter
- u30() # disp_id
- method_idx = u30()
- methods[self.multinames[trait_name_idx]] = method_idx
- elif kind == 0x04: # Class
- u30() # slot_id
- u30() # classi
- elif kind == 0x05: # Function
- u30() # slot_id
- function_idx = u30()
- methods[function_idx] = self.multinames[trait_name_idx]
- else:
- raise ExtractorError('Unsupported trait kind %d' % kind)
-
- if attrs & 0x4 != 0: # Metadata present
- metadata_count = u30()
- for _c3 in range(metadata_count):
- u30() # metadata index
-
- return methods, constants
-
- # Classes
- class_count = u30()
- classes = []
- for class_id in range(class_count):
- name_idx = u30()
-
- cname = self.multinames[name_idx]
- avm_class = _AVMClass(name_idx, cname)
- classes.append(avm_class)
-
- u30() # super_name idx
- flags = read_byte()
- if flags & 0x08 != 0: # Protected namespace is present
- u30() # protected_ns_idx
- intrf_count = u30()
- for _c2 in range(intrf_count):
- u30()
- u30() # iinit
- trait_count = u30()
- for _c2 in range(trait_count):
- trait_methods, trait_constants = parse_traits_info()
- avm_class.register_methods(trait_methods)
- if trait_constants:
- avm_class.constants.update(trait_constants)
-
- assert len(classes) == class_count
- self._classes_by_name = dict((c.name, c) for c in classes)
-
- for avm_class in classes:
- avm_class.cinit_idx = u30()
- trait_count = u30()
- for _c2 in range(trait_count):
- trait_methods, trait_constants = parse_traits_info()
- avm_class.register_methods(trait_methods)
- if trait_constants:
- avm_class.constants.update(trait_constants)
-
- # Scripts
- script_count = u30()
- for _c in range(script_count):
- u30() # init
- trait_count = u30()
- for _c2 in range(trait_count):
- parse_traits_info()
-
- # Method bodies
- method_body_count = u30()
- Method = collections.namedtuple('Method', ['code', 'local_count'])
- self._all_methods = []
- for _c in range(method_body_count):
- method_idx = u30()
- u30() # max_stack
- local_count = u30()
- u30() # init_scope_depth
- u30() # max_scope_depth
- code_length = u30()
- code = read_bytes(code_length)
- m = Method(code, local_count)
- self._all_methods.append(m)
- for avm_class in classes:
- if method_idx in avm_class.method_idxs:
- avm_class.methods[avm_class.method_idxs[method_idx]] = m
- exception_count = u30()
- for _c2 in range(exception_count):
- u30() # from
- u30() # to
- u30() # target
- u30() # exc_type
- u30() # var_name
- trait_count = u30()
- for _c2 in range(trait_count):
- parse_traits_info()
-
- assert p + code_reader.tell() == len(code_tag)
-
- def patch_function(self, avm_class, func_name, f):
- self._patched_functions[(avm_class, func_name)] = f
-
- def extract_class(self, class_name, call_cinit=True):
- try:
- res = self._classes_by_name[class_name]
- except KeyError:
- raise ExtractorError('Class %r not found' % class_name)
-
- if call_cinit and hasattr(res, 'cinit_idx'):
- res.register_methods({'$cinit': res.cinit_idx})
- res.methods['$cinit'] = self._all_methods[res.cinit_idx]
- cinit = self.extract_function(res, '$cinit')
- cinit([])
-
- return res
-
- def extract_function(self, avm_class, func_name):
- p = self._patched_functions.get((avm_class, func_name))
- if p:
- return p
- if func_name in avm_class.method_pyfunctions:
- return avm_class.method_pyfunctions[func_name]
- if func_name in self._classes_by_name:
- return self._classes_by_name[func_name].make_object()
- if func_name not in avm_class.methods:
- raise ExtractorError('Cannot find function %s.%s' % (
- avm_class.name, func_name))
- m = avm_class.methods[func_name]
-
- def resfunc(args):
- # Helper functions
- coder = io.BytesIO(m.code)
- s24 = lambda: _s24(coder)
- u30 = lambda: _u30(coder)
-
- registers = [avm_class.variables] + list(args) + [None] * m.local_count
- stack = []
- scopes = collections.deque([
- self._classes_by_name, avm_class.constants, avm_class.variables])
- while True:
- opcode = _read_byte(coder)
- if opcode == 9: # label
- pass # Spec says: "Do nothing."
- elif opcode == 16: # jump
- offset = s24()
- coder.seek(coder.tell() + offset)
- elif opcode == 17: # iftrue
- offset = s24()
- value = stack.pop()
- if value:
- coder.seek(coder.tell() + offset)
- elif opcode == 18: # iffalse
- offset = s24()
- value = stack.pop()
- if not value:
- coder.seek(coder.tell() + offset)
- elif opcode == 19: # ifeq
- offset = s24()
- value2 = stack.pop()
- value1 = stack.pop()
- if value2 == value1:
- coder.seek(coder.tell() + offset)
- elif opcode == 20: # ifne
- offset = s24()
- value2 = stack.pop()
- value1 = stack.pop()
- if value2 != value1:
- coder.seek(coder.tell() + offset)
- elif opcode == 21: # iflt
- offset = s24()
- value2 = stack.pop()
- value1 = stack.pop()
- if value1 < value2:
- coder.seek(coder.tell() + offset)
- elif opcode == 32: # pushnull
- stack.append(None)
- elif opcode == 33: # pushundefined
- stack.append(undefined)
- elif opcode == 36: # pushbyte
- v = _read_byte(coder)
- stack.append(v)
- elif opcode == 37: # pushshort
- v = u30()
- stack.append(v)
- elif opcode == 38: # pushtrue
- stack.append(True)
- elif opcode == 39: # pushfalse
- stack.append(False)
- elif opcode == 40: # pushnan
- stack.append(float('NaN'))
- elif opcode == 42: # dup
- value = stack[-1]
- stack.append(value)
- elif opcode == 44: # pushstring
- idx = u30()
- stack.append(self.constant_strings[idx])
- elif opcode == 48: # pushscope
- new_scope = stack.pop()
- scopes.append(new_scope)
- elif opcode == 66: # construct
- arg_count = u30()
- args = list(reversed(
- [stack.pop() for _ in range(arg_count)]))
- obj = stack.pop()
- res = obj.avm_class.make_object()
- stack.append(res)
- elif opcode == 70: # callproperty
- index = u30()
- mname = self.multinames[index]
- arg_count = u30()
- args = list(reversed(
- [stack.pop() for _ in range(arg_count)]))
- obj = stack.pop()
-
- if obj == StringClass:
- if mname == 'String':
- assert len(args) == 1
- assert isinstance(args[0], (
- int, compat_str, _Undefined))
- if args[0] == undefined:
- res = 'undefined'
- else:
- res = compat_str(args[0])
- stack.append(res)
- continue
- else:
- raise NotImplementedError(
- 'Function String.%s is not yet implemented'
- % mname)
- elif isinstance(obj, _AVMClass_Object):
- func = self.extract_function(obj.avm_class, mname)
- res = func(args)
- stack.append(res)
- continue
- elif isinstance(obj, _AVMClass):
- func = self.extract_function(obj, mname)
- res = func(args)
- stack.append(res)
- continue
- elif isinstance(obj, _ScopeDict):
- if mname in obj.avm_class.method_names:
- func = self.extract_function(obj.avm_class, mname)
- res = func(args)
- else:
- res = obj[mname]
- stack.append(res)
- continue
- elif isinstance(obj, compat_str):
- if mname == 'split':
- assert len(args) == 1
- assert isinstance(args[0], compat_str)
- if args[0] == '':
- res = list(obj)
- else:
- res = obj.split(args[0])
- stack.append(res)
- continue
- elif mname == 'charCodeAt':
- assert len(args) <= 1
- idx = 0 if len(args) == 0 else args[0]
- assert isinstance(idx, int)
- res = ord(obj[idx])
- stack.append(res)
- continue
- elif isinstance(obj, list):
- if mname == 'slice':
- assert len(args) == 1
- assert isinstance(args[0], int)
- res = obj[args[0]:]
- stack.append(res)
- continue
- elif mname == 'join':
- assert len(args) == 1
- assert isinstance(args[0], compat_str)
- res = args[0].join(obj)
- stack.append(res)
- continue
- raise NotImplementedError(
- 'Unsupported property %r on %r'
- % (mname, obj))
- elif opcode == 71: # returnvoid
- res = undefined
- return res
- elif opcode == 72: # returnvalue
- res = stack.pop()
- return res
- elif opcode == 73: # constructsuper
- # Not yet implemented, just hope it works without it
- arg_count = u30()
- args = list(reversed(
- [stack.pop() for _ in range(arg_count)]))
- obj = stack.pop()
- elif opcode == 74: # constructproperty
- index = u30()
- arg_count = u30()
- args = list(reversed(
- [stack.pop() for _ in range(arg_count)]))
- obj = stack.pop()
-
- mname = self.multinames[index]
- assert isinstance(obj, _AVMClass)
-
- # We do not actually call the constructor for now;
- # we just pretend it does nothing
- stack.append(obj.make_object())
- elif opcode == 79: # callpropvoid
- index = u30()
- mname = self.multinames[index]
- arg_count = u30()
- args = list(reversed(
- [stack.pop() for _ in range(arg_count)]))
- obj = stack.pop()
- if isinstance(obj, _AVMClass_Object):
- func = self.extract_function(obj.avm_class, mname)
- res = func(args)
- assert res is undefined
- continue
- if isinstance(obj, _ScopeDict):
- assert mname in obj.avm_class.method_names
- func = self.extract_function(obj.avm_class, mname)
- res = func(args)
- assert res is undefined
- continue
- if mname == 'reverse':
- assert isinstance(obj, list)
- obj.reverse()
- else:
- raise NotImplementedError(
- 'Unsupported (void) property %r on %r'
- % (mname, obj))
- elif opcode == 86: # newarray
- arg_count = u30()
- arr = []
- for i in range(arg_count):
- arr.append(stack.pop())
- arr = arr[::-1]
- stack.append(arr)
- elif opcode == 93: # findpropstrict
- index = u30()
- mname = self.multinames[index]
- for s in reversed(scopes):
- if mname in s:
- res = s
- break
- else:
- res = scopes[0]
- if mname not in res and mname in _builtin_classes:
- stack.append(_builtin_classes[mname])
- else:
- stack.append(res[mname])
- elif opcode == 94: # findproperty
- index = u30()
- mname = self.multinames[index]
- for s in reversed(scopes):
- if mname in s:
- res = s
- break
- else:
- res = avm_class.variables
- stack.append(res)
- elif opcode == 96: # getlex
- index = u30()
- mname = self.multinames[index]
- for s in reversed(scopes):
- if mname in s:
- scope = s
- break
- else:
- scope = avm_class.variables
-
- if mname in scope:
- res = scope[mname]
- elif mname in _builtin_classes:
- res = _builtin_classes[mname]
- else:
- # Assume uninitialized
- # TODO warn here
- res = undefined
- stack.append(res)
- elif opcode == 97: # setproperty
- index = u30()
- value = stack.pop()
- idx = self.multinames[index]
- if isinstance(idx, _Multiname):
- idx = stack.pop()
- obj = stack.pop()
- obj[idx] = value
- elif opcode == 98: # getlocal
- index = u30()
- stack.append(registers[index])
- elif opcode == 99: # setlocal
- index = u30()
- value = stack.pop()
- registers[index] = value
- elif opcode == 102: # getproperty
- index = u30()
- pname = self.multinames[index]
- if pname == 'length':
- obj = stack.pop()
- assert isinstance(obj, (compat_str, list))
- stack.append(len(obj))
- elif isinstance(pname, compat_str): # Member access
- obj = stack.pop()
- if isinstance(obj, _AVMClass):
- res = obj.static_properties[pname]
- stack.append(res)
- continue
-
- assert isinstance(obj, (dict, _ScopeDict)),\
- 'Accessing member %r on %r' % (pname, obj)
- res = obj.get(pname, undefined)
- stack.append(res)
- else: # Assume attribute access
- idx = stack.pop()
- assert isinstance(idx, int)
- obj = stack.pop()
- assert isinstance(obj, list)
- stack.append(obj[idx])
- elif opcode == 104: # initproperty
- index = u30()
- value = stack.pop()
- idx = self.multinames[index]
- if isinstance(idx, _Multiname):
- idx = stack.pop()
- obj = stack.pop()
- obj[idx] = value
- elif opcode == 115: # convert_
- value = stack.pop()
- intvalue = int(value)
- stack.append(intvalue)
- elif opcode == 128: # coerce
- u30()
- elif opcode == 130: # coerce_a
- value = stack.pop()
- # um, yes, it's any value
- stack.append(value)
- elif opcode == 133: # coerce_s
- assert isinstance(stack[-1], (type(None), compat_str))
- elif opcode == 147: # decrement
- value = stack.pop()
- assert isinstance(value, int)
- stack.append(value - 1)
- elif opcode == 149: # typeof
- value = stack.pop()
- return {
- _Undefined: 'undefined',
- compat_str: 'String',
- int: 'Number',
- float: 'Number',
- }[type(value)]
- elif opcode == 160: # add
- value2 = stack.pop()
- value1 = stack.pop()
- res = value1 + value2
- stack.append(res)
- elif opcode == 161: # subtract
- value2 = stack.pop()
- value1 = stack.pop()
- res = value1 - value2
- stack.append(res)
- elif opcode == 162: # multiply
- value2 = stack.pop()
- value1 = stack.pop()
- res = value1 * value2
- stack.append(res)
- elif opcode == 164: # modulo
- value2 = stack.pop()
- value1 = stack.pop()
- res = value1 % value2
- stack.append(res)
- elif opcode == 168: # bitand
- value2 = stack.pop()
- value1 = stack.pop()
- assert isinstance(value1, int)
- assert isinstance(value2, int)
- res = value1 & value2
- stack.append(res)
- elif opcode == 171: # equals
- value2 = stack.pop()
- value1 = stack.pop()
- result = value1 == value2
- stack.append(result)
- elif opcode == 175: # greaterequals
- value2 = stack.pop()
- value1 = stack.pop()
- result = value1 >= value2
- stack.append(result)
- elif opcode == 192: # increment_i
- value = stack.pop()
- assert isinstance(value, int)
- stack.append(value + 1)
- elif opcode == 208: # getlocal_0
- stack.append(registers[0])
- elif opcode == 209: # getlocal_1
- stack.append(registers[1])
- elif opcode == 210: # getlocal_2
- stack.append(registers[2])
- elif opcode == 211: # getlocal_3
- stack.append(registers[3])
- elif opcode == 212: # setlocal_0
- registers[0] = stack.pop()
- elif opcode == 213: # setlocal_1
- registers[1] = stack.pop()
- elif opcode == 214: # setlocal_2
- registers[2] = stack.pop()
- elif opcode == 215: # setlocal_3
- registers[3] = stack.pop()
- else:
- raise NotImplementedError(
- 'Unsupported opcode %d' % opcode)
-
- avm_class.method_pyfunctions[func_name] = resfunc
- return resfunc
diff --git a/youtube_dl/update.py b/youtube_dl/update.py
deleted file mode 100644
index ebce966..0000000
--- a/youtube_dl/update.py
+++ /dev/null
@@ -1,187 +0,0 @@
-from __future__ import unicode_literals
-
-import io
-import json
-import traceback
-import hashlib
-import os
-import subprocess
-import sys
-from zipimport import zipimporter
-
-from .utils import encode_compat_str
-
-from .version import __version__
-
-
-def rsa_verify(message, signature, key):
- from hashlib import sha256
- assert isinstance(message, bytes)
- byte_size = (len(bin(key[0])) - 2 + 8 - 1) // 8
- signature = ('%x' % pow(int(signature, 16), key[1], key[0])).encode()
- signature = (byte_size * 2 - len(signature)) * b'0' + signature
- asn1 = b'3031300d060960864801650304020105000420'
- asn1 += sha256(message).hexdigest().encode()
- if byte_size < len(asn1) // 2 + 11:
- return False
- expected = b'0001' + (byte_size - len(asn1) // 2 - 3) * b'ff' + b'00' + asn1
- return expected == signature
-
-
-def update_self(to_screen, verbose, opener):
- """Update the program file with the latest version from the repository"""
-
- UPDATE_URL = 'https://rg3.github.io/youtube-dl/update/'
- VERSION_URL = UPDATE_URL + 'LATEST_VERSION'
- JSON_URL = UPDATE_URL + 'versions.json'
- UPDATES_RSA_KEY = (0x9d60ee4d8f805312fdb15a62f87b95bd66177b91df176765d13514a0f1754bcd2057295c5b6f1d35daa6742c3ffc9a82d3e118861c207995a8031e151d863c9927e304576bc80692bc8e094896fcf11b66f3e29e04e3a71e9a11558558acea1840aec37fc396fb6b65dc81a1c4144e03bd1c011de62e3f1357b327d08426fe93, 65537)
-
- if not isinstance(globals().get('__loader__'), zipimporter) and not hasattr(sys, 'frozen'):
- to_screen('It looks like you installed youtube-dl with a package manager, pip, setup.py or a tarball. Please use that to update.')
- return
-
- # Check if there is a new version
- try:
- newversion = opener.open(VERSION_URL).read().decode('utf-8').strip()
- except Exception:
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: can\'t find the current version. Please try again later.')
- return
- if newversion == __version__:
- to_screen('youtube-dl is up-to-date (' + __version__ + ')')
- return
-
- # Download and check versions info
- try:
- versions_info = opener.open(JSON_URL).read().decode('utf-8')
- versions_info = json.loads(versions_info)
- except Exception:
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: can\'t obtain versions info. Please try again later.')
- return
- if 'signature' not in versions_info:
- to_screen('ERROR: the versions file is not signed or corrupted. Aborting.')
- return
- signature = versions_info['signature']
- del versions_info['signature']
- if not rsa_verify(json.dumps(versions_info, sort_keys=True).encode('utf-8'), signature, UPDATES_RSA_KEY):
- to_screen('ERROR: the versions file signature is invalid. Aborting.')
- return
-
- version_id = versions_info['latest']
-
- def version_tuple(version_str):
- return tuple(map(int, version_str.split('.')))
- if version_tuple(__version__) >= version_tuple(version_id):
- to_screen('youtube-dl is up to date (%s)' % __version__)
- return
-
- to_screen('Updating to version ' + version_id + ' ...')
- version = versions_info['versions'][version_id]
-
- print_notes(to_screen, versions_info['versions'])
-
- # sys.executable is set to the full pathname of the exe-file for py2exe
- filename = sys.executable if hasattr(sys, 'frozen') else sys.argv[0]
-
- if not os.access(filename, os.W_OK):
- to_screen('ERROR: no write permissions on %s' % filename)
- return
-
- # Py2EXE
- if hasattr(sys, 'frozen'):
- exe = filename
- directory = os.path.dirname(exe)
- if not os.access(directory, os.W_OK):
- to_screen('ERROR: no write permissions on %s' % directory)
- return
-
- try:
- urlh = opener.open(version['exe'][0])
- newcontent = urlh.read()
- urlh.close()
- except (IOError, OSError):
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: unable to download latest version')
- return
-
- newcontent_hash = hashlib.sha256(newcontent).hexdigest()
- if newcontent_hash != version['exe'][1]:
- to_screen('ERROR: the downloaded file hash does not match. Aborting.')
- return
-
- try:
- with open(exe + '.new', 'wb') as outf:
- outf.write(newcontent)
- except (IOError, OSError):
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: unable to write the new version')
- return
-
- try:
- bat = os.path.join(directory, 'youtube-dl-updater.bat')
- with io.open(bat, 'w') as batfile:
- batfile.write('''
-@echo off
-echo Waiting for file handle to be closed ...
-ping 127.0.0.1 -n 5 -w 1000 > NUL
-move /Y "%s.new" "%s" > NUL
-echo Updated youtube-dl to version %s.
-start /b "" cmd /c del "%%~f0"&exit /b"
- \n''' % (exe, exe, version_id))
-
- subprocess.Popen([bat]) # Continues to run in the background
- return # Do not show premature success messages
- except (IOError, OSError):
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: unable to overwrite current version')
- return
-
- # Zip unix package
- elif isinstance(globals().get('__loader__'), zipimporter):
- try:
- urlh = opener.open(version['bin'][0])
- newcontent = urlh.read()
- urlh.close()
- except (IOError, OSError):
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: unable to download latest version')
- return
-
- newcontent_hash = hashlib.sha256(newcontent).hexdigest()
- if newcontent_hash != version['bin'][1]:
- to_screen('ERROR: the downloaded file hash does not match. Aborting.')
- return
-
- try:
- with open(filename, 'wb') as outf:
- outf.write(newcontent)
- except (IOError, OSError):
- if verbose:
- to_screen(encode_compat_str(traceback.format_exc()))
- to_screen('ERROR: unable to overwrite current version')
- return
-
- to_screen('Updated youtube-dl. Restart youtube-dl to use the new version.')
-
-
-def get_notes(versions, fromVersion):
- notes = []
- for v, vdata in sorted(versions.items()):
- if v > fromVersion:
- notes.extend(vdata.get('notes', []))
- return notes
-
-
-def print_notes(to_screen, versions, fromVersion=__version__):
- notes = get_notes(versions, fromVersion)
- if notes:
- to_screen('PLEASE NOTE:')
- for note in notes:
- to_screen(note)
diff --git a/youtube_dl/utils.py b/youtube_dl/utils.py
deleted file mode 100644
index d2d3c1a..0000000
--- a/youtube_dl/utils.py
+++ /dev/null
@@ -1,3990 +0,0 @@
-#!/usr/bin/env python
-# coding: utf-8
-
-from __future__ import unicode_literals
-
-import base64
-import binascii
-import calendar
-import codecs
-import contextlib
-import ctypes
-import datetime
-import email.utils
-import email.header
-import errno
-import functools
-import gzip
-import io
-import itertools
-import json
-import locale
-import math
-import operator
-import os
-import platform
-import random
-import re
-import socket
-import ssl
-import subprocess
-import sys
-import tempfile
-import traceback
-import xml.etree.ElementTree
-import zlib
-
-from .compat import (
- compat_HTMLParseError,
- compat_HTMLParser,
- compat_basestring,
- compat_chr,
- compat_cookiejar,
- compat_ctypes_WINFUNCTYPE,
- compat_etree_fromstring,
- compat_expanduser,
- compat_html_entities,
- compat_html_entities_html5,
- compat_http_client,
- compat_kwargs,
- compat_os_name,
- compat_parse_qs,
- compat_shlex_quote,
- compat_str,
- compat_struct_pack,
- compat_struct_unpack,
- compat_urllib_error,
- compat_urllib_parse,
- compat_urllib_parse_urlencode,
- compat_urllib_parse_urlparse,
- compat_urllib_parse_unquote_plus,
- compat_urllib_request,
- compat_urlparse,
- compat_xpath,
-)
-
-from .socks import (
- ProxyType,
- sockssocket,
-)
-
-
-def register_socks_protocols():
- # "Register" SOCKS protocols
- # In Python < 2.6.5, urlsplit() suffers from bug https://bugs.python.org/issue7904
- # URLs with protocols not in urlparse.uses_netloc are not handled correctly
- for scheme in ('socks', 'socks4', 'socks4a', 'socks5'):
- if scheme not in compat_urlparse.uses_netloc:
- compat_urlparse.uses_netloc.append(scheme)
-
-
-# This is not clearly defined otherwise
-compiled_regex_type = type(re.compile(''))
-
-std_headers = {
- 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0',
- 'Accept-Charset': 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
- 'Accept-Encoding': 'gzip, deflate',
- 'Accept-Language': 'en-us,en;q=0.5',
-}
-
-
-USER_AGENTS = {
- 'Safari': 'Mozilla/5.0 (X11; Linux x86_64; rv:10.0) AppleWebKit/533.20.25 (KHTML, like Gecko) Version/5.0.4 Safari/533.20.27',
-}
-
-
-NO_DEFAULT = object()
-
-ENGLISH_MONTH_NAMES = [
- 'January', 'February', 'March', 'April', 'May', 'June',
- 'July', 'August', 'September', 'October', 'November', 'December']
-
-MONTH_NAMES = {
- 'en': ENGLISH_MONTH_NAMES,
- 'fr': [
- 'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
- 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],
-}
-
-KNOWN_EXTENSIONS = (
- 'mp4', 'm4a', 'm4p', 'm4b', 'm4r', 'm4v', 'aac',
- 'flv', 'f4v', 'f4a', 'f4b',
- 'webm', 'ogg', 'ogv', 'oga', 'ogx', 'spx', 'opus',
- 'mkv', 'mka', 'mk3d',
- 'avi', 'divx',
- 'mov',
- 'asf', 'wmv', 'wma',
- '3gp', '3g2',
- 'mp3',
- 'flac',
- 'ape',
- 'wav',
- 'f4f', 'f4m', 'm3u8', 'smil')
-
-# needed for sanitizing filenames in restricted mode
-ACCENT_CHARS = dict(zip('ÂÃÄÀÁÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖŐØŒÙÚÛÜŰÝÞßàáâãäåæçèéêëìíîïðñòóôõöőøœùúûüűýþÿ',
- itertools.chain('AAAAAA', ['AE'], 'CEEEEIIIIDNOOOOOOO', ['OE'], 'UUUUUYP', ['ss'],
- 'aaaaaa', ['ae'], 'ceeeeiiiionooooooo', ['oe'], 'uuuuuypy')))
-
-DATE_FORMATS = (
- '%d %B %Y',
- '%d %b %Y',
- '%B %d %Y',
- '%B %dst %Y',
- '%B %dnd %Y',
- '%B %dth %Y',
- '%b %d %Y',
- '%b %dst %Y',
- '%b %dnd %Y',
- '%b %dth %Y',
- '%b %dst %Y %I:%M',
- '%b %dnd %Y %I:%M',
- '%b %dth %Y %I:%M',
- '%Y %m %d',
- '%Y-%m-%d',
- '%Y/%m/%d',
- '%Y/%m/%d %H:%M',
- '%Y/%m/%d %H:%M:%S',
- '%Y-%m-%d %H:%M',
- '%Y-%m-%d %H:%M:%S',
- '%Y-%m-%d %H:%M:%S.%f',
- '%d.%m.%Y %H:%M',
- '%d.%m.%Y %H.%M',
- '%Y-%m-%dT%H:%M:%SZ',
- '%Y-%m-%dT%H:%M:%S.%fZ',
- '%Y-%m-%dT%H:%M:%S.%f0Z',
- '%Y-%m-%dT%H:%M:%S',
- '%Y-%m-%dT%H:%M:%S.%f',
- '%Y-%m-%dT%H:%M',
- '%b %d %Y at %H:%M',
- '%b %d %Y at %H:%M:%S',
- '%B %d %Y at %H:%M',
- '%B %d %Y at %H:%M:%S',
-)
-
-DATE_FORMATS_DAY_FIRST = list(DATE_FORMATS)
-DATE_FORMATS_DAY_FIRST.extend([
- '%d-%m-%Y',
- '%d.%m.%Y',
- '%d.%m.%y',
- '%d/%m/%Y',
- '%d/%m/%y',
- '%d/%m/%Y %H:%M:%S',
-])
-
-DATE_FORMATS_MONTH_FIRST = list(DATE_FORMATS)
-DATE_FORMATS_MONTH_FIRST.extend([
- '%m-%d-%Y',
- '%m.%d.%Y',
- '%m/%d/%Y',
- '%m/%d/%y',
- '%m/%d/%Y %H:%M:%S',
-])
-
-PACKED_CODES_RE = r"}\('(.+)',(\d+),(\d+),'([^']+)'\.split\('\|'\)"
-JSON_LD_RE = r'(?is)<script[^>]+type=(["\'])application/ld\+json\1[^>]*>(?P<json_ld>.+?)</script>'
-
-
-def preferredencoding():
- """Get preferred encoding.
-
- Returns the best encoding scheme for the system, based on
- locale.getpreferredencoding() and some further tweaks.
- """
- try:
- pref = locale.getpreferredencoding()
- 'TEST'.encode(pref)
- except Exception:
- pref = 'UTF-8'
-
- return pref
-
-
-def write_json_file(obj, fn):
- """ Encode obj as JSON and write it to fn, atomically if possible """
-
- fn = encodeFilename(fn)
- if sys.version_info < (3, 0) and sys.platform != 'win32':
- encoding = get_filesystem_encoding()
- # os.path.basename returns a bytes object, but NamedTemporaryFile
- # will fail if the filename contains non ascii characters unless we
- # use a unicode object
- path_basename = lambda f: os.path.basename(fn).decode(encoding)
- # the same for os.path.dirname
- path_dirname = lambda f: os.path.dirname(fn).decode(encoding)
- else:
- path_basename = os.path.basename
- path_dirname = os.path.dirname
-
- args = {
- 'suffix': '.tmp',
- 'prefix': path_basename(fn) + '.',
- 'dir': path_dirname(fn),
- 'delete': False,
- }
-
- # In Python 2.x, json.dump expects a bytestream.
- # In Python 3.x, it writes to a character stream
- if sys.version_info < (3, 0):
- args['mode'] = 'wb'
- else:
- args.update({
- 'mode': 'w',
- 'encoding': 'utf-8',
- })
-
- tf = tempfile.NamedTemporaryFile(**compat_kwargs(args))
-
- try:
- with tf:
- json.dump(obj, tf)
- if sys.platform == 'win32':
- # Need to remove existing file on Windows, else os.rename raises
- # WindowsError or FileExistsError.
- try:
- os.unlink(fn)
- except OSError:
- pass
- os.rename(tf.name, fn)
- except Exception:
- try:
- os.remove(tf.name)
- except OSError:
- pass
- raise
-
-
-if sys.version_info >= (2, 7):
- def find_xpath_attr(node, xpath, key, val=None):
- """ Find the xpath xpath[@key=val] """
- assert re.match(r'^[a-zA-Z_-]+$', key)
- expr = xpath + ('[@%s]' % key if val is None else "[@%s='%s']" % (key, val))
- return node.find(expr)
-else:
- def find_xpath_attr(node, xpath, key, val=None):
- for f in node.findall(compat_xpath(xpath)):
- if key not in f.attrib:
- continue
- if val is None or f.attrib.get(key) == val:
- return f
- return None
-
-# On python2.6 the xml.etree.ElementTree.Element methods don't support
-# the namespace parameter
-
-
-def xpath_with_ns(path, ns_map):
- components = [c.split(':') for c in path.split('/')]
- replaced = []
- for c in components:
- if len(c) == 1:
- replaced.append(c[0])
- else:
- ns, tag = c
- replaced.append('{%s}%s' % (ns_map[ns], tag))
- return '/'.join(replaced)
-
-
-def xpath_element(node, xpath, name=None, fatal=False, default=NO_DEFAULT):
- def _find_xpath(xpath):
- return node.find(compat_xpath(xpath))
-
- if isinstance(xpath, (str, compat_str)):
- n = _find_xpath(xpath)
- else:
- for xp in xpath:
- n = _find_xpath(xp)
- if n is not None:
- break
-
- if n is None:
- if default is not NO_DEFAULT:
- return default
- elif fatal:
- name = xpath if name is None else name
- raise ExtractorError('Could not find XML element %s' % name)
- else:
- return None
- return n
-
-
-def xpath_text(node, xpath, name=None, fatal=False, default=NO_DEFAULT):
- n = xpath_element(node, xpath, name, fatal=fatal, default=default)
- if n is None or n == default:
- return n
- if n.text is None:
- if default is not NO_DEFAULT:
- return default
- elif fatal:
- name = xpath if name is None else name
- raise ExtractorError('Could not find XML element\'s text %s' % name)
- else:
- return None
- return n.text
-
-
-def xpath_attr(node, xpath, key, name=None, fatal=False, default=NO_DEFAULT):
- n = find_xpath_attr(node, xpath, key)
- if n is None:
- if default is not NO_DEFAULT:
- return default
- elif fatal:
- name = '%s[@%s]' % (xpath, key) if name is None else name
- raise ExtractorError('Could not find XML attribute %s' % name)
- else:
- return None
- return n.attrib[key]
-
-
-def get_element_by_id(id, html):
- """Return the content of the tag with the specified ID in the passed HTML document"""
- return get_element_by_attribute('id', id, html)
-
-
-def get_element_by_class(class_name, html):
- """Return the content of the first tag with the specified class in the passed HTML document"""
- retval = get_elements_by_class(class_name, html)
- return retval[0] if retval else None
-
-
-def get_element_by_attribute(attribute, value, html, escape_value=True):
- retval = get_elements_by_attribute(attribute, value, html, escape_value)
- return retval[0] if retval else None
-
-
-def get_elements_by_class(class_name, html):
- """Return the content of all tags with the specified class in the passed HTML document as a list"""
- return get_elements_by_attribute(
- 'class', r'[^\'"]*\b%s\b[^\'"]*' % re.escape(class_name),
- html, escape_value=False)
-
-
-def get_elements_by_attribute(attribute, value, html, escape_value=True):
- """Return the content of the tag with the specified attribute in the passed HTML document"""
-
- value = re.escape(value) if escape_value else value
-
- retlist = []
- for m in re.finditer(r'''(?xs)
- <([a-zA-Z0-9:._-]+)
- (?:\s+[a-zA-Z0-9:._-]+(?:=[a-zA-Z0-9:._-]*|="[^"]*"|='[^']*'|))*?
- \s+%s=['"]?%s['"]?
- (?:\s+[a-zA-Z0-9:._-]+(?:=[a-zA-Z0-9:._-]*|="[^"]*"|='[^']*'|))*?
- \s*>
- (?P<content>.*?)
- </\1>
- ''' % (re.escape(attribute), value), html):
- res = m.group('content')
-
- if res.startswith('"') or res.startswith("'"):
- res = res[1:-1]
-
- retlist.append(unescapeHTML(res))
-
- return retlist
-
-
-class HTMLAttributeParser(compat_HTMLParser):
- """Trivial HTML parser to gather the attributes for a single element"""
- def __init__(self):
- self.attrs = {}
- compat_HTMLParser.__init__(self)
-
- def handle_starttag(self, tag, attrs):
- self.attrs = dict(attrs)
-
-
-def extract_attributes(html_element):
- """Given a string for an HTML element such as
- <el
- a="foo" B="bar" c="&98;az" d=boz
- empty= noval entity="&amp;"
- sq='"' dq="'"
- >
- Decode and return a dictionary of attributes.
- {
- 'a': 'foo', 'b': 'bar', c: 'baz', d: 'boz',
- 'empty': '', 'noval': None, 'entity': '&',
- 'sq': '"', 'dq': '\''
- }.
- NB HTMLParser is stricter in Python 2.6 & 3.2 than in later versions,
- but the cases in the unit test will work for all of 2.6, 2.7, 3.2-3.5.
- """
- parser = HTMLAttributeParser()
- try:
- parser.feed(html_element)
- parser.close()
- # Older Python may throw HTMLParseError in case of malformed HTML
- except compat_HTMLParseError:
- pass
- return parser.attrs
-
-
-def clean_html(html):
- """Clean an HTML snippet into a readable string"""
-
- if html is None: # Convenience for sanitizing descriptions etc.
- return html
-
- # Newline vs <br />
- html = html.replace('\n', ' ')
- html = re.sub(r'(?u)\s*<\s*br\s*/?\s*>\s*', '\n', html)
- html = re.sub(r'(?u)<\s*/\s*p\s*>\s*<\s*p[^>]*>', '\n', html)
- # Strip html tags
- html = re.sub('<.*?>', '', html)
- # Replace html entities
- html = unescapeHTML(html)
- return html.strip()
-
-
-def sanitize_open(filename, open_mode):
- """Try to open the given filename, and slightly tweak it if this fails.
-
- Attempts to open the given filename. If this fails, it tries to change
- the filename slightly, step by step, until it's either able to open it
- or it fails and raises a final exception, like the standard open()
- function.
-
- It returns the tuple (stream, definitive_file_name).
- """
- try:
- if filename == '-':
- if sys.platform == 'win32':
- import msvcrt
- msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
- return (sys.stdout.buffer if hasattr(sys.stdout, 'buffer') else sys.stdout, filename)
- stream = open(encodeFilename(filename), open_mode)
- return (stream, filename)
- except (IOError, OSError) as err:
- if err.errno in (errno.EACCES,):
- raise
-
- # In case of error, try to remove win32 forbidden chars
- alt_filename = sanitize_path(filename)
- if alt_filename == filename:
- raise
- else:
- # An exception here should be caught in the caller
- stream = open(encodeFilename(alt_filename), open_mode)
- return (stream, alt_filename)
-
-
-def timeconvert(timestr):
- """Convert RFC 2822 defined time string into system timestamp"""
- timestamp = None
- timetuple = email.utils.parsedate_tz(timestr)
- if timetuple is not None:
- timestamp = email.utils.mktime_tz(timetuple)
- return timestamp
-
-
-def sanitize_filename(s, restricted=False, is_id=False):
- """Sanitizes a string so it could be used as part of a filename.
- If restricted is set, use a stricter subset of allowed characters.
- Set is_id if this is not an arbitrary string, but an ID that should be kept
- if possible.
- """
- def replace_insane(char):
- if restricted and char in ACCENT_CHARS:
- return ACCENT_CHARS[char]
- if char == '?' or ord(char) < 32 or ord(char) == 127:
- return ''
- elif char == '"':
- return '' if restricted else '\''
- elif char == ':':
- return '_-' if restricted else ' -'
- elif char in '\\/|*<>':
- return '_'
- if restricted and (char in '!&\'()[]{}$;`^,#' or char.isspace()):
- return '_'
- if restricted and ord(char) > 127:
- return '_'
- return char
-
- # Handle timestamps
- s = re.sub(r'[0-9]+(?::[0-9]+)+', lambda m: m.group(0).replace(':', '_'), s)
- result = ''.join(map(replace_insane, s))
- if not is_id:
- while '__' in result:
- result = result.replace('__', '_')
- result = result.strip('_')
- # Common case of "Foreign band name - English song title"
- if restricted and result.startswith('-_'):
- result = result[2:]
- if result.startswith('-'):
- result = '_' + result[len('-'):]
- result = result.lstrip('.')
- if not result:
- result = '_'
- return result
-
-
-def sanitize_path(s):
- """Sanitizes and normalizes path on Windows"""
- if sys.platform != 'win32':
- return s
- drive_or_unc, _ = os.path.splitdrive(s)
- if sys.version_info < (2, 7) and not drive_or_unc:
- drive_or_unc, _ = os.path.splitunc(s)
- norm_path = os.path.normpath(remove_start(s, drive_or_unc)).split(os.path.sep)
- if drive_or_unc:
- norm_path.pop(0)
- sanitized_path = [
- path_part if path_part in ['.', '..'] else re.sub(r'(?:[/<>:"\|\\?\*]|[\s.]$)', '#', path_part)
- for path_part in norm_path]
- if drive_or_unc:
- sanitized_path.insert(0, drive_or_unc + os.path.sep)
- return os.path.join(*sanitized_path)
-
-
-def sanitize_url(url):
- # Prepend protocol-less URLs with `http:` scheme in order to mitigate
- # the number of unwanted failures due to missing protocol
- if url.startswith('//'):
- return 'http:%s' % url
- # Fix some common typos seen so far
- COMMON_TYPOS = (
- # https://github.com/rg3/youtube-dl/issues/15649
- (r'^httpss://', r'https://'),
- # https://bx1.be/lives/direct-tv/
- (r'^rmtp([es]?)://', r'rtmp\1://'),
- )
- for mistake, fixup in COMMON_TYPOS:
- if re.match(mistake, url):
- return re.sub(mistake, fixup, url)
- return url
-
-
-def sanitized_Request(url, *args, **kwargs):
- return compat_urllib_request.Request(sanitize_url(url), *args, **kwargs)
-
-
-def expand_path(s):
- """Expand shell variables and ~"""
- return os.path.expandvars(compat_expanduser(s))
-
-
-def orderedSet(iterable):
- """ Remove all duplicates from the input iterable """
- res = []
- for el in iterable:
- if el not in res:
- res.append(el)
- return res
-
-
-def _htmlentity_transform(entity_with_semicolon):
- """Transforms an HTML entity to a character."""
- entity = entity_with_semicolon[:-1]
-
- # Known non-numeric HTML entity
- if entity in compat_html_entities.name2codepoint:
- return compat_chr(compat_html_entities.name2codepoint[entity])
-
- # TODO: HTML5 allows entities without a semicolon. For example,
- # '&Eacuteric' should be decoded as 'Éric'.
- if entity_with_semicolon in compat_html_entities_html5:
- return compat_html_entities_html5[entity_with_semicolon]
-
- mobj = re.match(r'#(x[0-9a-fA-F]+|[0-9]+)', entity)
- if mobj is not None:
- numstr = mobj.group(1)
- if numstr.startswith('x'):
- base = 16
- numstr = '0%s' % numstr
- else:
- base = 10
- # See https://github.com/rg3/youtube-dl/issues/7518
- try:
- return compat_chr(int(numstr, base))
- except ValueError:
- pass
-
- # Unknown entity in name, return its literal representation
- return '&%s;' % entity
-
-
-def unescapeHTML(s):
- if s is None:
- return None
- assert type(s) == compat_str
-
- return re.sub(
- r'&([^&;]+;)', lambda m: _htmlentity_transform(m.group(1)), s)
-
-
-def get_subprocess_encoding():
- if sys.platform == 'win32' and sys.getwindowsversion()[0] >= 5:
- # For subprocess calls, encode with locale encoding
- # Refer to http://stackoverflow.com/a/9951851/35070
- encoding = preferredencoding()
- else:
- encoding = sys.getfilesystemencoding()
- if encoding is None:
- encoding = 'utf-8'
- return encoding
-
-
-def encodeFilename(s, for_subprocess=False):
- """
- @param s The name of the file
- """
-
- assert type(s) == compat_str
-
- # Python 3 has a Unicode API
- if sys.version_info >= (3, 0):
- return s
-
- # Pass '' directly to use Unicode APIs on Windows 2000 and up
- # (Detecting Windows NT 4 is tricky because 'major >= 4' would
- # match Windows 9x series as well. Besides, NT 4 is obsolete.)
- if not for_subprocess and sys.platform == 'win32' and sys.getwindowsversion()[0] >= 5:
- return s
-
- # Jython assumes filenames are Unicode strings though reported as Python 2.x compatible
- if sys.platform.startswith('java'):
- return s
-
- return s.encode(get_subprocess_encoding(), 'ignore')
-
-
-def decodeFilename(b, for_subprocess=False):
-
- if sys.version_info >= (3, 0):
- return b
-
- if not isinstance(b, bytes):
- return b
-
- return b.decode(get_subprocess_encoding(), 'ignore')
-
-
-def encodeArgument(s):
- if not isinstance(s, compat_str):
- # Legacy code that uses byte strings
- # Uncomment the following line after fixing all post processors
- # assert False, 'Internal error: %r should be of type %r, is %r' % (s, compat_str, type(s))
- s = s.decode('ascii')
- return encodeFilename(s, True)
-
-
-def decodeArgument(b):
- return decodeFilename(b, True)
-
-
-def decodeOption(optval):
- if optval is None:
- return optval
- if isinstance(optval, bytes):
- optval = optval.decode(preferredencoding())
-
- assert isinstance(optval, compat_str)
- return optval
-
-
-def formatSeconds(secs):
- if secs > 3600:
- return '%d:%02d:%02d' % (secs // 3600, (secs % 3600) // 60, secs % 60)
- elif secs > 60:
- return '%d:%02d' % (secs // 60, secs % 60)
- else:
- return '%d' % secs
-
-
-def make_HTTPS_handler(params, **kwargs):
- opts_no_check_certificate = params.get('nocheckcertificate', False)
- if hasattr(ssl, 'create_default_context'): # Python >= 3.4 or 2.7.9
- context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
- if opts_no_check_certificate:
- context.check_hostname = False
- context.verify_mode = ssl.CERT_NONE
- try:
- return YoutubeDLHTTPSHandler(params, context=context, **kwargs)
- except TypeError:
- # Python 2.7.8
- # (create_default_context present but HTTPSHandler has no context=)
- pass
-
- if sys.version_info < (3, 2):
- return YoutubeDLHTTPSHandler(params, **kwargs)
- else: # Python < 3.4
- context = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
- context.verify_mode = (ssl.CERT_NONE
- if opts_no_check_certificate
- else ssl.CERT_REQUIRED)
- context.set_default_verify_paths()
- return YoutubeDLHTTPSHandler(params, context=context, **kwargs)
-
-
-def bug_reports_message():
- if ytdl_is_updateable():
- update_cmd = 'type youtube-dl -U to update'
- else:
- update_cmd = 'see https://yt-dl.org/update on how to update'
- msg = '; please report this issue on https://yt-dl.org/bug .'
- msg += ' Make sure you are using the latest version; %s.' % update_cmd
- msg += ' Be sure to call youtube-dl with the --verbose flag and include its complete output.'
- return msg
-
-
-class YoutubeDLError(Exception):
- """Base exception for YoutubeDL errors."""
- pass
-
-
-class ExtractorError(YoutubeDLError):
- """Error during info extraction."""
-
- def __init__(self, msg, tb=None, expected=False, cause=None, video_id=None):
- """ tb, if given, is the original traceback (so that it can be printed out).
- If expected is set, this is a normal error message and most likely not a bug in youtube-dl.
- """
-
- if sys.exc_info()[0] in (compat_urllib_error.URLError, socket.timeout, UnavailableVideoError):
- expected = True
- if video_id is not None:
- msg = video_id + ': ' + msg
- if cause:
- msg += ' (caused by %r)' % cause
- if not expected:
- msg += bug_reports_message()
- super(ExtractorError, self).__init__(msg)
-
- self.traceback = tb
- self.exc_info = sys.exc_info() # preserve original exception
- self.cause = cause
- self.video_id = video_id
-
- def format_traceback(self):
- if self.traceback is None:
- return None
- return ''.join(traceback.format_tb(self.traceback))
-
-
-class UnsupportedError(ExtractorError):
- def __init__(self, url):
- super(UnsupportedError, self).__init__(
- 'Unsupported URL: %s' % url, expected=True)
- self.url = url
-
-
-class RegexNotFoundError(ExtractorError):
- """Error when a regex didn't match"""
- pass
-
-
-class GeoRestrictedError(ExtractorError):
- """Geographic restriction Error exception.
-
- This exception may be thrown when a video is not available from your
- geographic location due to geographic restrictions imposed by a website.
- """
- def __init__(self, msg, countries=None):
- super(GeoRestrictedError, self).__init__(msg, expected=True)
- self.msg = msg
- self.countries = countries
-
-
-class DownloadError(YoutubeDLError):
- """Download Error exception.
-
- This exception may be thrown by FileDownloader objects if they are not
- configured to continue on errors. They will contain the appropriate
- error message.
- """
-
- def __init__(self, msg, exc_info=None):
- """ exc_info, if given, is the original exception that caused the trouble (as returned by sys.exc_info()). """
- super(DownloadError, self).__init__(msg)
- self.exc_info = exc_info
-
-
-class SameFileError(YoutubeDLError):
- """Same File exception.
-
- This exception will be thrown by FileDownloader objects if they detect
- multiple files would have to be downloaded to the same file on disk.
- """
- pass
-
-
-class PostProcessingError(YoutubeDLError):
- """Post Processing exception.
-
- This exception may be raised by PostProcessor's .run() method to
- indicate an error in the postprocessing task.
- """
-
- def __init__(self, msg):
- super(PostProcessingError, self).__init__(msg)
- self.msg = msg
-
-
-class MaxDownloadsReached(YoutubeDLError):
- """ --max-downloads limit has been reached. """
- pass
-
-
-class UnavailableVideoError(YoutubeDLError):
- """Unavailable Format exception.
-
- This exception will be thrown when a video is requested
- in a format that is not available for that video.
- """
- pass
-
-
-class ContentTooShortError(YoutubeDLError):
- """Content Too Short exception.
-
- This exception may be raised by FileDownloader objects when a file they
- download is too small for what the server announced first, indicating
- the connection was probably interrupted.
- """
-
- def __init__(self, downloaded, expected):
- super(ContentTooShortError, self).__init__(
- 'Downloaded {0} bytes, expected {1} bytes'.format(downloaded, expected)
- )
- # Both in bytes
- self.downloaded = downloaded
- self.expected = expected
-
-
-class XAttrMetadataError(YoutubeDLError):
- def __init__(self, code=None, msg='Unknown error'):
- super(XAttrMetadataError, self).__init__(msg)
- self.code = code
- self.msg = msg
-
- # Parsing code and msg
- if (self.code in (errno.ENOSPC, errno.EDQUOT) or
- 'No space left' in self.msg or 'Disk quota excedded' in self.msg):
- self.reason = 'NO_SPACE'
- elif self.code == errno.E2BIG or 'Argument list too long' in self.msg:
- self.reason = 'VALUE_TOO_LONG'
- else:
- self.reason = 'NOT_SUPPORTED'
-
-
-class XAttrUnavailableError(YoutubeDLError):
- pass
-
-
-def _create_http_connection(ydl_handler, http_class, is_https, *args, **kwargs):
- # Working around python 2 bug (see http://bugs.python.org/issue17849) by limiting
- # expected HTTP responses to meet HTTP/1.0 or later (see also
- # https://github.com/rg3/youtube-dl/issues/6727)
- if sys.version_info < (3, 0):
- kwargs['strict'] = True
- hc = http_class(*args, **compat_kwargs(kwargs))
- source_address = ydl_handler._params.get('source_address')
-
- if source_address is not None:
- # This is to workaround _create_connection() from socket where it will try all
- # address data from getaddrinfo() including IPv6. This filters the result from
- # getaddrinfo() based on the source_address value.
- # This is based on the cpython socket.create_connection() function.
- # https://github.com/python/cpython/blob/master/Lib/socket.py#L691
- def _create_connection(address, timeout=socket._GLOBAL_DEFAULT_TIMEOUT, source_address=None):
- host, port = address
- err = None
- addrs = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
- af = socket.AF_INET if '.' in source_address[0] else socket.AF_INET6
- ip_addrs = [addr for addr in addrs if addr[0] == af]
- if addrs and not ip_addrs:
- ip_version = 'v4' if af == socket.AF_INET else 'v6'
- raise socket.error(
- "No remote IP%s addresses available for connect, can't use '%s' as source address"
- % (ip_version, source_address[0]))
- for res in ip_addrs:
- af, socktype, proto, canonname, sa = res
- sock = None
- try:
- sock = socket.socket(af, socktype, proto)
- if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT:
- sock.settimeout(timeout)
- sock.bind(source_address)
- sock.connect(sa)
- err = None # Explicitly break reference cycle
- return sock
- except socket.error as _:
- err = _
- if sock is not None:
- sock.close()
- if err is not None:
- raise err
- else:
- raise socket.error('getaddrinfo returns an empty list')
- if hasattr(hc, '_create_connection'):
- hc._create_connection = _create_connection
- sa = (source_address, 0)
- if hasattr(hc, 'source_address'): # Python 2.7+
- hc.source_address = sa
- else: # Python 2.6
- def _hc_connect(self, *args, **kwargs):
- sock = _create_connection(
- (self.host, self.port), self.timeout, sa)
- if is_https:
- self.sock = ssl.wrap_socket(
- sock, self.key_file, self.cert_file,
- ssl_version=ssl.PROTOCOL_TLSv1)
- else:
- self.sock = sock
- hc.connect = functools.partial(_hc_connect, hc)
-
- return hc
-
-
-def handle_youtubedl_headers(headers):
- filtered_headers = headers
-
- if 'Youtubedl-no-compression' in filtered_headers:
- filtered_headers = dict((k, v) for k, v in filtered_headers.items() if k.lower() != 'accept-encoding')
- del filtered_headers['Youtubedl-no-compression']
-
- return filtered_headers
-
-
-class YoutubeDLHandler(compat_urllib_request.HTTPHandler):
- """Handler for HTTP requests and responses.
-
- This class, when installed with an OpenerDirector, automatically adds
- the standard headers to every HTTP request and handles gzipped and
- deflated responses from web servers. If compression is to be avoided in
- a particular request, the original request in the program code only has
- to include the HTTP header "Youtubedl-no-compression", which will be
- removed before making the real request.
-
- Part of this code was copied from:
-
- http://techknack.net/python-urllib2-handlers/
-
- Andrew Rowls, the author of that code, agreed to release it to the
- public domain.
- """
-
- def __init__(self, params, *args, **kwargs):
- compat_urllib_request.HTTPHandler.__init__(self, *args, **kwargs)
- self._params = params
-
- def http_open(self, req):
- conn_class = compat_http_client.HTTPConnection
-
- socks_proxy = req.headers.get('Ytdl-socks-proxy')
- if socks_proxy:
- conn_class = make_socks_conn_class(conn_class, socks_proxy)
- del req.headers['Ytdl-socks-proxy']
-
- return self.do_open(functools.partial(
- _create_http_connection, self, conn_class, False),
- req)
-
- @staticmethod
- def deflate(data):
- try:
- return zlib.decompress(data, -zlib.MAX_WBITS)
- except zlib.error:
- return zlib.decompress(data)
-
- def http_request(self, req):
- # According to RFC 3986, URLs can not contain non-ASCII characters, however this is not
- # always respected by websites, some tend to give out URLs with non percent-encoded
- # non-ASCII characters (see telemb.py, ard.py [#3412])
- # urllib chokes on URLs with non-ASCII characters (see http://bugs.python.org/issue3991)
- # To work around aforementioned issue we will replace request's original URL with
- # percent-encoded one
- # Since redirects are also affected (e.g. http://www.southpark.de/alle-episoden/s18e09)
- # the code of this workaround has been moved here from YoutubeDL.urlopen()
- url = req.get_full_url()
- url_escaped = escape_url(url)
-
- # Substitute URL if any change after escaping
- if url != url_escaped:
- req = update_Request(req, url=url_escaped)
-
- for h, v in std_headers.items():
- # Capitalize is needed because of Python bug 2275: http://bugs.python.org/issue2275
- # The dict keys are capitalized because of this bug by urllib
- if h.capitalize() not in req.headers:
- req.add_header(h, v)
-
- req.headers = handle_youtubedl_headers(req.headers)
-
- if sys.version_info < (2, 7) and '#' in req.get_full_url():
- # Python 2.6 is brain-dead when it comes to fragments
- req._Request__original = req._Request__original.partition('#')[0]
- req._Request__r_type = req._Request__r_type.partition('#')[0]
-
- return req
-
- def http_response(self, req, resp):
- old_resp = resp
- # gzip
- if resp.headers.get('Content-encoding', '') == 'gzip':
- content = resp.read()
- gz = gzip.GzipFile(fileobj=io.BytesIO(content), mode='rb')
- try:
- uncompressed = io.BytesIO(gz.read())
- except IOError as original_ioerror:
- # There may be junk add the end of the file
- # See http://stackoverflow.com/q/4928560/35070 for details
- for i in range(1, 1024):
- try:
- gz = gzip.GzipFile(fileobj=io.BytesIO(content[:-i]), mode='rb')
- uncompressed = io.BytesIO(gz.read())
- except IOError:
- continue
- break
- else:
- raise original_ioerror
- resp = compat_urllib_request.addinfourl(uncompressed, old_resp.headers, old_resp.url, old_resp.code)
- resp.msg = old_resp.msg
- del resp.headers['Content-encoding']
- # deflate
- if resp.headers.get('Content-encoding', '') == 'deflate':
- gz = io.BytesIO(self.deflate(resp.read()))
- resp = compat_urllib_request.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code)
- resp.msg = old_resp.msg
- del resp.headers['Content-encoding']
- # Percent-encode redirect URL of Location HTTP header to satisfy RFC 3986 (see
- # https://github.com/rg3/youtube-dl/issues/6457).
- if 300 <= resp.code < 400:
- location = resp.headers.get('Location')
- if location:
- # As of RFC 2616 default charset is iso-8859-1 that is respected by python 3
- if sys.version_info >= (3, 0):
- location = location.encode('iso-8859-1').decode('utf-8')
- else:
- location = location.decode('utf-8')
- location_escaped = escape_url(location)
- if location != location_escaped:
- del resp.headers['Location']
- if sys.version_info < (3, 0):
- location_escaped = location_escaped.encode('utf-8')
- resp.headers['Location'] = location_escaped
- return resp
-
- https_request = http_request
- https_response = http_response
-
-
-def make_socks_conn_class(base_class, socks_proxy):
- assert issubclass(base_class, (
- compat_http_client.HTTPConnection, compat_http_client.HTTPSConnection))
-
- url_components = compat_urlparse.urlparse(socks_proxy)
- if url_components.scheme.lower() == 'socks5':
- socks_type = ProxyType.SOCKS5
- elif url_components.scheme.lower() in ('socks', 'socks4'):
- socks_type = ProxyType.SOCKS4
- elif url_components.scheme.lower() == 'socks4a':
- socks_type = ProxyType.SOCKS4A
-
- def unquote_if_non_empty(s):
- if not s:
- return s
- return compat_urllib_parse_unquote_plus(s)
-
- proxy_args = (
- socks_type,
- url_components.hostname, url_components.port or 1080,
- True, # Remote DNS
- unquote_if_non_empty(url_components.username),
- unquote_if_non_empty(url_components.password),
- )
-
- class SocksConnection(base_class):
- def connect(self):
- self.sock = sockssocket()
- self.sock.setproxy(*proxy_args)
- if type(self.timeout) in (int, float):
- self.sock.settimeout(self.timeout)
- self.sock.connect((self.host, self.port))
-
- if isinstance(self, compat_http_client.HTTPSConnection):
- if hasattr(self, '_context'): # Python > 2.6
- self.sock = self._context.wrap_socket(
- self.sock, server_hostname=self.host)
- else:
- self.sock = ssl.wrap_socket(self.sock)
-
- return SocksConnection
-
-
-class YoutubeDLHTTPSHandler(compat_urllib_request.HTTPSHandler):
- def __init__(self, params, https_conn_class=None, *args, **kwargs):
- compat_urllib_request.HTTPSHandler.__init__(self, *args, **kwargs)
- self._https_conn_class = https_conn_class or compat_http_client.HTTPSConnection
- self._params = params
-
- def https_open(self, req):
- kwargs = {}
- conn_class = self._https_conn_class
-
- if hasattr(self, '_context'): # python > 2.6
- kwargs['context'] = self._context
- if hasattr(self, '_check_hostname'): # python 3.x
- kwargs['check_hostname'] = self._check_hostname
-
- socks_proxy = req.headers.get('Ytdl-socks-proxy')
- if socks_proxy:
- conn_class = make_socks_conn_class(conn_class, socks_proxy)
- del req.headers['Ytdl-socks-proxy']
-
- return self.do_open(functools.partial(
- _create_http_connection, self, conn_class, True),
- req, **kwargs)
-
-
-class YoutubeDLCookieJar(compat_cookiejar.MozillaCookieJar):
- def save(self, filename=None, ignore_discard=False, ignore_expires=False):
- # Store session cookies with `expires` set to 0 instead of an empty
- # string
- for cookie in self:
- if cookie.expires is None:
- cookie.expires = 0
- compat_cookiejar.MozillaCookieJar.save(self, filename, ignore_discard, ignore_expires)
-
- def load(self, filename=None, ignore_discard=False, ignore_expires=False):
- compat_cookiejar.MozillaCookieJar.load(self, filename, ignore_discard, ignore_expires)
- # Session cookies are denoted by either `expires` field set to
- # an empty string or 0. MozillaCookieJar only recognizes the former
- # (see [1]). So we need force the latter to be recognized as session
- # cookies on our own.
- # Session cookies may be important for cookies-based authentication,
- # e.g. usually, when user does not check 'Remember me' check box while
- # logging in on a site, some important cookies are stored as session
- # cookies so that not recognizing them will result in failed login.
- # 1. https://bugs.python.org/issue17164
- for cookie in self:
- # Treat `expires=0` cookies as session cookies
- if cookie.expires == 0:
- cookie.expires = None
- cookie.discard = True
-
-
-class YoutubeDLCookieProcessor(compat_urllib_request.HTTPCookieProcessor):
- def __init__(self, cookiejar=None):
- compat_urllib_request.HTTPCookieProcessor.__init__(self, cookiejar)
-
- def http_response(self, request, response):
- # Python 2 will choke on next HTTP request in row if there are non-ASCII
- # characters in Set-Cookie HTTP header of last response (see
- # https://github.com/rg3/youtube-dl/issues/6769).
- # In order to at least prevent crashing we will percent encode Set-Cookie
- # header before HTTPCookieProcessor starts processing it.
- # if sys.version_info < (3, 0) and response.headers:
- # for set_cookie_header in ('Set-Cookie', 'Set-Cookie2'):
- # set_cookie = response.headers.get(set_cookie_header)
- # if set_cookie:
- # set_cookie_escaped = compat_urllib_parse.quote(set_cookie, b"%/;:@&=+$,!~*'()?#[] ")
- # if set_cookie != set_cookie_escaped:
- # del response.headers[set_cookie_header]
- # response.headers[set_cookie_header] = set_cookie_escaped
- return compat_urllib_request.HTTPCookieProcessor.http_response(self, request, response)
-
- https_request = compat_urllib_request.HTTPCookieProcessor.http_request
- https_response = http_response
-
-
-def extract_timezone(date_str):
- m = re.search(
- r'^.{8,}?(?P<tz>Z$| ?(?P<sign>\+|-)(?P<hours>[0-9]{2}):?(?P<minutes>[0-9]{2})$)',
- date_str)
- if not m:
- timezone = datetime.timedelta()
- else:
- date_str = date_str[:-len(m.group('tz'))]
- if not m.group('sign'):
- timezone = datetime.timedelta()
- else:
- sign = 1 if m.group('sign') == '+' else -1
- timezone = datetime.timedelta(
- hours=sign * int(m.group('hours')),
- minutes=sign * int(m.group('minutes')))
- return timezone, date_str
-
-
-def parse_iso8601(date_str, delimiter='T', timezone=None):
- """ Return a UNIX timestamp from the given date """
-
- if date_str is None:
- return None
-
- date_str = re.sub(r'\.[0-9]+', '', date_str)
-
- if timezone is None:
- timezone, date_str = extract_timezone(date_str)
-
- try:
- date_format = '%Y-%m-%d{0}%H:%M:%S'.format(delimiter)
- dt = datetime.datetime.strptime(date_str, date_format) - timezone
- return calendar.timegm(dt.timetuple())
- except ValueError:
- pass
-
-
-def date_formats(day_first=True):
- return DATE_FORMATS_DAY_FIRST if day_first else DATE_FORMATS_MONTH_FIRST
-
-
-def unified_strdate(date_str, day_first=True):
- """Return a string with the date in the format YYYYMMDD"""
-
- if date_str is None:
- return None
- upload_date = None
- # Replace commas
- date_str = date_str.replace(',', ' ')
- # Remove AM/PM + timezone
- date_str = re.sub(r'(?i)\s*(?:AM|PM)(?:\s+[A-Z]+)?', '', date_str)
- _, date_str = extract_timezone(date_str)
-
- for expression in date_formats(day_first):
- try:
- upload_date = datetime.datetime.strptime(date_str, expression).strftime('%Y%m%d')
- except ValueError:
- pass
- if upload_date is None:
- timetuple = email.utils.parsedate_tz(date_str)
- if timetuple:
- try:
- upload_date = datetime.datetime(*timetuple[:6]).strftime('%Y%m%d')
- except ValueError:
- pass
- if upload_date is not None:
- return compat_str(upload_date)
-
-
-def unified_timestamp(date_str, day_first=True):
- if date_str is None:
- return None
-
- date_str = re.sub(r'[,|]', '', date_str)
-
- pm_delta = 12 if re.search(r'(?i)PM', date_str) else 0
- timezone, date_str = extract_timezone(date_str)
-
- # Remove AM/PM + timezone
- date_str = re.sub(r'(?i)\s*(?:AM|PM)(?:\s+[A-Z]+)?', '', date_str)
-
- # Remove unrecognized timezones from ISO 8601 alike timestamps
- m = re.search(r'\d{1,2}:\d{1,2}(?:\.\d+)?(?P<tz>\s*[A-Z]+)$', date_str)
- if m:
- date_str = date_str[:-len(m.group('tz'))]
-
- # Python only supports microseconds, so remove nanoseconds
- m = re.search(r'^([0-9]{4,}-[0-9]{1,2}-[0-9]{1,2}T[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}\.[0-9]{6})[0-9]+$', date_str)
- if m:
- date_str = m.group(1)
-
- for expression in date_formats(day_first):
- try:
- dt = datetime.datetime.strptime(date_str, expression) - timezone + datetime.timedelta(hours=pm_delta)
- return calendar.timegm(dt.timetuple())
- except ValueError:
- pass
- timetuple = email.utils.parsedate_tz(date_str)
- if timetuple:
- return calendar.timegm(timetuple) + pm_delta * 3600
-
-
-def determine_ext(url, default_ext='unknown_video'):
- if url is None or '.' not in url:
- return default_ext
- guess = url.partition('?')[0].rpartition('.')[2]
- if re.match(r'^[A-Za-z0-9]+$', guess):
- return guess
- # Try extract ext from URLs like http://example.com/foo/bar.mp4/?download
- elif guess.rstrip('/') in KNOWN_EXTENSIONS:
- return guess.rstrip('/')
- else:
- return default_ext
-
-
-def subtitles_filename(filename, sub_lang, sub_format):
- return filename.rsplit('.', 1)[0] + '.' + sub_lang + '.' + sub_format
-
-
-def date_from_str(date_str):
- """
- Return a datetime object from a string in the format YYYYMMDD or
- (now|today)[+-][0-9](day|week|month|year)(s)?"""
- today = datetime.date.today()
- if date_str in ('now', 'today'):
- return today
- if date_str == 'yesterday':
- return today - datetime.timedelta(days=1)
- match = re.match(r'(now|today)(?P<sign>[+-])(?P<time>\d+)(?P<unit>day|week|month|year)(s)?', date_str)
- if match is not None:
- sign = match.group('sign')
- time = int(match.group('time'))
- if sign == '-':
- time = -time
- unit = match.group('unit')
- # A bad approximation?
- if unit == 'month':
- unit = 'day'
- time *= 30
- elif unit == 'year':
- unit = 'day'
- time *= 365
- unit += 's'
- delta = datetime.timedelta(**{unit: time})
- return today + delta
- return datetime.datetime.strptime(date_str, '%Y%m%d').date()
-
-
-def hyphenate_date(date_str):
- """
- Convert a date in 'YYYYMMDD' format to 'YYYY-MM-DD' format"""
- match = re.match(r'^(\d\d\d\d)(\d\d)(\d\d)$', date_str)
- if match is not None:
- return '-'.join(match.groups())
- else:
- return date_str
-
-
-class DateRange(object):
- """Represents a time interval between two dates"""
-
- def __init__(self, start=None, end=None):
- """start and end must be strings in the format accepted by date"""
- if start is not None:
- self.start = date_from_str(start)
- else:
- self.start = datetime.datetime.min.date()
- if end is not None:
- self.end = date_from_str(end)
- else:
- self.end = datetime.datetime.max.date()
- if self.start > self.end:
- raise ValueError('Date range: "%s" , the start date must be before the end date' % self)
-
- @classmethod
- def day(cls, day):
- """Returns a range that only contains the given day"""
- return cls(day, day)
-
- def __contains__(self, date):
- """Check if the date is in the range"""
- if not isinstance(date, datetime.date):
- date = date_from_str(date)
- return self.start <= date <= self.end
-
- def __str__(self):
- return '%s - %s' % (self.start.isoformat(), self.end.isoformat())
-
-
-def platform_name():
- """ Returns the platform name as a compat_str """
- res = platform.platform()
- if isinstance(res, bytes):
- res = res.decode(preferredencoding())
-
- assert isinstance(res, compat_str)
- return res
-
-
-def _windows_write_string(s, out):
- """ Returns True if the string was written using special methods,
- False if it has yet to be written out."""
- # Adapted from http://stackoverflow.com/a/3259271/35070
-
- import ctypes
- import ctypes.wintypes
-
- WIN_OUTPUT_IDS = {
- 1: -11,
- 2: -12,
- }
-
- try:
- fileno = out.fileno()
- except AttributeError:
- # If the output stream doesn't have a fileno, it's virtual
- return False
- except io.UnsupportedOperation:
- # Some strange Windows pseudo files?
- return False
- if fileno not in WIN_OUTPUT_IDS:
- return False
-
- GetStdHandle = compat_ctypes_WINFUNCTYPE(
- ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD)(
- ('GetStdHandle', ctypes.windll.kernel32))
- h = GetStdHandle(WIN_OUTPUT_IDS[fileno])
-
- WriteConsoleW = compat_ctypes_WINFUNCTYPE(
- ctypes.wintypes.BOOL, ctypes.wintypes.HANDLE, ctypes.wintypes.LPWSTR,
- ctypes.wintypes.DWORD, ctypes.POINTER(ctypes.wintypes.DWORD),
- ctypes.wintypes.LPVOID)(('WriteConsoleW', ctypes.windll.kernel32))
- written = ctypes.wintypes.DWORD(0)
-
- GetFileType = compat_ctypes_WINFUNCTYPE(ctypes.wintypes.DWORD, ctypes.wintypes.DWORD)(('GetFileType', ctypes.windll.kernel32))
- FILE_TYPE_CHAR = 0x0002
- FILE_TYPE_REMOTE = 0x8000
- GetConsoleMode = compat_ctypes_WINFUNCTYPE(
- ctypes.wintypes.BOOL, ctypes.wintypes.HANDLE,
- ctypes.POINTER(ctypes.wintypes.DWORD))(
- ('GetConsoleMode', ctypes.windll.kernel32))
- INVALID_HANDLE_VALUE = ctypes.wintypes.DWORD(-1).value
-
- def not_a_console(handle):
- if handle == INVALID_HANDLE_VALUE or handle is None:
- return True
- return ((GetFileType(handle) & ~FILE_TYPE_REMOTE) != FILE_TYPE_CHAR or
- GetConsoleMode(handle, ctypes.byref(ctypes.wintypes.DWORD())) == 0)
-
- if not_a_console(h):
- return False
-
- def next_nonbmp_pos(s):
- try:
- return next(i for i, c in enumerate(s) if ord(c) > 0xffff)
- except StopIteration:
- return len(s)
-
- while s:
- count = min(next_nonbmp_pos(s), 1024)
-
- ret = WriteConsoleW(
- h, s, count if count else 2, ctypes.byref(written), None)
- if ret == 0:
- raise OSError('Failed to write string')
- if not count: # We just wrote a non-BMP character
- assert written.value == 2
- s = s[1:]
- else:
- assert written.value > 0
- s = s[written.value:]
- return True
-
-
-def write_string(s, out=None, encoding=None):
- if out is None:
- out = sys.stderr
- assert type(s) == compat_str
-
- if sys.platform == 'win32' and encoding is None and hasattr(out, 'fileno'):
- if _windows_write_string(s, out):
- return
-
- if ('b' in getattr(out, 'mode', '') or
- sys.version_info[0] < 3): # Python 2 lies about mode of sys.stderr
- byt = s.encode(encoding or preferredencoding(), 'ignore')
- out.write(byt)
- elif hasattr(out, 'buffer'):
- enc = encoding or getattr(out, 'encoding', None) or preferredencoding()
- byt = s.encode(enc, 'ignore')
- out.buffer.write(byt)
- else:
- out.write(s)
- out.flush()
-
-
-def bytes_to_intlist(bs):
- if not bs:
- return []
- if isinstance(bs[0], int): # Python 3
- return list(bs)
- else:
- return [ord(c) for c in bs]
-
-
-def intlist_to_bytes(xs):
- if not xs:
- return b''
- return compat_struct_pack('%dB' % len(xs), *xs)
-
-
-# Cross-platform file locking
-if sys.platform == 'win32':
- import ctypes.wintypes
- import msvcrt
-
- class OVERLAPPED(ctypes.Structure):
- _fields_ = [
- ('Internal', ctypes.wintypes.LPVOID),
- ('InternalHigh', ctypes.wintypes.LPVOID),
- ('Offset', ctypes.wintypes.DWORD),
- ('OffsetHigh', ctypes.wintypes.DWORD),
- ('hEvent', ctypes.wintypes.HANDLE),
- ]
-
- kernel32 = ctypes.windll.kernel32
- LockFileEx = kernel32.LockFileEx
- LockFileEx.argtypes = [
- ctypes.wintypes.HANDLE, # hFile
- ctypes.wintypes.DWORD, # dwFlags
- ctypes.wintypes.DWORD, # dwReserved
- ctypes.wintypes.DWORD, # nNumberOfBytesToLockLow
- ctypes.wintypes.DWORD, # nNumberOfBytesToLockHigh
- ctypes.POINTER(OVERLAPPED) # Overlapped
- ]
- LockFileEx.restype = ctypes.wintypes.BOOL
- UnlockFileEx = kernel32.UnlockFileEx
- UnlockFileEx.argtypes = [
- ctypes.wintypes.HANDLE, # hFile
- ctypes.wintypes.DWORD, # dwReserved
- ctypes.wintypes.DWORD, # nNumberOfBytesToLockLow
- ctypes.wintypes.DWORD, # nNumberOfBytesToLockHigh
- ctypes.POINTER(OVERLAPPED) # Overlapped
- ]
- UnlockFileEx.restype = ctypes.wintypes.BOOL
- whole_low = 0xffffffff
- whole_high = 0x7fffffff
-
- def _lock_file(f, exclusive):
- overlapped = OVERLAPPED()
- overlapped.Offset = 0
- overlapped.OffsetHigh = 0
- overlapped.hEvent = 0
- f._lock_file_overlapped_p = ctypes.pointer(overlapped)
- handle = msvcrt.get_osfhandle(f.fileno())
- if not LockFileEx(handle, 0x2 if exclusive else 0x0, 0,
- whole_low, whole_high, f._lock_file_overlapped_p):
- raise OSError('Locking file failed: %r' % ctypes.FormatError())
-
- def _unlock_file(f):
- assert f._lock_file_overlapped_p
- handle = msvcrt.get_osfhandle(f.fileno())
- if not UnlockFileEx(handle, 0,
- whole_low, whole_high, f._lock_file_overlapped_p):
- raise OSError('Unlocking file failed: %r' % ctypes.FormatError())
-
-else:
- # Some platforms, such as Jython, is missing fcntl
- try:
- import fcntl
-
- def _lock_file(f, exclusive):
- fcntl.flock(f, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
-
- def _unlock_file(f):
- fcntl.flock(f, fcntl.LOCK_UN)
- except ImportError:
- UNSUPPORTED_MSG = 'file locking is not supported on this platform'
-
- def _lock_file(f, exclusive):
- raise IOError(UNSUPPORTED_MSG)
-
- def _unlock_file(f):
- raise IOError(UNSUPPORTED_MSG)
-
-
-class locked_file(object):
- def __init__(self, filename, mode, encoding=None):
- assert mode in ['r', 'a', 'w']
- self.f = io.open(filename, mode, encoding=encoding)
- self.mode = mode
-
- def __enter__(self):
- exclusive = self.mode != 'r'
- try:
- _lock_file(self.f, exclusive)
- except IOError:
- self.f.close()
- raise
- return self
-
- def __exit__(self, etype, value, traceback):
- try:
- _unlock_file(self.f)
- finally:
- self.f.close()
-
- def __iter__(self):
- return iter(self.f)
-
- def write(self, *args):
- return self.f.write(*args)
-
- def read(self, *args):
- return self.f.read(*args)
-
-
-def get_filesystem_encoding():
- encoding = sys.getfilesystemencoding()
- return encoding if encoding is not None else 'utf-8'
-
-
-def shell_quote(args):
- quoted_args = []
- encoding = get_filesystem_encoding()
- for a in args:
- if isinstance(a, bytes):
- # We may get a filename encoded with 'encodeFilename'
- a = a.decode(encoding)
- quoted_args.append(compat_shlex_quote(a))
- return ' '.join(quoted_args)
-
-
-def smuggle_url(url, data):
- """ Pass additional data in a URL for internal use. """
-
- url, idata = unsmuggle_url(url, {})
- data.update(idata)
- sdata = compat_urllib_parse_urlencode(
- {'__youtubedl_smuggle': json.dumps(data)})
- return url + '#' + sdata
-
-
-def unsmuggle_url(smug_url, default=None):
- if '#__youtubedl_smuggle' not in smug_url:
- return smug_url, default
- url, _, sdata = smug_url.rpartition('#')
- jsond = compat_parse_qs(sdata)['__youtubedl_smuggle'][0]
- data = json.loads(jsond)
- return url, data
-
-
-def format_bytes(bytes):
- if bytes is None:
- return 'N/A'
- if type(bytes) is str:
- bytes = float(bytes)
- if bytes == 0.0:
- exponent = 0
- else:
- exponent = int(math.log(bytes, 1024.0))
- suffix = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][exponent]
- converted = float(bytes) / float(1024 ** exponent)
- return '%.2f%s' % (converted, suffix)
-
-
-def lookup_unit_table(unit_table, s):
- units_re = '|'.join(re.escape(u) for u in unit_table)
- m = re.match(
- r'(?P<num>[0-9]+(?:[,.][0-9]*)?)\s*(?P<unit>%s)\b' % units_re, s)
- if not m:
- return None
- num_str = m.group('num').replace(',', '.')
- mult = unit_table[m.group('unit')]
- return int(float(num_str) * mult)
-
-
-def parse_filesize(s):
- if s is None:
- return None
-
- # The lower-case forms are of course incorrect and unofficial,
- # but we support those too
- _UNIT_TABLE = {
- 'B': 1,
- 'b': 1,
- 'bytes': 1,
- 'KiB': 1024,
- 'KB': 1000,
- 'kB': 1024,
- 'Kb': 1000,
- 'kb': 1000,
- 'kilobytes': 1000,
- 'kibibytes': 1024,
- 'MiB': 1024 ** 2,
- 'MB': 1000 ** 2,
- 'mB': 1024 ** 2,
- 'Mb': 1000 ** 2,
- 'mb': 1000 ** 2,
- 'megabytes': 1000 ** 2,
- 'mebibytes': 1024 ** 2,
- 'GiB': 1024 ** 3,
- 'GB': 1000 ** 3,
- 'gB': 1024 ** 3,
- 'Gb': 1000 ** 3,
- 'gb': 1000 ** 3,
- 'gigabytes': 1000 ** 3,
- 'gibibytes': 1024 ** 3,
- 'TiB': 1024 ** 4,
- 'TB': 1000 ** 4,
- 'tB': 1024 ** 4,
- 'Tb': 1000 ** 4,
- 'tb': 1000 ** 4,
- 'terabytes': 1000 ** 4,
- 'tebibytes': 1024 ** 4,
- 'PiB': 1024 ** 5,
- 'PB': 1000 ** 5,
- 'pB': 1024 ** 5,
- 'Pb': 1000 ** 5,
- 'pb': 1000 ** 5,
- 'petabytes': 1000 ** 5,
- 'pebibytes': 1024 ** 5,
- 'EiB': 1024 ** 6,
- 'EB': 1000 ** 6,
- 'eB': 1024 ** 6,
- 'Eb': 1000 ** 6,
- 'eb': 1000 ** 6,
- 'exabytes': 1000 ** 6,
- 'exbibytes': 1024 ** 6,
- 'ZiB': 1024 ** 7,
- 'ZB': 1000 ** 7,
- 'zB': 1024 ** 7,
- 'Zb': 1000 ** 7,
- 'zb': 1000 ** 7,
- 'zettabytes': 1000 ** 7,
- 'zebibytes': 1024 ** 7,
- 'YiB': 1024 ** 8,
- 'YB': 1000 ** 8,
- 'yB': 1024 ** 8,
- 'Yb': 1000 ** 8,
- 'yb': 1000 ** 8,
- 'yottabytes': 1000 ** 8,
- 'yobibytes': 1024 ** 8,
- }
-
- return lookup_unit_table(_UNIT_TABLE, s)
-
-
-def parse_count(s):
- if s is None:
- return None
-
- s = s.strip()
-
- if re.match(r'^[\d,.]+$', s):
- return str_to_int(s)
-
- _UNIT_TABLE = {
- 'k': 1000,
- 'K': 1000,
- 'm': 1000 ** 2,
- 'M': 1000 ** 2,
- 'kk': 1000 ** 2,
- 'KK': 1000 ** 2,
- }
-
- return lookup_unit_table(_UNIT_TABLE, s)
-
-
-def parse_resolution(s):
- if s is None:
- return {}
-
- mobj = re.search(r'\b(?P<w>\d+)\s*[xX×]\s*(?P<h>\d+)\b', s)
- if mobj:
- return {
- 'width': int(mobj.group('w')),
- 'height': int(mobj.group('h')),
- }
-
- mobj = re.search(r'\b(\d+)[pPiI]\b', s)
- if mobj:
- return {'height': int(mobj.group(1))}
-
- mobj = re.search(r'\b([48])[kK]\b', s)
- if mobj:
- return {'height': int(mobj.group(1)) * 540}
-
- return {}
-
-
-def month_by_name(name, lang='en'):
- """ Return the number of a month by (locale-independently) English name """
-
- month_names = MONTH_NAMES.get(lang, MONTH_NAMES['en'])
-
- try:
- return month_names.index(name) + 1
- except ValueError:
- return None
-
-
-def month_by_abbreviation(abbrev):
- """ Return the number of a month by (locale-independently) English
- abbreviations """
-
- try:
- return [s[:3] for s in ENGLISH_MONTH_NAMES].index(abbrev) + 1
- except ValueError:
- return None
-
-
-def fix_xml_ampersands(xml_str):
- """Replace all the '&' by '&amp;' in XML"""
- return re.sub(
- r'&(?!amp;|lt;|gt;|apos;|quot;|#x[0-9a-fA-F]{,4};|#[0-9]{,4};)',
- '&amp;',
- xml_str)
-
-
-def setproctitle(title):
- assert isinstance(title, compat_str)
-
- # ctypes in Jython is not complete
- # http://bugs.jython.org/issue2148
- if sys.platform.startswith('java'):
- return
-
- try:
- libc = ctypes.cdll.LoadLibrary('libc.so.6')
- except OSError:
- return
- except TypeError:
- # LoadLibrary in Windows Python 2.7.13 only expects
- # a bytestring, but since unicode_literals turns
- # every string into a unicode string, it fails.
- return
- title_bytes = title.encode('utf-8')
- buf = ctypes.create_string_buffer(len(title_bytes))
- buf.value = title_bytes
- try:
- libc.prctl(15, buf, 0, 0, 0)
- except AttributeError:
- return # Strange libc, just skip this
-
-
-def remove_start(s, start):
- return s[len(start):] if s is not None and s.startswith(start) else s
-
-
-def remove_end(s, end):
- return s[:-len(end)] if s is not None and s.endswith(end) else s
-
-
-def remove_quotes(s):
- if s is None or len(s) < 2:
- return s
- for quote in ('"', "'", ):
- if s[0] == quote and s[-1] == quote:
- return s[1:-1]
- return s
-
-
-def url_basename(url):
- path = compat_urlparse.urlparse(url).path
- return path.strip('/').split('/')[-1]
-
-
-def base_url(url):
- return re.match(r'https?://[^?#&]+/', url).group()
-
-
-def urljoin(base, path):
- if isinstance(path, bytes):
- path = path.decode('utf-8')
- if not isinstance(path, compat_str) or not path:
- return None
- if re.match(r'^(?:https?:)?//', path):
- return path
- if isinstance(base, bytes):
- base = base.decode('utf-8')
- if not isinstance(base, compat_str) or not re.match(
- r'^(?:https?:)?//', base):
- return None
- return compat_urlparse.urljoin(base, path)
-
-
-class HEADRequest(compat_urllib_request.Request):
- def get_method(self):
- return 'HEAD'
-
-
-class PUTRequest(compat_urllib_request.Request):
- def get_method(self):
- return 'PUT'
-
-
-def int_or_none(v, scale=1, default=None, get_attr=None, invscale=1):
- if get_attr:
- if v is not None:
- v = getattr(v, get_attr, None)
- if v == '':
- v = None
- if v is None:
- return default
- try:
- return int(v) * invscale // scale
- except ValueError:
- return default
-
-
-def str_or_none(v, default=None):
- return default if v is None else compat_str(v)
-
-
-def str_to_int(int_str):
- """ A more relaxed version of int_or_none """
- if int_str is None:
- return None
- int_str = re.sub(r'[,\.\+]', '', int_str)
- return int(int_str)
-
-
-def float_or_none(v, scale=1, invscale=1, default=None):
- if v is None:
- return default
- try:
- return float(v) * invscale / scale
- except ValueError:
- return default
-
-
-def bool_or_none(v, default=None):
- return v if isinstance(v, bool) else default
-
-
-def strip_or_none(v):
- return None if v is None else v.strip()
-
-
-def url_or_none(url):
- if not url or not isinstance(url, compat_str):
- return None
- url = url.strip()
- return url if re.match(r'^(?:[a-zA-Z][\da-zA-Z.+-]*:)?//', url) else None
-
-
-def parse_duration(s):
- if not isinstance(s, compat_basestring):
- return None
-
- s = s.strip()
-
- days, hours, mins, secs, ms = [None] * 5
- m = re.match(r'(?:(?:(?:(?P<days>[0-9]+):)?(?P<hours>[0-9]+):)?(?P<mins>[0-9]+):)?(?P<secs>[0-9]+)(?P<ms>\.[0-9]+)?Z?$', s)
- if m:
- days, hours, mins, secs, ms = m.groups()
- else:
- m = re.match(
- r'''(?ix)(?:P?
- (?:
- [0-9]+\s*y(?:ears?)?\s*
- )?
- (?:
- [0-9]+\s*m(?:onths?)?\s*
- )?
- (?:
- [0-9]+\s*w(?:eeks?)?\s*
- )?
- (?:
- (?P<days>[0-9]+)\s*d(?:ays?)?\s*
- )?
- T)?
- (?:
- (?P<hours>[0-9]+)\s*h(?:ours?)?\s*
- )?
- (?:
- (?P<mins>[0-9]+)\s*m(?:in(?:ute)?s?)?\s*
- )?
- (?:
- (?P<secs>[0-9]+)(?P<ms>\.[0-9]+)?\s*s(?:ec(?:ond)?s?)?\s*
- )?Z?$''', s)
- if m:
- days, hours, mins, secs, ms = m.groups()
- else:
- m = re.match(r'(?i)(?:(?P<hours>[0-9.]+)\s*(?:hours?)|(?P<mins>[0-9.]+)\s*(?:mins?\.?|minutes?)\s*)Z?$', s)
- if m:
- hours, mins = m.groups()
- else:
- return None
-
- duration = 0
- if secs:
- duration += float(secs)
- if mins:
- duration += float(mins) * 60
- if hours:
- duration += float(hours) * 60 * 60
- if days:
- duration += float(days) * 24 * 60 * 60
- if ms:
- duration += float(ms)
- return duration
-
-
-def prepend_extension(filename, ext, expected_real_ext=None):
- name, real_ext = os.path.splitext(filename)
- return (
- '{0}.{1}{2}'.format(name, ext, real_ext)
- if not expected_real_ext or real_ext[1:] == expected_real_ext
- else '{0}.{1}'.format(filename, ext))
-
-
-def replace_extension(filename, ext, expected_real_ext=None):
- name, real_ext = os.path.splitext(filename)
- return '{0}.{1}'.format(
- name if not expected_real_ext or real_ext[1:] == expected_real_ext else filename,
- ext)
-
-
-def check_executable(exe, args=[]):
- """ Checks if the given binary is installed somewhere in PATH, and returns its name.
- args can be a list of arguments for a short output (like -version) """
- try:
- subprocess.Popen([exe] + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
- except OSError:
- return False
- return exe
-
-
-def get_exe_version(exe, args=['--version'],
- version_re=None, unrecognized='present'):
- """ Returns the version of the specified executable,
- or False if the executable is not present """
- try:
- # STDIN should be redirected too. On UNIX-like systems, ffmpeg triggers
- # SIGTTOU if youtube-dl is run in the background.
- # See https://github.com/rg3/youtube-dl/issues/955#issuecomment-209789656
- out, _ = subprocess.Popen(
- [encodeArgument(exe)] + args,
- stdin=subprocess.PIPE,
- stdout=subprocess.PIPE, stderr=subprocess.STDOUT).communicate()
- except OSError:
- return False
- if isinstance(out, bytes): # Python 2.x
- out = out.decode('ascii', 'ignore')
- return detect_exe_version(out, version_re, unrecognized)
-
-
-def detect_exe_version(output, version_re=None, unrecognized='present'):
- assert isinstance(output, compat_str)
- if version_re is None:
- version_re = r'version\s+([-0-9._a-zA-Z]+)'
- m = re.search(version_re, output)
- if m:
- return m.group(1)
- else:
- return unrecognized
-
-
-class PagedList(object):
- def __len__(self):
- # This is only useful for tests
- return len(self.getslice())
-
-
-class OnDemandPagedList(PagedList):
- def __init__(self, pagefunc, pagesize, use_cache=True):
- self._pagefunc = pagefunc
- self._pagesize = pagesize
- self._use_cache = use_cache
- if use_cache:
- self._cache = {}
-
- def getslice(self, start=0, end=None):
- res = []
- for pagenum in itertools.count(start // self._pagesize):
- firstid = pagenum * self._pagesize
- nextfirstid = pagenum * self._pagesize + self._pagesize
- if start >= nextfirstid:
- continue
-
- page_results = None
- if self._use_cache:
- page_results = self._cache.get(pagenum)
- if page_results is None:
- page_results = list(self._pagefunc(pagenum))
- if self._use_cache:
- self._cache[pagenum] = page_results
-
- startv = (
- start % self._pagesize
- if firstid <= start < nextfirstid
- else 0)
-
- endv = (
- ((end - 1) % self._pagesize) + 1
- if (end is not None and firstid <= end <= nextfirstid)
- else None)
-
- if startv != 0 or endv is not None:
- page_results = page_results[startv:endv]
- res.extend(page_results)
-
- # A little optimization - if current page is not "full", ie. does
- # not contain page_size videos then we can assume that this page
- # is the last one - there are no more ids on further pages -
- # i.e. no need to query again.
- if len(page_results) + startv < self._pagesize:
- break
-
- # If we got the whole page, but the next page is not interesting,
- # break out early as well
- if end == nextfirstid:
- break
- return res
-
-
-class InAdvancePagedList(PagedList):
- def __init__(self, pagefunc, pagecount, pagesize):
- self._pagefunc = pagefunc
- self._pagecount = pagecount
- self._pagesize = pagesize
-
- def getslice(self, start=0, end=None):
- res = []
- start_page = start // self._pagesize
- end_page = (
- self._pagecount if end is None else (end // self._pagesize + 1))
- skip_elems = start - start_page * self._pagesize
- only_more = None if end is None else end - start
- for pagenum in range(start_page, end_page):
- page = list(self._pagefunc(pagenum))
- if skip_elems:
- page = page[skip_elems:]
- skip_elems = None
- if only_more is not None:
- if len(page) < only_more:
- only_more -= len(page)
- else:
- page = page[:only_more]
- res.extend(page)
- break
- res.extend(page)
- return res
-
-
-def uppercase_escape(s):
- unicode_escape = codecs.getdecoder('unicode_escape')
- return re.sub(
- r'\\U[0-9a-fA-F]{8}',
- lambda m: unicode_escape(m.group(0))[0],
- s)
-
-
-def lowercase_escape(s):
- unicode_escape = codecs.getdecoder('unicode_escape')
- return re.sub(
- r'\\u[0-9a-fA-F]{4}',
- lambda m: unicode_escape(m.group(0))[0],
- s)
-
-
-def escape_rfc3986(s):
- """Escape non-ASCII characters as suggested by RFC 3986"""
- if sys.version_info < (3, 0) and isinstance(s, compat_str):
- s = s.encode('utf-8')
- return compat_urllib_parse.quote(s, b"%/;:@&=+$,!~*'()?#[]")
-
-
-def escape_url(url):
- """Escape URL as suggested by RFC 3986"""
- url_parsed = compat_urllib_parse_urlparse(url)
- return url_parsed._replace(
- netloc=url_parsed.netloc.encode('idna').decode('ascii'),
- path=escape_rfc3986(url_parsed.path),
- params=escape_rfc3986(url_parsed.params),
- query=escape_rfc3986(url_parsed.query),
- fragment=escape_rfc3986(url_parsed.fragment)
- ).geturl()
-
-
-def read_batch_urls(batch_fd):
- def fixup(url):
- if not isinstance(url, compat_str):
- url = url.decode('utf-8', 'replace')
- BOM_UTF8 = '\xef\xbb\xbf'
- if url.startswith(BOM_UTF8):
- url = url[len(BOM_UTF8):]
- url = url.strip()
- if url.startswith(('#', ';', ']')):
- return False
- return url
-
- with contextlib.closing(batch_fd) as fd:
- return [url for url in map(fixup, fd) if url]
-
-
-def urlencode_postdata(*args, **kargs):
- return compat_urllib_parse_urlencode(*args, **kargs).encode('ascii')
-
-
-def update_url_query(url, query):
- if not query:
- return url
- parsed_url = compat_urlparse.urlparse(url)
- qs = compat_parse_qs(parsed_url.query)
- qs.update(query)
- return compat_urlparse.urlunparse(parsed_url._replace(
- query=compat_urllib_parse_urlencode(qs, True)))
-
-
-def update_Request(req, url=None, data=None, headers={}, query={}):
- req_headers = req.headers.copy()
- req_headers.update(headers)
- req_data = data or req.data
- req_url = update_url_query(url or req.get_full_url(), query)
- req_get_method = req.get_method()
- if req_get_method == 'HEAD':
- req_type = HEADRequest
- elif req_get_method == 'PUT':
- req_type = PUTRequest
- else:
- req_type = compat_urllib_request.Request
- new_req = req_type(
- req_url, data=req_data, headers=req_headers,
- origin_req_host=req.origin_req_host, unverifiable=req.unverifiable)
- if hasattr(req, 'timeout'):
- new_req.timeout = req.timeout
- return new_req
-
-
-def _multipart_encode_impl(data, boundary):
- content_type = 'multipart/form-data; boundary=%s' % boundary
-
- out = b''
- for k, v in data.items():
- out += b'--' + boundary.encode('ascii') + b'\r\n'
- if isinstance(k, compat_str):
- k = k.encode('utf-8')
- if isinstance(v, compat_str):
- v = v.encode('utf-8')
- # RFC 2047 requires non-ASCII field names to be encoded, while RFC 7578
- # suggests sending UTF-8 directly. Firefox sends UTF-8, too
- content = b'Content-Disposition: form-data; name="' + k + b'"\r\n\r\n' + v + b'\r\n'
- if boundary.encode('ascii') in content:
- raise ValueError('Boundary overlaps with data')
- out += content
-
- out += b'--' + boundary.encode('ascii') + b'--\r\n'
-
- return out, content_type
-
-
-def multipart_encode(data, boundary=None):
- '''
- Encode a dict to RFC 7578-compliant form-data
-
- data:
- A dict where keys and values can be either Unicode or bytes-like
- objects.
- boundary:
- If specified a Unicode object, it's used as the boundary. Otherwise
- a random boundary is generated.
-
- Reference: https://tools.ietf.org/html/rfc7578
- '''
- has_specified_boundary = boundary is not None
-
- while True:
- if boundary is None:
- boundary = '---------------' + str(random.randrange(0x0fffffff, 0xffffffff))
-
- try:
- out, content_type = _multipart_encode_impl(data, boundary)
- break
- except ValueError:
- if has_specified_boundary:
- raise
- boundary = None
-
- return out, content_type
-
-
-def dict_get(d, key_or_keys, default=None, skip_false_values=True):
- if isinstance(key_or_keys, (list, tuple)):
- for key in key_or_keys:
- if key not in d or d[key] is None or skip_false_values and not d[key]:
- continue
- return d[key]
- return default
- return d.get(key_or_keys, default)
-
-
-def try_get(src, getter, expected_type=None):
- if not isinstance(getter, (list, tuple)):
- getter = [getter]
- for get in getter:
- try:
- v = get(src)
- except (AttributeError, KeyError, TypeError, IndexError):
- pass
- else:
- if expected_type is None or isinstance(v, expected_type):
- return v
-
-
-def merge_dicts(*dicts):
- merged = {}
- for a_dict in dicts:
- for k, v in a_dict.items():
- if v is None:
- continue
- if (k not in merged or
- (isinstance(v, compat_str) and v and
- isinstance(merged[k], compat_str) and
- not merged[k])):
- merged[k] = v
- return merged
-
-
-def encode_compat_str(string, encoding=preferredencoding(), errors='strict'):
- return string if isinstance(string, compat_str) else compat_str(string, encoding, errors)
-
-
-US_RATINGS = {
- 'G': 0,
- 'PG': 10,
- 'PG-13': 13,
- 'R': 16,
- 'NC': 18,
-}
-
-
-TV_PARENTAL_GUIDELINES = {
- 'TV-Y': 0,
- 'TV-Y7': 7,
- 'TV-G': 0,
- 'TV-PG': 0,
- 'TV-14': 14,
- 'TV-MA': 17,
-}
-
-
-def parse_age_limit(s):
- if type(s) == int:
- return s if 0 <= s <= 21 else None
- if not isinstance(s, compat_basestring):
- return None
- m = re.match(r'^(?P<age>\d{1,2})\+?$', s)
- if m:
- return int(m.group('age'))
- if s in US_RATINGS:
- return US_RATINGS[s]
- m = re.match(r'^TV[_-]?(%s)$' % '|'.join(k[3:] for k in TV_PARENTAL_GUIDELINES), s)
- if m:
- return TV_PARENTAL_GUIDELINES['TV-' + m.group(1)]
- return None
-
-
-def strip_jsonp(code):
- return re.sub(
- r'''(?sx)^
- (?:window\.)?(?P<func_name>[a-zA-Z0-9_.$]*)
- (?:\s*&&\s*(?P=func_name))?
- \s*\(\s*(?P<callback_data>.*)\);?
- \s*?(?://[^\n]*)*$''',
- r'\g<callback_data>', code)
-
-
-def js_to_json(code):
- COMMENT_RE = r'/\*(?:(?!\*/).)*?\*/|//[^\n]*'
- SKIP_RE = r'\s*(?:{comment})?\s*'.format(comment=COMMENT_RE)
- INTEGER_TABLE = (
- (r'(?s)^(0[xX][0-9a-fA-F]+){skip}:?$'.format(skip=SKIP_RE), 16),
- (r'(?s)^(0+[0-7]+){skip}:?$'.format(skip=SKIP_RE), 8),
- )
-
- def fix_kv(m):
- v = m.group(0)
- if v in ('true', 'false', 'null'):
- return v
- elif v.startswith('/*') or v.startswith('//') or v == ',':
- return ""
-
- if v[0] in ("'", '"'):
- v = re.sub(r'(?s)\\.|"', lambda m: {
- '"': '\\"',
- "\\'": "'",
- '\\\n': '',
- '\\x': '\\u00',
- }.get(m.group(0), m.group(0)), v[1:-1])
-
- for regex, base in INTEGER_TABLE:
- im = re.match(regex, v)
- if im:
- i = int(im.group(1), base)
- return '"%d":' % i if v.endswith(':') else '%d' % i
-
- return '"%s"' % v
-
- return re.sub(r'''(?sx)
- "(?:[^"\\]*(?:\\\\|\\['"nurtbfx/\n]))*[^"\\]*"|
- '(?:[^'\\]*(?:\\\\|\\['"nurtbfx/\n]))*[^'\\]*'|
- {comment}|,(?={skip}[\]}}])|
- (?:(?<![0-9])[eE]|[a-df-zA-DF-Z_])[.a-zA-Z_0-9]*|
- \b(?:0[xX][0-9a-fA-F]+|0+[0-7]+)(?:{skip}:)?|
- [0-9]+(?={skip}:)
- '''.format(comment=COMMENT_RE, skip=SKIP_RE), fix_kv, code)
-
-
-def qualities(quality_ids):
- """ Get a numeric quality value out of a list of possible values """
- def q(qid):
- try:
- return quality_ids.index(qid)
- except ValueError:
- return -1
- return q
-
-
-DEFAULT_OUTTMPL = '%(title)s-%(id)s.%(ext)s'
-
-
-def limit_length(s, length):
- """ Add ellipses to overly long strings """
- if s is None:
- return None
- ELLIPSES = '...'
- if len(s) > length:
- return s[:length - len(ELLIPSES)] + ELLIPSES
- return s
-
-
-def version_tuple(v):
- return tuple(int(e) for e in re.split(r'[-.]', v))
-
-
-def is_outdated_version(version, limit, assume_new=True):
- if not version:
- return not assume_new
- try:
- return version_tuple(version) < version_tuple(limit)
- except ValueError:
- return not assume_new
-
-
-def ytdl_is_updateable():
- """ Returns if youtube-dl can be updated with -U """
- from zipimport import zipimporter
-
- return isinstance(globals().get('__loader__'), zipimporter) or hasattr(sys, 'frozen')
-
-
-def args_to_str(args):
- # Get a short string representation for a subprocess command
- return ' '.join(compat_shlex_quote(a) for a in args)
-
-
-def error_to_compat_str(err):
- err_str = str(err)
- # On python 2 error byte string must be decoded with proper
- # encoding rather than ascii
- if sys.version_info[0] < 3:
- err_str = err_str.decode(preferredencoding())
- return err_str
-
-
-def mimetype2ext(mt):
- if mt is None:
- return None
-
- ext = {
- 'audio/mp4': 'm4a',
- # Per RFC 3003, audio/mpeg can be .mp1, .mp2 or .mp3. Here use .mp3 as
- # it's the most popular one
- 'audio/mpeg': 'mp3',
- }.get(mt)
- if ext is not None:
- return ext
-
- _, _, res = mt.rpartition('/')
- res = res.split(';')[0].strip().lower()
-
- return {
- '3gpp': '3gp',
- 'smptett+xml': 'tt',
- 'ttaf+xml': 'dfxp',
- 'ttml+xml': 'ttml',
- 'x-flv': 'flv',
- 'x-mp4-fragmented': 'mp4',
- 'x-ms-sami': 'sami',
- 'x-ms-wmv': 'wmv',
- 'mpegurl': 'm3u8',
- 'x-mpegurl': 'm3u8',
- 'vnd.apple.mpegurl': 'm3u8',
- 'dash+xml': 'mpd',
- 'f4m+xml': 'f4m',
- 'hds+xml': 'f4m',
- 'vnd.ms-sstr+xml': 'ism',
- 'quicktime': 'mov',
- 'mp2t': 'ts',
- }.get(res, res)
-
-
-def parse_codecs(codecs_str):
- # http://tools.ietf.org/html/rfc6381
- if not codecs_str:
- return {}
- splited_codecs = list(filter(None, map(
- lambda str: str.strip(), codecs_str.strip().strip(',').split(','))))
- vcodec, acodec = None, None
- for full_codec in splited_codecs:
- codec = full_codec.split('.')[0]
- if codec in ('avc1', 'avc2', 'avc3', 'avc4', 'vp9', 'vp8', 'hev1', 'hev2', 'h263', 'h264', 'mp4v', 'hvc1', 'av01'):
- if not vcodec:
- vcodec = full_codec
- elif codec in ('mp4a', 'opus', 'vorbis', 'mp3', 'aac', 'ac-3', 'ec-3', 'eac3', 'dtsc', 'dtse', 'dtsh', 'dtsl'):
- if not acodec:
- acodec = full_codec
- else:
- write_string('WARNING: Unknown codec %s\n' % full_codec, sys.stderr)
- if not vcodec and not acodec:
- if len(splited_codecs) == 2:
- return {
- 'vcodec': vcodec,
- 'acodec': acodec,
- }
- elif len(splited_codecs) == 1:
- return {
- 'vcodec': 'none',
- 'acodec': vcodec,
- }
- else:
- return {
- 'vcodec': vcodec or 'none',
- 'acodec': acodec or 'none',
- }
- return {}
-
-
-def urlhandle_detect_ext(url_handle):
- getheader = url_handle.headers.get
-
- cd = getheader('Content-Disposition')
- if cd:
- m = re.match(r'attachment;\s*filename="(?P<filename>[^"]+)"', cd)
- if m:
- e = determine_ext(m.group('filename'), default_ext=None)
- if e:
- return e
-
- return mimetype2ext(getheader('Content-Type'))
-
-
-def encode_data_uri(data, mime_type):
- return 'data:%s;base64,%s' % (mime_type, base64.b64encode(data).decode('ascii'))
-
-
-def age_restricted(content_limit, age_limit):
- """ Returns True iff the content should be blocked """
-
- if age_limit is None: # No limit set
- return False
- if content_limit is None:
- return False # Content available for everyone
- return age_limit < content_limit
-
-
-def is_html(first_bytes):
- """ Detect whether a file contains HTML by examining its first bytes. """
-
- BOMS = [
- (b'\xef\xbb\xbf', 'utf-8'),
- (b'\x00\x00\xfe\xff', 'utf-32-be'),
- (b'\xff\xfe\x00\x00', 'utf-32-le'),
- (b'\xff\xfe', 'utf-16-le'),
- (b'\xfe\xff', 'utf-16-be'),
- ]
- for bom, enc in BOMS:
- if first_bytes.startswith(bom):
- s = first_bytes[len(bom):].decode(enc, 'replace')
- break
- else:
- s = first_bytes.decode('utf-8', 'replace')
-
- return re.match(r'^\s*<', s)
-
-
-def determine_protocol(info_dict):
- protocol = info_dict.get('protocol')
- if protocol is not None:
- return protocol
-
- url = info_dict['url']
- if url.startswith('rtmp'):
- return 'rtmp'
- elif url.startswith('mms'):
- return 'mms'
- elif url.startswith('rtsp'):
- return 'rtsp'
-
- ext = determine_ext(url)
- if ext == 'm3u8':
- return 'm3u8'
- elif ext == 'f4m':
- return 'f4m'
-
- return compat_urllib_parse_urlparse(url).scheme
-
-
-def render_table(header_row, data):
- """ Render a list of rows, each as a list of values """
- table = [header_row] + data
- max_lens = [max(len(compat_str(v)) for v in col) for col in zip(*table)]
- format_str = ' '.join('%-' + compat_str(ml + 1) + 's' for ml in max_lens[:-1]) + '%s'
- return '\n'.join(format_str % tuple(row) for row in table)
-
-
-def _match_one(filter_part, dct):
- COMPARISON_OPERATORS = {
- '<': operator.lt,
- '<=': operator.le,
- '>': operator.gt,
- '>=': operator.ge,
- '=': operator.eq,
- '!=': operator.ne,
- }
- operator_rex = re.compile(r'''(?x)\s*
- (?P<key>[a-z_]+)
- \s*(?P<op>%s)(?P<none_inclusive>\s*\?)?\s*
- (?:
- (?P<intval>[0-9.]+(?:[kKmMgGtTpPeEzZyY]i?[Bb]?)?)|
- (?P<quote>["\'])(?P<quotedstrval>(?:\\.|(?!(?P=quote)|\\).)+?)(?P=quote)|
- (?P<strval>(?![0-9.])[a-z0-9A-Z]*)
- )
- \s*$
- ''' % '|'.join(map(re.escape, COMPARISON_OPERATORS.keys())))
- m = operator_rex.search(filter_part)
- if m:
- op = COMPARISON_OPERATORS[m.group('op')]
- actual_value = dct.get(m.group('key'))
- if (m.group('quotedstrval') is not None or
- m.group('strval') is not None or
- # If the original field is a string and matching comparisonvalue is
- # a number we should respect the origin of the original field
- # and process comparison value as a string (see
- # https://github.com/rg3/youtube-dl/issues/11082).
- actual_value is not None and m.group('intval') is not None and
- isinstance(actual_value, compat_str)):
- if m.group('op') not in ('=', '!='):
- raise ValueError(
- 'Operator %s does not support string values!' % m.group('op'))
- comparison_value = m.group('quotedstrval') or m.group('strval') or m.group('intval')
- quote = m.group('quote')
- if quote is not None:
- comparison_value = comparison_value.replace(r'\%s' % quote, quote)
- else:
- try:
- comparison_value = int(m.group('intval'))
- except ValueError:
- comparison_value = parse_filesize(m.group('intval'))
- if comparison_value is None:
- comparison_value = parse_filesize(m.group('intval') + 'B')
- if comparison_value is None:
- raise ValueError(
- 'Invalid integer value %r in filter part %r' % (
- m.group('intval'), filter_part))
- if actual_value is None:
- return m.group('none_inclusive')
- return op(actual_value, comparison_value)
-
- UNARY_OPERATORS = {
- '': lambda v: (v is True) if isinstance(v, bool) else (v is not None),
- '!': lambda v: (v is False) if isinstance(v, bool) else (v is None),
- }
- operator_rex = re.compile(r'''(?x)\s*
- (?P<op>%s)\s*(?P<key>[a-z_]+)
- \s*$
- ''' % '|'.join(map(re.escape, UNARY_OPERATORS.keys())))
- m = operator_rex.search(filter_part)
- if m:
- op = UNARY_OPERATORS[m.group('op')]
- actual_value = dct.get(m.group('key'))
- return op(actual_value)
-
- raise ValueError('Invalid filter part %r' % filter_part)
-
-
-def match_str(filter_str, dct):
- """ Filter a dictionary with a simple string syntax. Returns True (=passes filter) or false """
-
- return all(
- _match_one(filter_part, dct) for filter_part in filter_str.split('&'))
-
-
-def match_filter_func(filter_str):
- def _match_func(info_dict):
- if match_str(filter_str, info_dict):
- return None
- else:
- video_title = info_dict.get('title', info_dict.get('id', 'video'))
- return '%s does not pass filter %s, skipping ..' % (video_title, filter_str)
- return _match_func
-
-
-def parse_dfxp_time_expr(time_expr):
- if not time_expr:
- return
-
- mobj = re.match(r'^(?P<time_offset>\d+(?:\.\d+)?)s?$', time_expr)
- if mobj:
- return float(mobj.group('time_offset'))
-
- mobj = re.match(r'^(\d+):(\d\d):(\d\d(?:(?:\.|:)\d+)?)$', time_expr)
- if mobj:
- return 3600 * int(mobj.group(1)) + 60 * int(mobj.group(2)) + float(mobj.group(3).replace(':', '.'))
-
-
-def srt_subtitles_timecode(seconds):
- return '%02d:%02d:%02d,%03d' % (seconds / 3600, (seconds % 3600) / 60, seconds % 60, (seconds % 1) * 1000)
-
-
-def dfxp2srt(dfxp_data):
- '''
- @param dfxp_data A bytes-like object containing DFXP data
- @returns A unicode object containing converted SRT data
- '''
- LEGACY_NAMESPACES = (
- (b'http://www.w3.org/ns/ttml', [
- b'http://www.w3.org/2004/11/ttaf1',
- b'http://www.w3.org/2006/04/ttaf1',
- b'http://www.w3.org/2006/10/ttaf1',
- ]),
- (b'http://www.w3.org/ns/ttml#styling', [
- b'http://www.w3.org/ns/ttml#style',
- ]),
- )
-
- SUPPORTED_STYLING = [
- 'color',
- 'fontFamily',
- 'fontSize',
- 'fontStyle',
- 'fontWeight',
- 'textDecoration'
- ]
-
- _x = functools.partial(xpath_with_ns, ns_map={
- 'xml': 'http://www.w3.org/XML/1998/namespace',
- 'ttml': 'http://www.w3.org/ns/ttml',
- 'tts': 'http://www.w3.org/ns/ttml#styling',
- })
-
- styles = {}
- default_style = {}
-
- class TTMLPElementParser(object):
- _out = ''
- _unclosed_elements = []
- _applied_styles = []
-
- def start(self, tag, attrib):
- if tag in (_x('ttml:br'), 'br'):
- self._out += '\n'
- else:
- unclosed_elements = []
- style = {}
- element_style_id = attrib.get('style')
- if default_style:
- style.update(default_style)
- if element_style_id:
- style.update(styles.get(element_style_id, {}))
- for prop in SUPPORTED_STYLING:
- prop_val = attrib.get(_x('tts:' + prop))
- if prop_val:
- style[prop] = prop_val
- if style:
- font = ''
- for k, v in sorted(style.items()):
- if self._applied_styles and self._applied_styles[-1].get(k) == v:
- continue
- if k == 'color':
- font += ' color="%s"' % v
- elif k == 'fontSize':
- font += ' size="%s"' % v
- elif k == 'fontFamily':
- font += ' face="%s"' % v
- elif k == 'fontWeight' and v == 'bold':
- self._out += '<b>'
- unclosed_elements.append('b')
- elif k == 'fontStyle' and v == 'italic':
- self._out += '<i>'
- unclosed_elements.append('i')
- elif k == 'textDecoration' and v == 'underline':
- self._out += '<u>'
- unclosed_elements.append('u')
- if font:
- self._out += '<font' + font + '>'
- unclosed_elements.append('font')
- applied_style = {}
- if self._applied_styles:
- applied_style.update(self._applied_styles[-1])
- applied_style.update(style)
- self._applied_styles.append(applied_style)
- self._unclosed_elements.append(unclosed_elements)
-
- def end(self, tag):
- if tag not in (_x('ttml:br'), 'br'):
- unclosed_elements = self._unclosed_elements.pop()
- for element in reversed(unclosed_elements):
- self._out += '</%s>' % element
- if unclosed_elements and self._applied_styles:
- self._applied_styles.pop()
-
- def data(self, data):
- self._out += data
-
- def close(self):
- return self._out.strip()
-
- def parse_node(node):
- target = TTMLPElementParser()
- parser = xml.etree.ElementTree.XMLParser(target=target)
- parser.feed(xml.etree.ElementTree.tostring(node))
- return parser.close()
-
- for k, v in LEGACY_NAMESPACES:
- for ns in v:
- dfxp_data = dfxp_data.replace(ns, k)
-
- dfxp = compat_etree_fromstring(dfxp_data)
- out = []
- paras = dfxp.findall(_x('.//ttml:p')) or dfxp.findall('.//p')
-
- if not paras:
- raise ValueError('Invalid dfxp/TTML subtitle')
-
- repeat = False
- while True:
- for style in dfxp.findall(_x('.//ttml:style')):
- style_id = style.get('id') or style.get(_x('xml:id'))
- if not style_id:
- continue
- parent_style_id = style.get('style')
- if parent_style_id:
- if parent_style_id not in styles:
- repeat = True
- continue
- styles[style_id] = styles[parent_style_id].copy()
- for prop in SUPPORTED_STYLING:
- prop_val = style.get(_x('tts:' + prop))
- if prop_val:
- styles.setdefault(style_id, {})[prop] = prop_val
- if repeat:
- repeat = False
- else:
- break
-
- for p in ('body', 'div'):
- ele = xpath_element(dfxp, [_x('.//ttml:' + p), './/' + p])
- if ele is None:
- continue
- style = styles.get(ele.get('style'))
- if not style:
- continue
- default_style.update(style)
-
- for para, index in zip(paras, itertools.count(1)):
- begin_time = parse_dfxp_time_expr(para.attrib.get('begin'))
- end_time = parse_dfxp_time_expr(para.attrib.get('end'))
- dur = parse_dfxp_time_expr(para.attrib.get('dur'))
- if begin_time is None:
- continue
- if not end_time:
- if not dur:
- continue
- end_time = begin_time + dur
- out.append('%d\n%s --> %s\n%s\n\n' % (
- index,
- srt_subtitles_timecode(begin_time),
- srt_subtitles_timecode(end_time),
- parse_node(para)))
-
- return ''.join(out)
-
-
-def cli_option(params, command_option, param):
- param = params.get(param)
- if param:
- param = compat_str(param)
- return [command_option, param] if param is not None else []
-
-
-def cli_bool_option(params, command_option, param, true_value='true', false_value='false', separator=None):
- param = params.get(param)
- if param is None:
- return []
- assert isinstance(param, bool)
- if separator:
- return [command_option + separator + (true_value if param else false_value)]
- return [command_option, true_value if param else false_value]
-
-
-def cli_valueless_option(params, command_option, param, expected_value=True):
- param = params.get(param)
- return [command_option] if param == expected_value else []
-
-
-def cli_configuration_args(params, param, default=[]):
- ex_args = params.get(param)
- if ex_args is None:
- return default
- assert isinstance(ex_args, list)
- return ex_args
-
-
-class ISO639Utils(object):
- # See http://www.loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt
- _lang_map = {
- 'aa': 'aar',
- 'ab': 'abk',
- 'ae': 'ave',
- 'af': 'afr',
- 'ak': 'aka',
- 'am': 'amh',
- 'an': 'arg',
- 'ar': 'ara',
- 'as': 'asm',
- 'av': 'ava',
- 'ay': 'aym',
- 'az': 'aze',
- 'ba': 'bak',
- 'be': 'bel',
- 'bg': 'bul',
- 'bh': 'bih',
- 'bi': 'bis',
- 'bm': 'bam',
- 'bn': 'ben',
- 'bo': 'bod',
- 'br': 'bre',
- 'bs': 'bos',
- 'ca': 'cat',
- 'ce': 'che',
- 'ch': 'cha',
- 'co': 'cos',
- 'cr': 'cre',
- 'cs': 'ces',
- 'cu': 'chu',
- 'cv': 'chv',
- 'cy': 'cym',
- 'da': 'dan',
- 'de': 'deu',
- 'dv': 'div',
- 'dz': 'dzo',
- 'ee': 'ewe',
- 'el': 'ell',
- 'en': 'eng',
- 'eo': 'epo',
- 'es': 'spa',
- 'et': 'est',
- 'eu': 'eus',
- 'fa': 'fas',
- 'ff': 'ful',
- 'fi': 'fin',
- 'fj': 'fij',
- 'fo': 'fao',
- 'fr': 'fra',
- 'fy': 'fry',
- 'ga': 'gle',
- 'gd': 'gla',
- 'gl': 'glg',
- 'gn': 'grn',
- 'gu': 'guj',
- 'gv': 'glv',
- 'ha': 'hau',
- 'he': 'heb',
- 'iw': 'heb', # Replaced by he in 1989 revision
- 'hi': 'hin',
- 'ho': 'hmo',
- 'hr': 'hrv',
- 'ht': 'hat',
- 'hu': 'hun',
- 'hy': 'hye',
- 'hz': 'her',
- 'ia': 'ina',
- 'id': 'ind',
- 'in': 'ind', # Replaced by id in 1989 revision
- 'ie': 'ile',
- 'ig': 'ibo',
- 'ii': 'iii',
- 'ik': 'ipk',
- 'io': 'ido',
- 'is': 'isl',
- 'it': 'ita',
- 'iu': 'iku',
- 'ja': 'jpn',
- 'jv': 'jav',
- 'ka': 'kat',
- 'kg': 'kon',
- 'ki': 'kik',
- 'kj': 'kua',
- 'kk': 'kaz',
- 'kl': 'kal',
- 'km': 'khm',
- 'kn': 'kan',
- 'ko': 'kor',
- 'kr': 'kau',
- 'ks': 'kas',
- 'ku': 'kur',
- 'kv': 'kom',
- 'kw': 'cor',
- 'ky': 'kir',
- 'la': 'lat',
- 'lb': 'ltz',
- 'lg': 'lug',
- 'li': 'lim',
- 'ln': 'lin',
- 'lo': 'lao',
- 'lt': 'lit',
- 'lu': 'lub',
- 'lv': 'lav',
- 'mg': 'mlg',
- 'mh': 'mah',
- 'mi': 'mri',
- 'mk': 'mkd',
- 'ml': 'mal',
- 'mn': 'mon',
- 'mr': 'mar',
- 'ms': 'msa',
- 'mt': 'mlt',
- 'my': 'mya',
- 'na': 'nau',
- 'nb': 'nob',
- 'nd': 'nde',
- 'ne': 'nep',
- 'ng': 'ndo',
- 'nl': 'nld',
- 'nn': 'nno',
- 'no': 'nor',
- 'nr': 'nbl',
- 'nv': 'nav',
- 'ny': 'nya',
- 'oc': 'oci',
- 'oj': 'oji',
- 'om': 'orm',
- 'or': 'ori',
- 'os': 'oss',
- 'pa': 'pan',
- 'pi': 'pli',
- 'pl': 'pol',
- 'ps': 'pus',
- 'pt': 'por',
- 'qu': 'que',
- 'rm': 'roh',
- 'rn': 'run',
- 'ro': 'ron',
- 'ru': 'rus',
- 'rw': 'kin',
- 'sa': 'san',
- 'sc': 'srd',
- 'sd': 'snd',
- 'se': 'sme',
- 'sg': 'sag',
- 'si': 'sin',
- 'sk': 'slk',
- 'sl': 'slv',
- 'sm': 'smo',
- 'sn': 'sna',
- 'so': 'som',
- 'sq': 'sqi',
- 'sr': 'srp',
- 'ss': 'ssw',
- 'st': 'sot',
- 'su': 'sun',
- 'sv': 'swe',
- 'sw': 'swa',
- 'ta': 'tam',
- 'te': 'tel',
- 'tg': 'tgk',
- 'th': 'tha',
- 'ti': 'tir',
- 'tk': 'tuk',
- 'tl': 'tgl',
- 'tn': 'tsn',
- 'to': 'ton',
- 'tr': 'tur',
- 'ts': 'tso',
- 'tt': 'tat',
- 'tw': 'twi',
- 'ty': 'tah',
- 'ug': 'uig',
- 'uk': 'ukr',
- 'ur': 'urd',
- 'uz': 'uzb',
- 've': 'ven',
- 'vi': 'vie',
- 'vo': 'vol',
- 'wa': 'wln',
- 'wo': 'wol',
- 'xh': 'xho',
- 'yi': 'yid',
- 'ji': 'yid', # Replaced by yi in 1989 revision
- 'yo': 'yor',
- 'za': 'zha',
- 'zh': 'zho',
- 'zu': 'zul',
- }
-
- @classmethod
- def short2long(cls, code):
- """Convert language code from ISO 639-1 to ISO 639-2/T"""
- return cls._lang_map.get(code[:2])
-
- @classmethod
- def long2short(cls, code):
- """Convert language code from ISO 639-2/T to ISO 639-1"""
- for short_name, long_name in cls._lang_map.items():
- if long_name == code:
- return short_name
-
-
-class ISO3166Utils(object):
- # From http://data.okfn.org/data/core/country-list
- _country_map = {
- 'AF': 'Afghanistan',
- 'AX': 'Åland Islands',
- 'AL': 'Albania',
- 'DZ': 'Algeria',
- 'AS': 'American Samoa',
- 'AD': 'Andorra',
- 'AO': 'Angola',
- 'AI': 'Anguilla',
- 'AQ': 'Antarctica',
- 'AG': 'Antigua and Barbuda',
- 'AR': 'Argentina',
- 'AM': 'Armenia',
- 'AW': 'Aruba',
- 'AU': 'Australia',
- 'AT': 'Austria',
- 'AZ': 'Azerbaijan',
- 'BS': 'Bahamas',
- 'BH': 'Bahrain',
- 'BD': 'Bangladesh',
- 'BB': 'Barbados',
- 'BY': 'Belarus',
- 'BE': 'Belgium',
- 'BZ': 'Belize',
- 'BJ': 'Benin',
- 'BM': 'Bermuda',
- 'BT': 'Bhutan',
- 'BO': 'Bolivia, Plurinational State of',
- 'BQ': 'Bonaire, Sint Eustatius and Saba',
- 'BA': 'Bosnia and Herzegovina',
- 'BW': 'Botswana',
- 'BV': 'Bouvet Island',
- 'BR': 'Brazil',
- 'IO': 'British Indian Ocean Territory',
- 'BN': 'Brunei Darussalam',
- 'BG': 'Bulgaria',
- 'BF': 'Burkina Faso',
- 'BI': 'Burundi',
- 'KH': 'Cambodia',
- 'CM': 'Cameroon',
- 'CA': 'Canada',
- 'CV': 'Cape Verde',
- 'KY': 'Cayman Islands',
- 'CF': 'Central African Republic',
- 'TD': 'Chad',
- 'CL': 'Chile',
- 'CN': 'China',
- 'CX': 'Christmas Island',
- 'CC': 'Cocos (Keeling) Islands',
- 'CO': 'Colombia',
- 'KM': 'Comoros',
- 'CG': 'Congo',
- 'CD': 'Congo, the Democratic Republic of the',
- 'CK': 'Cook Islands',
- 'CR': 'Costa Rica',
- 'CI': 'Côte d\'Ivoire',
- 'HR': 'Croatia',
- 'CU': 'Cuba',
- 'CW': 'Curaçao',
- 'CY': 'Cyprus',
- 'CZ': 'Czech Republic',
- 'DK': 'Denmark',
- 'DJ': 'Djibouti',
- 'DM': 'Dominica',
- 'DO': 'Dominican Republic',
- 'EC': 'Ecuador',
- 'EG': 'Egypt',
- 'SV': 'El Salvador',
- 'GQ': 'Equatorial Guinea',
- 'ER': 'Eritrea',
- 'EE': 'Estonia',
- 'ET': 'Ethiopia',
- 'FK': 'Falkland Islands (Malvinas)',
- 'FO': 'Faroe Islands',
- 'FJ': 'Fiji',
- 'FI': 'Finland',
- 'FR': 'France',
- 'GF': 'French Guiana',
- 'PF': 'French Polynesia',
- 'TF': 'French Southern Territories',
- 'GA': 'Gabon',
- 'GM': 'Gambia',
- 'GE': 'Georgia',
- 'DE': 'Germany',
- 'GH': 'Ghana',
- 'GI': 'Gibraltar',
- 'GR': 'Greece',
- 'GL': 'Greenland',
- 'GD': 'Grenada',
- 'GP': 'Guadeloupe',
- 'GU': 'Guam',
- 'GT': 'Guatemala',
- 'GG': 'Guernsey',
- 'GN': 'Guinea',
- 'GW': 'Guinea-Bissau',
- 'GY': 'Guyana',
- 'HT': 'Haiti',
- 'HM': 'Heard Island and McDonald Islands',
- 'VA': 'Holy See (Vatican City State)',
- 'HN': 'Honduras',
- 'HK': 'Hong Kong',
- 'HU': 'Hungary',
- 'IS': 'Iceland',
- 'IN': 'India',
- 'ID': 'Indonesia',
- 'IR': 'Iran, Islamic Republic of',
- 'IQ': 'Iraq',
- 'IE': 'Ireland',
- 'IM': 'Isle of Man',
- 'IL': 'Israel',
- 'IT': 'Italy',
- 'JM': 'Jamaica',
- 'JP': 'Japan',
- 'JE': 'Jersey',
- 'JO': 'Jordan',
- 'KZ': 'Kazakhstan',
- 'KE': 'Kenya',
- 'KI': 'Kiribati',
- 'KP': 'Korea, Democratic People\'s Republic of',
- 'KR': 'Korea, Republic of',
- 'KW': 'Kuwait',
- 'KG': 'Kyrgyzstan',
- 'LA': 'Lao People\'s Democratic Republic',
- 'LV': 'Latvia',
- 'LB': 'Lebanon',
- 'LS': 'Lesotho',
- 'LR': 'Liberia',
- 'LY': 'Libya',
- 'LI': 'Liechtenstein',
- 'LT': 'Lithuania',
- 'LU': 'Luxembourg',
- 'MO': 'Macao',
- 'MK': 'Macedonia, the Former Yugoslav Republic of',
- 'MG': 'Madagascar',
- 'MW': 'Malawi',
- 'MY': 'Malaysia',
- 'MV': 'Maldives',
- 'ML': 'Mali',
- 'MT': 'Malta',
- 'MH': 'Marshall Islands',
- 'MQ': 'Martinique',
- 'MR': 'Mauritania',
- 'MU': 'Mauritius',
- 'YT': 'Mayotte',
- 'MX': 'Mexico',
- 'FM': 'Micronesia, Federated States of',
- 'MD': 'Moldova, Republic of',
- 'MC': 'Monaco',
- 'MN': 'Mongolia',
- 'ME': 'Montenegro',
- 'MS': 'Montserrat',
- 'MA': 'Morocco',
- 'MZ': 'Mozambique',
- 'MM': 'Myanmar',
- 'NA': 'Namibia',
- 'NR': 'Nauru',
- 'NP': 'Nepal',
- 'NL': 'Netherlands',
- 'NC': 'New Caledonia',
- 'NZ': 'New Zealand',
- 'NI': 'Nicaragua',
- 'NE': 'Niger',
- 'NG': 'Nigeria',
- 'NU': 'Niue',
- 'NF': 'Norfolk Island',
- 'MP': 'Northern Mariana Islands',
- 'NO': 'Norway',
- 'OM': 'Oman',
- 'PK': 'Pakistan',
- 'PW': 'Palau',
- 'PS': 'Palestine, State of',
- 'PA': 'Panama',
- 'PG': 'Papua New Guinea',
- 'PY': 'Paraguay',
- 'PE': 'Peru',
- 'PH': 'Philippines',
- 'PN': 'Pitcairn',
- 'PL': 'Poland',
- 'PT': 'Portugal',
- 'PR': 'Puerto Rico',
- 'QA': 'Qatar',
- 'RE': 'Réunion',
- 'RO': 'Romania',
- 'RU': 'Russian Federation',
- 'RW': 'Rwanda',
- 'BL': 'Saint Barthélemy',
- 'SH': 'Saint Helena, Ascension and Tristan da Cunha',
- 'KN': 'Saint Kitts and Nevis',
- 'LC': 'Saint Lucia',
- 'MF': 'Saint Martin (French part)',
- 'PM': 'Saint Pierre and Miquelon',
- 'VC': 'Saint Vincent and the Grenadines',
- 'WS': 'Samoa',
- 'SM': 'San Marino',
- 'ST': 'Sao Tome and Principe',
- 'SA': 'Saudi Arabia',
- 'SN': 'Senegal',
- 'RS': 'Serbia',
- 'SC': 'Seychelles',
- 'SL': 'Sierra Leone',
- 'SG': 'Singapore',
- 'SX': 'Sint Maarten (Dutch part)',
- 'SK': 'Slovakia',
- 'SI': 'Slovenia',
- 'SB': 'Solomon Islands',
- 'SO': 'Somalia',
- 'ZA': 'South Africa',
- 'GS': 'South Georgia and the South Sandwich Islands',
- 'SS': 'South Sudan',
- 'ES': 'Spain',
- 'LK': 'Sri Lanka',
- 'SD': 'Sudan',
- 'SR': 'Suriname',
- 'SJ': 'Svalbard and Jan Mayen',
- 'SZ': 'Swaziland',
- 'SE': 'Sweden',
- 'CH': 'Switzerland',
- 'SY': 'Syrian Arab Republic',
- 'TW': 'Taiwan, Province of China',
- 'TJ': 'Tajikistan',
- 'TZ': 'Tanzania, United Republic of',
- 'TH': 'Thailand',
- 'TL': 'Timor-Leste',
- 'TG': 'Togo',
- 'TK': 'Tokelau',
- 'TO': 'Tonga',
- 'TT': 'Trinidad and Tobago',
- 'TN': 'Tunisia',
- 'TR': 'Turkey',
- 'TM': 'Turkmenistan',
- 'TC': 'Turks and Caicos Islands',
- 'TV': 'Tuvalu',
- 'UG': 'Uganda',
- 'UA': 'Ukraine',
- 'AE': 'United Arab Emirates',
- 'GB': 'United Kingdom',
- 'US': 'United States',
- 'UM': 'United States Minor Outlying Islands',
- 'UY': 'Uruguay',
- 'UZ': 'Uzbekistan',
- 'VU': 'Vanuatu',
- 'VE': 'Venezuela, Bolivarian Republic of',
- 'VN': 'Viet Nam',
- 'VG': 'Virgin Islands, British',
- 'VI': 'Virgin Islands, U.S.',
- 'WF': 'Wallis and Futuna',
- 'EH': 'Western Sahara',
- 'YE': 'Yemen',
- 'ZM': 'Zambia',
- 'ZW': 'Zimbabwe',
- }
-
- @classmethod
- def short2full(cls, code):
- """Convert an ISO 3166-2 country code to the corresponding full name"""
- return cls._country_map.get(code.upper())
-
-
-class GeoUtils(object):
- # Major IPv4 address blocks per country
- _country_ip_map = {
- 'AD': '85.94.160.0/19',
- 'AE': '94.200.0.0/13',
- 'AF': '149.54.0.0/17',
- 'AG': '209.59.64.0/18',
- 'AI': '204.14.248.0/21',
- 'AL': '46.99.0.0/16',
- 'AM': '46.70.0.0/15',
- 'AO': '105.168.0.0/13',
- 'AP': '159.117.192.0/21',
- 'AR': '181.0.0.0/12',
- 'AS': '202.70.112.0/20',
- 'AT': '84.112.0.0/13',
- 'AU': '1.128.0.0/11',
- 'AW': '181.41.0.0/18',
- 'AZ': '5.191.0.0/16',
- 'BA': '31.176.128.0/17',
- 'BB': '65.48.128.0/17',
- 'BD': '114.130.0.0/16',
- 'BE': '57.0.0.0/8',
- 'BF': '129.45.128.0/17',
- 'BG': '95.42.0.0/15',
- 'BH': '37.131.0.0/17',
- 'BI': '154.117.192.0/18',
- 'BJ': '137.255.0.0/16',
- 'BL': '192.131.134.0/24',
- 'BM': '196.12.64.0/18',
- 'BN': '156.31.0.0/16',
- 'BO': '161.56.0.0/16',
- 'BQ': '161.0.80.0/20',
- 'BR': '152.240.0.0/12',
- 'BS': '24.51.64.0/18',
- 'BT': '119.2.96.0/19',
- 'BW': '168.167.0.0/16',
- 'BY': '178.120.0.0/13',
- 'BZ': '179.42.192.0/18',
- 'CA': '99.224.0.0/11',
- 'CD': '41.243.0.0/16',
- 'CF': '196.32.200.0/21',
- 'CG': '197.214.128.0/17',
- 'CH': '85.0.0.0/13',
- 'CI': '154.232.0.0/14',
- 'CK': '202.65.32.0/19',
- 'CL': '152.172.0.0/14',
- 'CM': '165.210.0.0/15',
- 'CN': '36.128.0.0/10',
- 'CO': '181.240.0.0/12',
- 'CR': '201.192.0.0/12',
- 'CU': '152.206.0.0/15',
- 'CV': '165.90.96.0/19',
- 'CW': '190.88.128.0/17',
- 'CY': '46.198.0.0/15',
- 'CZ': '88.100.0.0/14',
- 'DE': '53.0.0.0/8',
- 'DJ': '197.241.0.0/17',
- 'DK': '87.48.0.0/12',
- 'DM': '192.243.48.0/20',
- 'DO': '152.166.0.0/15',
- 'DZ': '41.96.0.0/12',
- 'EC': '186.68.0.0/15',
- 'EE': '90.190.0.0/15',
- 'EG': '156.160.0.0/11',
- 'ER': '196.200.96.0/20',
- 'ES': '88.0.0.0/11',
- 'ET': '196.188.0.0/14',
- 'EU': '2.16.0.0/13',
- 'FI': '91.152.0.0/13',
- 'FJ': '144.120.0.0/16',
- 'FM': '119.252.112.0/20',
- 'FO': '88.85.32.0/19',
- 'FR': '90.0.0.0/9',
- 'GA': '41.158.0.0/15',
- 'GB': '25.0.0.0/8',
- 'GD': '74.122.88.0/21',
- 'GE': '31.146.0.0/16',
- 'GF': '161.22.64.0/18',
- 'GG': '62.68.160.0/19',
- 'GH': '45.208.0.0/14',
- 'GI': '85.115.128.0/19',
- 'GL': '88.83.0.0/19',
- 'GM': '160.182.0.0/15',
- 'GN': '197.149.192.0/18',
- 'GP': '104.250.0.0/19',
- 'GQ': '105.235.224.0/20',
- 'GR': '94.64.0.0/13',
- 'GT': '168.234.0.0/16',
- 'GU': '168.123.0.0/16',
- 'GW': '197.214.80.0/20',
- 'GY': '181.41.64.0/18',
- 'HK': '113.252.0.0/14',
- 'HN': '181.210.0.0/16',
- 'HR': '93.136.0.0/13',
- 'HT': '148.102.128.0/17',
- 'HU': '84.0.0.0/14',
- 'ID': '39.192.0.0/10',
- 'IE': '87.32.0.0/12',
- 'IL': '79.176.0.0/13',
- 'IM': '5.62.80.0/20',
- 'IN': '117.192.0.0/10',
- 'IO': '203.83.48.0/21',
- 'IQ': '37.236.0.0/14',
- 'IR': '2.176.0.0/12',
- 'IS': '82.221.0.0/16',
- 'IT': '79.0.0.0/10',
- 'JE': '87.244.64.0/18',
- 'JM': '72.27.0.0/17',
- 'JO': '176.29.0.0/16',
- 'JP': '126.0.0.0/8',
- 'KE': '105.48.0.0/12',
- 'KG': '158.181.128.0/17',
- 'KH': '36.37.128.0/17',
- 'KI': '103.25.140.0/22',
- 'KM': '197.255.224.0/20',
- 'KN': '198.32.32.0/19',
- 'KP': '175.45.176.0/22',
- 'KR': '175.192.0.0/10',
- 'KW': '37.36.0.0/14',
- 'KY': '64.96.0.0/15',
- 'KZ': '2.72.0.0/13',
- 'LA': '115.84.64.0/18',
- 'LB': '178.135.0.0/16',
- 'LC': '192.147.231.0/24',
- 'LI': '82.117.0.0/19',
- 'LK': '112.134.0.0/15',
- 'LR': '41.86.0.0/19',
- 'LS': '129.232.0.0/17',
- 'LT': '78.56.0.0/13',
- 'LU': '188.42.0.0/16',
- 'LV': '46.109.0.0/16',
- 'LY': '41.252.0.0/14',
- 'MA': '105.128.0.0/11',
- 'MC': '88.209.64.0/18',
- 'MD': '37.246.0.0/16',
- 'ME': '178.175.0.0/17',
- 'MF': '74.112.232.0/21',
- 'MG': '154.126.0.0/17',
- 'MH': '117.103.88.0/21',
- 'MK': '77.28.0.0/15',
- 'ML': '154.118.128.0/18',
- 'MM': '37.111.0.0/17',
- 'MN': '49.0.128.0/17',
- 'MO': '60.246.0.0/16',
- 'MP': '202.88.64.0/20',
- 'MQ': '109.203.224.0/19',
- 'MR': '41.188.64.0/18',
- 'MS': '208.90.112.0/22',
- 'MT': '46.11.0.0/16',
- 'MU': '105.16.0.0/12',
- 'MV': '27.114.128.0/18',
- 'MW': '105.234.0.0/16',
- 'MX': '187.192.0.0/11',
- 'MY': '175.136.0.0/13',
- 'MZ': '197.218.0.0/15',
- 'NA': '41.182.0.0/16',
- 'NC': '101.101.0.0/18',
- 'NE': '197.214.0.0/18',
- 'NF': '203.17.240.0/22',
- 'NG': '105.112.0.0/12',
- 'NI': '186.76.0.0/15',
- 'NL': '145.96.0.0/11',
- 'NO': '84.208.0.0/13',
- 'NP': '36.252.0.0/15',
- 'NR': '203.98.224.0/19',
- 'NU': '49.156.48.0/22',
- 'NZ': '49.224.0.0/14',
- 'OM': '5.36.0.0/15',
- 'PA': '186.72.0.0/15',
- 'PE': '186.160.0.0/14',
- 'PF': '123.50.64.0/18',
- 'PG': '124.240.192.0/19',
- 'PH': '49.144.0.0/13',
- 'PK': '39.32.0.0/11',
- 'PL': '83.0.0.0/11',
- 'PM': '70.36.0.0/20',
- 'PR': '66.50.0.0/16',
- 'PS': '188.161.0.0/16',
- 'PT': '85.240.0.0/13',
- 'PW': '202.124.224.0/20',
- 'PY': '181.120.0.0/14',
- 'QA': '37.210.0.0/15',
- 'RE': '139.26.0.0/16',
- 'RO': '79.112.0.0/13',
- 'RS': '178.220.0.0/14',
- 'RU': '5.136.0.0/13',
- 'RW': '105.178.0.0/15',
- 'SA': '188.48.0.0/13',
- 'SB': '202.1.160.0/19',
- 'SC': '154.192.0.0/11',
- 'SD': '154.96.0.0/13',
- 'SE': '78.64.0.0/12',
- 'SG': '152.56.0.0/14',
- 'SI': '188.196.0.0/14',
- 'SK': '78.98.0.0/15',
- 'SL': '197.215.0.0/17',
- 'SM': '89.186.32.0/19',
- 'SN': '41.82.0.0/15',
- 'SO': '197.220.64.0/19',
- 'SR': '186.179.128.0/17',
- 'SS': '105.235.208.0/21',
- 'ST': '197.159.160.0/19',
- 'SV': '168.243.0.0/16',
- 'SX': '190.102.0.0/20',
- 'SY': '5.0.0.0/16',
- 'SZ': '41.84.224.0/19',
- 'TC': '65.255.48.0/20',
- 'TD': '154.68.128.0/19',
- 'TG': '196.168.0.0/14',
- 'TH': '171.96.0.0/13',
- 'TJ': '85.9.128.0/18',
- 'TK': '27.96.24.0/21',
- 'TL': '180.189.160.0/20',
- 'TM': '95.85.96.0/19',
- 'TN': '197.0.0.0/11',
- 'TO': '175.176.144.0/21',
- 'TR': '78.160.0.0/11',
- 'TT': '186.44.0.0/15',
- 'TV': '202.2.96.0/19',
- 'TW': '120.96.0.0/11',
- 'TZ': '156.156.0.0/14',
- 'UA': '93.72.0.0/13',
- 'UG': '154.224.0.0/13',
- 'US': '3.0.0.0/8',
- 'UY': '167.56.0.0/13',
- 'UZ': '82.215.64.0/18',
- 'VA': '212.77.0.0/19',
- 'VC': '24.92.144.0/20',
- 'VE': '186.88.0.0/13',
- 'VG': '172.103.64.0/18',
- 'VI': '146.226.0.0/16',
- 'VN': '14.160.0.0/11',
- 'VU': '202.80.32.0/20',
- 'WF': '117.20.32.0/21',
- 'WS': '202.4.32.0/19',
- 'YE': '134.35.0.0/16',
- 'YT': '41.242.116.0/22',
- 'ZA': '41.0.0.0/11',
- 'ZM': '165.56.0.0/13',
- 'ZW': '41.85.192.0/19',
- }
-
- @classmethod
- def random_ipv4(cls, code_or_block):
- if len(code_or_block) == 2:
- block = cls._country_ip_map.get(code_or_block.upper())
- if not block:
- return None
- else:
- block = code_or_block
- addr, preflen = block.split('/')
- addr_min = compat_struct_unpack('!L', socket.inet_aton(addr))[0]
- addr_max = addr_min | (0xffffffff >> int(preflen))
- return compat_str(socket.inet_ntoa(
- compat_struct_pack('!L', random.randint(addr_min, addr_max))))
-
-
-class PerRequestProxyHandler(compat_urllib_request.ProxyHandler):
- def __init__(self, proxies=None):
- # Set default handlers
- for type in ('http', 'https'):
- setattr(self, '%s_open' % type,
- lambda r, proxy='__noproxy__', type=type, meth=self.proxy_open:
- meth(r, proxy, type))
- compat_urllib_request.ProxyHandler.__init__(self, proxies)
-
- def proxy_open(self, req, proxy, type):
- req_proxy = req.headers.get('Ytdl-request-proxy')
- if req_proxy is not None:
- proxy = req_proxy
- del req.headers['Ytdl-request-proxy']
-
- if proxy == '__noproxy__':
- return None # No Proxy
- if compat_urlparse.urlparse(proxy).scheme.lower() in ('socks', 'socks4', 'socks4a', 'socks5'):
- req.add_header('Ytdl-socks-proxy', proxy)
- # youtube-dl's http/https handlers do wrapping the socket with socks
- return None
- return compat_urllib_request.ProxyHandler.proxy_open(
- self, req, proxy, type)
-
-
-# Both long_to_bytes and bytes_to_long are adapted from PyCrypto, which is
-# released into Public Domain
-# https://github.com/dlitz/pycrypto/blob/master/lib/Crypto/Util/number.py#L387
-
-def long_to_bytes(n, blocksize=0):
- """long_to_bytes(n:long, blocksize:int) : string
- Convert a long integer to a byte string.
-
- If optional blocksize is given and greater than zero, pad the front of the
- byte string with binary zeros so that the length is a multiple of
- blocksize.
- """
- # after much testing, this algorithm was deemed to be the fastest
- s = b''
- n = int(n)
- while n > 0:
- s = compat_struct_pack('>I', n & 0xffffffff) + s
- n = n >> 32
- # strip off leading zeros
- for i in range(len(s)):
- if s[i] != b'\000'[0]:
- break
- else:
- # only happens when n == 0
- s = b'\000'
- i = 0
- s = s[i:]
- # add back some pad bytes. this could be done more efficiently w.r.t. the
- # de-padding being done above, but sigh...
- if blocksize > 0 and len(s) % blocksize:
- s = (blocksize - len(s) % blocksize) * b'\000' + s
- return s
-
-
-def bytes_to_long(s):
- """bytes_to_long(string) : long
- Convert a byte string to a long integer.
-
- This is (essentially) the inverse of long_to_bytes().
- """
- acc = 0
- length = len(s)
- if length % 4:
- extra = (4 - length % 4)
- s = b'\000' * extra + s
- length = length + extra
- for i in range(0, length, 4):
- acc = (acc << 32) + compat_struct_unpack('>I', s[i:i + 4])[0]
- return acc
-
-
-def ohdave_rsa_encrypt(data, exponent, modulus):
- '''
- Implement OHDave's RSA algorithm. See http://www.ohdave.com/rsa/
-
- Input:
- data: data to encrypt, bytes-like object
- exponent, modulus: parameter e and N of RSA algorithm, both integer
- Output: hex string of encrypted data
-
- Limitation: supports one block encryption only
- '''
-
- payload = int(binascii.hexlify(data[::-1]), 16)
- encrypted = pow(payload, exponent, modulus)
- return '%x' % encrypted
-
-
-def pkcs1pad(data, length):
- """
- Padding input data with PKCS#1 scheme
-
- @param {int[]} data input data
- @param {int} length target length
- @returns {int[]} padded data
- """
- if len(data) > length - 11:
- raise ValueError('Input data too long for PKCS#1 padding')
-
- pseudo_random = [random.randint(0, 254) for _ in range(length - len(data) - 3)]
- return [0, 2] + pseudo_random + [0] + data
-
-
-def encode_base_n(num, n, table=None):
- FULL_TABLE = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
- if not table:
- table = FULL_TABLE[:n]
-
- if n > len(table):
- raise ValueError('base %d exceeds table length %d' % (n, len(table)))
-
- if num == 0:
- return table[0]
-
- ret = ''
- while num:
- ret = table[num % n] + ret
- num = num // n
- return ret
-
-
-def decode_packed_codes(code):
- mobj = re.search(PACKED_CODES_RE, code)
- obfucasted_code, base, count, symbols = mobj.groups()
- base = int(base)
- count = int(count)
- symbols = symbols.split('|')
- symbol_table = {}
-
- while count:
- count -= 1
- base_n_count = encode_base_n(count, base)
- symbol_table[base_n_count] = symbols[count] or base_n_count
-
- return re.sub(
- r'\b(\w+)\b', lambda mobj: symbol_table[mobj.group(0)],
- obfucasted_code)
-
-
-def parse_m3u8_attributes(attrib):
- info = {}
- for (key, val) in re.findall(r'(?P<key>[A-Z0-9-]+)=(?P<val>"[^"]+"|[^",]+)(?:,|$)', attrib):
- if val.startswith('"'):
- val = val[1:-1]
- info[key] = val
- return info
-
-
-def urshift(val, n):
- return val >> n if val >= 0 else (val + 0x100000000) >> n
-
-
-# Based on png2str() written by @gdkchan and improved by @yokrysty
-# Originally posted at https://github.com/rg3/youtube-dl/issues/9706
-def decode_png(png_data):
- # Reference: https://www.w3.org/TR/PNG/
- header = png_data[8:]
-
- if png_data[:8] != b'\x89PNG\x0d\x0a\x1a\x0a' or header[4:8] != b'IHDR':
- raise IOError('Not a valid PNG file.')
-
- int_map = {1: '>B', 2: '>H', 4: '>I'}
- unpack_integer = lambda x: compat_struct_unpack(int_map[len(x)], x)[0]
-
- chunks = []
-
- while header:
- length = unpack_integer(header[:4])
- header = header[4:]
-
- chunk_type = header[:4]
- header = header[4:]
-
- chunk_data = header[:length]
- header = header[length:]
-
- header = header[4:] # Skip CRC
-
- chunks.append({
- 'type': chunk_type,
- 'length': length,
- 'data': chunk_data
- })
-
- ihdr = chunks[0]['data']
-
- width = unpack_integer(ihdr[:4])
- height = unpack_integer(ihdr[4:8])
-
- idat = b''
-
- for chunk in chunks:
- if chunk['type'] == b'IDAT':
- idat += chunk['data']
-
- if not idat:
- raise IOError('Unable to read PNG data.')
-
- decompressed_data = bytearray(zlib.decompress(idat))
-
- stride = width * 3
- pixels = []
-
- def _get_pixel(idx):
- x = idx % stride
- y = idx // stride
- return pixels[y][x]
-
- for y in range(height):
- basePos = y * (1 + stride)
- filter_type = decompressed_data[basePos]
-
- current_row = []
-
- pixels.append(current_row)
-
- for x in range(stride):
- color = decompressed_data[1 + basePos + x]
- basex = y * stride + x
- left = 0
- up = 0
-
- if x > 2:
- left = _get_pixel(basex - 3)
- if y > 0:
- up = _get_pixel(basex - stride)
-
- if filter_type == 1: # Sub
- color = (color + left) & 0xff
- elif filter_type == 2: # Up
- color = (color + up) & 0xff
- elif filter_type == 3: # Average
- color = (color + ((left + up) >> 1)) & 0xff
- elif filter_type == 4: # Paeth
- a = left
- b = up
- c = 0
-
- if x > 2 and y > 0:
- c = _get_pixel(basex - stride - 3)
-
- p = a + b - c
-
- pa = abs(p - a)
- pb = abs(p - b)
- pc = abs(p - c)
-
- if pa <= pb and pa <= pc:
- color = (color + a) & 0xff
- elif pb <= pc:
- color = (color + b) & 0xff
- else:
- color = (color + c) & 0xff
-
- current_row.append(color)
-
- return width, height, pixels
-
-
-def write_xattr(path, key, value):
- # This mess below finds the best xattr tool for the job
- try:
- # try the pyxattr module...
- import xattr
-
- if hasattr(xattr, 'set'): # pyxattr
- # Unicode arguments are not supported in python-pyxattr until
- # version 0.5.0
- # See https://github.com/rg3/youtube-dl/issues/5498
- pyxattr_required_version = '0.5.0'
- if version_tuple(xattr.__version__) < version_tuple(pyxattr_required_version):
- # TODO: fallback to CLI tools
- raise XAttrUnavailableError(
- 'python-pyxattr is detected but is too old. '
- 'youtube-dl requires %s or above while your version is %s. '
- 'Falling back to other xattr implementations' % (
- pyxattr_required_version, xattr.__version__))
-
- setxattr = xattr.set
- else: # xattr
- setxattr = xattr.setxattr
-
- try:
- setxattr(path, key, value)
- except EnvironmentError as e:
- raise XAttrMetadataError(e.errno, e.strerror)
-
- except ImportError:
- if compat_os_name == 'nt':
- # Write xattrs to NTFS Alternate Data Streams:
- # http://en.wikipedia.org/wiki/NTFS#Alternate_data_streams_.28ADS.29
- assert ':' not in key
- assert os.path.exists(path)
-
- ads_fn = path + ':' + key
- try:
- with open(ads_fn, 'wb') as f:
- f.write(value)
- except EnvironmentError as e:
- raise XAttrMetadataError(e.errno, e.strerror)
- else:
- user_has_setfattr = check_executable('setfattr', ['--version'])
- user_has_xattr = check_executable('xattr', ['-h'])
-
- if user_has_setfattr or user_has_xattr:
-
- value = value.decode('utf-8')
- if user_has_setfattr:
- executable = 'setfattr'
- opts = ['-n', key, '-v', value]
- elif user_has_xattr:
- executable = 'xattr'
- opts = ['-w', key, value]
-
- cmd = ([encodeFilename(executable, True)] +
- [encodeArgument(o) for o in opts] +
- [encodeFilename(path, True)])
-
- try:
- p = subprocess.Popen(
- cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
- except EnvironmentError as e:
- raise XAttrMetadataError(e.errno, e.strerror)
- stdout, stderr = p.communicate()
- stderr = stderr.decode('utf-8', 'replace')
- if p.returncode != 0:
- raise XAttrMetadataError(p.returncode, stderr)
-
- else:
- # On Unix, and can't find pyxattr, setfattr, or xattr.
- if sys.platform.startswith('linux'):
- raise XAttrUnavailableError(
- "Couldn't find a tool to set the xattrs. "
- "Install either the python 'pyxattr' or 'xattr' "
- "modules, or the GNU 'attr' package "
- "(which contains the 'setfattr' tool).")
- else:
- raise XAttrUnavailableError(
- "Couldn't find a tool to set the xattrs. "
- "Install either the python 'xattr' module, "
- "or the 'xattr' binary.")
-
-
-def random_birthday(year_field, month_field, day_field):
- start_date = datetime.date(1950, 1, 1)
- end_date = datetime.date(1995, 12, 31)
- offset = random.randint(0, (end_date - start_date).days)
- random_date = start_date + datetime.timedelta(offset)
- return {
- year_field: str(random_date.year),
- month_field: str(random_date.month),
- day_field: str(random_date.day),
- }