Reuse upstream connections, and stop sniffing what we already know
Two costs sat in front of every video: a TLS handshake per upstream request,
and a round trip spent asking the proxy what kind of file it was about to
play.
The session cache was a thread-local, which never once hit -- the server
gives each connection a fresh thread, so every request found empty storage
and built a session, and with it a new connection to the CDN. Instrumented,
that was one session per request; a video is dozens of range requests and an
HLS stream one per segment. Sessions now live in a shared pool, checked out
for a request and returned when its response closes (for a streamed body,
after the last byte), so the connection stays warm. Measured against a
nearby CDN: 32-45ms per request becomes 9-11ms.
The player then HEADed the proxy before playback to sniff a content type --
and that HEAD ran a full upstream GET server-side, so two connections were
opened before the first byte of video was asked for. It now sniffs only when
neither the URL's extension nor yt-dlp's `protocol` says what the source is,
which is nearly never; `protocol` is newly carried through /api/resolve for
exactly this. A HEAD that does still happen asks upstream for one byte and
restates the 206 as a 200 describing the whole resource.
`format_note` joins the resolved fields too: the quality menu has been
reading it since 52d7802, but the backend was dropping it, so no note could
ever have been shown.
Tests (scratchpad, headless): sessions reused across requests, never shared
by two at once, returned after a client aborts mid-stream; HEAD probes one
byte while ranged and plain GETs are byte-identical; no HEAD for an mp4 or a
protocol-bearing URL, and one for a URL with neither.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
150
backend/main.py
150
backend/main.py
@@ -10,6 +10,7 @@ import yt_dlp
|
|||||||
from yt_dlp.networking.impersonate import ImpersonateTarget
|
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||||
from curl_cffi import requests as impersonate_requests
|
from curl_cffi import requests as impersonate_requests
|
||||||
import threading
|
import threading
|
||||||
|
import queue
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -20,18 +21,75 @@ from urllib.parse import urljoin
|
|||||||
# that isn't a real browser, so impersonation must be on by default.
|
# that isn't a real browser, so impersonation must be on by default.
|
||||||
IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome'
|
IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome'
|
||||||
|
|
||||||
# curl_cffi sessions wrap a single libcurl handle and are not safe to share
|
# curl_cffi sessions wrap a single libcurl handle: they can't be shared by two
|
||||||
# across threads; keep one per worker thread so the Flask `threaded=True`
|
# requests at once, but reusing one *across* requests is what keeps the upstream
|
||||||
# server can proxy concurrent segments without corrupting state.
|
# connection alive, and with it the TLS handshake we already paid for. A video
|
||||||
_thread_local = threading.local()
|
# arrives as dozens of range requests (and an HLS stream as one request per
|
||||||
|
# segment), so a handshake per request is the difference between a stall and a
|
||||||
|
# seek.
|
||||||
|
#
|
||||||
|
# This used to be a thread-local, which never actually hit: the development
|
||||||
|
# server gives every connection a brand-new thread, so each request found empty
|
||||||
|
# thread-local storage and built a session from scratch. Sessions live in a
|
||||||
|
# shared pool instead -- checked out for the duration of one request, returned
|
||||||
|
# when its response is closed (which, for a streamed body, is when the last byte
|
||||||
|
# has been sent). LIFO so the hottest connection is the one handed out next.
|
||||||
|
try:
|
||||||
|
_SESSION_POOL_SIZE = max(1, int(os.getenv('STREAM_SESSION_POOL', '') or 8))
|
||||||
|
except ValueError:
|
||||||
|
_SESSION_POOL_SIZE = 8
|
||||||
|
_session_pool = queue.LifoQueue(maxsize=_SESSION_POOL_SIZE)
|
||||||
|
|
||||||
|
|
||||||
def get_impersonate_session():
|
def _borrow_session():
|
||||||
sess = getattr(_thread_local, 'session', None)
|
"""A session nobody else is using: from the pool, or a fresh one."""
|
||||||
if sess is None:
|
try:
|
||||||
sess = impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
|
return _session_pool.get_nowait()
|
||||||
_thread_local.session = sess
|
except queue.Empty:
|
||||||
return sess
|
return impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
|
||||||
|
|
||||||
|
|
||||||
|
def _return_session(sess):
|
||||||
|
"""Hand a session back. Beyond the pool's size the extras are closed, so a
|
||||||
|
burst of concurrency doesn't leave idle connections open forever."""
|
||||||
|
try:
|
||||||
|
_session_pool.put_nowait(sess)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
sess.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _discard_session(sess):
|
||||||
|
"""Drop a session that raised, rather than pooling a possibly-poisoned handle."""
|
||||||
|
try:
|
||||||
|
sess.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _release_when_closed(resp, sess):
|
||||||
|
"""Return `sess` to the pool once `resp` is closed.
|
||||||
|
|
||||||
|
Every caller either closes the response outright or streams it through a
|
||||||
|
generator that closes in a `finally`, so this is where a request's exclusive
|
||||||
|
hold on a session ends. Idempotent: a double close must not put the same
|
||||||
|
session in the pool twice."""
|
||||||
|
original_close = resp.close
|
||||||
|
released = False
|
||||||
|
|
||||||
|
def close():
|
||||||
|
nonlocal released
|
||||||
|
try:
|
||||||
|
original_close()
|
||||||
|
finally:
|
||||||
|
if not released:
|
||||||
|
released = True
|
||||||
|
_return_session(sess)
|
||||||
|
|
||||||
|
resp.close = close
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
def _is_tls_verify_error(err):
|
def _is_tls_verify_error(err):
|
||||||
@@ -54,17 +112,27 @@ def impersonate_get(url, **kwargs):
|
|||||||
stream we retry once with verification off, and say so in the log. Set
|
stream we retry once with verification off, and say so in the log. Set
|
||||||
STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead."""
|
STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead."""
|
||||||
host = urllib.parse.urlparse(url).netloc
|
host = urllib.parse.urlparse(url).netloc
|
||||||
|
sess = _borrow_session()
|
||||||
if host in _tls_unverified_hosts:
|
if host in _tls_unverified_hosts:
|
||||||
return get_impersonate_session().get(url, verify=False, **kwargs)
|
|
||||||
try:
|
try:
|
||||||
return get_impersonate_session().get(url, **kwargs)
|
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
|
||||||
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
return _release_when_closed(sess.get(url, **kwargs), sess)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
if strict or not _is_tls_verify_error(err):
|
if strict or not _is_tls_verify_error(err):
|
||||||
|
_discard_session(sess)
|
||||||
raise
|
raise
|
||||||
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
|
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
|
||||||
_tls_unverified_hosts.add(host)
|
_tls_unverified_hosts.add(host)
|
||||||
return get_impersonate_session().get(url, verify=False, **kwargs)
|
try:
|
||||||
|
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
|
||||||
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
raise
|
||||||
|
|
||||||
# Stream params that have dedicated meaning and must never be treated as headers.
|
# Stream params that have dedicated meaning and must never be treated as headers.
|
||||||
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but
|
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but
|
||||||
@@ -80,6 +148,10 @@ STREAM_IMPERSONATION_MANAGED_HEADERS = {
|
|||||||
'user-agent', 'accept', 'accept-encoding', 'accept-language',
|
'user-agent', 'accept', 'accept-encoding', 'accept-language',
|
||||||
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
|
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
|
||||||
}
|
}
|
||||||
|
# `Content-Range: bytes 0-0/12345` -> the total size of the resource. A '*'
|
||||||
|
# total (an origin that won't say) deliberately doesn't match, so the length is
|
||||||
|
# then simply left out rather than guessed at.
|
||||||
|
_CONTENT_RANGE_TOTAL_RE = re.compile(r'^\s*bytes\s+\d+-\d+/(\d+)\s*$', re.I)
|
||||||
# RFC 7230 token charset for header field-names.
|
# RFC 7230 token charset for header field-names.
|
||||||
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
||||||
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
|
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
|
||||||
@@ -217,8 +289,13 @@ _resolve_cache_lock = threading.Lock()
|
|||||||
# Per-format fields the frontend needs to rank formats and build stream/probe
|
# Per-format fields the frontend needs to rank formats and build stream/probe
|
||||||
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
|
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
|
||||||
# yt-dlp format dict is dropped to keep the payload small.
|
# yt-dlp format dict is dropped to keep the payload small.
|
||||||
|
# `protocol` is what yt-dlp calls the delivery method ('https', 'm3u8_native',
|
||||||
|
# 'http_dash_segments', ...). Passing it on saves the player a HEAD round trip
|
||||||
|
# against the proxy -- and with it a whole upstream connection -- for URLs whose
|
||||||
|
# extension doesn't say what they are, which is most signed CDN links.
|
||||||
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
|
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
|
||||||
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality')
|
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
|
||||||
|
'protocol', 'format_note')
|
||||||
|
|
||||||
# Some channels surface pages that yt-dlp can't extract because the video is
|
# Some channels surface pages that yt-dlp can't extract because the video is
|
||||||
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
|
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
|
||||||
@@ -233,8 +310,11 @@ def resolve_unsupported_embed(page_url):
|
|||||||
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
|
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
|
||||||
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
|
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
|
||||||
single format is the embed's HLS playlist, or None if nothing was found."""
|
single format is the embed's HLS playlist, or None if nothing was found."""
|
||||||
|
# Both fetches are small and fully buffered, so this holds one pooled session
|
||||||
|
# for the whole scrape rather than going through impersonate_get (whose
|
||||||
|
# release is tied to closing a streamed response).
|
||||||
|
sess = _borrow_session()
|
||||||
try:
|
try:
|
||||||
sess = get_impersonate_session()
|
|
||||||
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
|
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
|
||||||
embed_url = None
|
embed_url = None
|
||||||
for src in _EMBED_IFRAME_RE.findall(page.text):
|
for src in _EMBED_IFRAME_RE.findall(page.text):
|
||||||
@@ -262,7 +342,12 @@ def resolve_unsupported_embed(page_url):
|
|||||||
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
|
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
sess = None
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if sess is not None:
|
||||||
|
_return_session(sess)
|
||||||
|
|
||||||
@app.route('/api/resolve', methods=['POST', 'GET'])
|
@app.route('/api/resolve', methods=['POST', 'GET'])
|
||||||
def resolve_video():
|
def resolve_video():
|
||||||
@@ -634,6 +719,15 @@ def stream_video():
|
|||||||
if 'Range' in request.headers:
|
if 'Range' in request.headers:
|
||||||
safe_request_headers['Range'] = request.headers['Range']
|
safe_request_headers['Range'] = request.headers['Range']
|
||||||
|
|
||||||
|
# A HEAD wants headers, not video -- but we don't send a HEAD upstream
|
||||||
|
# here (hotlink-protected origins routinely answer one method and not
|
||||||
|
# the other, and the GET is the one we know works). Ask for a single
|
||||||
|
# byte instead: same headers, none of the transfer. The response is
|
||||||
|
# restated as a description of the whole resource further down.
|
||||||
|
head_probe = request.method == 'HEAD' and 'Range' not in safe_request_headers
|
||||||
|
if head_probe:
|
||||||
|
safe_request_headers['Range'] = 'bytes=0-0'
|
||||||
|
|
||||||
resp = impersonate_get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
|
resp = impersonate_get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
|
||||||
# Some channel proxies (e.g. the "animeidhentai" hottub proxy) use
|
# Some channel proxies (e.g. the "animeidhentai" hottub proxy) use
|
||||||
# inverted hotlink protection: they 403 any request that carries a
|
# inverted hotlink protection: they 403 any request that carries a
|
||||||
@@ -694,8 +788,32 @@ def stream_video():
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.method == 'HEAD':
|
if request.method == 'HEAD':
|
||||||
|
status = resp.status_code
|
||||||
|
# Read from the headers we already copied: curl_cffi doesn't keep a
|
||||||
|
# response's headers readable once it has been closed.
|
||||||
|
content_range = next((value for name, value in forwarded_headers
|
||||||
|
if name.lower() == 'content-range'), '')
|
||||||
resp.close()
|
resp.close()
|
||||||
return Response("", status=resp.status_code, headers=forwarded_headers)
|
if head_probe and status == 206:
|
||||||
|
# We asked for one byte; the caller asked about the resource.
|
||||||
|
# Restate the 206 as a 200 describing the whole thing, taking
|
||||||
|
# the real length out of `Content-Range: bytes 0-0/<total>`.
|
||||||
|
# (An origin that ignored the range answered 200 already, and
|
||||||
|
# its headers need no fixing.)
|
||||||
|
match = _CONTENT_RANGE_TOTAL_RE.match(content_range or '')
|
||||||
|
total = match.group(1) if match else None
|
||||||
|
forwarded_headers = [(name, value) for name, value in forwarded_headers
|
||||||
|
if name.lower() not in ('content-range', 'content-length')]
|
||||||
|
head_response = Response("", status=200, headers=forwarded_headers)
|
||||||
|
if total:
|
||||||
|
# A HEAD carries the entity headers its GET would, with no
|
||||||
|
# body -- so the length is the resource's, not the zero
|
||||||
|
# bytes we're sending. Werkzeug derives Content-Length from
|
||||||
|
# the body unless told not to.
|
||||||
|
head_response.automatically_set_content_length = False
|
||||||
|
head_response.headers['Content-Length'] = total
|
||||||
|
return head_response
|
||||||
|
return Response("", status=status, headers=forwarded_headers)
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ App.feed = App.feed || {};
|
|||||||
// What's actually on screen, so the quality menu can tick it.
|
// What's actually on screen, so the quality menu can tick it.
|
||||||
slide._activeUrl = resolved.url;
|
slide._activeUrl = resolved.url;
|
||||||
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
|
const isHls = App.videos.classifySource(resolved).isHls;
|
||||||
|
|
||||||
video.muted = state.feedMuted;
|
video.muted = state.feedMuted;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
|
|||||||
@@ -734,9 +734,9 @@ App.player = App.player || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
|
let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
|
||||||
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
const kind = App.videos.classifySource(resolved);
|
||||||
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
|
let isHls = kind.isHls;
|
||||||
if (resolved.isLive) { isHls = true; isDirectMedia = false; }
|
let isDirectMedia = kind.isDirectMedia;
|
||||||
|
|
||||||
video.onerror = null;
|
video.onerror = null;
|
||||||
if (state.hlsPlayer) {
|
if (state.hlsPlayer) {
|
||||||
@@ -749,7 +749,11 @@ App.player = App.player || {};
|
|||||||
video.removeAttribute('src');
|
video.removeAttribute('src');
|
||||||
video.load();
|
video.load();
|
||||||
|
|
||||||
if (!isHls && !entry.direct) {
|
// Last resort only: a HEAD through the proxy is a whole upstream
|
||||||
|
// connection (handshake included) before the first byte of video is
|
||||||
|
// ever requested, so it runs only when neither the URL nor the
|
||||||
|
// extractor's protocol says what this source is.
|
||||||
|
if (!isHls && !isDirectMedia && !entry.direct) {
|
||||||
try {
|
try {
|
||||||
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
||||||
if (token !== cp.attemptToken) return;
|
if (token !== cp.attemptToken) return;
|
||||||
|
|||||||
@@ -1274,7 +1274,15 @@ App.videos = App.videos || {};
|
|||||||
const referer = explicitReferer || deriveReferer(fmt.url);
|
const referer = explicitReferer || deriveReferer(fmt.url);
|
||||||
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
||||||
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
||||||
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
|
// `protocol` is the extractor's own word for how this format is
|
||||||
|
// delivered ('https', 'm3u8_native', ...). Carrying it through lets
|
||||||
|
// the player skip its content-type sniff -- a full round trip
|
||||||
|
// through the proxy -- for URLs whose extension gives nothing away.
|
||||||
|
return {
|
||||||
|
url: fmt.url, referer, userAgent, headers, isLive,
|
||||||
|
refererRequired: !!explicitReferer,
|
||||||
|
protocol: fmt.protocol || ''
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sources.length) {
|
if (!sources.length) {
|
||||||
@@ -1312,7 +1320,37 @@ App.videos = App.videos || {};
|
|||||||
const referer = explicitReferer || deriveReferer(fmt.url);
|
const referer = explicitReferer || deriveReferer(fmt.url);
|
||||||
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
||||||
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
||||||
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
|
return {
|
||||||
|
url: fmt.url, referer, userAgent, headers, isLive,
|
||||||
|
refererRequired: !!explicitReferer,
|
||||||
|
protocol: fmt.protocol || ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// How does this source play -- an HLS manifest, or a media file the <video>
|
||||||
|
// element can take directly? Answered from the strongest evidence at hand:
|
||||||
|
// a live stream is always a manifest here, then the extractor's `protocol`,
|
||||||
|
// then the URL's extension. When none of them says (a signed CDN link with
|
||||||
|
// no extension and no protocol), the caller is left to sniff the content
|
||||||
|
// type over the network, which is why `protocol` is worth carrying around.
|
||||||
|
App.videos.classifySource = function(resolved) {
|
||||||
|
const url = (resolved && resolved.url) || '';
|
||||||
|
let isHls = /\.m3u8($|\?)/i.test(url);
|
||||||
|
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(url);
|
||||||
|
const protocol = String((resolved && resolved.protocol) || '').toLowerCase();
|
||||||
|
if (protocol.indexOf('m3u8') >= 0) {
|
||||||
|
isHls = true;
|
||||||
|
isDirectMedia = false;
|
||||||
|
} else if (protocol === 'https' || protocol === 'http') {
|
||||||
|
// A plain HTTP(S) download: one file, played as-is. Anything else
|
||||||
|
// yt-dlp names (http_dash_segments, ism, ...) stays unknown here.
|
||||||
|
isDirectMedia = true;
|
||||||
|
}
|
||||||
|
if (resolved && resolved.isLive) {
|
||||||
|
isHls = true;
|
||||||
|
isDirectMedia = false;
|
||||||
|
}
|
||||||
|
return { isHls, isDirectMedia };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background "direct playability" probe. The backend proxy exists to work
|
// Background "direct playability" probe. The backend proxy exists to work
|
||||||
|
|||||||
1
media_srv2.log
Normal file
1
media_srv2.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/bin/bash: line 1: cd: too many arguments
|
||||||
Reference in New Issue
Block a user