300 lines
9.6 KiB
Python
300 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
play_playlist.py
|
|
|
|
Sequential playlist player for macOS using Music.app.
|
|
Monitors playback state and auto-advances to the next song.
|
|
|
|
Behavior:
|
|
- Asks user once before starting: "可以开始播放吗?"
|
|
- On song finished → auto-play next song
|
|
- On user paused → ask "要播放下一首吗?"
|
|
- On user not listening to anything + already agreed → auto-play next
|
|
|
|
Usage:
|
|
python3 play_playlist.py ~/Music/minimax-gen/playlists/深夜放松/
|
|
python3 play_playlist.py file1.mp3 file2.mp3 file3.mp3
|
|
python3 play_playlist.py --playlist /tmp/playlist_plan.json
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
|
|
LANG = "zh"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Music.app interaction via osascript
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _osascript(script: str) -> str:
|
|
try:
|
|
r = subprocess.run(
|
|
["osascript", "-e", script],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
return r.stdout.strip()
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return ""
|
|
|
|
|
|
def music_app_state() -> dict:
|
|
"""Query Music.app for player state, position, and duration."""
|
|
running = _osascript(
|
|
'tell application "System Events" to (name of processes) contains "Music"'
|
|
)
|
|
if running != "true":
|
|
return {"state": "not_running", "position": 0.0, "duration": 0.0}
|
|
|
|
state_str = _osascript('tell application "Music" to player state as string')
|
|
if not state_str:
|
|
return {"state": "stopped", "position": 0.0, "duration": 0.0}
|
|
|
|
if "playing" in state_str.lower():
|
|
state = "playing"
|
|
elif "pause" in state_str.lower():
|
|
state = "paused"
|
|
else:
|
|
state = "stopped"
|
|
|
|
pos_dur = _osascript(
|
|
'tell application "Music" to '
|
|
'(player position as string) & "|||" & (duration of current track as string)'
|
|
)
|
|
position = 0.0
|
|
duration = 0.0
|
|
if "|||" in pos_dur:
|
|
parts = pos_dur.split("|||")
|
|
try:
|
|
position = float(parts[0].replace(",", "."))
|
|
duration = float(parts[1].replace(",", "."))
|
|
except ValueError:
|
|
pass
|
|
|
|
return {"state": state, "position": position, "duration": duration}
|
|
|
|
|
|
def is_idle() -> bool:
|
|
"""Check if Music.app is idle (not playing anything)."""
|
|
info = music_app_state()
|
|
return info["state"] in ("paused", "stopped", "not_running")
|
|
|
|
|
|
def play_file(filepath: str):
|
|
"""Open a file in Music.app and ensure playback starts."""
|
|
subprocess.Popen(["open", str(filepath)])
|
|
time.sleep(1.5)
|
|
_osascript('tell application "Music" to play')
|
|
|
|
|
|
def monitor_until_done() -> str:
|
|
"""Monitor Music.app until current song ends or user pauses.
|
|
|
|
Returns: "finished" | "paused" | "stopped"
|
|
"""
|
|
time.sleep(1)
|
|
|
|
was_playing = False
|
|
last_position = -1.0
|
|
startup_grace = 15
|
|
|
|
while True:
|
|
info = music_app_state()
|
|
state = info["state"]
|
|
pos = info["position"]
|
|
dur = info["duration"]
|
|
|
|
if state == "playing":
|
|
was_playing = True
|
|
last_position = pos
|
|
|
|
if dur > 0 and pos >= dur - 3:
|
|
time.sleep(3)
|
|
info2 = music_app_state()
|
|
if info2["state"] != "playing":
|
|
return "finished"
|
|
|
|
elif state == "paused":
|
|
if was_playing:
|
|
if dur > 0 and pos >= dur - 3:
|
|
return "finished"
|
|
if pos < 1.0 and last_position > 5.0:
|
|
return "finished"
|
|
return "paused"
|
|
else:
|
|
startup_grace -= 1
|
|
if startup_grace <= 0:
|
|
return "stopped"
|
|
|
|
elif state in ("stopped", "not_running"):
|
|
if was_playing:
|
|
return "stopped"
|
|
startup_grace -= 1
|
|
if startup_grace <= 0:
|
|
return "stopped"
|
|
|
|
time.sleep(1.5)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playlist player
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_duration_str(filepath: str) -> str:
|
|
"""Get human-readable duration string."""
|
|
try:
|
|
result = subprocess.run(
|
|
["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
|
"-of", "csv=p=0", str(filepath)],
|
|
capture_output=True, text=True, timeout=5
|
|
)
|
|
if result.returncode == 0:
|
|
d = float(result.stdout.strip())
|
|
return f"{int(d // 60)}:{int(d % 60):02d}"
|
|
except (ValueError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
return "?:??"
|
|
|
|
|
|
def play_playlist(files: list, auto: bool = False):
|
|
"""Play a list of files sequentially with smart state tracking."""
|
|
total = len(files)
|
|
print(f"🎵 Playlist: {total} songs")
|
|
for i, f in enumerate(files, 1):
|
|
name = Path(f).stem
|
|
dur = get_duration_str(f)
|
|
print(f" {i}. {name} ({dur})")
|
|
|
|
# Ask user once before starting (skip in auto mode)
|
|
if not auto:
|
|
print("▶️ Start playback? [Y/n] ", end="", flush=True)
|
|
answer = input().strip().lower()
|
|
if answer not in ("y", "yes", "", "是", "好", "ok"):
|
|
print("❌ Playback cancelled.")
|
|
print(json.dumps({"action": "cancelled", "played": 0, "total": total}))
|
|
return
|
|
|
|
auto_advance = True # User agreed to play, auto-advance by default
|
|
|
|
for i, filepath in enumerate(files):
|
|
song_num = i + 1
|
|
name = Path(filepath).stem
|
|
dur = get_duration_str(filepath)
|
|
|
|
print(f"\n🎵 [{song_num}/{total}] {name} ({dur})")
|
|
|
|
# Play the file
|
|
play_file(filepath)
|
|
print("▶️ Now playing...")
|
|
|
|
# Monitor until done
|
|
status = monitor_until_done()
|
|
|
|
if status == "finished":
|
|
print("✅ Song finished.")
|
|
# Auto-advance to next (user didn't intervene)
|
|
auto_advance = True
|
|
|
|
elif status == "paused":
|
|
print("⏸️ Song paused.")
|
|
if song_num < total:
|
|
if not auto:
|
|
print("▶️ Play next? [Y/n] ", end="", flush=True)
|
|
answer = input().strip().lower()
|
|
if answer not in ("y", "yes", "", "是", "好", "ok"):
|
|
print(f"🎵 Playlist ended. Played {song_num}/{total}.")
|
|
print(json.dumps({
|
|
"action": "stopped_by_user",
|
|
"played": song_num,
|
|
"total": total,
|
|
"stopped_at": song_num,
|
|
}))
|
|
return
|
|
auto_advance = True
|
|
|
|
elif status == "stopped":
|
|
print("⏹️ Player stopped.")
|
|
if song_num < total:
|
|
if not auto:
|
|
print("▶️ Continue to next? [Y/n] ", end="", flush=True)
|
|
answer = input().strip().lower()
|
|
if answer not in ("y", "yes", "", "是", "好", "ok"):
|
|
print(f"🎵 Playlist ended. Played {song_num}/{total}.")
|
|
print(json.dumps({
|
|
"action": "stopped_by_user",
|
|
"played": song_num,
|
|
"total": total,
|
|
"stopped_at": song_num,
|
|
}))
|
|
return
|
|
auto_advance = True
|
|
|
|
# All songs played
|
|
print(f"🎉 Playlist complete! {total} songs played.")
|
|
print(json.dumps({"action": "completed", "played": total, "total": total}))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Sequential playlist player")
|
|
parser.add_argument("files", nargs="*", help="MP3 files or playlist directory")
|
|
parser.add_argument("--playlist", default=None,
|
|
help="Path to playlist_plan.json")
|
|
parser.add_argument("--lang", default="zh", choices=["zh", "en"],
|
|
help="UI language")
|
|
parser.add_argument("--auto", action="store_true",
|
|
help="Non-interactive mode: auto-confirm all prompts")
|
|
args = parser.parse_args()
|
|
|
|
global LANG
|
|
LANG = args.lang
|
|
|
|
files = []
|
|
|
|
if args.playlist:
|
|
# Read from playlist plan JSON
|
|
with open(args.playlist, "r") as f:
|
|
plan = json.load(f)
|
|
playlist_name = plan.get("playlist_name", "playlist")
|
|
base_dir = os.path.expanduser(
|
|
f"~/Music/minimax-gen/playlists/{playlist_name}"
|
|
)
|
|
for song in plan.get("songs", []):
|
|
fp = os.path.join(base_dir, song["filename"])
|
|
if os.path.exists(fp):
|
|
files.append(fp)
|
|
else:
|
|
print(f"⚠️ File missing, skipping: {fp}", file=sys.stderr)
|
|
|
|
elif args.files:
|
|
for f in args.files:
|
|
p = Path(f).expanduser()
|
|
if p.is_dir():
|
|
# Directory: play all mp3s sorted by name
|
|
files.extend(
|
|
sorted(str(x) for x in p.glob("*.mp3"))
|
|
)
|
|
elif p.exists():
|
|
files.append(str(p))
|
|
else:
|
|
print(f"⚠️ File missing, skipping: {f}", file=sys.stderr)
|
|
|
|
if not files:
|
|
print("❌ No playable files found.")
|
|
sys.exit(1)
|
|
|
|
play_playlist(files, auto=args.auto)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|