Compare commits
42 Commits
c791be328e
..
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 | |||
| 85122ba2e9 | |||
| 6e363f0787 |
+5
-1
@@ -1 +1,5 @@
|
||||
.vscode
|
||||
.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,79 +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).
|
||||
|
||||
|
||||
## 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,80 +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):
|
||||
entries = info.get('entries')
|
||||
if not entries: return None
|
||||
formats = entries[0].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 do_GET(self):
|
||||
# block other bot junk in reverse proxy
|
||||
if self.path in ["/", "/favicon.ico"]:
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
query = unquote(self.path[1:])
|
||||
dict = query_dicts.get(query)
|
||||
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)
|
||||
|
||||
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("ytsearch:" + 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)
|
||||
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 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://",'')
|
||||
self.send_response(302)
|
||||
self.send_header("Location", url)
|
||||
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):
|
||||
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
|
||||
|
||||
video_url = ytdl_resolve_mp4_url(self, video_id)
|
||||
|
||||
if 'PROXY' in environ:
|
||||
video_url = environ['PROXY'] + video_url.replace("https://",'')
|
||||
self.send_response(302)
|
||||
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,30 +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;
|
||||
}
|
||||
</style>
|
||||
</head><body>
|
||||
<i><h2>Problem:</h2></i>
|
||||
<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>
|
||||
<i><h2>SOLUTION!</h2></i>
|
||||
<ol>
|
||||
<li>Type the following exactly into the URL bar: <code>https://u2b.cx/</code></li>
|
||||
<li>TIP: Press the <img alt="copy" 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 wanna watch. For example, if you wanna play nyan cat, type in like so: <code>https://u2b.cx/nyan cat</code></li>
|
||||
<li>The first result from YouTube will start playing. YAAY!</li>
|
||||
<li>TIP: When you wanna watch again, press the <img alt="paste" 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 it all again 👌</li>
|
||||
</ol>
|
||||
<i><h3>Demo</h3></i>
|
||||
<video controls style="max-height: 80vh"><source src="com.vrchat.oculus.quest-20230321-223838.mp4" type="video/mp4"/></video>
|
||||
<i><h3>Problems?</h3></i>
|
||||
<p>If you encounter any issues, please submit on <a href="https://gitea.moe/lamp/u2b.cx/issues">issue tracker</a>. You can use Discord or GitHub account.</p>
|
||||
</body></html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Reference in New Issue
Block a user