AI slop update to switch warp docker ip on bot block
This commit is contained in:
@@ -89,7 +89,7 @@ class CommandHandler():
|
|||||||
Ret = await Command._rfunc(line, RMatch, player)
|
Ret = await Command._rfunc(line, RMatch, player)
|
||||||
else:
|
else:
|
||||||
#self.Logger.debug(f"_rfunc line: {line}")
|
#self.Logger.debug(f"_rfunc line: {line}")
|
||||||
if line.startswith("!yt"): #transfering the line if its !yt or !yts in case people want to include pitch or tempo.
|
if line.lower().startswith("!yt"): #transfering the line if its !yt or !yts in case people want to include pitch or tempo. (case-insensitive: trigger matching itself is case-insensitive, so this must be too)
|
||||||
Ret = await Command._func(Message, player, line)
|
Ret = await Command._func(Message, player, line)
|
||||||
else:
|
else:
|
||||||
Ret = await Command._func(Message, player)
|
Ret = await Command._func(Message, player)
|
||||||
|
|||||||
@@ -8,6 +8,58 @@ import math
|
|||||||
from .Utils import Utils, DataHolder
|
from .Utils import Utils, DataHolder
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
# Path to the WARP IP-rotation script. Must be executable (chmod +x) and live
|
||||||
|
# in the same folder as _start.sh (the systemd unit's WorkingDirectory), since
|
||||||
|
# rotate_warp.sh itself cd's into the warp-docker compose folder.
|
||||||
|
WARP_ROTATE_SCRIPT = "/home/gameservers/css_ze/torchlight3/rotate_warp.sh"
|
||||||
|
WARP_ROTATE_TIMEOUT = 60 # seconds to allow rotate_warp.sh to bring WARP back up
|
||||||
|
|
||||||
|
# yt-dlp's stderr when YouTube's bot-check trips. Matched as two substrings
|
||||||
|
# rather than one exact string since the apostrophe yt-dlp prints can vary
|
||||||
|
# (straight vs curly quote) between versions.
|
||||||
|
YOUTUBE_BOT_CHECK_MARKERS = (b"Sign in to confirm", b"not a bot")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_ytdlp_with_rotation(logger, *args):
|
||||||
|
"""
|
||||||
|
Run `yt-dlp *args` exactly as before. If yt-dlp's stderr shows YouTube's
|
||||||
|
"Sign in to confirm you're not a bot" bot-check, rotate the WARP proxy's
|
||||||
|
IP once (via rotate_warp.sh) and transparently retry the exact same
|
||||||
|
yt-dlp call before giving up. The caller (and the end user) never sees
|
||||||
|
the first, blocked attempt -- they just get a result, possibly a couple
|
||||||
|
seconds slower.
|
||||||
|
|
||||||
|
Returns (stdout_bytes, stderr_bytes) from the final attempt.
|
||||||
|
"""
|
||||||
|
Out, Err = b"", b""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
Proc = await asyncio.create_subprocess_exec(
|
||||||
|
"yt-dlp", *args,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE)
|
||||||
|
Out, Err = await Proc.communicate()
|
||||||
|
|
||||||
|
BotChecked = all(marker in Err for marker in YOUTUBE_BOT_CHECK_MARKERS)
|
||||||
|
if BotChecked and attempt == 1:
|
||||||
|
logger.warning("yt-dlp hit YouTube's bot-check, rotating WARP IP and retrying: %s", args)
|
||||||
|
try:
|
||||||
|
RotateProc = await asyncio.create_subprocess_exec(
|
||||||
|
WARP_ROTATE_SCRIPT,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE)
|
||||||
|
RotateOut, RotateErr = await asyncio.wait_for(
|
||||||
|
RotateProc.communicate(), timeout=WARP_ROTATE_TIMEOUT)
|
||||||
|
if RotateProc.returncode != 0:
|
||||||
|
logger.error("rotate_warp.sh failed (rc=%s): %s", RotateProc.returncode,
|
||||||
|
RotateErr.decode(errors="replace"))
|
||||||
|
except (asyncio.TimeoutError, OSError) as e:
|
||||||
|
logger.error("WARP rotation did not complete in time, retrying with same IP anyway: %s", e)
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
return Out, Err
|
||||||
|
|
||||||
|
|
||||||
def get_birtate(message):
|
def get_birtate(message):
|
||||||
bitrate = []
|
bitrate = []
|
||||||
try:
|
try:
|
||||||
@@ -114,18 +166,22 @@ class URLFilter(BaseCommand):
|
|||||||
if TimeStr:
|
if TimeStr:
|
||||||
Time = Utils.ParseTime(TimeStr)
|
Time = Utils.ParseTime(TimeStr)
|
||||||
|
|
||||||
Proc = await asyncio.create_subprocess_exec("yt-dlp", "--dump-json", "-g", url,
|
# No "-g" here: we only need metadata (title/duration/webpage_url)
|
||||||
stdout = asyncio.subprocess.PIPE)
|
# for the chat message. Actual playback happens by handing the
|
||||||
Out, _ = await Proc.communicate()
|
# canonical webpage URL to yt-dlp again at play time (see
|
||||||
|
# YTDLP_URI_PREFIX in FFmpegAudioPlayer.py) rather than resolving
|
||||||
|
# to a direct googlevideo URL here and having a separate process
|
||||||
|
# (ffmpeg) try to fetch it -- that signed-URL handoff turned out
|
||||||
|
# to reliably 403 even with matching proxy/headers.
|
||||||
|
Out, Err = await run_ytdlp_with_rotation(self.Logger, "--dump-json", "--no-playlist", url)
|
||||||
|
|
||||||
parts = Out.split(b'\n')
|
if not Out.strip():
|
||||||
parts.pop() # trailing new line
|
self.Logger.error("yt-dlp returned no output for %s: %s", url, Err.decode(errors = "replace"))
|
||||||
|
self.Torchlight().SayChat("Error: Could not fetch YouTube info, try again in a moment.")
|
||||||
|
return url, Text
|
||||||
|
|
||||||
Info = parts.pop()
|
Info = self.json.loads(Out.strip().split(b'\n')[-1])
|
||||||
url = parts.pop()
|
url = "ytdlp:" + Info.get("webpage_url", url)
|
||||||
|
|
||||||
url = url.strip().decode("ascii")
|
|
||||||
Info = self.json.loads(Info)
|
|
||||||
|
|
||||||
if Info["extractor_key"] == "Youtube":
|
if Info["extractor_key"] == "Youtube":
|
||||||
self.Torchlight().SayChat("\x07E52D27[YouTube]\x01 {0} | {1} | {2:,}".format(
|
self.Torchlight().SayChat("\x07E52D27[YouTube]\x01 {0} | {1} | {2:,}".format(
|
||||||
@@ -704,14 +760,19 @@ class YouTubeSearch(BaseCommand):
|
|||||||
message[1] = message[1][:Temp.value]
|
message[1] = message[1][:Temp.value]
|
||||||
|
|
||||||
search_term = message[1].split("pitch=")[0].split("tempo=")[0].split('backward=')[0].split('backwards=')[0]
|
search_term = message[1].split("pitch=")[0].split("tempo=")[0].split('backward=')[0].split('backwards=')[0]
|
||||||
Proc = await asyncio.create_subprocess_exec("yt-dlp", "--dump-json", "-xg", "ytsearch:" + search_term,
|
# No "-xg" here: only metadata is needed at this point. Playback goes
|
||||||
stdout = asyncio.subprocess.PIPE)
|
# through yt-dlp itself again (piped into ffmpeg) rather than a
|
||||||
Out, _ = await Proc.communicate()
|
# resolved googlevideo URL -- see the comment above YTDLP_URI_PREFIX
|
||||||
|
# in FFmpegAudioPlayer.py for why.
|
||||||
|
Out, Err = await run_ytdlp_with_rotation(self.Logger, "--dump-json", "--no-playlist", "ytsearch:" + search_term)
|
||||||
|
|
||||||
print('out value: ', Out)
|
if not Out.strip():
|
||||||
url, Info = Out.split(b'\n', maxsplit = 1)
|
self.Logger.error("yt-dlp returned no output for search '%s': %s", search_term, Err.decode(errors = "replace"))
|
||||||
url = url.strip().decode("ascii")
|
self.Torchlight().SayChat("Error: Could not fetch YouTube info, try again in a moment.")
|
||||||
Info = self.json.loads(Info)
|
return 1
|
||||||
|
|
||||||
|
Info = self.json.loads(Out.strip().split(b'\n')[-1])
|
||||||
|
url = "ytdlp:" + Info.get("webpage_url", "ytsearch:" + search_term)
|
||||||
|
|
||||||
if Info["extractor_key"] == "Youtube":
|
if Info["extractor_key"] == "Youtube":
|
||||||
self.Torchlight().SayChat("\x07E52D27[YouTube]\x01 {0} | {1} | {2:,}".format(
|
self.Torchlight().SayChat("\x07E52D27[YouTube]\x01 {0} | {1} | {2:,}".format(
|
||||||
@@ -987,4 +1048,3 @@ class Reload(BaseCommand):
|
|||||||
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message))
|
self.Logger.debug(sys._getframe().f_code.co_name + ' ' + str(message))
|
||||||
self.Torchlight().Reload()
|
self.Torchlight().Reload()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,27 @@ import time
|
|||||||
import socket
|
import socket
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
SAMPLEBYTES = 2
|
SAMPLEBYTES = 2
|
||||||
|
|
||||||
|
# Playing a googlevideo.com URL directly with ffmpeg turned out unreliable:
|
||||||
|
# the signed URL (and the exact headers/User-Agent yt-dlp used to obtain it)
|
||||||
|
# is tied to the specific request that generated it, and a *separate* ffmpeg
|
||||||
|
# process making its own connection -- even through the same proxy, even with
|
||||||
|
# matching headers copied from the --dump-json output -- still got HTTP 403
|
||||||
|
# from Google. Matching proxy + headers wasn't enough because the header set
|
||||||
|
# also drifts (yt-dlp's impersonated Chrome build number alone changed
|
||||||
|
# between two of our test runs a few minutes apart), so hardcoding it is a
|
||||||
|
# losing game.
|
||||||
|
#
|
||||||
|
# The reliable fix is to stop trying to replay yt-dlp's signed URL from a
|
||||||
|
# second process at all: let yt-dlp itself fetch the media (same process,
|
||||||
|
# same session, same proxy/headers it used to resolve the URL) and stream its
|
||||||
|
# output straight into ffmpeg's stdin via a pipe. URIs that should be played
|
||||||
|
# this way are prefixed with YTDLP_URI_PREFIX by Commands.py.
|
||||||
|
YTDLP_URI_PREFIX = "ytdlp:"
|
||||||
|
|
||||||
class FFmpegAudioPlayerFactory():
|
class FFmpegAudioPlayerFactory():
|
||||||
VALID_CALLBACKS = ["Play", "Stop", "Update"]
|
VALID_CALLBACKS = ["Play", "Stop", "Update"]
|
||||||
|
|
||||||
@@ -50,6 +68,10 @@ class FFmpegAudioPlayer():
|
|||||||
|
|
||||||
self.Writer = None
|
self.Writer = None
|
||||||
self.Process = None
|
self.Process = None
|
||||||
|
# Only set in ytdlp-pipe mode (see PlayURI): the upstream yt-dlp
|
||||||
|
# process feeding ffmpeg's stdin. Needs to be killed alongside
|
||||||
|
# self.Process on Stop(), or it lingers as an orphaned process.
|
||||||
|
self.SourceProcess = None
|
||||||
|
|
||||||
self.Callbacks = []
|
self.Callbacks = []
|
||||||
|
|
||||||
@@ -59,7 +81,25 @@ class FFmpegAudioPlayer():
|
|||||||
|
|
||||||
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
|
def PlayURI(self, uri, position, rubberband = None, dec_params = None, bitrate = None,
|
||||||
backwards = None, *args):
|
backwards = None, *args):
|
||||||
if position:
|
IsYtdlp = uri.startswith(YTDLP_URI_PREFIX)
|
||||||
|
# Commands.py may append a "#t=..." fragment to the URI purely so a
|
||||||
|
# downstream handler can re-parse it into a start position (see
|
||||||
|
# URLFilter.URLInfo) -- strip it back off here since the actual seek
|
||||||
|
# already happens via `position`/"-ss" below, and yt-dlp doesn't need
|
||||||
|
# (or want) an arbitrary fragment tacked onto the watch URL.
|
||||||
|
SourceUrl = uri[len(YTDLP_URI_PREFIX):].split("#")[0] if IsYtdlp else None
|
||||||
|
|
||||||
|
if IsYtdlp:
|
||||||
|
# Input is a pipe (yt-dlp's stdout), which isn't seekable, so a
|
||||||
|
# requested start position has to be an *output*-side "-ss"
|
||||||
|
# (placed after "-i") rather than the normal fast input-side
|
||||||
|
# seek. Slower (ffmpeg decodes and discards up to that point
|
||||||
|
# instead of jumping straight there) but correct.
|
||||||
|
Command = ["/usr/bin/ffmpeg", "-i", "pipe:0"]
|
||||||
|
if position:
|
||||||
|
Command += ["-ss", str(datetime.timedelta(seconds = position))]
|
||||||
|
Command += ["-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||||
|
elif position:
|
||||||
PosStr = str(datetime.timedelta(seconds = position))
|
PosStr = str(datetime.timedelta(seconds = position))
|
||||||
Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
Command = ["/usr/bin/ffmpeg", "-ss", PosStr, "-i", uri, "-acodec", "pcm_s16le", "-ac", "1", "-ar", str(int(self.SampleRate)), "-f", "s16le", "-vn", *args]
|
||||||
else:
|
else:
|
||||||
@@ -92,7 +132,7 @@ class FFmpegAudioPlayer():
|
|||||||
self.Master.Logger.debug(f"command: {Command}")
|
self.Master.Logger.debug(f"command: {Command}")
|
||||||
Command += ["-"]
|
Command += ["-"]
|
||||||
#self.Master.Logger.debug(f"command: {Command}")
|
#self.Master.Logger.debug(f"command: {Command}")
|
||||||
asyncio.ensure_future(self._stream_subprocess(Command))
|
asyncio.ensure_future(self._stream_subprocess(Command, SourceUrl))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def Stop(self, force = True):
|
def Stop(self, force = True):
|
||||||
@@ -107,6 +147,14 @@ class FFmpegAudioPlayer():
|
|||||||
except ProcessLookupError:
|
except ProcessLookupError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
if self.SourceProcess:
|
||||||
|
try:
|
||||||
|
self.SourceProcess.terminate()
|
||||||
|
self.SourceProcess.kill()
|
||||||
|
self.SourceProcess = None
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
if self.Writer:
|
if self.Writer:
|
||||||
if force:
|
if force:
|
||||||
Socket = self.Writer.transport.get_extra_info("socket")
|
Socket = self.Writer.transport.get_extra_info("socket")
|
||||||
@@ -188,18 +236,99 @@ class FFmpegAudioPlayer():
|
|||||||
|
|
||||||
self.StoppedPlaying = time.time()
|
self.StoppedPlaying = time.time()
|
||||||
|
|
||||||
async def _stream_subprocess(self, cmd):
|
async def _stream_subprocess(self, cmd, source_url = None):
|
||||||
if not self.Playing:
|
if not self.Playing:
|
||||||
return
|
return
|
||||||
|
|
||||||
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1])
|
_, self.Writer = await asyncio.open_connection(self.Host[0], self.Host[1])
|
||||||
|
|
||||||
Process = await asyncio.create_subprocess_exec(*cmd,
|
# The metadata fetch (talks to youtube.com) and the actual media
|
||||||
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.DEVNULL)
|
# fetch (talks to googlevideo.com) are separate TCP connections even
|
||||||
self.Process = Process
|
# within one yt-dlp run, and WARP's proxy doesn't guarantee the same
|
||||||
|
# apparent exit IP across separate connections -- Google signs the
|
||||||
|
# media URL to whichever IP it saw on the first connection, and a
|
||||||
|
# mismatched second connection gets 403'd. A retry opens a fresh
|
||||||
|
# connection through WARP and has a decent chance of landing on a
|
||||||
|
# matching IP, so we give it a few tries before giving up.
|
||||||
|
MaxAttempts = 3 if source_url else 1
|
||||||
|
|
||||||
await self._read_stream(Process.stdout, self.Writer)
|
for Attempt in range(1, MaxAttempts + 1):
|
||||||
await Process.wait()
|
if not self.Playing:
|
||||||
|
return
|
||||||
|
|
||||||
|
SourceProcess = None
|
||||||
|
SourceStdErrTask = None
|
||||||
|
StdinArg = None
|
||||||
|
ReadFd = None
|
||||||
|
if source_url:
|
||||||
|
# asyncio.subprocess.PIPE gives back a StreamReader, which is
|
||||||
|
# NOT a real file descriptor and can't be handed to a second
|
||||||
|
# subprocess as stdin -- so we make an actual OS pipe and pass
|
||||||
|
# the raw fds instead, exactly like a shell "|" would, letting
|
||||||
|
# the kernel move the bytes without going through this
|
||||||
|
# process.
|
||||||
|
ReadFd, WriteFd = os.pipe()
|
||||||
|
try:
|
||||||
|
SourceProcess = await asyncio.create_subprocess_exec(
|
||||||
|
"yt-dlp", "-f", "bestaudio/best", "-o", "-", "--no-playlist", "--quiet", source_url,
|
||||||
|
stdout = WriteFd, stderr = asyncio.subprocess.PIPE)
|
||||||
|
finally:
|
||||||
|
os.close(WriteFd) # our copy; the child inherited its own
|
||||||
|
|
||||||
|
self.SourceProcess = SourceProcess
|
||||||
|
SourceStdErrTask = asyncio.ensure_future(SourceProcess.stderr.read())
|
||||||
|
StdinArg = ReadFd
|
||||||
|
|
||||||
|
Process = await asyncio.create_subprocess_exec(*cmd,
|
||||||
|
stdin = StdinArg,
|
||||||
|
stdout = asyncio.subprocess.PIPE, stderr = asyncio.subprocess.PIPE)
|
||||||
|
self.Process = Process
|
||||||
|
|
||||||
|
if source_url:
|
||||||
|
os.close(ReadFd) # our copy; the child inherited its own
|
||||||
|
|
||||||
|
# Drain stderr concurrently so it can't fill its pipe buffer and
|
||||||
|
# stall ffmpeg; we only care about it if playback produced
|
||||||
|
# nothing.
|
||||||
|
StdErrTask = asyncio.ensure_future(Process.stderr.read())
|
||||||
|
|
||||||
|
self.Seconds = 0.0
|
||||||
|
await self._read_stream(Process.stdout, self.Writer)
|
||||||
|
await Process.wait()
|
||||||
|
if SourceProcess:
|
||||||
|
await SourceProcess.wait()
|
||||||
|
|
||||||
|
if self.Seconds > 0.0:
|
||||||
|
return # success
|
||||||
|
|
||||||
|
StdErr = b""
|
||||||
|
try:
|
||||||
|
StdErr = await asyncio.wait_for(StdErrTask, timeout = 2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
SourceStdErr = b""
|
||||||
|
if SourceStdErrTask:
|
||||||
|
try:
|
||||||
|
SourceStdErr = await asyncio.wait_for(SourceStdErrTask, timeout = 2)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
Is403 = source_url and b"403" in SourceStdErr
|
||||||
|
|
||||||
|
if Is403 and Attempt < MaxAttempts and self.Playing:
|
||||||
|
self.Master.Logger.warning("yt-dlp download got 403 (attempt %s/%s), retrying: %s",
|
||||||
|
Attempt, MaxAttempts, source_url)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.Master.Logger.warning(
|
||||||
|
"no audio produced (ffmpeg rc=%s cmd=%s stderr=%s) (yt-dlp rc=%s url=%s stderr=%s)",
|
||||||
|
Process.returncode, cmd, StdErr.decode(errors = "replace")[-1500:],
|
||||||
|
SourceProcess.returncode if SourceProcess else None, source_url,
|
||||||
|
SourceStdErr.decode(errors = "replace")[-1500:])
|
||||||
|
|
||||||
|
if Is403:
|
||||||
|
self.Torchlight().SayChat("Error: YouTube blocked this stream after {0} attempts, try again.".format(MaxAttempts))
|
||||||
|
|
||||||
if self.Seconds == 0.0:
|
|
||||||
self.Stop()
|
self.Stop()
|
||||||
|
return
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# rotate_warp.sh — force warp-docker to register fresh and get a new exit IP.
|
||||||
|
# Called automatically by Commands.py (run_ytdlp_with_rotation) when yt-dlp
|
||||||
|
# hits YouTube's "Sign in to confirm you're not a bot" check.
|
||||||
|
#
|
||||||
|
# Lives at /home/gameservers/css_ze/torchlight3/rotate_warp.sh — same folder
|
||||||
|
# as _start.sh. Make sure it's executable: chmod +x rotate_warp.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WARP_DIR="/home/gameservers/warp-docker"
|
||||||
|
PROXY="http://127.0.0.1:1080"
|
||||||
|
|
||||||
|
cd "$WARP_DIR"
|
||||||
|
|
||||||
|
docker compose down
|
||||||
|
rm -rf ./data/*
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Wait for the new WARP registration to actually come online.
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if curl -s -x "$PROXY" https://cloudflare.com/cdn-cgi/trace 2>/dev/null | grep -q "warp=on"; then
|
||||||
|
echo "WARP rotated, new IP confirmed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "WARP did not come back up in time" >&2
|
||||||
|
exit 1
|
||||||
Reference in New Issue
Block a user