Compare commits
40 Commits
85122ba2e9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9de3ef8e1f | |||
| 92ad71dbd4 | |||
| bd3e1447e4 | |||
| eec86be88c | |||
| a2b64041ba | |||
| 162bd1c1b9 | |||
| 63cc3e90b9 | |||
| a08303eb3a | |||
| 6bc155c6d2 | |||
| f704098473 | |||
| d5ead66a07 | |||
| f8436927cf | |||
| 3b4ee632b1 | |||
| 6b868c2b63 | |||
| 40bda207e1 | |||
| ea8bf8fa1e | |||
| b2a3c5a048 | |||
| 5a91e654f8 | |||
| 3a19f8fe72 | |||
| b49918f8a6 | |||
| a79ee967d2 | |||
| cddbbcc866 | |||
| a007796147 | |||
| 83690c4535 | |||
| 8364c23c5f | |||
| 510cca1b3a | |||
| 14e5fe6df7 | |||
| 879661a2ac | |||
| c5e2eb3c37 | |||
| f9fab223e8 | |||
| 29a951f4fa | |||
| 81ef6df1cf | |||
| 5ea0cccc13 | |||
| d2c18a0814 | |||
| b9f304dc01 | |||
| 7528b6e76b | |||
| c65c254d80 | |||
| 99b6000b9b | |||
| 8000cb1532 | |||
| a62b8f0359 |
@@ -1 +1,5 @@
|
||||
.vscode
|
||||
__pycache__
|
||||
test.mp4
|
||||
env
|
||||
errors
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.11
|
||||
RUN useradd -r -m u2b
|
||||
RUN apt update && apt install -y ffmpeg
|
||||
RUN pip install --no-cache-dir python-ffmpeg yt-dlp
|
||||
#COPY . /app
|
||||
#WORKDIR /app
|
||||
USER u2b
|
||||
#ENV PORT=8080
|
||||
#CMD ["python", "server.py"]
|
||||
@@ -1,83 +0,0 @@
|
||||
# u2b.cx
|
||||
|
||||
A raw YouTube video server/resolver via search query, made to let you watch what you want in Quest VRChat where you don't really have any other options to do so.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
### GET `https://u2b.cx/<query>`
|
||||
|
||||
The server will search YouTube for `<query>`, pick the first result, pick the best quality all-in-one MP4 format available, and respond with a 302 redirect to the proxied raw MP4 file.
|
||||
|
||||
Example: [`https://u2b.cx/nyan cat`](https://u2b.cx/nyan%20cat) will give you the raw mp4 file for [https://www.youtube.com/watch?v=QH2-TGUlwu4](https://www.youtube.com/watch?v=QH2-TGUlwu4), the first result on [https://www.youtube.com/results?search_query=nyan+cat](https://www.youtube.com/results?search_query=nyan+cat).
|
||||
|
||||
### GET `https://u2b.cx/id/<video id>`
|
||||
|
||||
Bypasses search to serve the video directly.
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
Only python module needed is `yt-dlp`. It must be kept up to date, recommend auto update with cron.
|
||||
|
||||
### caddy file
|
||||
```Caddyfile
|
||||
u2b.cx {
|
||||
import common
|
||||
route {
|
||||
@www path / /favicon.ico
|
||||
redir @www https://www.u2b.cx{uri} permanent
|
||||
|
||||
respond /robots.txt "User-agent: *
|
||||
Disallow: /
|
||||
"
|
||||
respond /.* 403
|
||||
@notget not method GET
|
||||
respond @notget 403
|
||||
|
||||
reverse_proxy http://127.29.151.200:52482
|
||||
}
|
||||
}
|
||||
|
||||
proxy.u2b.cx {
|
||||
import common
|
||||
@gv path_regexp gvurl ^\/([a-z0-9-]+\.googlevideo\.com)
|
||||
handle @gv {
|
||||
uri strip_prefix /{re.gvurl.1}
|
||||
#uri strip_prefix {re.gvurl.0} doesn't work for some reason despite that value being equal to /{re.gvurl.1}
|
||||
reverse_proxy {
|
||||
to {re.gvurl.1}:443
|
||||
header_up Host {re.gvurl.1}
|
||||
transport http {
|
||||
tls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
www.u2b.cx {
|
||||
import common
|
||||
root * /srv/u2b.cx/www/
|
||||
file_server
|
||||
}
|
||||
```
|
||||
|
||||
### service
|
||||
youtubedl writes cache to disk, make user with home directory!
|
||||
|
||||
```systemd
|
||||
[Unit]
|
||||
Description=u2b.cx youtube search resolver service thingy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=u2b
|
||||
Group=u2b
|
||||
WorkingDirectory=/srv/u2b.cx/
|
||||
Environment=ADDRESS=127.29.151.200 PORT=52482 PROXY=https://proxy.u2b.cx/
|
||||
ExecStart=/usr/bin/python3.9 server.py
|
||||
MemoryMax=1G
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
log http.log.access {
|
||||
include http.log.access
|
||||
output stdout
|
||||
format formatted "[35m[{ts}][0m [96m[1m{request>remote_ip}[0m [31m{request>headers>X-Forwarded-For}[0m [33m{request>method}[0m [92m{request>host}[32m{request>uri}[0m [97m{status}[0m [90m{request>headers>User-Agent}[0m [34m{request>headers>Referer}[0m" {
|
||||
time_format "02/Jan/2006:15:04:05-0700"
|
||||
}
|
||||
}
|
||||
log default {
|
||||
exclude http.log.access
|
||||
output stderr
|
||||
format console
|
||||
}
|
||||
}
|
||||
|
||||
{$CADDY_SITE:":80"} {
|
||||
log
|
||||
tls {
|
||||
dns porkbun {
|
||||
api_key {env.PORKBUN_API_KEY}
|
||||
api_secret_key {env.PORKBUN_API_SECRET}
|
||||
}
|
||||
}
|
||||
handle_path /proxy/* {
|
||||
@gv path_regexp gvurl ^\/([a-z0-9-]+\.googlevideo\.com)
|
||||
handle @gv {
|
||||
uri strip_prefix /{re.gvurl.1}
|
||||
reverse_proxy {
|
||||
to {re.gvurl.1}:443
|
||||
header_up Host {re.gvurl.1}
|
||||
header_down Location "^https://(.*)" "/proxy/$1"
|
||||
transport http {
|
||||
tls
|
||||
}
|
||||
}
|
||||
}
|
||||
handle {
|
||||
respond 403
|
||||
}
|
||||
}
|
||||
handle {
|
||||
route {
|
||||
@www path / /favicon.ico
|
||||
redir @www https://www.u2b.cx{uri} permanent
|
||||
|
||||
respond /robots.txt "User-agent: *
|
||||
Disallow: /
|
||||
"
|
||||
respond /.* 403
|
||||
@notget not method GET
|
||||
respond @notget 403
|
||||
|
||||
reverse_proxy http://127.0.0.1:8080
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM caddy:2.6-builder AS builder
|
||||
RUN xcaddy build \
|
||||
--with github.com/caddyserver/transform-encoder \
|
||||
--with github.com/caddy-dns/porkbun
|
||||
FROM caddy:2.6
|
||||
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||
@@ -0,0 +1,27 @@
|
||||
version: "3.8"
|
||||
name: "u2bcx"
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
restart: always
|
||||
volumes:
|
||||
- ./:/app/
|
||||
working_dir: /app/
|
||||
environment:
|
||||
- ADDRESS=127.0.0.1
|
||||
- PORT=8080
|
||||
- PROXY=/proxy/
|
||||
network_mode: host
|
||||
command: python server.py
|
||||
caddy:
|
||||
build: caddy
|
||||
restart: always
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./caddy/Caddyfile:/etc/caddy/Caddyfile
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
env_file: env
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
Binary file not shown.
@@ -1,82 +1,217 @@
|
||||
from yt_dlp import YoutubeDL
|
||||
from importlib.metadata import version
|
||||
print("yt-dlp version", version("yt_dlp"))
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import unquote, urlparse, parse_qs
|
||||
from threading import Event
|
||||
from threading import Event, Thread
|
||||
from datetime import datetime, timedelta
|
||||
from os import environ
|
||||
from time import sleep
|
||||
from os import environ, makedirs, stat
|
||||
import logging
|
||||
import re
|
||||
from ffmpeg import FFmpeg
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from hashlib import sha256
|
||||
from shutil import copyfileobj
|
||||
from math import ceil
|
||||
|
||||
def getBestMp4UrlFromInfo(info):
|
||||
formats = info['entries'][0].get('formats') if info.get("entries") else info.get("formats")
|
||||
if not formats: return None
|
||||
valid = list(filter(lambda x: x['ext'] == "mp4" and x['vcodec'] != 'none' and x['acodec'] != 'none', formats))
|
||||
if not valid: return None
|
||||
best = max(valid, key=lambda x: x['height'])
|
||||
return best.get('url')
|
||||
|
||||
def getExpireDatetime(url):
|
||||
alt_expire = datetime.now() + timedelta(hours=5)
|
||||
if not url: return alt_expire
|
||||
q = parse_qs(urlparse(url).query)
|
||||
expire = q.get('expire')
|
||||
if not expire: return alt_expire
|
||||
expire = datetime.fromtimestamp(int(expire[0])) #this seems to always be +6 hours
|
||||
return min([expire, alt_expire])
|
||||
|
||||
query_dicts = {}
|
||||
ips_running_ytdl = []
|
||||
if 'DEBUG' in environ:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
class Ratelimit(Exception): pass
|
||||
class CachedException(Exception): pass
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def address_string(self):
|
||||
return getattr(self, 'headers', {}).get('X-Forwarded-For', '').split(',')[0] or self.client_address[0]
|
||||
|
||||
def send_error(self, code, message=""):
|
||||
body = bytes(message, "utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def send_error_video(self, text: str):
|
||||
makedirs("errors", exist_ok=True)
|
||||
hash = sha256(bytes(text, "utf8")).hexdigest()
|
||||
file = Path(f"errors/{hash}.mp4")
|
||||
|
||||
if not file.exists():
|
||||
text = re.sub("(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]", '', text)
|
||||
text = text.replace("\\", "\\\\").replace('"', '""').replace("'", "''").replace("%", "\\%").replace(":", "\\:")
|
||||
text = textwrap.fill(text, 90)
|
||||
peg = FFmpeg().option("y").input("bg.png", {
|
||||
"framerate": "0.1"
|
||||
}).output(str(file), {
|
||||
"f": "mp4",
|
||||
"t": "10",
|
||||
"c:v": "libx264",
|
||||
"pix_fmt": "yuv420p",
|
||||
"vf": "drawtext=font=monospace:fontsize=24:x=10:y=10:text='"+text+"':"
|
||||
})
|
||||
@peg.on("start")
|
||||
def on_start(arguments):
|
||||
logging.debug("cmd:" + ' '.join(arguments))
|
||||
@peg.on("stderr")
|
||||
def on_stderr(line):
|
||||
logging.debug("stderr:" + line)
|
||||
peg.execute()
|
||||
|
||||
with file.open('rb') as f:
|
||||
fs = stat(f.fileno())
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(fs.st_size))
|
||||
self.end_headers()
|
||||
copyfileobj(f, self.wfile)
|
||||
|
||||
def do_GET(self):
|
||||
# block other bot junk in reverse proxy
|
||||
if self.path in ["/", "/favicon.ico"]:
|
||||
self.send_error(404)
|
||||
try:
|
||||
if self.path in ["/", "/favicon.ico"] or self.path.startswith("/."):
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
path = unquote(self.path)
|
||||
id_match = re.match("\/(?:id\/|(?:https?:\/\/)?(?:(?:www\.|music\.|m\.)?youtube\.com\/(?:watch\?v=|shorts\/|live\/)|youtu\.be\/))([A-Za-z0-9_-]{11})", path)
|
||||
|
||||
if id_match:
|
||||
video_id = id_match[1]
|
||||
else:
|
||||
search_match = re.match("^\/(.+?)(?:\/(\d*))?$", path)
|
||||
if not search_match:
|
||||
self.send_error(404)
|
||||
return
|
||||
search_query = search_match[1]
|
||||
search_index = int(search_match[2]) if search_match[2] and search_match[2].isdigit() else 1
|
||||
search_index = max(min(search_index, 100), 1)
|
||||
video_id = ytdl_search_to_id(self, search_query, search_index)
|
||||
|
||||
self.send_response(302)
|
||||
self.send_header("Location", f"https://www.youtube.com/watch?v={video_id}")
|
||||
self.end_headers()
|
||||
#half this code now defunct
|
||||
return
|
||||
|
||||
path = unquote(self.path)
|
||||
match = re.match("\/(?:id\/|(?:https?:\/\/)?(?:(?:www\.)?youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/))([A-Za-z0-9_-]{11})", path)
|
||||
query = match[1] if match else "ytsearch:" + path[1:]
|
||||
video_url = ytdl_resolve_mp4_url(self, video_id)
|
||||
|
||||
dict = query_dicts.get(query)
|
||||
|
||||
if not dict or 'expire' in dict and datetime.now() >= dict['expire']:
|
||||
client_ip = self.address_string()
|
||||
if client_ip in ips_running_ytdl:
|
||||
self.send_error(429)
|
||||
return
|
||||
try:
|
||||
ips_running_ytdl.append(client_ip)
|
||||
query_dicts[query] = dict = {'event': Event()}
|
||||
with YoutubeDL() as ydl:
|
||||
info = ydl.extract_info(query, download=False)
|
||||
url = getBestMp4UrlFromInfo(info)
|
||||
dict['url'] = url
|
||||
dict['expire'] = getExpireDatetime(url)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
dict['exception'] = e
|
||||
finally:
|
||||
ips_running_ytdl.remove(client_ip)
|
||||
dict['event'].set()
|
||||
elif 'url' not in dict:
|
||||
dict['event'].wait(60)
|
||||
|
||||
if not dict.get('url'):
|
||||
if 'exception' in dict:
|
||||
self.send_error(500, message=str(dict['exception']), explain="An unexpected exception was encountered while resolving this query")
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
url = dict['url']
|
||||
if 'PROXY' in environ:
|
||||
url = environ['PROXY'] + url.replace("https://",'')
|
||||
video_url = environ['PROXY'] + video_url.replace("https://",'')
|
||||
self.send_response(302)
|
||||
self.send_header("Location", url)
|
||||
self.send_header("Location", video_url)
|
||||
self.end_headers()
|
||||
except Ratelimit:
|
||||
self.send_error(429)
|
||||
except CachedException as e:
|
||||
self.send_error_video(str(e))
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
self.send_error_video(str(e))
|
||||
|
||||
|
||||
ips_running_ytdl = []
|
||||
def invoke_youtubedl(self: Handler, input: str) -> dict:
|
||||
ip = self.address_string()
|
||||
if ip in ips_running_ytdl:
|
||||
raise Ratelimit()
|
||||
ips_running_ytdl.append(ip)
|
||||
try:
|
||||
with YoutubeDL({'extractor_args': {'youtube': {'skip': ['dash', 'hls']}}}) as ydl:
|
||||
return ydl.extract_info(input, download=False, process=False)
|
||||
finally:
|
||||
ips_running_ytdl.remove(ip)
|
||||
|
||||
|
||||
search_cache = {}
|
||||
def ytdl_search_to_id(self: Handler, query: str, index: int) -> str:
|
||||
ctx = search_cache.get(query)
|
||||
if ctx:
|
||||
ctx['event'].wait(60)
|
||||
if 'error' in ctx:
|
||||
raise CachedException(ctx['error'])
|
||||
results = ctx.get('results')
|
||||
else:
|
||||
results = None
|
||||
|
||||
if results == None or results['count'] < index or datetime.now() >= ctx['expires_at']:
|
||||
search_cache[query] = ctx = {
|
||||
'event': Event(),
|
||||
'expires_at': datetime.now() + timedelta(hours=5)
|
||||
}
|
||||
try:
|
||||
count = ceil(index/10)*10
|
||||
info = invoke_youtubedl(self, f"ytsearch{count}:{query}")
|
||||
entries = list(info['entries'])
|
||||
if not entries:
|
||||
raise Exception("ERROR: No results!")
|
||||
ids = [video['id'] for video in entries]
|
||||
ctx['results'] = results = {'ids': ids, 'count': count}
|
||||
except Exception as e:
|
||||
ctx['error'] = str(e)
|
||||
raise
|
||||
finally:
|
||||
ctx['event'].set()
|
||||
|
||||
return results['ids'][min(index, len(results['ids'])) - 1]
|
||||
|
||||
|
||||
resolve_cache = {}
|
||||
def ytdl_resolve_mp4_url(self: Handler, input: str) -> str:
|
||||
ctx = resolve_cache.get(input)
|
||||
if ctx and datetime.now() <= ctx['expires_at']:
|
||||
ctx['event'].wait(60)
|
||||
if 'error' in ctx:
|
||||
raise CachedException(ctx['error'])
|
||||
return ctx['url']
|
||||
|
||||
resolve_cache[input] = ctx = {
|
||||
'event': Event(),
|
||||
'expires_at': datetime.now() + timedelta(hours=5)
|
||||
}
|
||||
try:
|
||||
info = invoke_youtubedl(self, input)
|
||||
selection = info
|
||||
if "entries" in info:
|
||||
if not info["entries"]:
|
||||
raise Exception("ERROR: No video found!")
|
||||
else:
|
||||
selection = info["entries"][0]
|
||||
|
||||
suitable_formats = list(filter(lambda x: x['ext'] == "mp4" and x['vcodec'] != 'none' and x['acodec'] != 'none', selection["formats"]))
|
||||
if not suitable_formats:
|
||||
raise Exception(f"ERROR: {selection['id']}: No suitable formats of this video available!")
|
||||
best_format = max(suitable_formats, key=lambda x: x['height'])
|
||||
|
||||
ctx['url'] = url = best_format['url']
|
||||
|
||||
try:
|
||||
expire = parse_qs(urlparse(url).query).get('expire', [])[0]
|
||||
if expire:
|
||||
expire = datetime.fromtimestamp(int(expire))
|
||||
if expire < ctx['expires_at']:
|
||||
ctx['expires_at'] = expire
|
||||
except Exception as e:
|
||||
logging.exception("failed parsing expire", e)
|
||||
except Exception as e:
|
||||
ctx['error'] = str(e)
|
||||
raise
|
||||
finally:
|
||||
ctx['event'].set()
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def cache_prune_loop():
|
||||
while True:
|
||||
sleep(3600)
|
||||
for key in list(search_cache.keys()):
|
||||
if datetime.now() >= search_cache[key]['expires_at']:
|
||||
del search_cache[key]
|
||||
for key in list(resolve_cache.keys()):
|
||||
if datetime.now() >= resolve_cache[key]['expires_at']:
|
||||
del resolve_cache[key]
|
||||
Thread(target=cache_prune_loop, daemon=True).start()
|
||||
|
||||
with ThreadingHTTPServer((environ.get('ADDRESS', ''), int(environ.get('PORT', 80))), Handler) as server:
|
||||
server.serve_forever()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=u2b.cx youtube search resolver service thingy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
#youtubedl writes cache on disk so user should have home
|
||||
User=u2b
|
||||
Group=u2b
|
||||
WorkingDirectory=/srv/u2b.cx/
|
||||
Environment=ADDRESS=127.0.0.1 PORT=52482
|
||||
ExecStart=/usr/bin/python3.11 server.py
|
||||
MemoryMax=1G
|
||||
LimitNOFILE=262144
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 923 KiB |
Binary file not shown.
@@ -1,83 +0,0 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<meta property="og:title" content="Watch YouTube on Quest VRChat!!!" />
|
||||
<meta property="og:description" content="Just type in https://u2b.cx/ followed by whatever you wanna watch!" />
|
||||
<meta property="og:image" content="https://www.u2b.cx/promo.gif" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="keywords" content="youtube,quest,vrchat" />
|
||||
<title>Watch YouTube on Quest VRChat with u2b.cx!!!</title>
|
||||
<style>
|
||||
img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
code {
|
||||
white-space: nowrap;
|
||||
color: brown;
|
||||
}
|
||||
</style>
|
||||
</head><body>
|
||||
<h2 id="Problem">Problem:</h2>
|
||||
<img src="VRChat_2023-03-21_22-37-32.758_1920x1080.png" alt="A video player in VRChat" style="max-height: 50vh" />
|
||||
<p>No video! No PC to put video! No URLs memorized! What do???</p>
|
||||
<h2 id="Solution">SOLUTION!</h2>
|
||||
<ol>
|
||||
<li>Type the following exactly into the URL bar: <code>https://u2b.cx/</code></li>
|
||||
<li>TIP: Press the copy <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADUAAAA0CAMAAAAdZIDnAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADYUExURRwiKxwqMhsvNhwvNhwuNhwmLxwkLRo9QxhKUBdQVhZTWRZSWBdOVBhGTBsyORlCSRVVXBAdIw8XHQ8VGxNUWRdPVRs0Oxs3PhZUWhJFShlESxJJTxwkLBhJTxArMRVYXho6QBAqLxo6QRhLURApLxVZXxo7Qf///8rKy/39/VZYWfv7+2ZnaFBRU2RlZhRaYBo7QhhIThAxNhVXXRlARxRUWw8VHBZRWBswOBE2PRRYXxlCSBJKUBFITRNKURZTWxlFSxwpMRwtNRo8QxlGTBlFTBo4PxwjLAQa4UkAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADhSURBVEhL7dbXDoIwFIDhulFxD3Di3nvvPd7/jSxyIhjBHJF4I99VT5M/KU1ISr5gsdpw7A4oiNPFuD1eJNbnl6pAMBSOoEVjcTHieJiREox4ymQKRiyenjGdycKEJeToXTB5mLCEAq2KJZiwyhWzkv1ZVX2AjWdaVQ0W6oyu4IRUHTZlb6oGrJuv32ZWSoZWrbaeivppJYMdmXbVgUTtZ9Gu3jErJbNS0ld1e/S90R/AhDXk6NtmNIYJaTKd0crKzmHGWSzF1xfh2MVqjbXZ7vb3ihyOy9MZaXS5StHHCLkBefveLzCsA+EAAAAASUVORK5CYII=" width="24" height="24" /> button on the lower left 😉</li>
|
||||
<li>Now after that just type what you want to watch. Here are just a few examples:</li>
|
||||
<ul>
|
||||
<li><code>https://u2b.cx/nyan cat</code></li>
|
||||
<li><code>https://u2b.cx/never gonna give you up</code></li>
|
||||
<li><code>https://u2b.cx/arabic cat</code></li>
|
||||
<li><code>https://u2b.cx/gangnam style</code></li>
|
||||
<li><code>https://u2b.cx/miku</code></li>
|
||||
<li><code>https://u2b.cx/despacito</code></li>
|
||||
<li><code>https://u2b.cx/baby shark dance</code></li>
|
||||
<li><code>https://u2b.cx/technology connections</code></li>
|
||||
</ul>
|
||||
<li>Just like with the Discord music bots, the first search result from YouTube will start playing.</li>
|
||||
<li>TIP: Next time, press the paste <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADUAAAA0CAMAAAAdZIDnAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAADeUExURRwiKxwqMhsvNxwvNhwtNRsyORhGTBdOVBZRVhZSWBZRVxhLURo+RBwkLBs2PBdPVRNUWg8ZHw8VGw8WHBAdIxVXXBlFSxwlLhhKUBJHTA8WGxZUWho5QBszOhJKTxo6QRVYXhArMBhJTxwkLRo7QRVZXxAqLxo7QhApL6Skpf///1ZXWVxdXvf39/Hx8bKzs7KysrGxsrCxsUlKTBVZXhs4PhVYXREwNhwjLBsvNhRUWxlBRxlBSBE2PRsxOBwoMBZTWhNMUhFITRJHThRYXxdMUhs3PhlFTBo9RBwuNtYqywoAAAAJcEhZcwAADsMAAA7DAcdvqGQAAADwSURBVEhL7dbZboJQEIDhcalacatWRLHWvfUUF1AsLlXrUvX9X6gcnFhCghliYlLDf3OYSb6Qk3ABXFEgGCIWRgHwEInGHonFhUTSQql05ilLLfecFzkqSEXc0CrJZVO9yDhSq/CXvVZxolarNwCaLZyotQVTvXlV776y9c9VhzF8cuSqPhRFYV3GzAM3ttwUs8r2Toczd4XQJDdQ53x1v6rfHwxUVdU0ja6G+D1Z4e4vN3U5X9nzlb1r1AgnapbSxzhR+5QMgEk8hyOx6Yz/SM29XewrveBqKa6+19Q2290PRwCGPovsiYmH4wl5DuAXf0nZllJEIHwAAAAASUVORK5CYII=" width="24" height="24" /> button on the lower right so you don't have to type the whole thing all over again 👍</li>
|
||||
</ol>
|
||||
<h3 id="Demo">Demo</h3>
|
||||
<video controls style="max-height: 80vh"><source src="com.vrchat.oculus.quest-20230321-223838.mp4" type="video/mp4"/></video>
|
||||
<h3 id="Advanced_usage">Advanced Usage</h3>
|
||||
<p>For PC users or world creators who want to make a particular video work on Quest, u2b.cx can also be used as an alternative to sites like <a href="https://nextnex.com" target="_blank">nextnex.com</a> and <a href="https://ytdlh.cf/" target="_blank">ytdlh.cf</a> by using the following syntax: <code>https://u2b.cx/id/<youtube video id></code></p>
|
||||
<p>Paste a YouTube URL here to have it converted for you: <input id="input" type="text" placeholder="https://www.youtube.com/watch?v=dQw4w9WgXcQ" style="width: 309px" /> <span id="output_container" style="display: none"> ➡ <code><span id="output"></span></code> <button id="copy_btn">Copy</button></span></p>
|
||||
<script>
|
||||
var input = document.getElementById("input");
|
||||
var output = document.getElementById("output");
|
||||
var output_container = document.getElementById("output_container");
|
||||
var copy_btn = document.getElementById("copy_btn");
|
||||
input.oninput = function() {
|
||||
var match = input.value.match(/[A-Za-z0-9_-]{11}/);
|
||||
if (match) {
|
||||
output.innerText = `https://u2b.cx/id/${match[0]}`;
|
||||
output_container.style.display = '';
|
||||
} else {
|
||||
output.innerText = '';
|
||||
output_container.style.display = "none";
|
||||
}
|
||||
};
|
||||
copy_btn.onclick = function() {
|
||||
navigator.clipboard.writeText(output.innerText);
|
||||
copy_btn.disabled = true;
|
||||
copy_btn.innerText = "Copied";
|
||||
setTimeout(function(){
|
||||
copy_btn.disabled = false;
|
||||
copy_btn.innerText = "Copy";
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
<p>For potential support of prefixing implementations, known YouTube URL formats will also be parsed for the ID. This means any one of the following will bypass the YouTube search and look up the video directly:</p>
|
||||
<ul>
|
||||
<li><code>https://u2b.cx/id/dQw4w9WgXcQ</code> (recommended)</li>
|
||||
<li><code>https://u2b.cx/https://www.youtube.com/watch?v=dQw4w9WgXcQ</code></li>
|
||||
<li><code>https://u2b.cx/https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ</code></li>
|
||||
<li><code>https://u2b.cx/http://youtube.com/watch?v=dQw4w9WgXcQ</code></li>
|
||||
<li><code>https://u2b.cx/youtube.com/watch?v=dQw4w9WgXcQ</code></li>
|
||||
<li><code>https://u2b.cx/https://youtu.be/dQw4w9WgXcQ</code></li>
|
||||
<li><code>https://u2b.cx/https://youtube/shorts/dQw4w9WgXcQ</code></li>
|
||||
</ul>
|
||||
<p>Anything else will be sent to YouTube search, which will probably still get what you want.</p>
|
||||
<h3 id="Problems">Problems?</h3>
|
||||
<p>If you encounter any issues such as a service outage, please submit to the <a href="https://gitea.moe/lamp/u2b.cx/issues/new">issue tracker</a>. Thanks.</p>
|
||||
</body></html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Reference in New Issue
Block a user