diff --git a/README.md b/README.md index 3b15b3b..3f732eb 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,24 @@ inside your JSON configuration. This routes all requests through WARP, which helps prevent YouTube blocking server IP addresses. --- +### Myinstants + +To play sounds from myinstants website (!mi) you need to install `FlareSolverr` +Link: https://github.com/Flaresolverr/Flaresolverr + +Follow the steps of its installation. + +After that, specify its Host and Port on the config.json file: +```yaml +"FlareSolverr" +{ + "Host": "127.0.0.1", + "Port": 8191 +}, +``` +If you are using docker for torchlight, please specify `Host` as the main server's public IP. +`8191` is just the default port used on `FlareSolverr` + ### Notes - Keep your cookie file private (it contains session authentication data). diff --git a/config/config.json b/config/config.json index 342068a..07daecc 100644 --- a/config/config.json +++ b/config/config.json @@ -54,6 +54,12 @@ } }, + "FlareSolverr": + { + "Host": "127.0.0.1", + "Port": 8191 + }, + "GeoIP": { "Path": "/usr/share/GeoIP", diff --git a/pyproject.toml b/pyproject.toml index 3451852..fb946f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,14 +15,16 @@ requires-python = ">=3.10" dependencies = [ "Pillow", "aiohttp", + "aiohttp-socks>=0.8.4", "beautifulsoup4", "click", + "curl_cffi>=0.16.3", "defusedxml", "gTTS", "geoip2", "lxml", "python-magic", - "yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@master#egg=yt-dlp", + "yt-dlp", "translatepy", "requests" ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 7be9bee..049653e 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,70 +1,70 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --no-emit-index-url --output-file=requirements-dev.txt --python-platform=linux pyproject.toml --extra dev --constraint requirements.txt -aiohttp==3.9.1 +# uv pip compile pyproject.toml --extra dev -o requirements-dev.txt +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 # via - # -c requirements.txt # torchlight (pyproject.toml) + # aiohttp-socks # geoip2 -aiosignal==1.3.1 - # via - # -c requirements.txt - # aiohttp +aiohttp-socks==0.12.0 + # via torchlight (pyproject.toml) +aiosignal==1.4.0 + # via aiohttp +ansicon==1.89.0 + # via jinxed attrs==23.2.0 - # via - # -c requirements.txt - # aiohttp + # via aiohttp beautifulsoup4==4.12.3 # via - # -c requirements.txt # torchlight (pyproject.toml) -certifi==2023.11.17 + # translatepy +blessed==1.49.0 + # via inquirer +certifi==2026.7.22 # via - # -c requirements.txt + # curl-cffi # requests +cffi==2.1.1 + # via curl-cffi charset-normalizer==3.3.2 - # via - # -c requirements.txt - # requests + # via requests click==8.1.7 # via - # -c requirements.txt # torchlight (pyproject.toml) # gtts +colorama==0.4.6 + # via click +curl-cffi==0.16.3 + # via torchlight (pyproject.toml) defusedxml==0.7.1 - # via - # -c requirements.txt - # torchlight (pyproject.toml) + # via torchlight (pyproject.toml) +editor==1.8.0 + # via inquirer frozenlist==1.4.1 # via - # -c requirements.txt # aiohttp # aiosignal geoip2==4.8.0 - # via - # -c requirements.txt - # torchlight (pyproject.toml) + # via torchlight (pyproject.toml) gtts==2.5.0 - # via - # -c requirements.txt - # torchlight (pyproject.toml) + # via torchlight (pyproject.toml) idna==3.6 # via - # -c requirements.txt # requests # yarl +inquirer==3.4.1 + # via translatepy +jinxed==2.1.0 + # via blessed lxml==5.1.0 - # via - # -c requirements.txt - # torchlight (pyproject.toml) + # via torchlight (pyproject.toml) maxminddb==2.5.2 - # via - # -c requirements.txt - # geoip2 + # via geoip2 memory-profiler==0.61.0 # via torchlight (pyproject.toml) multidict==6.0.4 # via - # -c requirements.txt # aiohttp # yarl mypy==1.8.0 @@ -72,52 +72,58 @@ mypy==1.8.0 mypy-extensions==1.0.0 # via mypy pillow==10.2.0 + # via torchlight (pyproject.toml) +propcache==0.5.2 # via - # -c requirements.txt - # torchlight (pyproject.toml) + # aiohttp + # yarl psutil==6.1.1 # via memory-profiler +pycparser==3.0 + # via cffi python-magic==0.4.27 - # via - # -c requirements.txt - # torchlight (pyproject.toml) + # via torchlight (pyproject.toml) +python-socks==3.0.0 + # via aiohttp-socks +pyuseragents==1.0.5 + # via translatepy +readchar==4.2.2 + # via inquirer requests==2.32.3 # via - # -c requirements.txt + # torchlight (pyproject.toml) # geoip2 # gtts + # translatepy ruff==0.9.2 # via torchlight (pyproject.toml) +runs==1.3.0 + # via editor +safeio==1.2 + # via translatepy setuptools==75.8.0 # via - # -c requirements.txt # geoip2 # maxminddb soupsieve==2.5 - # via - # -c requirements.txt - # beautifulsoup4 + # via beautifulsoup4 +translatepy==2.3 + # via torchlight (pyproject.toml) +types-requests==2.33.0.20260712 + # via torchlight (pyproject.toml) typing-extensions==4.9.0 # via mypy urllib3==2.1.0 # via - # -c requirements.txt # requests -yarl==1.9.4 - # via - # -c requirements.txt - # aiohttp -yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@5ff7a43623e3a92270f66a7e37b5fc53d7a57fdf#egg=yt-dlp - # via - # -c requirements.txt - # torchlight (pyproject.toml) -types-requests - # via requests -translatepy==2.3 - # via - # -c requirements.txt - # torchlight (pyproject.toml) -langdetect==1.0.9 - # via - # -c requirements.txt - # translatepy + # types-requests +wcwidth==0.8.3 + # via blessed +xmod==1.10.0 + # via + # editor + # runs +yarl==1.24.5 + # via aiohttp +yt-dlp==2026.8.19 + # via torchlight (pyproject.toml) diff --git a/requirements.txt b/requirements.txt index c2cf792..c9ea069 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,46 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --no-emit-index-url --output-file=requirements.txt --python-platform=linux pyproject.toml -aiohttp==3.9.1 +# uv pip compile pyproject.toml -o requirements.txt +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 # via # torchlight (pyproject.toml) + # aiohttp-socks # geoip2 -aiosignal==1.3.1 +aiohttp-socks==0.12.0 + # via torchlight (pyproject.toml) +aiosignal==1.4.0 # via aiohttp +ansicon==1.89.0 + # via jinxed attrs==23.2.0 # via aiohttp beautifulsoup4==4.12.3 - # via torchlight (pyproject.toml) -certifi==2023.11.17 - # via requests + # via + # torchlight (pyproject.toml) + # translatepy +blessed==1.49.0 + # via inquirer +certifi==2026.7.22 + # via + # curl-cffi + # requests +cffi==2.1.1 + # via curl-cffi charset-normalizer==3.3.2 # via requests click==8.1.7 # via # torchlight (pyproject.toml) # gtts +colorama==0.4.6 + # via click +curl-cffi==0.16.3 + # via torchlight (pyproject.toml) defusedxml==0.7.1 # via torchlight (pyproject.toml) +editor==1.8.0 + # via inquirer frozenlist==1.4.1 # via # aiohttp @@ -32,6 +53,10 @@ idna==3.6 # via # requests # yarl +inquirer==3.4.1 + # via translatepy +jinxed==2.1.0 + # via blessed lxml==5.1.0 # via torchlight (pyproject.toml) maxminddb==2.5.2 @@ -42,25 +67,47 @@ multidict==6.0.4 # yarl pillow==10.2.0 # via torchlight (pyproject.toml) +propcache==0.5.2 + # via + # aiohttp + # yarl +pycparser==3.0 + # via cffi python-magic==0.4.27 # via torchlight (pyproject.toml) +python-socks==3.0.0 + # via aiohttp-socks +pyuseragents==1.0.5 + # via translatepy +readchar==4.2.2 + # via inquirer requests==2.32.3 # via + # torchlight (pyproject.toml) # geoip2 # gtts + # translatepy +runs==1.3.0 + # via editor +safeio==1.2 + # via translatepy setuptools==75.8.0 # via # geoip2 # maxminddb soupsieve==2.5 # via beautifulsoup4 +translatepy==2.3 + # via torchlight (pyproject.toml) urllib3==2.1.0 # via requests -yarl==1.9.4 +wcwidth==0.8.3 + # via blessed +xmod==1.10.0 + # via + # editor + # runs +yarl==1.24.5 # via aiohttp -yt-dlp @ git+https://github.com/yt-dlp/yt-dlp@5ff7a43623e3a92270f66a7e37b5fc53d7a57fdf#egg=yt-dlp +yt-dlp==2026.8.19 # via torchlight (pyproject.toml) -translatepy==2.3 - # via torchlight (pyproject.toml) -langdetect==1.0.9 - # via translatepy diff --git a/src/torchlight/AudioClip.py b/src/torchlight/AudioClip.py index 1de48de..8225dff 100644 --- a/src/torchlight/AudioClip.py +++ b/src/torchlight/AudioClip.py @@ -33,6 +33,9 @@ def __init__( def __del__(self) -> None: self.logger.debug("~AudioClip()") + def SetDuration(self, duration: float) -> None: + self.audio_player.SetDuration(duration) + def Play( self, seconds: int | None = None, @@ -41,8 +44,11 @@ def Play( volume: float | None = None, speed: float | None = None, pitch: float | None = None, + needs_cf_bypass: bool = False, ) -> bool: - return self.audio_player.PlayURI(self.uri, seconds, duration, *args, volume=volume, speed=speed, pitch=pitch) + return self.audio_player.PlayURI( + self.uri, seconds, duration, *args, volume=volume, speed=speed, pitch=pitch, needs_cf_bypass=needs_cf_bypass + ) def Stop(self) -> bool: return self.audio_player.Stop() diff --git a/src/torchlight/Commands.py b/src/torchlight/Commands.py index 520cdf2..00f187c 100644 --- a/src/torchlight/Commands.py +++ b/src/torchlight/Commands.py @@ -966,6 +966,9 @@ async def _func(self, message: list[str], player: Player) -> int: self.torchlight.last_url = url + # Set the duration of the audio clip because youtube already provides it in the metadata + # Helps fixing youtube not sending buffer in time. + audio_clip.SetDuration(float(info["duration"])) return audio_clip.Play(real_time) @@ -1525,7 +1528,7 @@ async def _func(self, message: list[str], player: Player) -> int: return 1 self.torchlight.last_url = urls - return audio_clip.Play() + return audio_clip.Play(needs_cf_bypass=True) elif isinstance(urls, dict): # get the play cmd play_cmd: str = "" diff --git a/src/torchlight/FFmpegAudioPlayer.py b/src/torchlight/FFmpegAudioPlayer.py index 0ac9c8f..29c2fd6 100644 --- a/src/torchlight/FFmpegAudioPlayer.py +++ b/src/torchlight/FFmpegAudioPlayer.py @@ -1,15 +1,21 @@ import asyncio import datetime import logging +import os import socket import struct import time import traceback -from asyncio import StreamReader, StreamWriter -from asyncio.subprocess import Process from collections.abc import Callable from typing import Any +from urllib.parse import urlparse +import aiohttp +from aiohttp_socks import ProxyConnector +from curl_cffi import requests as cffi_requests + +from torchlight.FlareSolverr import get_cf_session +from torchlight.MyInstants import MYINSTANTS_URL from torchlight.Torchlight import Torchlight SAMPLEBYTES = 2 @@ -39,11 +45,13 @@ def __init__(self, torchlight: Torchlight) -> None: self.started_playing: float | None = None self.stopped_playing: float | None = None - self.seconds = 0.0 + self.seconds: float = 0.0 + self.duration_set: bool = False - self.writer: StreamWriter | None = None - self.ffmpeg_process: Process | None = None - self.curl_process: Process | None = None + self.writer: asyncio.StreamWriter | None = None + self.ffmpeg_process: asyncio.subprocess.Process | None = None + self.stream_task: asyncio.Task | None = None + self.session: aiohttp.ClientSession | None = None self.callbacks: list[tuple[str, Callable]] = [] @@ -51,7 +59,206 @@ def __del__(self) -> None: self.logger.debug("~FFmpegAudioPlayer()") self.Stop() - # @profile + async def _stream_url_to_ffmpeg(self, uri: str, ffmpeg_command: list[str], needs_cf_bypass: bool = False) -> None: + parsed = urlparse(uri) + is_local_file = parsed.scheme in ("file", "") or os.path.exists(uri) + + if is_local_file: + file_path = parsed.path if parsed.scheme == "file" else uri + + if "-i" in ffmpeg_command: + idx = ffmpeg_command.index("-i") + ffmpeg_command[idx + 1] = file_path + else: + ffmpeg_command.extend(["-i", file_path]) + + try: + _, self.writer = await asyncio.open_connection(self.host, self.port) + except Exception as e: + self.logger.error("Failed to connect to voice server at %s:%s - %s", self.host, self.port, e) + self.Stop(False) + return + + stdin_mode = asyncio.subprocess.DEVNULL if is_local_file else asyncio.subprocess.PIPE + + try: + self.ffmpeg_process = await asyncio.create_subprocess_exec( + *ffmpeg_command, stdin=stdin_mode, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL + ) + except Exception as e: + self.logger.error("Failed to spawn FFmpeg process: %s", e) + self.Stop(False) + return + + if self.ffmpeg_process.stdout: + asyncio.ensure_future(self._read_stream(self.ffmpeg_process.stdout, self.writer)) + + if is_local_file: + return + + cookies: dict[str, str] = {} + user_agent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/124.0.0.0 Safari/537.36" + ) + + proxy_url = self.proxy if self.proxy else None + + if needs_cf_bypass: + try: + self.logger.info("Solving Cloudflare challenge for URL: %s", uri) + cookies, user_agent = await get_cf_session(uri, proxy_url) + except Exception as e: + self.logger.error("FlareSolverr failed to bypass Cloudflare: %s", e) + self.Stop(False) + return + + headers = { + "User-Agent": user_agent, + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + } + if needs_cf_bypass and "myinstants.com" in uri: + headers["Referer"] = MYINSTANTS_URL + headers["Sec-Fetch-Dest"] = "audio" + headers["Sec-Fetch-Mode"] = "no-cors" + headers["Sec-Fetch-Site"] = "same-origin" + + if needs_cf_bypass: + queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=10) + + def sync_fetch_curl_cffi() -> None: + proxies: cffi_requests.ProxySpec | None = {"http": proxy_url, "https": proxy_url} if proxy_url else None + try: + resp = cffi_requests.get( + uri, + headers=headers, + cookies=cookies, + proxies=proxies, + impersonate="chrome120", + stream=True, + timeout=15, + ) + if resp.status_code not in (200, 206): + self.logger.error("HTTP stream failed via curl_cffi with status %d", resp.status_code) + asyncio.run_coroutine_threadsafe(queue.put(None), self.torchlight.loop) + return + + for chunk in resp.iter_content(chunk_size=32 * 1024): + if not chunk or not self.playing: + break + + while self.playing: + try: + future = asyncio.run_coroutine_threadsafe(queue.put(chunk), self.torchlight.loop) + future.result(timeout=0.5) + break + except Exception: + if not self.playing: + break + except Exception as err: + self.logger.error("curl_cffi fetch exception: %s", err) + finally: + asyncio.run_coroutine_threadsafe(queue.put(None), self.torchlight.loop) + + fetch_future = self.torchlight.loop.run_in_executor(None, sync_fetch_curl_cffi) + + try: + proc = self.ffmpeg_process + while self.playing and proc and proc.returncode is None: + chunk = await queue.get() + if chunk is None: + break + + if proc.stdin and not proc.stdin.is_closing(): + proc.stdin.write(chunk) + await proc.stdin.drain() + finally: + await fetch_future + if self.ffmpeg_process and self.ffmpeg_process.stdin: + try: + self.ffmpeg_process.stdin.close() + await self.ffmpeg_process.stdin.wait_closed() + except Exception as e: + self.logger.debug("Failed to cleanly close FFmpeg stdin: %s", e) + return + + timeout = aiohttp.ClientTimeout(total=None, connect=10.0, sock_read=15.0) + bytes_downloaded = 0 + max_network_retries = 5 + + connector = None + get_proxy_param = proxy_url + + if proxy_url and proxy_url.startswith(("socks5://", "socks4://")): + connector = ProxyConnector.from_url(proxy_url) + get_proxy_param = None + + try: + if self.session is None or self.session.closed or connector is not None: + req_session = aiohttp.ClientSession(connector=connector) + if connector is None: + self.session = req_session + else: + req_session = self.session + + try: + for attempt in range(1, max_network_retries + 1): + if not self.playing or self.ffmpeg_process.returncode is not None: + break + + req_headers = headers.copy() + if bytes_downloaded > 0: + req_headers["Range"] = f"bytes={bytes_downloaded}-" + + try: + async with req_session.get( + uri, headers=req_headers, timeout=timeout, proxy=get_proxy_param + ) as resp: + if resp.status not in (200, 206): + self.logger.error("HTTP stream failed with status %d", resp.status) + break + + async for chunk in resp.content.iter_chunked(32 * 1024): + if not self.playing or self.ffmpeg_process.returncode is not None: + break + + bytes_downloaded += len(chunk) + + if self.ffmpeg_process.stdin: + self.ffmpeg_process.stdin.write(chunk) + await self.ffmpeg_process.stdin.drain() + + break + + except (asyncio.TimeoutError, aiohttp.ClientError) as err: + self.logger.warning( + "Stream network drop/timeout (%s). Retrying (%d/%d)...", err, attempt, max_network_retries + ) + await asyncio.sleep(0.5) + + finally: + if connector is not None and not req_session.closed: + await req_session.close() + except Exception as e: + self.logger.error("Unexpected streaming error: %s", e, exc_info=True) + finally: + if self.ffmpeg_process and self.ffmpeg_process.stdin: + try: + self.ffmpeg_process.stdin.close() + await self.ffmpeg_process.stdin.wait_closed() + except Exception as e: + self.logger.debug("Failed to cleanly close FFmpeg stdin: %s", e) + if self.session and not self.session.closed: + await self.session.close() + self.session = None + + def SetDuration(self, duration: float) -> None: + self.seconds = duration + if self.seconds > 0: + self.duration_set = True + def PlayURI( self, uri: str, @@ -61,6 +268,7 @@ def PlayURI( volume: float | None = None, speed: float | None = None, pitch: float | None = None, + needs_cf_bypass: bool = False, ) -> bool: if volume is None: volume = self.volume @@ -71,28 +279,12 @@ def PlayURI( if pitch is None: pitch = self.pitch - curl_command = [ - "/usr/bin/curl", - "--silent", - "--show-error", - "--connect-timeout", - "1", - "--retry", - "2", - "--retry-delay", - "1", - "--output", - "-", - "-L", - uri, - ] - if self.proxy: - curl_command.extend( - [ - "-x", - self.proxy, - ] - ) + if not self.duration_set: + self.seconds = 0.0 + + self.started_playing = None + self.stopped_playing = None + ffmpeg_command = [ "/usr/bin/ffmpeg", "-i", @@ -113,25 +305,14 @@ def PlayURI( if position is not None: pos_str = str(datetime.timedelta(seconds=position)) - ffmpeg_command.extend( - [ - "-ss", - pos_str, - ] - ) + ffmpeg_command.extend(["-ss", pos_str]) self.position = position if duration is not None: - ffmpeg_command.extend( - [ - "-t", - str(duration), - ] - ) + ffmpeg_command.extend(["-t", str(duration)]) ffmpeg_command.append("-") - self.logger.debug(curl_command) self.logger.debug(ffmpeg_command) self.playing = True @@ -139,31 +320,32 @@ def PlayURI( self.logger.info("Playing %s", self.uri) - asyncio.ensure_future(self._stream_subprocess(curl_command, ffmpeg_command)) + self.stream_task = asyncio.ensure_future(self._stream_url_to_ffmpeg(uri, ffmpeg_command, needs_cf_bypass)) return True - # @profile def Stop(self, force: bool = True) -> bool: if not self.playing: return False self.playing = False - if self.ffmpeg_process: - try: - self.ffmpeg_process.terminate() - self.ffmpeg_process.kill() - except ProcessLookupError as exc: - self.logger.debug(exc) - self.ffmpeg_process = None + if self.stream_task and not self.stream_task.done(): + self.stream_task.cancel() + self.stream_task = None + + if self.session and not self.session.closed: + asyncio.run_coroutine_threadsafe(self.session.close(), self.torchlight.loop) + self.session = None + + proc = self.ffmpeg_process + self.ffmpeg_process = None - if self.curl_process: + if proc: try: - self.curl_process.terminate() - self.curl_process.kill() + proc.terminate() + proc.kill() except ProcessLookupError as exc: self.logger.debug(exc) - self.curl_process = None if self.writer: if force: @@ -176,34 +358,31 @@ def Stop(self, force: bool = True) -> bool: struct.pack("ii", 1, 0), ) except OSError as exc: - # Errno 9: Bad file descriptor - if exc.errno == 9: - self.logger.error("Unable to setsockopt: %s", exc) + self.logger.error("Unable to setsockopt: %s", exc) self.writer.transport.abort() - - self.writer.close() - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - loop.create_task(self.writer.wait_closed()) - else: - loop.run_until_complete(self.writer.wait_closed()) - except Exception as exc: - self.logger.warning(exc) - - self.writer = None + self.writer = None + else: + self.writer.close() + try: + if self.torchlight.loop.is_running(): + asyncio.run_coroutine_threadsafe(self.writer.wait_closed(), self.torchlight.loop) + else: + self.torchlight.loop.run_until_complete(self.writer.wait_closed()) + except Exception as exc: + self.logger.warning(exc) + finally: + self.writer = None self.logger.info("Stopped %s", self.uri) - self.uri = "" + self.duration_set = False + self.seconds = 0.0 self.Callback("Stop") - del self.callbacks return True - # @profile def AddCallback(self, cbtype: str, cbfunc: Callable) -> bool: if cbtype not in self.VALID_CALLBACKS: return False @@ -211,7 +390,6 @@ def AddCallback(self, cbtype: str, cbfunc: Callable) -> bool: self.callbacks.append((cbtype, cbfunc)) return True - # @profile def Callback(self, cbtype: str, *args: Any, **kwargs: Any) -> None: for callback in self.callbacks: if callback[0] == cbtype: @@ -221,7 +399,6 @@ def Callback(self, cbtype: str, *args: Any, **kwargs: Any) -> None: except Exception: self.logger.error(traceback.format_exc()) - # @profile async def _updater(self) -> None: try: last_seconds_elapsed = 0.0 @@ -232,27 +409,35 @@ async def _updater(self) -> None: if self.started_playing: seconds_elapsed = time.time() - self.started_playing - if seconds_elapsed > self.seconds: + if self.seconds > 0 and seconds_elapsed > self.seconds: seconds_elapsed = self.seconds self.Callback("Update", last_seconds_elapsed, seconds_elapsed) - if seconds_elapsed >= self.seconds: - if not self.stopped_playing: - self.logger.debug("BUFFER UNDERRUN!") - self.Stop(False) - return + is_ffmpeg_done = self.ffmpeg_process is None or self.ffmpeg_process.returncode is not None - last_seconds_elapsed = seconds_elapsed + if self.seconds > 0: + if seconds_elapsed >= self.seconds: + if not self.stopped_playing and not is_ffmpeg_done: + self.logger.debug("BUFFER UNDERRUN!") + self.logger.debug("Playback naturally finished (duration reached).") + self.Stop(False) + return + else: + if is_ffmpeg_done and self.stopped_playing: + self.logger.debug("Playback naturally finished (Stream EOF).") + self.Stop(False) + return + last_seconds_elapsed = seconds_elapsed await asyncio.sleep(0.1) + except Exception as exc: self.Stop() self.torchlight.SayChat(f"Error: {str(exc)}") raise exc - # @profile - async def _read_stream(self, stream: StreamReader | None, writer: StreamWriter) -> None: + async def _read_stream(self, stream: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: try: started = False @@ -269,7 +454,8 @@ async def _read_stream(self, stream: StreamReader | None, writer: StreamWriter) samples = bytes_len / SAMPLEBYTES seconds = samples / self.sample_rate - self.seconds += seconds + if not self.duration_set: + self.seconds += seconds if not started: self.logger.info("Streaming %s", self.uri) @@ -283,69 +469,3 @@ async def _read_stream(self, stream: StreamReader | None, writer: StreamWriter) self.Stop() self.torchlight.SayChat(f"Error: {str(exc)}") raise exc - - # @profile - async def _stream_subprocess(self, curl_command: list[str], ffmpeg_command: list[str]) -> None: - if not self.playing: - return - - try: - _, self.writer = await asyncio.open_connection(self.host, self.port) - - self.curl_process = await asyncio.create_subprocess_exec( - *curl_command, - stdout=asyncio.subprocess.PIPE, - ) - - self.ffmpeg_process = await asyncio.create_subprocess_exec( - *ffmpeg_command, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - - asyncio.create_task(self._wait_for_process_exit(self.curl_process)) - - asyncio.create_task(self._write_stream(self.curl_process.stdout, self.ffmpeg_process.stdin)) - - asyncio.create_task(self._read_stream(self.ffmpeg_process.stdout, self.writer)) - - if self.ffmpeg_process is not None: - await self.ffmpeg_process.wait() - - if self.seconds == 0.0: - self.Stop() - - except Exception as exc: - self.Stop() - self.torchlight.SayChat(f"Error: {str(exc)}") - raise exc - - async def _write_stream(self, stream: StreamReader | None, writer: StreamWriter | None) -> None: - try: - while True: - if not stream: - break - chunk = await stream.read(65536) - if not chunk: - break - - if writer: - writer.write(chunk) - await writer.drain() - if writer: - writer.close() - except Exception as exc: - self.Stop() - self.torchlight.SayChat(f"Error: {str(exc)}") - raise exc - - async def _wait_for_process_exit(self, curl_process: Process) -> None: - try: - await curl_process.wait() - if curl_process.returncode != 0 and curl_process.returncode != -15: - raise Exception(f"Curl process exited with error code {curl_process.returncode}") - except Exception as exc: - self.Stop() - self.torchlight.SayChat(f"Error: {str(exc)}") - raise exc diff --git a/src/torchlight/FlareSolverr.py b/src/torchlight/FlareSolverr.py new file mode 100644 index 0000000..d64e4b9 --- /dev/null +++ b/src/torchlight/FlareSolverr.py @@ -0,0 +1,27 @@ +import aiohttp + +FLARESOLVERR_URL = "http://127.0.0.1:8191/v1" + + +def set_flaresolverr_url(url: str) -> None: + global FLARESOLVERR_URL + FLARESOLVERR_URL = url + + +async def get_cf_session(url: str, proxy: str | None = None) -> tuple[dict[str, str], str]: + payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000} + + # Pass proxy to FlareSolverr if specified + if proxy: + payload["proxy"] = {"url": proxy} + + async with aiohttp.ClientSession() as session: + async with session.post(FLARESOLVERR_URL, json=payload, timeout=65) as response: + res_data = await response.json() + if res_data.get("status") == "ok": + solution = res_data.get("solution", {}) + user_agent = solution.get("userAgent", "") + cookies = {c["name"]: c["value"] for c in solution.get("cookies", [])} + return cookies, user_agent + else: + raise RuntimeError(f"FlareSolverr error: {res_data.get('message')}") diff --git a/src/torchlight/TorchlightHandler.py b/src/torchlight/TorchlightHandler.py index 416c254..9a11376 100644 --- a/src/torchlight/TorchlightHandler.py +++ b/src/torchlight/TorchlightHandler.py @@ -6,6 +6,7 @@ from torchlight.AudioManager import AudioManager from torchlight.CommandHandler import CommandHandler from torchlight.Config import Config +from torchlight.FlareSolverr import set_flaresolverr_url from torchlight.PlayerManager import PlayerManager from torchlight.Sourcemod import SourcemodConfig from torchlight.Torchlight import Torchlight @@ -75,6 +76,11 @@ def Init(self) -> None: self.player_manager.torchlight.command_handler = self.command_handler + if "FlareSolverr" in self.config.config: + set_flaresolverr_url( + f"http://{self.config['FlareSolverr']['Host']}:{self.config['FlareSolverr']['Port']}/v1" + ) + def InitModules(self) -> None: self.player_manager.Setup()