Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba3959e40 | ||
|
|
7d767ff9ce | ||
|
|
65e7d85549 | ||
|
|
599a09d7fc | ||
|
|
6c29802eb7 | ||
|
|
6225dd085e | ||
|
|
0cbdc78c3c | ||
|
|
a1dd283832 | ||
|
|
ed6c3ae036 | ||
|
|
1fbc0cdd46 | ||
|
|
263469cd30 | ||
|
|
79fd2966cd | ||
|
|
dcd4b0f0ae | ||
|
|
e8cbc5074a | ||
|
|
4768835766 | ||
|
|
3f4db4199c | ||
|
|
5260716d14 | ||
|
|
32d30bde9c | ||
|
|
cd876f65e3 | ||
|
|
a2723d76cd | ||
|
|
fef9c778ed | ||
|
|
6188ba81a0 | ||
|
|
a465805cb9 | ||
|
|
12c0daa58a | ||
|
|
0f58f1d114 | ||
|
|
f46035c6b6 | ||
|
|
3b57335e4c | ||
|
|
a5ef801c07 | ||
|
|
63c92e0c4e | ||
|
|
693b4ac98b | ||
|
|
90b080b7bb | ||
|
|
90338c25c6 | ||
|
|
f572bb62aa | ||
|
|
f2fc1cf564 | ||
|
|
7b7e69a8b1 | ||
|
|
217541bd9c | ||
|
|
b21b2a6009 | ||
|
|
a1d3cc5045 | ||
|
|
92067638b1 | ||
|
|
99b70497f2 | ||
|
|
4405742b72 | ||
|
|
f3d3c4c0a4 | ||
|
|
5006149b59 | ||
|
|
bcbd83fa30 | ||
|
|
0820909b7e | ||
|
|
519b7e64e7 | ||
|
|
5d753351c5 | ||
|
|
df7e41b61a | ||
|
|
dd498e63d9 | ||
|
|
8e5b6dc831 | ||
|
|
66b2b20007 | ||
|
|
2e5a1133e3 | ||
|
|
ec5e995262 | ||
|
|
2fe0b5e539 | ||
|
|
896655ddbd | ||
|
|
f3469b1ff4 | ||
|
|
c5dce849f1 | ||
|
|
a0c3ca0159 | ||
|
|
d116351aed | ||
|
|
8b745907cc | ||
|
|
ecb8d406f8 | ||
|
|
d2d6e4e56d | ||
|
|
82e82b1cb7 | ||
|
|
f129cfcc9a | ||
|
|
66f396ce32 | ||
|
|
88803ced44 | ||
|
|
3066f9a37e | ||
|
|
9c7e93ecf8 | ||
|
|
854ab81b91 | ||
|
|
2360958862 | ||
|
|
02480553b6 | ||
|
|
cd3383e6e3 | ||
|
|
fc0fa9aaba | ||
|
|
06e091e020 | ||
|
|
7143e1b321 | ||
|
|
98d9b35765 | ||
|
|
676536a294 | ||
|
|
1632ab5cda | ||
|
|
12561c0ed3 | ||
|
|
5bf4c284a5 | ||
|
|
2ab5b96178 | ||
|
|
7c79f530a5 | ||
|
|
30e59081b1 | ||
|
|
85cf943850 | ||
|
|
4a45a699ae | ||
|
|
7264bbeaed | ||
|
|
a7527986c8 | ||
|
|
00c812ff4a | ||
|
|
2ae81f2a78 | ||
|
|
99cb1c48ea | ||
|
|
c1dbc6c411 | ||
|
|
92bdbf072e | ||
|
|
77fffee34f | ||
|
|
aacbf07ad7 | ||
|
|
9d3ebca622 | ||
|
|
ef867e3759 | ||
|
|
309ff40943 | ||
|
|
56b17c634c | ||
|
|
dd01c8ca4c | ||
|
|
81e61f9893 | ||
|
|
ae68c84a26 | ||
|
|
1c591b4457 | ||
|
|
6e39ae19b6 | ||
|
|
9f44e0474b | ||
|
|
ee581c56a3 | ||
|
|
5ff216d1ba | ||
|
|
70eb5cc94f | ||
|
|
fa3b78583f | ||
|
|
d942883c78 | ||
|
|
a7da23c6da | ||
|
|
c9a75042d2 | ||
|
|
e4af99fd17 | ||
|
|
d56df02e7b | ||
|
|
0c106bb111 | ||
|
|
59f32b31d9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,7 +4,7 @@ debug/
|
||||
data/
|
||||
python/
|
||||
release/
|
||||
youtube-local/
|
||||
yt-local/
|
||||
banned_addresses.txt
|
||||
settings.txt
|
||||
get-pip.py
|
||||
@@ -12,3 +12,4 @@ latest-dist.zip
|
||||
*.7z
|
||||
*.zip
|
||||
*venv*
|
||||
flycheck_*
|
||||
|
||||
43
README.md
43
README.md
@@ -1,4 +1,4 @@
|
||||
[](https://builds.sr.ht/~heckyel/yt-local/commits/.build.yml?)
|
||||
[](https://drone.hgit.ga/heckyel/yt-local)
|
||||
|
||||
# yt-local
|
||||
|
||||
@@ -27,7 +27,7 @@ The YouTube API is not used, so no keys or anything are needed. It uses the same
|
||||
* Easily download videos or their audio
|
||||
* No ads
|
||||
* View comments
|
||||
* Javascript not required
|
||||
* JavaScript not required
|
||||
* Theater and non-theater mode
|
||||
* Subscriptions that are independent from YouTube
|
||||
* Can import subscriptions from YouTube
|
||||
@@ -46,6 +46,7 @@ The YouTube API is not used, so no keys or anything are needed. It uses the same
|
||||
* 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
|
||||
@@ -54,7 +55,7 @@ The YouTube API is not used, so no keys or anything are needed. It uses the same
|
||||
- [ ] Auto-saving of local playlist videos
|
||||
- [ ] Import youtube playlist into a local playlist
|
||||
- [ ] Rearrange items of local playlist
|
||||
- [ ] Video qualities other than 360p and 720p by muxing video and audio
|
||||
- [x] Video qualities other than 360p and 720p by muxing video and audio
|
||||
- [ ] Corrected .m4a downloads
|
||||
- [x] Indicate if comments are disabled
|
||||
- [x] Indicate how many comments a video has
|
||||
@@ -89,15 +90,15 @@ Download the tarball under the Releases page and extract it. `cd` into the direc
|
||||
|
||||
## 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 GNU+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 GNU+Linux/MacOS, run `python3 server.py`.
|
||||
|
||||
|
||||
Access youtube URLs by prefixing them with `http://localhost:8080/`, For instance, `http://localhost:8080/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 the redirect pattern `http://localhost:8080/$1` (Make sure you're using regular expression mode).
|
||||
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 the redirect pattern `http://localhost:9010/$1` (Make sure you're using regular expression mode).
|
||||
|
||||
If you want embeds on the web to also redirect to yt-local, make sure "Iframes" is checked under advanced options in your redirector rule. Check test `http://localhost:8080/youtube.com/embed/vBgulDeV2RU`
|
||||
If you want embeds on the 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`
|
||||
|
||||
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.
|
||||
|
||||
@@ -105,13 +106,21 @@ yt-local can be added as a search engine in firefox to make searching more conve
|
||||
|
||||
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). Your connections should now be routed through Tor.
|
||||
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 the 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.
|
||||
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.
|
||||
|
||||
### Importing subscriptions
|
||||
|
||||
@@ -120,8 +129,14 @@ In general, Tor video routing will be slower (for instance, moving around in the
|
||||
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 .json file. It is usually located under `YouTube and YouTube Music/subscriptions/subscriptions.json`
|
||||
7. Go to the subscriptions manager in yt-local. In the import area, select your .json file, then press import.
|
||||
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
|
||||
|
||||
@@ -129,6 +144,12 @@ Pull requests and issues are welcome
|
||||
|
||||
For coding guidelines and an overview of the software architecture, see the [HACKING.md](docs/HACKING.md) file.
|
||||
|
||||
## 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://fast-gorge-89206.herokuapp.com>
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3 (GNU AGPLv3) or any later version.
|
||||
|
||||
@@ -73,4 +73,4 @@ after, modified execute permissions:
|
||||
- disable: `doas rc-update del ytlocal`
|
||||
|
||||
When yt-local is run with administrator privileges,
|
||||
the configuration file is stored in /root/.youtube-local
|
||||
the configuration file is stored in /root/.yt-local
|
||||
|
||||
@@ -77,22 +77,22 @@ 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 -----------
|
||||
@@ -176,7 +176,7 @@ with open(r'./python/path_fixes.pth', 'w', encoding='utf-8') as f:
|
||||
|
||||
'''# 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')
|
||||
@@ -216,15 +216,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 + '-windows.zip'
|
||||
if os.path.exists('./' + output_filename):
|
||||
log('Removing previous zipped release')
|
||||
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')
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
attrs>=20.3.0
|
||||
Brotli>=1.0.9
|
||||
cachetools>=4.2.2
|
||||
click>=8.0.1
|
||||
dataclasses>=0.6
|
||||
defusedxml>=0.7.1
|
||||
Flask>=2.0.1
|
||||
gevent>=21.8.0
|
||||
greenlet>=1.1.1
|
||||
importlib-metadata>=4.6.4
|
||||
iniconfig>=1.1.1
|
||||
itsdangerous>=2.0.1
|
||||
Jinja2>=3.0.1
|
||||
MarkupSafe>=2.0.1
|
||||
packaging>=20.9
|
||||
attrs==22.1.0
|
||||
Brotli==1.0.9
|
||||
cachetools==4.2.4
|
||||
click==8.0.4
|
||||
dataclasses==0.6
|
||||
defusedxml==0.7.1
|
||||
Flask==2.0.1
|
||||
gevent==21.12.0
|
||||
greenlet==1.1.2
|
||||
importlib-metadata==4.6.4
|
||||
iniconfig==1.1.1
|
||||
itsdangerous==2.0.1
|
||||
Jinja2==3.0.3
|
||||
MarkupSafe==2.0.1
|
||||
packaging==20.9
|
||||
pluggy>=0.13.1
|
||||
py>=1.10.0
|
||||
pyparsing>=2.4.7
|
||||
PySocks>=1.7.1
|
||||
pytest>=6.2.2
|
||||
stem>=1.8.0
|
||||
toml>=0.10.2
|
||||
typing-extensions>=3.10.0.0
|
||||
urllib3>=1.26.6
|
||||
Werkzeug>=2.0.1
|
||||
zipp>=3.5.0
|
||||
zope.event>=4.5.0
|
||||
zope.interface>=5.4.0
|
||||
py==1.10.0
|
||||
pyparsing==2.4.7
|
||||
PySocks==1.7.1
|
||||
pytest==6.2.5
|
||||
stem==1.8.0
|
||||
toml==0.10.2
|
||||
typing-extensions==3.10.0.2
|
||||
urllib3==1.26.11
|
||||
Werkzeug==2.0.3
|
||||
zipp==3.5.1
|
||||
zope.event==4.5.0
|
||||
zope.interface==5.4.0
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
Brotli>=1.0.9
|
||||
cachetools>=4.2.2
|
||||
click>=8.0.1
|
||||
dataclasses>=0.6
|
||||
defusedxml>=0.7.1
|
||||
Flask>=2.0.1
|
||||
gevent>=21.8.0
|
||||
greenlet>=1.1.1
|
||||
importlib-metadata>=4.6.4
|
||||
itsdangerous>=2.0.1
|
||||
Jinja2>=3.0.1
|
||||
MarkupSafe>=2.0.1
|
||||
PySocks>=1.7.1
|
||||
stem>=1.8.0
|
||||
typing-extensions>=3.10.0.0
|
||||
urllib3>=1.26.6
|
||||
Werkzeug>=2.0.1
|
||||
zipp>=3.5.0
|
||||
zope.event>=4.5.0
|
||||
zope.interface>=5.4.0
|
||||
Brotli==1.0.9
|
||||
cachetools==4.2.4
|
||||
click==8.0.4
|
||||
dataclasses==0.6
|
||||
defusedxml==0.7.1
|
||||
Flask==2.0.1
|
||||
gevent==21.12.0
|
||||
greenlet==1.1.2
|
||||
importlib-metadata==4.6.4
|
||||
itsdangerous==2.0.1
|
||||
Jinja2==3.0.3
|
||||
MarkupSafe==2.0.1
|
||||
PySocks==1.7.1
|
||||
stem==1.8.0
|
||||
typing-extensions==3.10.0.2
|
||||
urllib3==1.26.11
|
||||
Werkzeug==2.0.3
|
||||
zipp==3.5.1
|
||||
zope.event==4.5.0
|
||||
zope.interface==5.4.0
|
||||
|
||||
13
server.py
13
server.py
@@ -87,6 +87,9 @@ def proxy_site(env, start_response, video=False):
|
||||
response_headers = response.getheaders()
|
||||
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,
|
||||
@@ -247,12 +250,14 @@ def site_dispatch(env, start_response):
|
||||
|
||||
class FilteredRequestLog:
|
||||
'''Don't log noisy thumbnail and avatar requests'''
|
||||
filter_re = re.compile(r"""(?x)^
|
||||
"GET /https://(i[.]ytimg[.]com/|
|
||||
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).*" 200
|
||||
""")
|
||||
www[.]youtube[.]com/api/timedtext|
|
||||
[-\w]+[.]googlevideo[.]com/).*"\ (200|206)
|
||||
''')
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
77
settings.py
77
settings.py
@@ -39,7 +39,7 @@ SETTINGS_INFO = collections.OrderedDict([
|
||||
|
||||
('tor_port', {
|
||||
'type': int,
|
||||
'default': 9150,
|
||||
'default': 9050,
|
||||
'comment': '',
|
||||
'category': 'network',
|
||||
}),
|
||||
@@ -53,7 +53,7 @@ SETTINGS_INFO = collections.OrderedDict([
|
||||
|
||||
('port_number', {
|
||||
'type': int,
|
||||
'default': 8080,
|
||||
'default': 9010,
|
||||
'comment': '',
|
||||
'category': 'network',
|
||||
}),
|
||||
@@ -156,13 +156,58 @@ 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': 'Prefer integrated sources',
|
||||
'type': bool,
|
||||
'default': False,
|
||||
'comment': '',
|
||||
'category': 'playback',
|
||||
'description': 'If enabled 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 disabled, always uses the separate audio and video files through custom buffer management in av-merge via MediaSource.',
|
||||
}),
|
||||
|
||||
('use_video_player', {
|
||||
'type': int,
|
||||
'default': 1,
|
||||
@@ -255,14 +300,16 @@ For security reasons, enabling this is not recommended.''',
|
||||
|
||||
('settings_version', {
|
||||
'type': int,
|
||||
'default': 3,
|
||||
'default': 4,
|
||||
'comment': '''Do not change, remove, or comment out this value, or else your settings may be lost or corrupted''',
|
||||
'hidden': True,
|
||||
}),
|
||||
])
|
||||
|
||||
program_directory = os.path.dirname(os.path.realpath(__file__))
|
||||
acceptable_targets = SETTINGS_INFO.keys() | {'enable_comments', 'enable_related_videos'}
|
||||
acceptable_targets = SETTINGS_INFO.keys() | {
|
||||
'enable_comments', 'enable_related_videos', 'preferred_video_codec'
|
||||
}
|
||||
|
||||
|
||||
def comment_string(comment):
|
||||
@@ -309,9 +356,27 @@ def upgrade_to_3(settings_dict):
|
||||
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
|
||||
|
||||
|
||||
upgrade_functions = {
|
||||
1: upgrade_to_2,
|
||||
2: upgrade_to_3,
|
||||
3: upgrade_to_4,
|
||||
}
|
||||
|
||||
|
||||
@@ -325,8 +390,8 @@ if os.path.isfile("settings.txt"):
|
||||
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)
|
||||
|
||||
|
||||
@@ -153,6 +153,12 @@ 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']
|
||||
|
||||
@@ -33,6 +33,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -200,8 +202,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -504,15 +508,19 @@ hr {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
width: calc(100% + 100px);
|
||||
max-height: 80vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.author-container {
|
||||
@@ -528,7 +536,7 @@ hr {
|
||||
grid-area: playlist;
|
||||
}
|
||||
.play-clean {
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
}
|
||||
.play-clean > button {
|
||||
padding-left: 0px;
|
||||
|
||||
@@ -39,6 +39,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -105,9 +107,7 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -272,7 +272,7 @@ label[for=options-toggle-cbox] {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: minmax(50px, 120px);
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
@@ -280,6 +280,12 @@ label[for=options-toggle-cbox] {
|
||||
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;
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
--background: #212121;
|
||||
--text: #FFFFFF;
|
||||
--secondary-hover: #73828c;
|
||||
--secondary-focus: #606060;
|
||||
--secondary-focus: #303030;
|
||||
--secondary-inverse: #FFF;
|
||||
--primary-background: #757575;
|
||||
--primary-background: #242424;
|
||||
--secondary-background: #424242;
|
||||
--thumb-background: #757575;
|
||||
--link: #00B0FF;
|
||||
--link-visited: #40C4FF;
|
||||
--border-bg: #FFFFFF;
|
||||
--buttom: #dcdcdb;
|
||||
--buttom-text: #415462;
|
||||
--button-border: #91918c;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
--thumb-background: #35404D;
|
||||
--link: #22aaff;
|
||||
--link-visited: #7755ff;
|
||||
--border-bg: #FFFFFF;
|
||||
--buttom: #DCDCDC;
|
||||
--buttom-text: #415462;
|
||||
--button-border: #91918c;
|
||||
|
||||
@@ -29,6 +29,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -95,7 +97,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -135,8 +136,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -186,15 +189,20 @@ label[for=options-toggle-cbox] {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
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 {
|
||||
|
||||
953
youtube/static/js/av-merge.js
Normal file
953
youtube/static/js/av-merge.js
Normal file
@@ -0,0 +1,953 @@
|
||||
// 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?
|
||||
|
||||
|
||||
|
||||
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) {
|
||||
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*50*10**6;
|
||||
} else {
|
||||
this.bufferTarget = 50*10**6; // 50 megabytes
|
||||
}
|
||||
|
||||
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,
|
||||
(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,
|
||||
this.setupInitSegment.bind(this),
|
||||
);
|
||||
// sidx (segment index) table
|
||||
fetchRange(
|
||||
this.url,
|
||||
this.indexRange.start,
|
||||
this.indexRange.end,
|
||||
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.reportInfo('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,
|
||||
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);
|
||||
}
|
||||
Stream.prototype.reportInfo = function(...args) {
|
||||
reportInfo(String(this.streamType) + ':', ...args);
|
||||
}
|
||||
|
||||
|
||||
// Utility functions
|
||||
|
||||
function fetchRange(url, start, end, cb) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open('get', url);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.setRequestHeader('Range', 'bytes=' + start + '-' + end);
|
||||
xhr.onload = function() {
|
||||
//bytesFetched += end - start + 1;
|
||||
resolve(cb(xhr.response));
|
||||
};
|
||||
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 reportInfo(...args){
|
||||
console.info(...args);
|
||||
}
|
||||
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
|
||||
@@ -1,9 +1,9 @@
|
||||
function onClickReplies(e) {
|
||||
var details = e.target.parentElement;
|
||||
let details = e.target.parentElement;
|
||||
// e.preventDefault();
|
||||
console.log("loading replies ..");
|
||||
doXhr(details.getAttribute("data-src") + "&slim=1", (html) => {
|
||||
var div = details.querySelector(".comment_page");
|
||||
let div = details.querySelector(".comment_page");
|
||||
div.innerHTML = html;
|
||||
});
|
||||
details.removeEventListener('click', onClickReplies);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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) {
|
||||
var seconds = Math.floor(seconds);
|
||||
seconds = Math.floor(seconds);
|
||||
|
||||
var minutes = Math.floor(seconds/60);
|
||||
var seconds = seconds % 60;
|
||||
minutes = Math.floor(seconds/60);
|
||||
seconds = seconds % 60;
|
||||
|
||||
var hours = Math.floor(minutes/60);
|
||||
var minutes = minutes % 60;
|
||||
hours = Math.floor(minutes/60);
|
||||
minutes = minutes % 60;
|
||||
|
||||
if (hours) {
|
||||
return `0${hours}:`.slice(-3) + `0${minutes}:`.slice(-3) + `0${seconds}`.slice(-2);
|
||||
@@ -18,8 +21,7 @@ function toTimestamp(seconds) {
|
||||
return `0${minutes}:`.slice(-3) + `0${seconds}`.slice(-2);
|
||||
}
|
||||
|
||||
|
||||
var cur_track_idx = 0;
|
||||
let cur_track_idx = 0;
|
||||
function getActiveTranscriptTrackIdx() {
|
||||
let textTracks = QId("js-video-player").textTracks;
|
||||
if (!textTracks.length) return;
|
||||
@@ -39,7 +41,7 @@ function getDefaultTranscriptTrackIdx() {
|
||||
}
|
||||
|
||||
function doXhr(url, callback=null) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
let xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url);
|
||||
xhr.onload = (e) => {
|
||||
callback(e.currentTarget.response);
|
||||
@@ -50,7 +52,7 @@ function doXhr(url, callback=null) {
|
||||
|
||||
// https://stackoverflow.com/a/30810322
|
||||
function copyTextToClipboard(text) {
|
||||
var textArea = document.createElement("textarea");
|
||||
let textArea = document.createElement("textarea");
|
||||
|
||||
//
|
||||
// *** This styling is an extra step which is likely not required. ***
|
||||
@@ -92,19 +94,20 @@ function copyTextToClipboard(text) {
|
||||
|
||||
textArea.value = text;
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
let parent_el = video.parentElement;
|
||||
parent_el.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
var msg = successful ? 'successful' : 'unsuccessful';
|
||||
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');
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
parent_el.removeChild(textArea);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ function onKeyDown(e) {
|
||||
|
||||
// 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") {
|
||||
@@ -26,8 +27,17 @@ function onKeyDown(e) {
|
||||
}
|
||||
else if (c == "f") {
|
||||
e.preventDefault();
|
||||
if (document.fullscreenElement && document.fullscreenElement.nodeName == 'VIDEO') {document.exitFullscreen();}
|
||||
else {v.requestFullscreen()};
|
||||
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;}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
// https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Sending_forms_through_JavaScript
|
||||
function sendData(event){
|
||||
var clicked_button = document.activeElement;
|
||||
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;
|
||||
@@ -46,8 +46,8 @@
|
||||
return; // video(s) are being removed from playlist, just let it refresh the page
|
||||
}
|
||||
event.preventDefault();
|
||||
var XHR = new XMLHttpRequest();
|
||||
var FD = new FormData(playlistAddForm);
|
||||
let XHR = new XMLHttpRequest();
|
||||
let FD = new FormData(playlistAddForm);
|
||||
|
||||
if(FD.getAll('video_info_list').length === 0){
|
||||
displayMessage('Error: No videos selected', true);
|
||||
|
||||
@@ -1,36 +1,130 @@
|
||||
let captionsActive;
|
||||
(function main() {
|
||||
'use strict';
|
||||
|
||||
switch(true) {
|
||||
case data.settings.subtitles_mode == 2:
|
||||
captionsActive = true;
|
||||
break;
|
||||
case data.settings.subtitles_mode == 1 && data.has_manual_captions:
|
||||
captionsActive = true;
|
||||
break;
|
||||
default:
|
||||
captionsActive = false;
|
||||
}
|
||||
let captionsActive;
|
||||
|
||||
const player = new Plyr(document.getElementById('js-video-player'), {
|
||||
disableContextMenu: false,
|
||||
captions: {
|
||||
active: captionsActive,
|
||||
language: data.settings.subtitles_language,
|
||||
},
|
||||
controls: [
|
||||
'play-large',
|
||||
'play',
|
||||
'progress',
|
||||
'current-time',
|
||||
'duration',
|
||||
'mute',
|
||||
'volume',
|
||||
'captions',
|
||||
'settings',
|
||||
'fullscreen'
|
||||
],
|
||||
iconUrl: "/youtube.com/static/modules/plyr/plyr.svg",
|
||||
blankVideo: "/youtube.com/static/modules/plyr/blank.webm",
|
||||
debug: false,
|
||||
storage: {enabled: false}
|
||||
});
|
||||
switch(true) {
|
||||
case data.settings.subtitles_mode == 2:
|
||||
captionsActive = true;
|
||||
break;
|
||||
case data.settings.subtitles_mode == 1 && data.has_manual_captions:
|
||||
captionsActive = true;
|
||||
break;
|
||||
default:
|
||||
captionsActive = 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;
|
||||
|
||||
if (!options.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// removing this line:
|
||||
//let quality = [!is.empty(input) && Number(input), this.storage.get('quality'), config.selected, config.default].find(is.number);
|
||||
// replacing with:
|
||||
quality = input;
|
||||
let updateStorage = true;
|
||||
|
||||
if (!options.includes(quality)) {
|
||||
// Plyr sets quality to null at startup, resulting in the erroneous
|
||||
// calling of this setter function with input = null, and the
|
||||
// commented out code below would set the quality to something
|
||||
// unrelated at startup. Comment out and just return.
|
||||
return;
|
||||
/*const value = closest(options, quality);
|
||||
this.debug.warn(`Unsupported quality option: ${quality}, using ${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
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const player = new Plyr(document.getElementById('js-video-player'), {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
(function main() {
|
||||
'use strict';
|
||||
const video = document.getElementById('js-video-player');
|
||||
const speedInput = document.getElementById('speed-control');
|
||||
speedInput.addEventListener('keyup', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
let speed = parseFloat(speedInput.value);
|
||||
if(!isNaN(speed)){
|
||||
video.playbackRate = speed;
|
||||
}
|
||||
}
|
||||
});
|
||||
}());
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// from: https://git.gir.st/subscriptionfeed.git/blob/59a590d:/app/youtube/templates/watch.html.j2#l28
|
||||
|
||||
var 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)*/
|
||||
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', ()=>{
|
||||
|
||||
199
youtube/static/js/watch.js
Normal file
199
youtube/static/js/watch.js
Normal file
@@ -0,0 +1,199 @@
|
||||
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)
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -95,7 +97,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -135,8 +136,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -263,7 +266,7 @@ label[for=options-toggle-cbox] {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: minmax(50px, 120px);
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
@@ -271,7 +274,12 @@ label[for=options-toggle-cbox] {
|
||||
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);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
--secondary-background: #EEEEEE;
|
||||
--thumb-background: #F5F5F5;
|
||||
--link: #212121;
|
||||
--link-visited: #606060;
|
||||
--link-visited: #808080;
|
||||
--border-bg: #212121;
|
||||
--buttom: #DCDCDC;
|
||||
--buttom-text: #212121;
|
||||
--button-border: #91918c;
|
||||
|
||||
@@ -33,6 +33,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -100,7 +102,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -200,8 +201,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -474,17 +477,19 @@ hr {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
z-index: 1;
|
||||
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;
|
||||
}
|
||||
@@ -498,7 +503,7 @@ hr {
|
||||
grid-area: playlist;
|
||||
}
|
||||
.play-clean {
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
}
|
||||
.play-clean > button {
|
||||
padding-left: 0px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
4
youtube/static/modules/plyr/plyr.min.js
vendored
4
youtube/static/modules/plyr/plyr.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -33,6 +33,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -100,7 +102,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -200,8 +201,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -484,17 +487,19 @@ hr {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
z-index: 1;
|
||||
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;
|
||||
}
|
||||
@@ -508,7 +513,7 @@ hr {
|
||||
grid-area: playlist;
|
||||
}
|
||||
.play-clean {
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
}
|
||||
.play-clean > button {
|
||||
padding-left: 0px;
|
||||
|
||||
@@ -33,6 +33,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -100,7 +102,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -200,8 +201,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -252,7 +255,7 @@ hr {
|
||||
/* Video list item */
|
||||
.video-container {
|
||||
display: grid;
|
||||
grid-row-gap: 0.5rem;
|
||||
grid-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.length {
|
||||
@@ -295,6 +298,12 @@ hr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-video address {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-video.channel-item .thumbnail.channel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -430,7 +439,7 @@ hr {
|
||||
@media (min-width: 600px) {
|
||||
.video-container {
|
||||
display: grid;
|
||||
grid-row-gap: 0.5rem;
|
||||
grid-gap: 0.5rem;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -456,17 +465,19 @@ hr {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
z-index: 1;
|
||||
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;
|
||||
@@ -476,7 +487,7 @@ hr {
|
||||
grid-area: playlist;
|
||||
}
|
||||
.play-clean {
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
}
|
||||
.play-clean > button {
|
||||
padding-left: 0px;
|
||||
@@ -494,7 +505,7 @@ hr {
|
||||
.video-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-row-gap: 1rem;
|
||||
grid-gap: 1rem;
|
||||
grid-column-gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -95,7 +97,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -135,8 +136,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -151,6 +154,11 @@ label[for=options-toggle-cbox] {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.settings-form > h2 {
|
||||
border-bottom: 2px solid var(--border-bg);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
display: grid;
|
||||
grid-row-gap: 1rem;
|
||||
@@ -161,7 +169,6 @@ label[for=options-toggle-cbox] {
|
||||
.setting-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
background-color: var(--secondary-focus);
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
}
|
||||
@@ -215,15 +222,18 @@ label[for=options-toggle-cbox] {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
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;
|
||||
}
|
||||
.main {
|
||||
display: grid;
|
||||
|
||||
@@ -33,6 +33,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -100,7 +102,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -200,8 +201,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -477,17 +480,20 @@ hr {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
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;
|
||||
}
|
||||
@@ -501,7 +507,7 @@ hr {
|
||||
grid-area: playlist;
|
||||
}
|
||||
.play-clean {
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
}
|
||||
.play-clean > button {
|
||||
padding-left: 0px;
|
||||
|
||||
@@ -33,6 +33,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -100,7 +102,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -120,66 +121,6 @@ header {
|
||||
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 {
|
||||
@@ -200,8 +141,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -385,17 +328,19 @@ hr {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 100px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
z-index: 1;
|
||||
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;
|
||||
}
|
||||
@@ -408,37 +353,6 @@ hr {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 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: minmax(50px, 100px);
|
||||
}
|
||||
.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);
|
||||
|
||||
269
youtube/static/unsubscribe.css
Normal file
269
youtube/static/unsubscribe.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ input[type="search"] {
|
||||
padding: 0.4rem 0.4rem;
|
||||
font-size: 15px;
|
||||
color: var(--search-text);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
input[type='search'] {
|
||||
@@ -121,7 +123,6 @@ header {
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
z-index: 1;
|
||||
}
|
||||
.dropdown-label {
|
||||
grid-area: dropdown-label;
|
||||
@@ -219,9 +220,10 @@ label[for=options-toggle-cbox] {
|
||||
}
|
||||
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
display: inline-grid;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
padding-left: 1rem;
|
||||
background: var(--secondary-background);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
/*- ----------- End Menu Mobile sin JS ------------- */
|
||||
|
||||
@@ -236,6 +238,9 @@ label[for=options-toggle-cbox] {
|
||||
"sc-video"
|
||||
"sc-info";
|
||||
}
|
||||
figure.sc-video {
|
||||
margin: 1rem 0px;
|
||||
}
|
||||
.sc-video { grid-area: sc-video; }
|
||||
.sc-info {
|
||||
display: grid;
|
||||
@@ -618,17 +623,18 @@ label[for=options-toggle-cbox] {
|
||||
.dropdown {
|
||||
display: grid;
|
||||
grid-gap: 1px;
|
||||
grid-template-columns: minmax(50px, 120px);
|
||||
grid-template-columns: 100px auto;
|
||||
grid-template-areas:
|
||||
"dropdown-label"
|
||||
"dropdown-content";
|
||||
grid-area: dropdown;
|
||||
|
||||
background: var(--background);
|
||||
padding-right: 4rem;
|
||||
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
#options-toggle-cbox:checked ~ .dropdown-content {
|
||||
width: calc(100% + 100px);
|
||||
max-height: 80vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
.playlist {
|
||||
display: grid;
|
||||
@@ -638,7 +644,7 @@ label[for=options-toggle-cbox] {
|
||||
grid-area: playlist;
|
||||
}
|
||||
.play-clean {
|
||||
grid-template-columns: minmax(50px, 120px);
|
||||
grid-template-columns: 100px auto;
|
||||
}
|
||||
.play-clean > button {
|
||||
padding-bottom: 6px;
|
||||
|
||||
@@ -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
|
||||
@@ -682,7 +684,7 @@ 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():
|
||||
|
||||
@@ -700,15 +702,36 @@ def import_subscriptions():
|
||||
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
|
||||
@@ -729,8 +752,25 @@ def import_subscriptions():
|
||||
|
||||
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)
|
||||
|
||||
@@ -747,7 +787,7 @@ def export_subscriptions():
|
||||
_get_subscribed_channels(cursor)):
|
||||
if muted and not include_muted:
|
||||
continue
|
||||
if request.values['export_format'] == 'json':
|
||||
if request.values['export_format'] == 'json_google_takeout':
|
||||
sub_list.append({
|
||||
'kind': 'youtube#subscription',
|
||||
'snippet': {
|
||||
@@ -760,21 +800,38 @@ def export_subscriptions():
|
||||
'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,
|
||||
})
|
||||
if request.values['export_format'] == 'json':
|
||||
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.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.xml"'
|
||||
cd = 'attachment; filename="subscriptions_%s.xml"' % date_time
|
||||
r.headers['Content-Disposition'] = cd
|
||||
return r
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{% 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 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' 'unsafe-eval'; media-src 'self' https://*.googlevideo.com; {{ "img-src 'self' https://*.googleusercontent.com https://*.ggpht.com https://*.ytimg.com;" if not settings.proxy_images else "" }}"/>
|
||||
<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>
|
||||
<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"/>
|
||||
@@ -13,6 +18,14 @@
|
||||
{% 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>
|
||||
@@ -22,7 +35,7 @@
|
||||
</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 == "/" else "" }} required placeholder="Type to search...">
|
||||
{{ "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">
|
||||
@@ -120,7 +133,7 @@
|
||||
|
||||
{% 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="I added your playlist...">
|
||||
<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>
|
||||
@@ -128,7 +141,7 @@
|
||||
</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 selection</button>
|
||||
<button type="reset" id="item-selection-reset">Clear</button>
|
||||
</div>
|
||||
</form>
|
||||
<script src="/youtube.com/static/js/playlistadd.js"></script>
|
||||
@@ -151,8 +164,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<p>This site is Free/Libre Software</p>
|
||||
{% if current_commit and current_version %}
|
||||
<p>Current version: {{ current_version }}-{{ current_commit }} @ {{ current_branch }}</p>
|
||||
{% if current_commit != None %}
|
||||
<p>Current version: {{ current_commit }} @ {{ current_branch }}</p>
|
||||
{% else %}
|
||||
<p>Current version: {{ current_version }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -38,10 +38,21 @@
|
||||
<h4 class="title"><a href="{{ info['url'] }}" title="{{ info['title'] }}">{{ info['title'] }}</a></h4>
|
||||
|
||||
{% 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 info.get('author_url') %}
|
||||
<address title="{{ info['author'] }}"><b><a href="{{ info['author_url'] }}">{{ info['author'] }}</a></b></address>
|
||||
<address title="{{ info['author'] }}"><b><a href="{{ info['author_url'] }}">{{ author_description }}</a></b></address>
|
||||
{% else %}
|
||||
<address title="{{ info['author'] }}"><b>{{ info['author'] }}</b></address>
|
||||
<address title="{{ info['author'] }}"><b>{{ author_description }}</b></address>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
@@ -26,13 +26,20 @@
|
||||
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 }}">
|
||||
{% for video_source in video_sources %}
|
||||
<source src="{{ video_source['src'] }}" type="{{ video_source['type'] }}">
|
||||
{% endfor %}
|
||||
{% 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>
|
||||
@@ -48,10 +55,15 @@
|
||||
// @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-LxSGuB4I2iAln3VLWi8t3RYhEks4/2rtcCw6kqiBghbqBJHXg5ikpeRxEOm0luiIuKDiqwNI3rsCXI/d+MPPAA=="
|
||||
integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="/youtube.com/static/js/plyr-start.js"></script>
|
||||
<!-- /plyr -->
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
</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>
|
||||
@@ -45,15 +50,15 @@
|
||||
<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/speedyplay.js">speedyplay.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/speedyplay.js">speedyplay.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>
|
||||
<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 %}
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
</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 class="play-action" type="submit" id="playlist-remove-button" name="action" value="remove" form="playlist-edit" formaction="">Remove from playlist</button>
|
||||
</div>
|
||||
@@ -31,6 +33,14 @@
|
||||
{{ common_elements.item(video_info) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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) }}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<form class="subscriptions-import-form" enctype="multipart/form-data" action="/youtube.com/import_subscriptions" method="POST">
|
||||
<h2>Import subscriptions</h2>
|
||||
<div class="subscriptions-import-options">
|
||||
<input type="file" id="subscriptions-import" accept="application/json, application/xml, text/x-opml" name="subscriptions_file">
|
||||
<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>
|
||||
@@ -29,7 +29,8 @@
|
||||
<h2>Export subscriptions</h2>
|
||||
<div class="subscriptions-export-options">
|
||||
<select id="export-type" name="export_format" title="Export format">
|
||||
<option value="json">JSON</option>
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% elif (video_sources.__len__() == 0 or live) and hls_formats.__len__() != 0 %}
|
||||
{% 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>
|
||||
@@ -41,9 +41,9 @@
|
||||
{% else %}
|
||||
<figure class="sc-video">
|
||||
<video id="js-video-player" playsinline controls>
|
||||
{% for video_source in video_sources %}
|
||||
<source src="{{ video_source['src'] }}" type="{{ video_source['type'] }}" data-res="{{ video_source['quality'] }}">
|
||||
{% endfor %}
|
||||
{% 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'] %}
|
||||
@@ -54,12 +54,6 @@
|
||||
{% endfor %}
|
||||
</video>
|
||||
</figure>
|
||||
|
||||
{% if time_start != 0 %}
|
||||
<script>
|
||||
document.getElementById('js-video-player').currentTime = {{ time_start|tojson }};
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="sc-info">
|
||||
@@ -84,13 +78,21 @@
|
||||
<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 {{ dislike_count }} dislikes</span>
|
||||
<span class="v-likes-dislikes">{{ like_count }} likes</span>
|
||||
|
||||
<div class="external-player-controls">
|
||||
<input class="speed" id="speed-control" type="text" title="Video speed">
|
||||
<script src="/youtube.com/static/js/speedyplay.js"></script>
|
||||
{% 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">
|
||||
|
||||
<span class="v-direct-link"><a href="https://youtu.be/{{ video_id }}" rel="noopener noreferrer" target="_blank">Direct Link</a></span>
|
||||
@@ -161,7 +163,7 @@
|
||||
<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 type="checkbox" id="playlist-autoplay-toggle"></li>
|
||||
<li><label for="playlist-autoplay-toggle">Autoplay: </label><input type="checkbox" class="autoplay-toggle"></li>
|
||||
{% if playlist['current_index'] is none %}
|
||||
<li>[Error!]/{{ playlist['video_count'] }}</li>
|
||||
{% else %}
|
||||
@@ -182,173 +184,11 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% if playlist['id'] is not none %}
|
||||
<script>
|
||||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
// 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);
|
||||
});
|
||||
// @license-end
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif settings.related_videos_mode != 0 %}
|
||||
<div class="related-autoplay"><label for="related-autoplay-toggle">Autoplay: </label><input type="checkbox" id="related-autoplay-toggle"></div>
|
||||
<div class="related-autoplay"><label for="related-autoplay-toggle">Autoplay: </label><input type="checkbox" class="autoplay-toggle"></div>
|
||||
{% endif %}
|
||||
|
||||
{% if settings.related_videos_mode != 0 or playlist %}
|
||||
<script>
|
||||
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
|
||||
let playability_error = {{ 'true' if playability_error else 'false' }};
|
||||
{% if playlist and playlist['current_index'] is not none %}
|
||||
{% set isPlaylist = true %}
|
||||
{% endif %}
|
||||
|
||||
{% if isPlaylist %}
|
||||
// from https://stackoverflow.com/a/6969486
|
||||
function escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
let playlist_id = {{ playlist['id']|tojson }};
|
||||
playlist_id = escapeRegExp(playlist_id);
|
||||
{% endif %}
|
||||
|
||||
// read cookies on whether to autoplay
|
||||
// pain in the ass:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie
|
||||
{% if isPlaylist %}
|
||||
let cookieValue = document.cookie.replace(new RegExp(
|
||||
'(?:(?:^|.*;\\s*)autoplay_' + playlist_id + '\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1');
|
||||
{% else %}
|
||||
let cookieValue = document.cookie.replace(new RegExp(
|
||||
'(?:(?:^|.*;\\s*)autoplay\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1');
|
||||
{% endif %}
|
||||
|
||||
let autoplayEnabled = 0;
|
||||
if(cookieValue.length === 0){
|
||||
autoplayEnabled = 0;
|
||||
} else {
|
||||
autoplayEnabled = Number(cookieValue);
|
||||
}
|
||||
|
||||
// check the autoplay checkbox if autoplay is on
|
||||
{% if isPlaylist %}
|
||||
let PlaylistAutoplayCheck = document.getElementById('playlist-autoplay-toggle');
|
||||
if(autoplayEnabled){
|
||||
PlaylistAutoplayCheck.checked = true;
|
||||
}
|
||||
{% else %}
|
||||
let RelatedAutoplayCheck = document.getElementById('related-autoplay-toggle');
|
||||
if(autoplayEnabled){
|
||||
RelatedAutoplayCheck.checked = true;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// listen for checkbox to turn autoplay on and off
|
||||
let cookie = 'autoplay'
|
||||
{% if isPlaylist %}
|
||||
cookie += '_' + playlist_id;
|
||||
PlaylistAutoplayCheck.addEventListener( 'change', function() {
|
||||
if(this.checked) {
|
||||
autoplayEnabled = 1;
|
||||
document.cookie = cookie + '=1; SameSite=Strict';
|
||||
} else {
|
||||
autoplayEnabled = 0;
|
||||
document.cookie = cookie + '=0; SameSite=Strict';
|
||||
}
|
||||
});
|
||||
{% else %}
|
||||
RelatedAutoplayCheck.addEventListener( 'change', function() {
|
||||
if(this.checked) {
|
||||
autoplayEnabled = 1;
|
||||
document.cookie = cookie + '=1; SameSite=Strict';
|
||||
} else {
|
||||
autoplayEnabled = 0;
|
||||
document.cookie = cookie + '=0; SameSite=Strict';
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
const vid = document.getElementById('js-video-player');
|
||||
if(!playability_error){
|
||||
// play the video if autoplay is on
|
||||
if(autoplayEnabled){
|
||||
vid.play();
|
||||
}
|
||||
}
|
||||
|
||||
// determine next video url
|
||||
{% if isPlaylist %}
|
||||
let currentIndex = {{ playlist['current_index']|tojson }};
|
||||
{% if playlist['current_index']+1 == playlist['items']|length %}
|
||||
let nextVideoUrl = null;
|
||||
{% else %}
|
||||
let nextVideoUrl = {{ (playlist['items'][playlist['current_index']+1]['url'])|tojson }};
|
||||
{% endif %}
|
||||
|
||||
// scroll playlist to proper position
|
||||
// item height + gap == 100
|
||||
let pl = document.querySelector('.playlist-videos');
|
||||
pl.scrollTop = 100*currentIndex;
|
||||
{% else %}
|
||||
{% if related|length == 0 %}
|
||||
let nextVideoUrl = null;
|
||||
{% else %}
|
||||
let nextVideoUrl = {{ (related[0]['url'])|tojson }};
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
let nextVideoDelay = 1000;
|
||||
|
||||
// go to next video when video ends
|
||||
// https://stackoverflow.com/a/2880950
|
||||
if (nextVideoUrl) {
|
||||
if(playability_error){
|
||||
videoEnded();
|
||||
} else {
|
||||
vid.addEventListener('ended', videoEnded, false);
|
||||
}
|
||||
function nextVideo(){
|
||||
if(autoplayEnabled){
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
}
|
||||
function videoEnded(e) {
|
||||
window.setTimeout(nextVideo, nextVideoDelay);
|
||||
}
|
||||
}
|
||||
// @license-end
|
||||
</script>
|
||||
{% endif %}
|
||||
<!-- /playlist -->
|
||||
|
||||
{% if subtitle_sources %}
|
||||
<details id="transcript-details">
|
||||
<summary>Transcript</summary>
|
||||
@@ -397,9 +237,11 @@
|
||||
|
||||
</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
|
||||
data = {{ js_data|tojson }};
|
||||
let storyboard_url = {{ storyboard_url | tojson }};
|
||||
// @license-end
|
||||
</script>
|
||||
<script src="/youtube.com/static/js/common.js"></script>
|
||||
@@ -407,7 +249,7 @@
|
||||
{% if settings.use_video_player == 2 %}
|
||||
<!-- plyr -->
|
||||
<script src="/youtube.com/static/modules/plyr/plyr.min.js"
|
||||
integrity="sha512-LxSGuB4I2iAln3VLWi8t3RYhEks4/2rtcCw6kqiBghbqBJHXg5ikpeRxEOm0luiIuKDiqwNI3rsCXI/d+MPPAA=="
|
||||
integrity="sha512-l6ZzdXpfMHRfifqaR79wbYCEWjLDMI9DnROvb+oLkKq6d7MGroGpMbI7HFpicvmAH/2aQO+vJhewq8rhysrImw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="/youtube.com/static/js/plyr-start.js"></script>
|
||||
<!-- /plyr -->
|
||||
|
||||
@@ -268,14 +268,15 @@ def fetch_url_response(url, headers=(), timeout=15, data=None,
|
||||
# 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)
|
||||
retries = urllib3.Retry(3+max_redirects, redirect=max_redirects, raise_on_redirect=False)
|
||||
else:
|
||||
retries = urllib3.Retry(3)
|
||||
retries = urllib3.Retry(3, raise_on_redirect=False)
|
||||
pool = get_pool(use_tor and settings.route_tor)
|
||||
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)
|
||||
@@ -328,11 +329,22 @@ def fetch_url(url, headers=(), timeout=15, report_text=None, data=None,
|
||||
with open(os.path.join(save_dir, debug_name), 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
if response.status == 429:
|
||||
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.getheaders())
|
||||
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:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
__version__ = '0.1.0'
|
||||
__version__ = '0.2.2'
|
||||
|
||||
384
youtube/watch.py
384
youtube/watch.py
@@ -15,6 +15,9 @@ 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:
|
||||
@@ -23,26 +26,147 @@ except FileNotFoundError:
|
||||
decrypt_cache = {}
|
||||
|
||||
|
||||
def get_video_sources(info):
|
||||
video_sources = []
|
||||
max_resolution = settings.default_resolution
|
||||
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_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 ('quality', 'width', 'ext', 'url')):
|
||||
if not all(fmt[attr] for attr in ('ext', 'url', 'itag')):
|
||||
continue
|
||||
if (fmt['acodec'] and fmt['vcodec']
|
||||
and fmt['quality'] <= max_resolution):
|
||||
video_sources.append({
|
||||
'src': fmt['url'],
|
||||
|
||||
# unified source
|
||||
if fmt['acodec'] and fmt['vcodec']:
|
||||
source = {
|
||||
'type': 'video/' + fmt['ext'],
|
||||
'quality': fmt['quality'],
|
||||
'height': fmt['height'],
|
||||
'width': fmt['width'],
|
||||
})
|
||||
'quality_string': short_video_quality_string(fmt),
|
||||
}
|
||||
source['quality_string'] += ' (integrated)'
|
||||
source.update(fmt)
|
||||
uni_sources.append(source)
|
||||
continue
|
||||
|
||||
# order the videos sources so the preferred resolution is first #
|
||||
video_sources.sort(key=lambda source: source['quality'], reverse=True)
|
||||
if not (fmt['init_range'] and fmt['index_range']):
|
||||
continue
|
||||
|
||||
return video_sources
|
||||
# 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'],
|
||||
'bitrate': fmt['audio_bitrate'],
|
||||
'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 {
|
||||
'uni_sources': uni_sources,
|
||||
'uni_idx': uni_idx,
|
||||
'pair_sources': pair_sources,
|
||||
'pair_idx': pair_idx,
|
||||
}
|
||||
|
||||
|
||||
def make_caption_src(info, lang, auto=False, trans_lang=None):
|
||||
@@ -52,7 +176,7 @@ def make_caption_src(info, lang, auto=False, trans_lang=None):
|
||||
if trans_lang:
|
||||
label += ' -> ' + trans_lang
|
||||
return {
|
||||
'url': '/' + yt_data_extract.get_caption_url(info, lang, 'vtt', auto, trans_lang),
|
||||
'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,
|
||||
@@ -96,6 +220,8 @@ def get_subtitle_sources(info):
|
||||
pref_lang (Automatic)
|
||||
pref_lang (Manual)'''
|
||||
sources = []
|
||||
if not yt_data_extract.captions_available(info):
|
||||
return []
|
||||
pref_lang = settings.subtitles_language
|
||||
native_video_lang = None
|
||||
if info['automatic_caption_languages']:
|
||||
@@ -233,45 +359,46 @@ def extract_info(video_id, use_invidious, playlist_id=None, index=None):
|
||||
watch_page = watch_page.decode('utf-8')
|
||||
info = yt_data_extract.extract_watch_info_from_html(watch_page)
|
||||
|
||||
# request player urls if it's missing
|
||||
# see https://github.com/user234683/youtube-local/issues/22#issuecomment-706395160
|
||||
context = {
|
||||
'client': {
|
||||
'clientName': 'ANDROID',
|
||||
'clientVersion': '16.20',
|
||||
'gl': 'US',
|
||||
'hl': 'en',
|
||||
},
|
||||
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
|
||||
'thirdParty': {
|
||||
'embedUrl': 'https://google.com', # Can be any valid URL
|
||||
}
|
||||
}
|
||||
if info['age_restricted'] or info['player_urls_missing']:
|
||||
if info['age_restricted']:
|
||||
print('Age restricted video. Fetching /youtubei/v1/player page')
|
||||
else:
|
||||
print('Missing player. Fetching /youtubei/v1/player page')
|
||||
context['client']['clientScreen'] = 'EMBED'
|
||||
else:
|
||||
print('Fetching /youtubei/v1/player page')
|
||||
|
||||
# https://github.com/yt-dlp/yt-dlp/issues/574#issuecomment-887171136
|
||||
# ANDROID is used instead because its urls don't require decryption
|
||||
# The URLs returned with WEB for videos requiring decryption
|
||||
# couldn't be decrypted with the base.js from the web page for some
|
||||
# reason
|
||||
url ='https://youtubei.googleapis.com/youtubei/v1/player'
|
||||
url += '?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
||||
data = {
|
||||
'videoId': video_id,
|
||||
'context': {
|
||||
'client': {
|
||||
'clientName': 'ANDROID',
|
||||
'clientVersion': '16.20',
|
||||
'clientScreen': 'EMBED',
|
||||
'gl': 'US',
|
||||
'hl': 'en',
|
||||
},
|
||||
# https://github.com/yt-dlp/yt-dlp/pull/575#issuecomment-887739287
|
||||
'thirdParty': {
|
||||
'embedUrl': 'https://google.com', # Can be any valid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
data = json.dumps(data)
|
||||
content_header = (('Content-Type', 'application/json'),)
|
||||
player_response = util.fetch_url(
|
||||
url, data=data, headers=util.mobile_ua + content_header,
|
||||
debug_name='youtubei_player',
|
||||
report_text='Fetched youtubei player page').decode('utf-8')
|
||||
yt_data_extract.update_with_age_restricted_info(info,
|
||||
player_response)
|
||||
# https://github.com/yt-dlp/yt-dlp/issues/574#issuecomment-887171136
|
||||
# ANDROID is used instead because its urls don't require decryption
|
||||
# The URLs returned with WEB for videos requiring decryption
|
||||
# couldn't be decrypted with the base.js from the web page for some
|
||||
# reason
|
||||
url ='https://youtubei.googleapis.com/youtubei/v1/player'
|
||||
url += '?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8'
|
||||
data = {
|
||||
'videoId': video_id,
|
||||
'context': context,
|
||||
}
|
||||
data = json.dumps(data)
|
||||
content_header = (('Content-Type', 'application/json'),)
|
||||
player_response = util.fetch_url(
|
||||
url, data=data, headers=util.mobile_ua + content_header,
|
||||
debug_name='youtubei_player',
|
||||
report_text='Fetched youtubei player page').decode('utf-8')
|
||||
|
||||
yt_data_extract.update_with_age_restricted_info(info, player_response)
|
||||
|
||||
# signature decryption
|
||||
decryption_error = decrypt_signatures(info, video_id)
|
||||
@@ -343,15 +470,30 @@ def video_quality_string(format):
|
||||
return '?'
|
||||
|
||||
|
||||
def audio_quality_string(format):
|
||||
if format['acodec']:
|
||||
result = str(format['audio_bitrate'] or '?') + 'k'
|
||||
if format['audio_sample_rate']:
|
||||
result += ' ' + str(format['audio_sample_rate']) + ' Hz'
|
||||
return result
|
||||
elif format['vcodec']:
|
||||
return 'video only'
|
||||
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 '?'
|
||||
|
||||
|
||||
@@ -370,12 +512,73 @@ def format_bytes(bytes):
|
||||
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')
|
||||
@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:
|
||||
@@ -438,10 +641,11 @@ def get_watch_page(video_id=None):
|
||||
item['url'] += '&index=' + str(item['index'])
|
||||
info['playlist']['author_url'] = util.prefix_url(
|
||||
info['playlist']['author_url'])
|
||||
# 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'])
|
||||
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
|
||||
@@ -477,9 +681,43 @@ def get_watch_page(video_id=None):
|
||||
'codecs': codecs_string,
|
||||
})
|
||||
|
||||
video_sources = get_video_sources(info)
|
||||
video_height = yt_data_extract.deep_get(video_sources, 0, 'height', default=360)
|
||||
video_width = yt_data_extract.deep_get(video_sources, 0, 'width', default=640)
|
||||
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']
|
||||
video_height = yt_data_extract.deep_get(source_info, 'uni_sources',
|
||||
uni_idx, 'height',
|
||||
default=360)
|
||||
video_width = yt_data_extract.deep_get(source_info, 'uni_sources',
|
||||
uni_idx, 'width',
|
||||
default=640)
|
||||
|
||||
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'
|
||||
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'] or 0, video_width)
|
||||
|
||||
@@ -520,11 +758,9 @@ def get_watch_page(video_id=None):
|
||||
time_published_utc=time_utc_isoformat(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,
|
||||
hls_formats = info['hls_formats'],
|
||||
subtitle_sources = subtitle_sources,
|
||||
related = info['related_videos'],
|
||||
@@ -554,15 +790,25 @@ def get_watch_page(video_id=None):
|
||||
invidious_reload_button = info['invidious_reload_button'],
|
||||
video_url = util.URL_ORIGIN + '/watch?v=' + video_id,
|
||||
video_id = video_id,
|
||||
time_start = time_start,
|
||||
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': video_info['id'],
|
||||
'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'],
|
||||
},
|
||||
# for embed page
|
||||
font_family=youtube.font_choices[settings.font],
|
||||
**source_info,
|
||||
using_pair_sources = using_pair_sources,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,4 +10,4 @@ from .watch_extraction import (extract_watch_info, get_caption_url,
|
||||
update_with_age_restricted_info, requires_decryption,
|
||||
extract_decryption_function, decrypt_signatures, _formats,
|
||||
update_format_with_type_info, extract_hls_formats,
|
||||
extract_watch_info_from_html)
|
||||
extract_watch_info_from_html, captions_available)
|
||||
|
||||
@@ -166,14 +166,17 @@ def extract_formatted_text(node):
|
||||
return [{'text': node['simpleText']}]
|
||||
return []
|
||||
|
||||
def extract_int(string, default=None):
|
||||
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
|
||||
match = re.search(r'\b(\d+)\b', string.replace(',', ''))
|
||||
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:
|
||||
|
||||
@@ -74,7 +74,11 @@ def extract_channel_info(polymer_json, tab):
|
||||
|
||||
if tab in ('videos', 'playlists', 'search'):
|
||||
items, ctoken = extract_items(response)
|
||||
additional_info = {'author': info['channel_name'], 'author_url': info['channel_url']}
|
||||
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'):
|
||||
|
||||
@@ -111,10 +111,14 @@ _formats = {
|
||||
'_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'},
|
||||
'394': {'ext': 'mp4', 'height': 144, 'format_note': 'DASH video', 'vcodec': 'av01.0.00M.08'},
|
||||
'395': {'ext': 'mp4', 'height': 240, 'format_note': 'DASH video', 'vcodec': 'av01.0.00M.08'},
|
||||
'396': {'ext': 'mp4', 'height': 360, 'format_note': 'DASH video', 'vcodec': 'av01.0.01M.08'},
|
||||
'397': {'ext': 'mp4', 'height': 480, 'format_note': 'DASH video', 'vcodec': 'av01.0.04M.08'},
|
||||
'398': {'ext': 'mp4', 'height': 720, 'format_note': 'DASH video', 'vcodec': 'av01.0.05M.08'},
|
||||
'399': {'ext': 'mp4', 'height': 1080, 'format_note': 'DASH video', 'vcodec': 'av01.0.08M.08'},
|
||||
'400': {'ext': 'mp4', 'height': 1440, 'format_note': 'DASH video', 'vcodec': 'av01.0.12M.08'},
|
||||
'401': {'ext': 'mp4', 'height': 2160, 'format_note': 'DASH video', 'vcodec': 'av01.0.12M.08'},
|
||||
}
|
||||
|
||||
|
||||
@@ -135,7 +139,6 @@ def _extract_from_video_information_renderer(renderer_content):
|
||||
def _extract_likes_dislikes(renderer_content):
|
||||
info = {
|
||||
'like_count': None,
|
||||
'dislike_count': None,
|
||||
}
|
||||
for button in renderer_content.get('buttons', ()):
|
||||
button_renderer = button.get('slimMetadataToggleButtonRenderer', {})
|
||||
@@ -157,8 +160,6 @@ def _extract_likes_dislikes(renderer_content):
|
||||
|
||||
if 'isLike' in button_renderer:
|
||||
info['like_count'] = count
|
||||
elif 'isDislike' in button_renderer:
|
||||
info['dislike_count'] = count
|
||||
return info
|
||||
|
||||
def _extract_from_owner_renderer(renderer_content):
|
||||
@@ -353,10 +354,8 @@ def _extract_watch_info_desktop(top_level):
|
||||
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'))
|
||||
@@ -415,13 +414,21 @@ def _extract_formats(info, player_response):
|
||||
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'] = yt_fmt.get('contentLength')
|
||||
fmt['audio_sample_rate'] = yt_fmt.get('audioSampleRate')
|
||||
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='')))
|
||||
@@ -437,6 +444,14 @@ def _extract_formats(info, player_response):
|
||||
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)
|
||||
|
||||
@@ -459,7 +474,7 @@ def extract_hls_formats(hls_manifest):
|
||||
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,
|
||||
'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
|
||||
@@ -546,6 +561,25 @@ def extract_watch_info(polymer_json):
|
||||
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:
|
||||
@@ -635,6 +669,8 @@ def extract_watch_info(polymer_json):
|
||||
|
||||
# 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 = {
|
||||
@@ -714,10 +750,15 @@ def extract_watch_info_from_html(watch_html):
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user