Repository: Rapptz/discord.py

Issue:
io.BufferedIOBase doesn't work with FFmpegPCMAudio despite what the documentation says
# **Summary**

when supplying a `io.BufferedIOBase` object as `source` to `FFmpegPCMAudio`, it get complete instantly when played.


# **Reproduction Steps**

example `io.BufferedIOBase` object:
```py
buffer = io.BytesIO()
gTTS("test").write_to_fp(buffer)
buffer.seek(0)
```
execution with that object:
```FFmpegPCMAudio(buffer, options="-vn -loglevel fatal")```

with `pipe = True`, there's an `io.UnsupportedOperation: fileno` exception, because the file-like object is no actual file.


# **Expected Results**

I expected the audio to play, hearing "test"


# **Actual Results**

the audio got 'skipped'. It instantly compelted after starting without raising any exception

Exception for `source: io.BufferedIOBase` and `pipe = True`:
```py
Traceback (most recent call last):
  File "D:\PyCharm\Project\DiscordBot\venv\lib\site-packages\discord\client.py", line 343, in _run_event
    await coro(*args, **kwargs)
  File "D:/PyCharm/Project/DiscordBot/main.py", line 91, in on_voice_state_update
    await cmd_music.ex([], None, client, "play", file=buffer, channel=after.channel)
  File "D:\PyCharm\Project\DiscordBot\commands\cmd_music.py", line 224, in ex
    vc.play(discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(file, pipe=True, options=ffmpeg_options["options"], executable=ffmpeg_path.path), 0.5))
  File "D:\PyCharm\Project\DiscordBot\venv\lib\site-packages\discord\player.py", line 225, in __init__
    super().__init__(source, executable=executable, args=args, **subprocess_kwargs)
  File "D:\PyCharm\Project\DiscordBot\venv\lib\site-packages\discord\player.py", line 138, in __init__
    self._process = self._spawn_process(args, **kwargs)
  File "D:\PyCharm\Project\DiscordBot\venv\lib\site-packages\discord\player.py", line 144, in _spawn_process
    process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, **subprocess_kwargs)
  File "D:\Python373\lib\subprocess.py", line 728, in __init__
    errread, errwrite) = self._get_handles(stdin, stdout, stderr)
  File "D:\Python373\lib\subprocess.py", line 1039, in _get_handles
    p2cread = msvcrt.get_osfhandle(stdin.fileno())
io.UnsupportedOperation: fileno
```


A temporary fix for this is the answer to #5192, where 
```py
process = subprocess.Popen(args, creationflags=CREATE_NO_WINDOW, **subprocess_kwargs)
```
is changed to 
```py
self._process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr)
self._stdout = io.BytesIO(self._process.communicate(input=stdin)[0])
``` 
and the `io.BufferedIOBase` is `read()`
The problem with this fix is that it only works for `io.BufferedIOBase` types though.


# **Intents**

`discord.Intents.all()`

# **System Information**

discord.py version: 1.6.0
python version: 3.7
windows version: 10 build 1903

# **Checklist**

- [X] I have searched the open issues for duplicates.
- [X] I have shown the entire traceback, if possible.
- [X] I have removed my token from display, if visible.


Accept bytes/byte-like in FFmpegPCMAudio sources.
## Summary

Allow bytes or bytes like objects to be piped into FFmpeg via FFmpegPCMAudio sources.

Currently you are not able to pass bytes or bytes-like into FFmpegPCMAudio even though documentation states you can. This can be especially disadvantageous to users when needing to generate audio in memory; eliminating the need to write to disk.

This does not change the behavior of currently working methods of creating sources. E.g passing a path or URL.

Some basic working examples:
```python
# Traditionally 
vc.play(discord.FFmpegPCMAudio('test.mp3'))

# Providing a BufferedIOBase
fp = open('test.mp3, 'rb')
vc.play(discord.FFmpegPCMAudio(fp, pipe=True))

# Providing Bytes
fp = open('test.mp3, 'rb')
vc.play(discord.FFmpegPCMAudio(fp.read(), pipe=True))

# A commonly asked question Example
fp = io.BytesIO()

gTTS('Hello World').write_to_fp(fp)
fp.seek(0)

vc.play(discord.FFmpegPCMAudio(fp, pipe=True))
```

Fixes: #5192

## Checklist

- [x] If code changes were made then they have been tested.
    - [ ] I have updated the documentation to reflect the changes.
- [x] This PR fixes an issue.
- [ ] This PR adds something new (e.g. new method or parameters).
- [ ] This PR is a breaking change (e.g. methods or parameters removed/renamed)
- [ ] This PR is **not** a code change (e.g. documentation, README, ...)



Discussion:
I am having the same issues after trying the same thing. Any ideas?
> 
> 
> I am having the same issues after trying the same thing. Any ideas?

You can use the workaround mentioned in the comment to the mentioned issue (#5192).
Though keep in mind that that workaround doesn't work with normal streams.


Resolving patch:
diff --git a/discord/player.py b/discord/player.py
--- a/discord/player.py
+++ b/discord/player.py
@@ -140,13 +140,25 @@ class FFmpegAudio(AudioSource):
     .. versionadded:: 1.3
     """
 
-    def __init__(self, source: str, *, executable: str = 'ffmpeg', args: Any, **subprocess_kwargs: Any):
+    def __init__(self, source: Union[str, io.BufferedIOBase], *, executable: str = 'ffmpeg', args: Any, **subprocess_kwargs: Any):
+        piping = subprocess_kwargs.get('stdin') == subprocess.PIPE
+        if piping and isinstance(source, str):
+            raise TypeError("parameter conflict: 'source' parameter cannot be a string when piping to stdin")
+
         args = [executable, *args]
         kwargs = {'stdout': subprocess.PIPE}
         kwargs.update(subprocess_kwargs)
 
         self._process: subprocess.Popen = self._spawn_process(args, **kwargs)
         self._stdout: IO[bytes] = self._process.stdout  # type: ignore
+        self._stdin: Optional[IO[Bytes]] = None
+        self._pipe_thread: Optional[threading.Thread] = None
+
+        if piping:
+            n = f'PopenStdinWriter:{id(self):#x}'
+            self._stdin = self._process.stdin
+            self._pipe_thread = threading.Thread(target=self._pipe_writer, args=(source,), daemon=True, name=n)
+            self._pipe_thread.start()
 
     def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Popen:
         process = None
@@ -160,6 +172,21 @@ def _spawn_process(self, args: Any, **subprocess_kwargs: Any) -> subprocess.Pope
         else:
             return process
 
+    def _pipe_writer(self, source: io.BufferedIOBase) -> None:
+        while self._process:
+            # arbitrarily large read size 
+            data = source.read(8192)
+            if not data:
+                self._stdin.close() # EOF
+                break
+            try:
+                self._stdin.write(data)
+            except Exception:
+                _log.debug('Write error for %s, this is probably not a problem', self, exc_info=True)
+                # at this point the source data is either exhausted or the process is fubar
+                self._stdin.close()
+                break
+
     def cleanup(self) -> None:
         proc = self._process
         if proc is MISSING:
@@ -170,7 +197,7 @@ def cleanup(self) -> None:
         try:
             proc.kill()
         except Exception:
-            _log.exception("Ignoring error attempting to kill ffmpeg process %s", proc.pid)
+            _log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid)
 
         if proc.poll() is None:
             _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid)
@@ -218,16 +245,16 @@ class FFmpegPCMAudio(FFmpegAudio):
 
     def __init__(
         self,
-        source: str,
+        source: Union[str, io.BufferedIOBase],
         *,
         executable: str = 'ffmpeg',
         pipe: bool = False,
         stderr: Optional[IO[str]] = None,
-        before_options: Optional[str] = None, 
+        before_options: Optional[str] = None,
         options: Optional[str] = None
     ) -> None:
         args = []
-        subprocess_kwargs = {'stdin': source if pipe else subprocess.DEVNULL, 'stderr': stderr}
+        subprocess_kwargs = {'stdin': subprocess.PIPE if pipe else subprocess.DEVNULL, 'stderr': stderr}
 
         if isinstance(before_options, str):
             args.extend(shlex.split(before_options))
@@ -315,7 +342,7 @@ class FFmpegOpusAudio(FFmpegAudio):
 
     def __init__(
         self,
-        source: str,
+        source: Union[str, io.BufferedIOBase],
         *,
         bitrate: int = 128,
         codec: Optional[str] = None,
@@ -327,7 +354,7 @@ def __init__(
     ) -> None:
 
         args = []
-        subprocess_kwargs = {'stdin': source if pipe else subprocess.DEVNULL, 'stderr': stderr}
+        subprocess_kwargs = {'stdin': subprocess.PIPE if pipe else subprocess.DEVNULL, 'stderr': stderr}
 
         if isinstance(before_options, str):
             args.extend(shlex.split(before_options))
@@ -384,7 +411,6 @@ async def from_probe(
 
             def custom_probe(source, executable):
                 # some analysis code here
-
                 return codec, bitrate
 
             source = await discord.FFmpegOpusAudio.from_probe("song.webm", method=custom_probe)


Test patch: