Skip to content
This repository was archived by the owner on Mar 6, 2025. It is now read-only.

Commit a9ecd80

Browse files
committed
single stream only. Allow HLS
1 parent c5075df commit a9ecd80

17 files changed

+137
-261
lines changed

README.md

+7-10
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![pypi versions](https://img.shields.io/pypi/pyversions/PyLivestream.svg)](https://pypi.python.org/pypi/PyLivestream)
66
[![PyPi Download stats](https://static.pepy.tech/badge/pylivestream)](https://pepy.tech/project/pylivestream)
77

8-
Streams to one or **multiple** streaming sites simultaneously, using pure object-oriented Python (no extra packages) and FFmpeg.
8+
Streams to a livestreaming video service, using pure object-oriented Python (no extra packages) to call FFmpeg.
99
Tested with `flake8`, `mypy` type checking and `pytest`.
1010
`visual_tests.py` is a quick check of several command line scripting scenarios on your laptop.
1111
FFmpeg is used from Python `subprocess` to stream to sites including:
@@ -15,7 +15,7 @@ FFmpeg is used from Python `subprocess` to stream to sites including:
1515
* Twitch
1616
* also IBM Live Video, Vimeo, Restream.io and more for streaming broadcasts.
1717

18-
![PyLivestream diagram showing screen capture or camera simultaneously livestreaming to multiple services.](./doc/logo.png)
18+
![PyLivestream diagram showing screen capture or camera livestreaming](./doc/logo.png)
1919

2020
[Troubleshooting](./Troubleshooting.md)
2121

@@ -201,28 +201,25 @@ JSON:
201201
* `camera_size`: camera resolution -- find from `v4l2-ctl --list-formats-ext` or camera spec sheet.
202202
* `camera_fps`: camera fps -- found from command above or camera spec sheet
203203

204-
Stream to multiple sites. This uses FFmpeg
205-
[-f tee](https://trac.ffmpeg.org/wiki/Creating%20multiple%20outputs#Teepseudo-muxer).
206-
For example, Facebook Live and YouTube Live simultaneously:
207-
204+
Stream to livestreaming service using FFmpeg.
208205
```sh
209-
python -m pylivestream.camera youtube facebook ./pylivestream.json
206+
python -m pylivestream.camera youtube ./pylivestream.json
210207
```
211208

212209
### Screen Share Livestream
213210

214-
Stream to multiple sites, in this example Facebook Live and YouTube Live simultaneously:
211+
Stream to livestreaming service, in this example Facebook Live:
215212

216213
```sh
217-
python -m pylivestream.screen youtube facebook ./pylivestream.json
214+
python -m pylivestream.screen facebook ./pylivestream.json
218215
```
219216

220217
### Image + Audio Livestream
221218

222219
Microphone audio + static image is accomplished by
223220

224221
```sh
225-
python -m pylivestream.microphone youtube facebook ./pylivestream.json -image doc/logo.jpg
222+
python -m pylivestream.microphone youtube ./pylivestream.json -image doc/logo.jpg
226223
```
227224

228225
or wherever your image file is.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ classifiers = [
1818
"Topic :: Multimedia :: Video :: Capture"
1919
]
2020
dynamic = ["readme", "version"]
21-
requires-python = ">=3.9"
21+
requires-python = ">=3.10"
2222

2323
[tool.setuptools.dynamic]
2424
readme = {file = ["README.md"], content-type = "text/markdown"}

src/pylivestream/api.py

+12-13
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
pls.microphone('twitch')
1010
"""
1111

12-
from __future__ import annotations
1312
from pathlib import Path
1413

1514
from .base import FileIn, Microphone, SaveDisk, Camera
@@ -28,27 +27,27 @@
2827

2928
def stream_file(
3029
ini_file: Path,
31-
websites: str | list[str],
30+
websites: str,
3231
video_file: Path,
3332
loop: bool | None = None,
3433
assume_yes: bool = False,
3534
timeout: float | None = None,
3635
):
3736
S = FileIn(ini_file, websites, infn=video_file, loop=loop, yes=assume_yes, timeout=timeout)
38-
sites: list[str] = list(S.streams.keys())
37+
3938
# %% Go live
4039
if assume_yes:
41-
print(f"going live on {sites} with file {video_file}")
40+
print(f"going live on {websites} with file {video_file}")
4241
else:
43-
input(f"Press Enter to go live on {sites} with file {video_file}")
42+
input(f"Press Enter to go live on {websites} with file {video_file}")
4443
print("Or Ctrl C to abort.")
4544

4645
S.golive()
4746

4847

4948
def stream_microphone(
5049
ini_file: Path,
51-
websites: list[str],
50+
websites: str,
5251
*,
5352
still_image: Path | None = None,
5453
assume_yes: bool | None = False,
@@ -59,12 +58,12 @@ def stream_microphone(
5958
"""
6059

6160
s = Microphone(ini_file, websites, image=still_image, yes=assume_yes, timeout=timeout)
62-
sites = list(s.streams.keys())
61+
6362
# %% Go live
6463
if assume_yes:
65-
print("going live on", sites)
64+
print("going live on", websites)
6665
else:
67-
input(f"Press Enter to go live on {sites}. Or Ctrl C to abort.")
66+
input(f"Press Enter to go live on {websites}. Or Ctrl C to abort.")
6867

6968
s.golive()
7069

@@ -83,13 +82,13 @@ def capture_screen(
8382
s.save()
8483

8584

86-
def stream_camera(ini_file: Path, websites: list[str], *, assume_yes: bool, timeout: float):
85+
def stream_camera(ini_file: Path, websites: str, *, assume_yes: bool, timeout: float):
86+
8787
S = Camera(ini_file, websites, yes=assume_yes, timeout=timeout)
88-
sites: list[str] = list(S.streams.keys())
8988
# %% Go live
9089
if assume_yes:
91-
print("going live on", sites)
90+
print("going live on", websites)
9291
else:
93-
input(f"Press Enter to go live on {sites}. Or Ctrl C to abort.")
92+
input(f"Press Enter to go live on {websites}. Or Ctrl C to abort.")
9493

9594
S.golive()

src/pylivestream/base.py

+14-131
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
from __future__ import annotations
21
from pathlib import Path
3-
import logging
42
import os
5-
import typing
63

74
from .stream import Stream
85
from .utils import run, check_device
@@ -75,7 +72,7 @@ def __init__(self, inifn: Path, site: str, **kwargs) -> None:
7572
+ ["-f", "null", "-"] # camera needs at output
7673
)
7774

78-
def startlive(self, sinks: list[str] | None = None):
75+
def startlive(self):
7976
"""
8077
start the stream(s)
8178
"""
@@ -94,53 +91,7 @@ def startlive(self, sinks: list[str] | None = None):
9491
# listener stopped prematurely, probably due to error
9592
raise RuntimeError(f"listener stopped with code {proc.poll()}")
9693
# %% RUN STREAM
97-
if not sinks: # single stream
98-
run(self.cmd)
99-
elif self.movingimage:
100-
if len(sinks) > 1:
101-
logging.warning(f"streaming only to {sinks[0]}")
102-
103-
run(self.cmd)
104-
elif len(sinks) == 1:
105-
run(self.cmd)
106-
else:
107-
"""
108-
multi-stream output tee
109-
https://trac.ffmpeg.org/wiki/Creating%20multiple%20outputs#Teepseudo-muxer
110-
https://trac.ffmpeg.org/wiki/EncodingForStreamingSites#Outputtingtomultiplestreamingserviceslocalfile
111-
"""
112-
cmdstem: list[str] = self.cmd[:-3]
113-
# +global_header is necessary to tee to multiple services
114-
cmd: list[str] = cmdstem + ["-flags:v", "+global_header", "-f", "tee"]
115-
116-
if self.image:
117-
# connect image to video stream, audio file to audio stream
118-
cmd += ["-map", "0:v", "-map", "1:a"]
119-
else:
120-
if self.vidsource == "file":
121-
# picks first video and audio stream, often correct
122-
cmd += ["-map", "0:v", "-map", "0:a:0"]
123-
else:
124-
# device (Camera)
125-
# connect video device to video stream,
126-
# audio device to audio stream
127-
cmd += ["-map", "0:v", "-map", "1:a"]
128-
129-
# cannot have double quotes for Mac/Linux,
130-
# but need double quotes for Windows
131-
if os.name == "nt":
132-
sink = f'"[f=flv]{sinks[0][1:-1]}'
133-
for s in sinks[1:]:
134-
sink += f"|[f=flv]{s[1:-1]}"
135-
sink += '"'
136-
else:
137-
sink = f"[f=flv]{sinks[0]}"
138-
for s in sinks[1:]:
139-
sink += f"|[f=flv]{s}"
140-
141-
cmd.append(sink)
142-
143-
run(cmd)
94+
run(self.cmd)
14495

14596
# %% stop the listener before starting the next process, or upon final process closing.
14697
if proc is not None and proc.poll() is None:
@@ -168,92 +119,44 @@ def check_device(self, site: str | None = None) -> bool:
168119

169120
# %% operators
170121
class Screenshare:
171-
def __init__(self, inifn: Path, websites: list[str], **kwargs) -> None:
172-
173-
if isinstance(websites, str):
174-
websites = [websites]
122+
def __init__(self, inifn: Path, websites: str, **kwargs) -> None:
175123

176-
streams = {}
177-
for site in websites:
178-
streams[site] = Livestream(inifn, site, vidsource="screen", **kwargs)
179-
180-
self.streams: typing.Mapping[str, Livestream] = streams
124+
self.streams = Livestream(inifn, websites, vidsource="screen", **kwargs)
181125

182126
def golive(self) -> None:
183127

184-
sinks: list[str] = [self.streams[stream].sink for stream in self.streams]
185-
186-
try:
187-
next(self.streams[unify_streams(self.streams)].startlive(sinks))
188-
except StopIteration:
189-
pass
128+
self.streams.startlive()
190129

191130

192131
class Camera:
193-
def __init__(self, inifn: Path, websites: list[str], **kwargs):
194-
195-
if isinstance(websites, str):
196-
websites = [websites]
197-
198-
streams = {}
199-
for site in websites:
200-
streams[site] = Livestream(inifn, site, vidsource="camera", **kwargs)
132+
def __init__(self, inifn: Path, websites: str, **kwargs):
201133

202-
self.streams: dict[str, Livestream] = streams
134+
self.streams = Livestream(inifn, websites, vidsource="camera", **kwargs)
203135

204136
def golive(self) -> None:
205137

206-
sinks: list[str] = [self.streams[stream].sink for stream in self.streams]
207-
208-
try:
209-
next(self.streams[unify_streams(self.streams)].startlive(sinks))
210-
except StopIteration:
211-
pass
138+
self.streams.startlive()
212139

213140

214141
class Microphone:
215-
def __init__(self, inifn: Path, websites: list[str], **kwargs):
216-
217-
if isinstance(websites, str):
218-
websites = [websites]
142+
def __init__(self, inifn: Path, websites: str, **kwargs):
219143

220-
streams = {}
221-
for site in websites:
222-
streams[site] = Livestream(inifn, site, **kwargs)
223-
224-
self.streams: dict[str, Livestream] = streams
144+
self.streams = Livestream(inifn, websites, **kwargs)
225145

226146
def golive(self) -> None:
227147

228-
sinks: list[str] = [self.streams[stream].sink for stream in self.streams]
229-
230-
try:
231-
next(self.streams[unify_streams(self.streams)].startlive(sinks))
232-
except StopIteration:
233-
pass
148+
self.streams.startlive()
234149

235150

236151
# %% File-based inputs
237152
class FileIn:
238-
def __init__(self, inifn: Path, websites: str | list[str], **kwargs):
153+
def __init__(self, inifn: Path, websites: str, **kwargs):
239154

240-
if isinstance(websites, str):
241-
websites = [websites]
242-
243-
streams = {}
244-
for site in websites:
245-
streams[site] = Livestream(inifn, site, vidsource="file", **kwargs)
246-
247-
self.streams: dict[str, Livestream] = streams
155+
self.streams = Livestream(inifn, websites, vidsource="file", **kwargs)
248156

249157
def golive(self) -> None:
250158

251-
sinks: list[str] = [self.streams[stream].sink for stream in self.streams]
252-
253-
try:
254-
next(self.streams[unify_streams(self.streams)].startlive(sinks))
255-
except StopIteration:
256-
pass
159+
self.streams.startlive()
257160

258161

259162
class SaveDisk(Stream):
@@ -295,23 +198,3 @@ def save(self):
295198

296199
else:
297200
print("specify filename to save screen capture w/ audio to disk.")
298-
299-
300-
def unify_streams(streams: typing.Mapping[str, Stream]) -> str:
301-
"""
302-
find least common denominator stream settings,
303-
so "tee" output can generate multiple streams.
304-
First try: use stream with lowest video bitrate.
305-
306-
Exploits that Python has guaranteed dict() ordering.
307-
308-
fast native Python argmin()
309-
https://stackoverflow.com/a/11825864
310-
"""
311-
vid_bw: list[int] = [streams[s].video_kbps for s in streams]
312-
313-
argmin: int = min(range(len(vid_bw)), key=vid_bw.__getitem__)
314-
315-
key: str = list(streams.keys())[argmin]
316-
317-
return key

src/pylivestream/camera.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@
88
signal.signal(signal.SIGINT, signal.SIG_DFL)
99

1010
p = argparse.ArgumentParser(description="livestream camera")
11-
p.add_argument(
12-
"websites",
13-
help="site to stream, e.g. localhost youtube facebook twitch",
14-
nargs="+",
15-
)
11+
p.add_argument("websites", help="site to stream, e.g. localhost youtube facebook twitch")
1612
p.add_argument("json", help="JSON file with stream parameters such as key")
1713
p.add_argument("-y", "--yes", help="no confirmation dialog", action="store_true")
1814
p.add_argument("-t", "--timeout", help="stop streaming after --timeout seconds", type=int)

src/pylivestream/data/pylivestream.json

-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"camera_fps": 30,
77
"audio_rate": 44100,
88
"audio_bps": 128000,
9-
"preset": "veryfast",
109
"audio_codec": "aac",
1110
"exe": "ffmpeg",
1211
"ffprobe_exe": "ffprobe",

src/pylivestream/ffmpeg.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from __future__ import annotations
21
import typing as T
32
import subprocess
43
from time import sleep

src/pylivestream/fglob.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from __future__ import annotations
21
import random
32
from pathlib import Path
43
import signal
@@ -15,7 +14,7 @@
1514

1615
def stream_files(
1716
ini_file: Path,
18-
websites: list[str],
17+
websites: str,
1918
*,
2019
video_path: Path,
2120
glob: str | None = None,
@@ -50,7 +49,7 @@ def stream_files(
5049
def playonce(
5150
flist: list[Path],
5251
image: Path | None,
53-
sites: list[str],
52+
sites: str,
5453
inifn: Path,
5554
shuffle: bool,
5655
usemeta: bool,
@@ -105,7 +104,6 @@ def cli():
105104
p.add_argument(
106105
"websites",
107106
help="site to stream, e.g. localhost youtube facebook twitch",
108-
nargs="+",
109107
)
110108
p.add_argument("json", help="JSON file with stream parameters such as key")
111109
p.add_argument("-glob", help="file glob pattern to stream.")

0 commit comments

Comments
 (0)