Skip to content

Commit 2783210

Browse files
committed
Refactor Airplay provider a bit
1 parent 8d9507f commit 2783210

File tree

6 files changed

+1491
-62
lines changed

6 files changed

+1491
-62
lines changed

music_assistant/providers/airplay/helpers.py

Lines changed: 180 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44

55
import os
66
import platform
7+
import time
78
from typing import TYPE_CHECKING
89

910
from zeroconf import IPVersion
1011

1112
from music_assistant.helpers.process import check_output
12-
from music_assistant.providers.airplay.constants import BROKEN_RAOP_MODELS
13+
from music_assistant.providers.airplay.constants import BROKEN_RAOP_MODELS, StreamingProtocol
1314

1415
if TYPE_CHECKING:
1516
from zeroconf.asyncio import AsyncServiceInfo
1617

18+
# NTP epoch delta: difference between Unix epoch (1970) and NTP epoch (1900)
19+
NTP_EPOCH_DELTA = 0x83AA7E80 # 2208988800 seconds
20+
1721

1822
def convert_airplay_volume(value: float) -> int:
1923
"""Remap AirPlay Volume to 0..100 scale."""
@@ -25,7 +29,7 @@ def convert_airplay_volume(value: float) -> int:
2529
return int(portion + normal_min)
2630

2731

28-
def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]:
32+
def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]: # noqa: PLR0911
2933
"""Return Manufacturer and Model name from mdns info."""
3034
manufacturer = info.decoded_properties.get("manufacturer")
3135
model = info.decoded_properties.get("model")
@@ -57,13 +61,35 @@ def get_model_info(info: AsyncServiceInfo) -> tuple[str, str]:
5761
return ("Apple", "Apple TV 4K Gen2")
5862
if model == "AppleTV14,1":
5963
return ("Apple", "Apple TV 4K Gen3")
64+
if model == "UPL-AMP":
65+
return ("Ubiquiti Inc.", "UPL-AMP")
6066
if "AirPort" in model:
6167
return ("Apple", "AirPort Express")
6268
if "AudioAccessory" in model:
6369
return ("Apple", "HomePod")
6470
if "AppleTV" in model:
6571
model = "Apple TV"
6672
manufacturer = "Apple"
73+
# Detect Mac devices (Mac mini, MacBook, iMac, etc.)
74+
# Model identifiers like: Mac16,11, MacBookPro18,3, iMac21,1
75+
if model.startswith(("Mac", "iMac")):
76+
# Parse Mac model to friendly name
77+
if model.startswith("MacBookPro"):
78+
return ("Apple", f"MacBook Pro ({model})")
79+
if model.startswith("MacBookAir"):
80+
return ("Apple", f"MacBook Air ({model})")
81+
if model.startswith("MacBook"):
82+
return ("Apple", f"MacBook ({model})")
83+
if model.startswith("iMac"):
84+
return ("Apple", f"iMac ({model})")
85+
if model.startswith("Macmini"):
86+
return ("Apple", f"Mac mini ({model})")
87+
if model.startswith("MacPro"):
88+
return ("Apple", f"Mac Pro ({model})")
89+
if model.startswith("MacStudio"):
90+
return ("Apple", f"Mac Studio ({model})")
91+
# Generic Mac device (e.g. Mac16,11 for Mac mini M4)
92+
return ("Apple", f"Mac ({model})")
6793

6894
return (manufacturer or "AirPlay", model)
6995

@@ -89,17 +115,43 @@ def is_broken_raop_model(manufacturer: str, model: str) -> bool:
89115
return False
90116

91117

92-
async def get_cliraop_binary() -> str:
93-
"""Find the correct raop/airplay binary belonging to the platform."""
118+
async def get_cli_binary(protocol: StreamingProtocol) -> str:
119+
"""Find the correct raop/airplay binary belonging to the platform.
120+
121+
Args:
122+
protocol: The streaming protocol (RAOP or AIRPLAY2)
123+
124+
Returns:
125+
Path to the CLI binary
94126
95-
async def check_binary(cliraop_path: str) -> str | None:
127+
Raises:
128+
RuntimeError: If the binary cannot be found
129+
"""
130+
131+
async def check_binary(cli_path: str) -> str | None:
96132
try:
97-
returncode, output = await check_output(
98-
cliraop_path,
99-
"-check",
100-
)
101-
if returncode == 0 and output.strip().decode() == "cliraop check":
102-
return cliraop_path
133+
if protocol == StreamingProtocol.RAOP:
134+
args = [
135+
cli_path,
136+
"-check",
137+
]
138+
passing_output = "cliraop check"
139+
else:
140+
config_file = os.path.join(os.path.dirname(__file__), "bin", "cliap2.conf")
141+
args = [
142+
cli_path,
143+
"--testrun",
144+
"--config",
145+
config_file,
146+
]
147+
148+
returncode, output = await check_output(*args)
149+
if (
150+
protocol == StreamingProtocol.RAOP
151+
and returncode == 0
152+
and output.strip().decode() == passing_output
153+
) or (protocol == StreamingProtocol.AIRPLAY2 and returncode == 0):
154+
return cli_path
103155
except OSError:
104156
pass
105157
return None
@@ -108,10 +160,125 @@ async def check_binary(cliraop_path: str) -> str | None:
108160
system = platform.system().lower().replace("darwin", "macos")
109161
architecture = platform.machine().lower()
110162

163+
if protocol == StreamingProtocol.RAOP:
164+
package = "cliraop"
165+
elif protocol == StreamingProtocol.AIRPLAY2:
166+
package = "cliap2"
167+
else:
168+
raise RuntimeError(f"Unsupported streaming protocol requested: {protocol}")
169+
111170
if bridge_binary := await check_binary(
112-
os.path.join(base_path, f"cliraop-{system}-{architecture}")
171+
os.path.join(base_path, f"{package}-{system}-{architecture}")
113172
):
114173
return bridge_binary
115174

116-
msg = f"Unable to locate RAOP Play binary for {system}/{architecture}"
175+
msg = (
176+
f"Unable to locate {protocol.name} CLI stream binary {package} for {system}/{architecture}"
177+
)
117178
raise RuntimeError(msg)
179+
180+
181+
def get_ntp_timestamp() -> int:
182+
"""
183+
Get current NTP timestamp (64-bit).
184+
185+
Returns:
186+
int: 64-bit NTP timestamp (upper 32 bits = seconds, lower 32 bits = fraction)
187+
"""
188+
# Get current Unix timestamp with microsecond precision
189+
current_time = time.time()
190+
191+
# Split into seconds and microseconds
192+
seconds = int(current_time)
193+
microseconds = int((current_time - seconds) * 1_000_000)
194+
195+
# Convert to NTP epoch (add offset from 1970 to 1900)
196+
ntp_seconds = seconds + NTP_EPOCH_DELTA
197+
198+
# Convert microseconds to NTP fraction (2^32 parts per second)
199+
# fraction = (microseconds * 2^32) / 1_000_000
200+
ntp_fraction = int((microseconds << 32) / 1_000_000)
201+
202+
# Combine into 64-bit value
203+
return (ntp_seconds << 32) | ntp_fraction
204+
205+
206+
def ntp_to_seconds_fraction(ntp_timestamp: int) -> tuple[int, int]:
207+
"""
208+
Split NTP timestamp into seconds and fraction components.
209+
210+
Args:
211+
ntp_timestamp: 64-bit NTP timestamp
212+
213+
Returns:
214+
tuple: (seconds, fraction)
215+
"""
216+
seconds = ntp_timestamp >> 32
217+
fraction = ntp_timestamp & 0xFFFFFFFF
218+
return seconds, fraction
219+
220+
221+
def ntp_to_unix_time(ntp_timestamp: int) -> float:
222+
"""
223+
Convert NTP timestamp to Unix timestamp (float).
224+
225+
Args:
226+
ntp_timestamp: 64-bit NTP timestamp
227+
228+
Returns:
229+
float: Unix timestamp (seconds since 1970-01-01)
230+
"""
231+
seconds = ntp_timestamp >> 32
232+
fraction = ntp_timestamp & 0xFFFFFFFF
233+
234+
# Convert back to Unix epoch
235+
unix_seconds = seconds - NTP_EPOCH_DELTA
236+
237+
# Convert fraction to microseconds
238+
microseconds = (fraction * 1_000_000) >> 32
239+
240+
return unix_seconds + (microseconds / 1_000_000)
241+
242+
243+
def unix_time_to_ntp(unix_timestamp: float) -> int:
244+
"""
245+
Convert Unix timestamp (float) to NTP timestamp.
246+
247+
Args:
248+
unix_timestamp: Unix timestamp (seconds since 1970-01-01)
249+
250+
Returns:
251+
int: 64-bit NTP timestamp
252+
"""
253+
seconds = int(unix_timestamp)
254+
microseconds = int((unix_timestamp - seconds) * 1_000_000)
255+
256+
# Convert to NTP epoch
257+
ntp_seconds = seconds + NTP_EPOCH_DELTA
258+
259+
# Convert microseconds to NTP fraction
260+
ntp_fraction = int((microseconds << 32) / 1_000_000)
261+
262+
return (ntp_seconds << 32) | ntp_fraction
263+
264+
265+
def add_seconds_to_ntp(ntp_timestamp: int, seconds: float) -> int:
266+
"""
267+
Add seconds to an NTP timestamp.
268+
269+
Args:
270+
ntp_timestamp: 64-bit NTP timestamp
271+
seconds: Number of seconds to add (can be fractional)
272+
273+
Returns:
274+
int: New NTP timestamp with seconds added
275+
"""
276+
# Extract whole seconds and fraction
277+
whole_seconds = int(seconds)
278+
fraction = seconds - whole_seconds
279+
280+
# Convert to NTP format (upper 32 bits = seconds, lower 32 bits = fraction)
281+
ntp_seconds = whole_seconds << 32
282+
ntp_fraction = int(fraction * (1 << 32))
283+
284+
return ntp_timestamp + ntp_seconds + ntp_fraction

0 commit comments

Comments
 (0)