Skip to content

Commit 1cea2d7

Browse files
authored
feat(sync): enable-all group + stop after break (#30)
* feat(sync): add enable-all and stop players after break Replace multi-step Apple Shortcuts with `sync enable <primary>` (group every other player) and clear leftover AirPlay/capture on `sync break` by stopping freed secondaries and empty primaries. * fix(sync): resolve mypy optional slave reassignment in create --------- Co-authored-by: Tim Baur <tbaur@users.noreply.github.com>
1 parent ada7387 commit 1cea2d7

3 files changed

Lines changed: 112 additions & 11 deletions

File tree

cli.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ def print_help(self) -> None:
9595

9696
print(f"{BOLD}Presets & Sync:{RESET}")
9797
print(f" {GREEN}presets{RESET} {DIM}[name] [id]{RESET} List or play a preset")
98+
print(f" {GREEN}sync{RESET} {DIM}enable <primary>{RESET} Group all others under primary")
9899
print(f" {GREEN}sync{RESET} {DIM}create <master> <slaves>{RESET} Create sync group")
99-
print(f" {GREEN}sync{RESET} {DIM}break [name]{RESET} Break sync group")
100+
print(f" {GREEN}sync{RESET} {DIM}break [name]{RESET} Break sync group (stops freed players)")
100101
print(f" {GREEN}sync{RESET} {DIM}list{RESET} Show sync groups\n")
101102

102103
print(f"{BOLD}System:{RESET}")
@@ -813,7 +814,43 @@ def sync(self, args) -> None:
813814
"""Manage sync groups."""
814815
action = args.action
815816

816-
if action == 'create':
817+
if action == 'enable':
818+
primary_name = args.master
819+
if not primary_name:
820+
print(f"{RED}Usage: sync enable <primary-name>{RESET}")
821+
return
822+
823+
all_devices = self._get_matching_devices(None)
824+
devices = {d.name.lower(): d for d in all_devices}
825+
master = devices.get(primary_name.lower())
826+
if not master:
827+
# Allow substring match like other commands
828+
matches = [d for d in all_devices if primary_name.lower() in d.name.lower()]
829+
if len(matches) == 1:
830+
master = matches[0]
831+
elif len(matches) > 1:
832+
print(f"{RED}Primary '{primary_name}' is ambiguous:{RESET}")
833+
for d in matches:
834+
print(f" - {d.name}")
835+
return
836+
else:
837+
print(f"{RED}Primary device '{primary_name}' not found.{RESET}")
838+
return
839+
840+
slaves = [d for d in all_devices if d.ip != master.ip]
841+
if not slaves:
842+
print(f"{YELLOW}No other players to group under {master.name}.{RESET}")
843+
return
844+
845+
print(f"\n{BOLD}Enabling runtime group: {master.name} leads {len(slaves)} player(s)...{RESET}")
846+
print("-" * 40)
847+
for slave in sorted(slaves, key=lambda d: d.name.lower()):
848+
res = self.ctl.add_sync_slave(master.ip, slave.ip)
849+
state = f"{GREEN}ADDED{RESET}" if res else f"{RED}ERROR{RESET}"
850+
print(f"[{state}] {slave.name} -> {master.name}")
851+
print()
852+
853+
elif action == 'create':
817854
master_name = args.master
818855
slave_names = args.slaves.split(',') if args.slaves else []
819856

@@ -829,11 +866,11 @@ def sync(self, args) -> None:
829866
print("-" * 40)
830867

831868
for slave_name in slave_names:
832-
slave = devices.get(slave_name.strip().lower())
833-
if slave:
834-
res = self.ctl.add_sync_slave(master.ip, slave.ip)
869+
slave_dev = devices.get(slave_name.strip().lower())
870+
if slave_dev:
871+
res = self.ctl.add_sync_slave(master.ip, slave_dev.ip)
835872
state = f"{GREEN}ADDED{RESET}" if res else f"{RED}ERROR{RESET}"
836-
print(f"[{state}] {slave.name} -> {master.name}")
873+
print(f"[{state}] {slave_dev.name} -> {master.name}")
837874
else:
838875
print(f"{YELLOW}Slave '{slave_name}' not found.{RESET}")
839876
print()
@@ -856,10 +893,22 @@ def sync(self, args) -> None:
856893

857894
print(f"\n{BOLD}Breaking sync groups...{RESET}")
858895
print("-" * 40)
896+
primary_ips: set[str] = set()
859897
for master_ip, slave_ip, label in operations:
860898
success = self.ctl.remove_sync_slave(master_ip, slave_ip)
861899
state = f"{GREEN}BROKEN{RESET}" if success else f"{RED}ERROR{RESET}"
862900
print(f"[{state}] {label}")
901+
if success:
902+
# Clear leftover AirPlay/capture on freed secondaries.
903+
self.ctl.stop(slave_ip)
904+
primary_ips.add(master_ip)
905+
906+
for master_ip in sorted(primary_ips):
907+
primary = self.ctl.get_device_info(master_ip)
908+
if not primary.slaves:
909+
self.ctl.stop(master_ip)
910+
name = primary.name if primary.name != "Unknown" else master_ip
911+
print(f"[{GREEN}STOPPED{RESET}] {name} (cleared capture)")
863912
print()
864913

865914
elif action == 'list':

main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,8 +296,17 @@ def main() -> None:
296296

297297
# Sync groups
298298
sync_cmd = sub.add_parser("sync")
299-
sync_cmd.add_argument("action", choices=["create", "break", "list"])
300-
sync_cmd.add_argument("master", nargs="?", default=None, help="Master device (for create)")
299+
sync_cmd.add_argument(
300+
"action",
301+
choices=["enable", "create", "break", "list"],
302+
help="enable: group all others under primary; create/break/list as before",
303+
)
304+
sync_cmd.add_argument(
305+
"master",
306+
nargs="?",
307+
default=None,
308+
help="Primary/master device name (enable, create) or break target",
309+
)
301310
sync_cmd.add_argument("slaves", nargs="?", default=None, help="Slave devices, comma-separated (for create)")
302311
sync_cmd.add_argument("target", nargs="?", default=None, help="Target device (for break)")
303312
sync_cmd.add_argument("--scan", action="store_true", help="Force network rescan before command")

tests/test_cli_coverage.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,22 @@ def test_sync_break(self, cli):
234234
mock_executor_class.return_value = mock_executor
235235

236236
with patch.object(cli.ctl, 'remove_sync_slave', return_value=True) as mock_remove:
237-
with patch('builtins.print'):
238-
cli.sync(args)
237+
with patch.object(cli.ctl, 'stop', return_value=True) as mock_stop:
238+
with patch.object(
239+
cli.ctl,
240+
'get_device_info',
241+
return_value=PlayerStatus(
242+
ip="192.168.1.100",
243+
name="Living Room",
244+
slaves=[],
245+
),
246+
):
247+
with patch('builtins.print'):
248+
cli.sync(args)
239249
mock_remove.assert_called_once_with("192.168.1.100", "192.168.1.101")
250+
assert mock_stop.call_count == 2
251+
mock_stop.assert_any_call("192.168.1.101")
252+
mock_stop.assert_any_call("192.168.1.100")
240253

241254
def test_sync_break_with_master_arg(self, cli):
242255
"""Test sync break accepts device name in master positional arg."""
@@ -250,9 +263,39 @@ def test_sync_break_with_master_arg(self, cli):
250263

251264
with patch.object(cli, '_get_matching_devices', side_effect=[[primary, slave], [slave]]):
252265
with patch.object(cli.ctl, 'remove_sync_slave', return_value=True) as mock_remove:
266+
with patch.object(cli.ctl, 'stop', return_value=True):
267+
with patch.object(
268+
cli.ctl,
269+
'get_device_info',
270+
return_value=PlayerStatus(
271+
ip="192.168.1.100",
272+
name="Living Room",
273+
slaves=[],
274+
),
275+
):
276+
with patch('builtins.print'):
277+
cli.sync(args)
278+
mock_remove.assert_called_once_with("192.168.1.100", "192.168.1.101")
279+
280+
def test_sync_enable_groups_all_others(self, cli):
281+
"""sync enable adds every other player under the primary."""
282+
primary = PlayerStatus(ip="192.168.1.100", name="Living Room Speakers")
283+
kitchen = PlayerStatus(ip="192.168.1.101", name="Kitchen Speakers")
284+
patio = PlayerStatus(ip="192.168.1.102", name="Patio Speakers")
285+
286+
args = MagicMock()
287+
args.action = 'enable'
288+
args.master = 'Living Room Speakers'
289+
args.slaves = None
290+
args.target = None
291+
292+
with patch.object(cli, '_get_matching_devices', return_value=[primary, kitchen, patio]):
293+
with patch.object(cli.ctl, 'add_sync_slave', return_value=True) as mock_add:
253294
with patch('builtins.print'):
254295
cli.sync(args)
255-
mock_remove.assert_called_once_with("192.168.1.100", "192.168.1.101")
296+
assert mock_add.call_count == 2
297+
mock_add.assert_any_call("192.168.1.100", "192.168.1.101")
298+
mock_add.assert_any_call("192.168.1.100", "192.168.1.102")
256299

257300
def test_sync_list_no_ips(self, cli):
258301
"""Test sync list warns when no devices discovered."""

0 commit comments

Comments
 (0)