station-server 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- station_server/__init__.py +9 -0
- station_server/__main__.py +299 -0
- station_server/audio.py +171 -0
- station_server/channel.py +241 -0
- station_server/config.py +188 -0
- station_server/desktop.py +122 -0
- station_server/onair.py +359 -0
- station_server/playlist.py +72 -0
- station_server/schedule.py +123 -0
- station_server/selfupdate.py +277 -0
- station_server/senders.py +314 -0
- station_server/static/app.js +793 -0
- station_server/static/icon-192.png +0 -0
- station_server/static/icon-512.png +0 -0
- station_server/static/icon.icns +0 -0
- station_server/static/icon.ico +0 -0
- station_server/static/icon.png +0 -0
- station_server/static/index.html +41 -0
- station_server/static/manifest.webmanifest +31 -0
- station_server/static/offline.html +19 -0
- station_server/static/offline.js +29 -0
- station_server/static/style.css +333 -0
- station_server/static/sw.js +29 -0
- station_server/updates.py +91 -0
- station_server/web.py +236 -0
- station_server/window.py +69 -0
- station_server-0.4.0.dist-info/METADATA +275 -0
- station_server-0.4.0.dist-info/RECORD +31 -0
- station_server-0.4.0.dist-info/WHEEL +4 -0
- station_server-0.4.0.dist-info/entry_points.txt +2 -0
- station_server-0.4.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Station Server — broadcasts station-studio playlists to SHOUTcast or Icecast.
|
|
2
|
+
|
|
3
|
+
Reads the iTunes XML playlists station-studio exports, plays them in order
|
|
4
|
+
in real time, encodes the result once and sends it to a streaming server
|
|
5
|
+
with each track's title. No sound card is involved, so nothing else
|
|
6
|
+
playing on the computer reaches the broadcast.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""`station-server` command: the app, or put one playlist on air directly.
|
|
2
|
+
|
|
3
|
+
station-server # the app: stations, on air, play log
|
|
4
|
+
station-server play week-12.xml --to shoutcast://my.server:8000
|
|
5
|
+
station-server play week-12.xml --to icecast://source@my.server:8000/radio
|
|
6
|
+
station-server play week-12.xml --to test.mp3 # record instead
|
|
7
|
+
|
|
8
|
+
The password comes from --password or $STATION_SERVER_PASSWORD (or the
|
|
9
|
+
URL). Ctrl+C stops. Type `s` and Enter to skip the playing track.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import signal
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from station_server import __version__
|
|
22
|
+
from station_server.audio import FfmpegMissing, find_ffmpeg, has_mp3_encoder
|
|
23
|
+
from station_server.channel import Channel
|
|
24
|
+
from station_server.playlist import PlaylistError, load_queue
|
|
25
|
+
from station_server.senders import SenderError, StreamInfo, sender_from_url
|
|
26
|
+
from station_server.web import DEFAULT_PORT
|
|
27
|
+
|
|
28
|
+
PASSWORD_ENV = "STATION_SERVER_PASSWORD"
|
|
29
|
+
# station-server://start starts Station Server without opening a window:
|
|
30
|
+
# the window asking (the installed app opened from the Dock while Station
|
|
31
|
+
# Server wasn't running, see static/offline.js) is already open. The Mac
|
|
32
|
+
# app registers it in its Info.plist, the Windows setup in the registry.
|
|
33
|
+
URL_SCHEME = "station-server"
|
|
34
|
+
UPDATE_CHECK_EVERY = 60 * 60 # looks hourly; GitHub is asked at most daily
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parser() -> argparse.ArgumentParser:
|
|
38
|
+
p = argparse.ArgumentParser(prog="station-server", description="Broadcast station-studio playlists.")
|
|
39
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
40
|
+
sub = p.add_subparsers(dest="command")
|
|
41
|
+
|
|
42
|
+
app = sub.add_parser("app", help="run the app and open its window (the default)")
|
|
43
|
+
app.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"local port for the page (default {DEFAULT_PORT})")
|
|
44
|
+
app.add_argument("--no-window", action="store_true", help="don't open the window")
|
|
45
|
+
app.add_argument("-v", "--verbose", action="store_true")
|
|
46
|
+
|
|
47
|
+
sub.add_parser("remove-login", help="stop starting Station Server at login (the uninstaller runs this)")
|
|
48
|
+
|
|
49
|
+
play = sub.add_parser("play", help="play a playlist to a server (or an MP3 file)")
|
|
50
|
+
play.add_argument("xml", type=Path, help="station-studio export or iTunes XML")
|
|
51
|
+
play.add_argument("--to", required=True, metavar="URL",
|
|
52
|
+
help="shoutcast://host:port[?sid=N], icecast://[user@]host:port/mount, or an .mp3 path")
|
|
53
|
+
play.add_argument("--password", help=f"source password (default: ${PASSWORD_ENV}, or from --to)")
|
|
54
|
+
play.add_argument("--playlist", help="which playlist, when the XML has several")
|
|
55
|
+
play.add_argument("--music-folder", action="append", default=[], metavar="DIR",
|
|
56
|
+
help="where to look for tracks that have moved (repeatable)")
|
|
57
|
+
play.add_argument("--remap", action="append", default=[], metavar="FROM=TO",
|
|
58
|
+
help="rewrite a path prefix, e.g. '/Users/rob/Music=D:/Music' (repeatable)")
|
|
59
|
+
play.add_argument("--start", type=int, default=1, metavar="N", help="start at track N (default 1)")
|
|
60
|
+
play.add_argument("--once", action="store_true", help="stop at the end instead of starting again")
|
|
61
|
+
play.add_argument("--bitrate", type=int, default=128, help="MP3 bitrate in kbps (default 128)")
|
|
62
|
+
play.add_argument("--no-level", action="store_true", help="turn off volume levelling")
|
|
63
|
+
play.add_argument("--name", help="station name shown by the server (default: the playlist name)")
|
|
64
|
+
play.add_argument("--genre", default="", help="station genre shown by the server")
|
|
65
|
+
play.add_argument("--url", default="", help="station website shown by the server")
|
|
66
|
+
play.add_argument("--public", action="store_true", help="ask the server to list the station in its directory")
|
|
67
|
+
play.add_argument("--ffmpeg", help="path to ffmpeg (default: $STATION_SERVER_FFMPEG, then PATH)")
|
|
68
|
+
play.add_argument("-v", "--verbose", action="store_true")
|
|
69
|
+
|
|
70
|
+
check = sub.add_parser("check", help="check ffmpeg and read a playlist without broadcasting")
|
|
71
|
+
check.add_argument("xml", type=Path, nargs="?")
|
|
72
|
+
check.add_argument("--playlist")
|
|
73
|
+
check.add_argument("--music-folder", action="append", default=[], metavar="DIR")
|
|
74
|
+
check.add_argument("--remap", action="append", default=[], metavar="FROM=TO")
|
|
75
|
+
check.add_argument("--ffmpeg")
|
|
76
|
+
return p
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _remaps(pairs: list[str]) -> list[dict]:
|
|
80
|
+
out = []
|
|
81
|
+
for pair in pairs:
|
|
82
|
+
src, sep, dst = pair.partition("=")
|
|
83
|
+
if not sep or not src or not dst:
|
|
84
|
+
raise SystemExit(f"--remap needs FROM=TO, got {pair!r}")
|
|
85
|
+
out.append({"from": src, "to": dst})
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _ffmpeg(explicit: str | None) -> str:
|
|
90
|
+
try:
|
|
91
|
+
ffmpeg = find_ffmpeg(explicit)
|
|
92
|
+
except FfmpegMissing as exc:
|
|
93
|
+
raise SystemExit(str(exc))
|
|
94
|
+
if not has_mp3_encoder(ffmpeg):
|
|
95
|
+
raise SystemExit(f"{ffmpeg} has no MP3 encoder (libmp3lame); install a full ffmpeg build")
|
|
96
|
+
return ffmpeg
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _queue(args):
|
|
100
|
+
try:
|
|
101
|
+
return load_queue(args.xml, args.music_folder, _remaps(args.remap), args.playlist)
|
|
102
|
+
except PlaylistError as exc:
|
|
103
|
+
raise SystemExit(str(exc))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _report(queue) -> None:
|
|
107
|
+
print(f'"{queue.name}": {len(queue.tracks)} tracks to play', end="")
|
|
108
|
+
if queue.missing:
|
|
109
|
+
print(f", {len(queue.missing)} missing (skipped):")
|
|
110
|
+
for t in queue.missing[:20]:
|
|
111
|
+
print(f" - {t.artist + ' - ' if t.artist else ''}{t.name} [{t.location}]")
|
|
112
|
+
if len(queue.missing) > 20:
|
|
113
|
+
print(f" ...and {len(queue.missing) - 20} more")
|
|
114
|
+
else:
|
|
115
|
+
print()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _check(args) -> int:
|
|
119
|
+
ffmpeg = _ffmpeg(args.ffmpeg)
|
|
120
|
+
print(f"ffmpeg: {ffmpeg} (MP3 encoder found)")
|
|
121
|
+
if args.xml:
|
|
122
|
+
_report(_queue(args))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _keyboard(channel: Channel) -> None:
|
|
127
|
+
"""`s` + Enter skips; end of input is ignored (running in the background)."""
|
|
128
|
+
for line in sys.stdin:
|
|
129
|
+
if line.strip().lower() in ("s", "skip"):
|
|
130
|
+
channel.skip()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _play(args) -> int:
|
|
134
|
+
ffmpeg = _ffmpeg(args.ffmpeg)
|
|
135
|
+
queue = _queue(args)
|
|
136
|
+
_report(queue)
|
|
137
|
+
info = StreamInfo(
|
|
138
|
+
name=args.name or queue.name, genre=args.genre, url=args.url,
|
|
139
|
+
public=args.public, bitrate=args.bitrate,
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
sender = sender_from_url(args.to, info, args.password or os.environ.get(PASSWORD_ENV) or None)
|
|
143
|
+
sender.connect()
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise SystemExit(str(exc))
|
|
146
|
+
except (SenderError, OSError) as exc:
|
|
147
|
+
raise SystemExit(f"Couldn't connect to {args.to}: {exc}")
|
|
148
|
+
print(f"On air to {sender.describe}. Ctrl+C stops; s + Enter skips.")
|
|
149
|
+
|
|
150
|
+
channel = Channel(
|
|
151
|
+
queue, sender, ffmpeg, bitrate=args.bitrate, loop=not args.once,
|
|
152
|
+
start=max(args.start, 1) - 1, level=not args.no_level,
|
|
153
|
+
)
|
|
154
|
+
signal.signal(signal.SIGINT, lambda *_: channel.stop())
|
|
155
|
+
if hasattr(signal, "SIGTERM"):
|
|
156
|
+
signal.signal(signal.SIGTERM, lambda *_: channel.stop())
|
|
157
|
+
if sys.stdin and sys.stdin.isatty():
|
|
158
|
+
threading.Thread(target=_keyboard, args=(channel,), daemon=True).start()
|
|
159
|
+
try:
|
|
160
|
+
channel.run()
|
|
161
|
+
except RuntimeError as exc:
|
|
162
|
+
print(exc, file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
finally:
|
|
165
|
+
sender.close()
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _running(port: int) -> bool:
|
|
170
|
+
"""Whether Station Server already answers on `port`."""
|
|
171
|
+
import json
|
|
172
|
+
import urllib.request
|
|
173
|
+
try:
|
|
174
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/state", timeout=2) as resp:
|
|
175
|
+
return "stations" in json.loads(resp.read())
|
|
176
|
+
except (OSError, ValueError):
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _log_to_file(home: Path) -> None:
|
|
181
|
+
"""The downloaded app and the login start have no terminal: keep a log."""
|
|
182
|
+
import logging.handlers
|
|
183
|
+
try:
|
|
184
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
186
|
+
home / "station-server.log", maxBytes=2_000_000, backupCount=2, encoding="utf-8")
|
|
187
|
+
except OSError:
|
|
188
|
+
return
|
|
189
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
|
190
|
+
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _hand_over(port: int, url: str) -> int:
|
|
194
|
+
"""The Mac app itself starts its helper, which runs Station Server, and
|
|
195
|
+
exits: while the app's own process lives, macOS sends later clicks on
|
|
196
|
+
the app to it, so they'd do nothing."""
|
|
197
|
+
import subprocess
|
|
198
|
+
import time
|
|
199
|
+
from station_server.desktop import CHILD_ENV, app_executable
|
|
200
|
+
if not _running(port):
|
|
201
|
+
subprocess.Popen([str(app_executable()), "app", "--no-window", "--port", str(port)],
|
|
202
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
203
|
+
start_new_session=True,
|
|
204
|
+
env={**os.environ, CHILD_ENV: "1", "PYINSTALLER_RESET_ENVIRONMENT": "1"})
|
|
205
|
+
end = time.time() + 60 # the first start of a new download is slow while macOS checks it
|
|
206
|
+
while not _running(port):
|
|
207
|
+
if time.time() > end:
|
|
208
|
+
logging.error("Station Server did not start")
|
|
209
|
+
return 1
|
|
210
|
+
time.sleep(0.5)
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _watch_for_updates(app, every: float = UPDATE_CHECK_EVERY, wait=None) -> None:
|
|
215
|
+
"""Keep the new-version notice current while Station Server runs
|
|
216
|
+
(a station computer can run for weeks)."""
|
|
217
|
+
wait = wait or threading.Event().wait
|
|
218
|
+
while True:
|
|
219
|
+
try:
|
|
220
|
+
app.check_for_updates()
|
|
221
|
+
except Exception: # never let the check stop Station Server
|
|
222
|
+
logging.exception("Checking for a new version failed")
|
|
223
|
+
if wait(every):
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _app(args) -> int:
|
|
228
|
+
from station_server.config import ConfigStore
|
|
229
|
+
from station_server.desktop import CHILD_ENV, app_executable, bundled
|
|
230
|
+
from station_server.onair import ServerApp
|
|
231
|
+
from station_server.web import make_server
|
|
232
|
+
from station_server.window import open_ui
|
|
233
|
+
|
|
234
|
+
url = f"http://127.0.0.1:{args.port}/"
|
|
235
|
+
store = ConfigStore()
|
|
236
|
+
if bundled() or os.environ.get(CHILD_ENV):
|
|
237
|
+
_log_to_file(store.home)
|
|
238
|
+
if bundled() and sys.platform == "darwin" and app_executable() != Path(sys.executable):
|
|
239
|
+
code = _hand_over(args.port, url)
|
|
240
|
+
if code == 0 and not args.no_window:
|
|
241
|
+
open_ui(url, store.home, store.config.open_window)
|
|
242
|
+
return code
|
|
243
|
+
if _running(args.port): # opened again: just show the window
|
|
244
|
+
if not args.no_window:
|
|
245
|
+
open_ui(url, store.home, store.config.open_window)
|
|
246
|
+
return 0
|
|
247
|
+
app = ServerApp(store)
|
|
248
|
+
try:
|
|
249
|
+
server = make_server(app, port=args.port)
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
raise SystemExit(f"Port {args.port} is in use ({exc}); try --port")
|
|
252
|
+
app.resume()
|
|
253
|
+
logging.info("Station Server %s at %s", __version__, url)
|
|
254
|
+
print(f"Station Server {__version__} at {url} (Ctrl+C quits)")
|
|
255
|
+
|
|
256
|
+
def stop(*_):
|
|
257
|
+
threading.Thread(target=lambda: (app.shutdown(), server.shutdown()), daemon=True).start()
|
|
258
|
+
|
|
259
|
+
signal.signal(signal.SIGINT, stop)
|
|
260
|
+
if hasattr(signal, "SIGTERM"):
|
|
261
|
+
signal.signal(signal.SIGTERM, stop)
|
|
262
|
+
threading.Thread(target=_watch_for_updates, args=(app,), name="update-check", daemon=True).start()
|
|
263
|
+
if not args.no_window:
|
|
264
|
+
threading.Timer(0.3, open_ui, args=(url, store.home, store.config.open_window)).start()
|
|
265
|
+
server.serve_forever()
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _from_link(argv: list[str]) -> list[str]:
|
|
270
|
+
"""Opened through a station-server: link (or by an older macOS, with a
|
|
271
|
+
process id): start in the background, with no window."""
|
|
272
|
+
argv = [a for a in argv if not a.startswith("-psn_")]
|
|
273
|
+
if any(a.lower().startswith(f"{URL_SCHEME}:") for a in argv):
|
|
274
|
+
argv = ["app", "--no-window"]
|
|
275
|
+
return argv
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main(argv: list[str] | None = None) -> int:
|
|
279
|
+
argv = _from_link(sys.argv[1:] if argv is None else argv)
|
|
280
|
+
args = _parser().parse_args(argv)
|
|
281
|
+
if args.command is None:
|
|
282
|
+
args = _parser().parse_args(["app", *(argv or [])])
|
|
283
|
+
logging.basicConfig(
|
|
284
|
+
level=logging.DEBUG if getattr(args, "verbose", False) else logging.INFO,
|
|
285
|
+
format="%(asctime)s %(message)s", datefmt="%H:%M:%S",
|
|
286
|
+
)
|
|
287
|
+
if args.command == "remove-login":
|
|
288
|
+
from station_server.desktop import Login
|
|
289
|
+
Login().set(False)
|
|
290
|
+
return 0
|
|
291
|
+
if args.command == "check":
|
|
292
|
+
return _check(args)
|
|
293
|
+
if args.command == "app":
|
|
294
|
+
return _app(args)
|
|
295
|
+
return _play(args)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
sys.exit(main())
|
station_server/audio.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Decoding and encoding, done by ffmpeg.
|
|
2
|
+
|
|
3
|
+
Every track is decoded to the same raw format (PCM below), so the mixer can
|
|
4
|
+
join them with no gap and, later, crossfade, trim to the clock or switch to
|
|
5
|
+
a live input. One encoder runs for the whole broadcast: starting a new one
|
|
6
|
+
per track would leave a gap, or a reconnect, between songs.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable, Iterator
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
RATE = 44100
|
|
22
|
+
CHANNELS = 2
|
|
23
|
+
SAMPLE_BYTES = 2 # signed 16-bit little-endian
|
|
24
|
+
BYTES_PER_SECOND = RATE * CHANNELS * SAMPLE_BYTES
|
|
25
|
+
# About a tenth of a second of audio: small enough to pace smoothly, and a
|
|
26
|
+
# whole number of frames so a chunk never splits a sample.
|
|
27
|
+
CHUNK = 4410 * CHANNELS * SAMPLE_BYTES
|
|
28
|
+
|
|
29
|
+
PCM_ARGS = ["-f", "s16le", "-ar", str(RATE), "-ac", str(CHANNELS)]
|
|
30
|
+
ENV_VAR = "STATION_SERVER_FFMPEG"
|
|
31
|
+
|
|
32
|
+
# Hide the console window ffmpeg would otherwise open on Windows.
|
|
33
|
+
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FfmpegMissing(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bundled_ffmpeg() -> Path | None:
|
|
41
|
+
"""The ffmpeg inside the downloaded app (see packaging/station-server.spec)."""
|
|
42
|
+
base = getattr(sys, "_MEIPASS", None)
|
|
43
|
+
if not base:
|
|
44
|
+
return None
|
|
45
|
+
exe = Path(base) / "ffmpeg" / ("ffmpeg.exe" if sys.platform == "win32" else "ffmpeg")
|
|
46
|
+
return exe if exe.is_file() else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Where Homebrew puts ffmpeg: an app opened from the Finder doesn't get the
|
|
50
|
+
# terminal's PATH, so look there too.
|
|
51
|
+
_MORE_PLACES = ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_ffmpeg(explicit: str | None = None) -> str:
|
|
55
|
+
"""The ffmpeg to use: given on the command line, then $STATION_SERVER_FFMPEG,
|
|
56
|
+
then the one inside the downloaded app, then whatever is on PATH."""
|
|
57
|
+
for candidate in (explicit, os.environ.get(ENV_VAR, "").strip() or None):
|
|
58
|
+
if candidate:
|
|
59
|
+
if Path(candidate).expanduser().is_file():
|
|
60
|
+
return str(Path(candidate).expanduser())
|
|
61
|
+
raise FfmpegMissing(f"ffmpeg not found at {candidate}")
|
|
62
|
+
inside = bundled_ffmpeg()
|
|
63
|
+
if inside:
|
|
64
|
+
return str(inside)
|
|
65
|
+
found = shutil.which("ffmpeg") or next((p for p in _MORE_PLACES if os.path.isfile(p)), None)
|
|
66
|
+
if not found:
|
|
67
|
+
raise FfmpegMissing(
|
|
68
|
+
"ffmpeg not found. Install it (macOS: brew install ffmpeg; "
|
|
69
|
+
"Windows: winget install ffmpeg) or pass --ffmpeg PATH."
|
|
70
|
+
)
|
|
71
|
+
return found
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def silence(seconds: float) -> Iterator[bytes]:
|
|
75
|
+
"""Digital silence, in CHUNK-sized pieces."""
|
|
76
|
+
left = int(seconds * BYTES_PER_SECOND) // 4 * 4
|
|
77
|
+
while left > 0:
|
|
78
|
+
n = min(CHUNK, left)
|
|
79
|
+
yield bytes(n)
|
|
80
|
+
left -= n
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def has_mp3_encoder(ffmpeg: str) -> bool:
|
|
84
|
+
out = subprocess.run(
|
|
85
|
+
[ffmpeg, "-hide_banner", "-encoders"],
|
|
86
|
+
capture_output=True, text=True, creationflags=_NO_WINDOW,
|
|
87
|
+
).stdout
|
|
88
|
+
return "libmp3lame" in out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def decode(ffmpeg: str, path: Path, gain_db: float = 0.0, start: float = 0.0) -> Iterator[bytes]:
|
|
92
|
+
"""Raw PCM for one file, in CHUNK-sized pieces (the last may be short).
|
|
93
|
+
|
|
94
|
+
`gain_db` is applied while decoding (volume levelling); `start` skips
|
|
95
|
+
that many seconds into the file (picking up mid-track after a restart).
|
|
96
|
+
If ffmpeg can't read the file the iterator simply ends early; the
|
|
97
|
+
caller moves on.
|
|
98
|
+
"""
|
|
99
|
+
args = [ffmpeg, "-nostdin", "-hide_banner", "-loglevel", "error"]
|
|
100
|
+
if start > 0:
|
|
101
|
+
args += ["-ss", f"{start:.3f}"]
|
|
102
|
+
args += ["-i", str(path), "-vn"]
|
|
103
|
+
if gain_db:
|
|
104
|
+
args += ["-af", f"volume={gain_db:.2f}dB"]
|
|
105
|
+
args += PCM_ARGS + ["pipe:1"]
|
|
106
|
+
proc = subprocess.Popen(
|
|
107
|
+
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, creationflags=_NO_WINDOW,
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
assert proc.stdout is not None
|
|
111
|
+
while True:
|
|
112
|
+
chunk = proc.stdout.read(CHUNK)
|
|
113
|
+
if not chunk:
|
|
114
|
+
break
|
|
115
|
+
yield chunk
|
|
116
|
+
finally:
|
|
117
|
+
if proc.poll() is None:
|
|
118
|
+
proc.kill()
|
|
119
|
+
_, err = proc.communicate()
|
|
120
|
+
if proc.returncode not in (0, None, -9) and err:
|
|
121
|
+
logger.warning("ffmpeg could not fully decode %s: %s", path.name, err.decode(errors="replace").strip())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class Encoder:
|
|
125
|
+
"""One ffmpeg turning PCM into a continuous MP3 stream.
|
|
126
|
+
|
|
127
|
+
`write` feeds it PCM; everything it encodes is handed to `on_data` from
|
|
128
|
+
a reader thread, in order.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, ffmpeg: str, on_data: Callable[[bytes], None], bitrate: int = 128) -> None:
|
|
132
|
+
self.bitrate = bitrate
|
|
133
|
+
self._on_data = on_data
|
|
134
|
+
self._proc = subprocess.Popen(
|
|
135
|
+
[ffmpeg, "-nostdin", "-hide_banner", "-loglevel", "error"]
|
|
136
|
+
+ PCM_ARGS + ["-i", "pipe:0",
|
|
137
|
+
"-c:a", "libmp3lame", "-b:a", f"{bitrate}k",
|
|
138
|
+
"-flush_packets", "1", "-f", "mp3", "pipe:1"],
|
|
139
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
140
|
+
creationflags=_NO_WINDOW,
|
|
141
|
+
)
|
|
142
|
+
self._reader = threading.Thread(target=self._read, name="encoder-out", daemon=True)
|
|
143
|
+
self._reader.start()
|
|
144
|
+
|
|
145
|
+
def _read(self) -> None:
|
|
146
|
+
assert self._proc.stdout is not None
|
|
147
|
+
while True:
|
|
148
|
+
data = self._proc.stdout.read1(16384)
|
|
149
|
+
if not data:
|
|
150
|
+
return
|
|
151
|
+
try:
|
|
152
|
+
self._on_data(data)
|
|
153
|
+
except Exception: # a sender bug must not stall the encoder
|
|
154
|
+
logger.exception("sending encoded audio failed")
|
|
155
|
+
|
|
156
|
+
def write(self, pcm: bytes) -> None:
|
|
157
|
+
assert self._proc.stdin is not None
|
|
158
|
+
self._proc.stdin.write(pcm)
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
"""Finish encoding what was written, then stop."""
|
|
162
|
+
try:
|
|
163
|
+
if self._proc.stdin:
|
|
164
|
+
self._proc.stdin.close()
|
|
165
|
+
except OSError:
|
|
166
|
+
pass
|
|
167
|
+
try:
|
|
168
|
+
self._proc.wait(timeout=10)
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
self._proc.kill()
|
|
171
|
+
self._reader.join(timeout=5)
|