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.
@@ -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())
@@ -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)